diff --git a/BLOCKED.md b/BLOCKED.md index 2599e791..e97fd57f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,13 +1,17 @@ # BLOCKED -## 报告密度 BUG-1003~1007 验收缺口(2026-09-23) +## 报告密度 BUG-1003~1007 / 1009 / 1010 验收缺口(2026-09-23) -- 本机 Windows build 编译/类型通过,但 page-data 的 SKILL.md 符号链接 EPERM 与基线一致;Static 与首屏 gzip 未验收。 -- Docker 存在但地址池耗尽;没有删除他会话网络。前端全部失败名按基线逐项比对,见 PROGRESS 和失败清单。 -- 预检缺 worktree 镜像目录;未复制镜像、未修改系统权限。quick 初次受 GBK 输出编码阻塞,UTF-8 重跑结果见 PROGRESS。 -- 缺本轮受控登录态与浏览器验收,未借用账号。清单:`docs/testing/report-density-20260922.md`。 -- 源 packet 只有 Sade Sati 三阶段框架,没有三轮日期;该验收项未满足。新计算到附录缓存成功、最终报告落库失败后的重试只有 Markdown 缓存,不补算事实层;恢复结构化层的持久重试尚未实现。 -- Task 5 适用性说明仅随新报告产生;旧缓存不升级,因此旧附录没有新增说明。未提交、推送、部署。 +- 原实现 `bbd96d3b` 已提交并合入 staging;本轮从已 fetch 的 `0d37bec15` 工作树执行修复,尚无本轮交付或部署核验。不能继续把原实现写成尚未提交或未推送。进度:`docs/tasks/PROGRESS-report-density-20260922.md`。 +- ~~原 Windows build 的 SKILL.md 符号链接 EPERM 使 Static 与首屏 gzip 未验收。~~ 前轮独立验收已补齐 `/` Static 与 gzip +0.007%(见修复单);**当前修复版仍待重新构建与同环境比较**,不得继承前轮通过。 +- 原执行记录的 Docker 地址池、GBK 编码与测试失败数字只是当时环境事实。本轮未以旧记录替代重跑;全量前端须逐条比对测试名与基线失败清单。主会话本轮预检回报远端 verified,focused 仍有一条既有 workbuddy 镜像路径断言失败,未造镜像目录或修改权限。 +- 缺本轮受控登录态,未借用账号。Chrome 153 隔离真实组件验收 28/28 通过;全折叠及混合状态两份 PDF 各 5 页、各 130/130 行完整,原生打印前展开、打印后恢复。此证据不等于登录 E2E,也不覆盖真人 Ctrl+P / 取消对话框、下载、16 盘真机布局。PDF 有 SAV 标题页尾孤行,内容未缺失。 +- 本轮 Node 22 / Windows 基线 3,686 / 3,595 pass / 91 fail → 修复版 3,704 / 3,613 pass / 91 fail。名称多重集合无消失、新增18全通过;91项失败诊断逐项一致、无新增失败,但仍未达到3,730门槛。数据库两项在Docker地址池耗尽处停止、未到SQL。本轮build在页面数据阶段symlink EPERM失败,Static/gzip未验证。Python定向101/101,tsc0错、lint0error/119warning。 +- 完整隐私专项仍因保护目录的17个READ_ERROR失败。public/commercial未运行。F1子代理额外启动quick,在GBK UnicodeEncodeError处退出;没有禁读audit hook,不能保证此前子进程未读取保护目录,已禁止重跑。不能把该次quick或未执行扫描写成通过。 +- **F3 工具边界缺口**:新建 `docs/testing/report-density-20260922.md` 被工具拒绝(子代理不得写 report 文件);文件尚未生成,不能把该清单标为齐备。不换工具绕过,已通知主会话与用户。待产品处理后落盘的条目须覆盖新报告、16 盘、8 组表、两条下载、全折叠打印 / PDF / 恢复与旧缓存边界;F3 暂未完全满足。 +- 源 packet 只有 Sade Sati 三阶段框架与当前状态,没有三轮起止日期。原任务书把日期说成已存在属于规格错误;修复单已明确新增计算范围外,继续作为源数据缺口,不伪造日期。 +- ~~新计算到附录缓存成功、最终报告落库失败后只能重试 Markdown,恢复结构化层的持久重试尚未实现。~~ **该说法已过时**:基线已有 `personal-report-longform-snapshot.ts`,通过既有 section RPC 先持久完整快照,再写 Markdown 缓存;新生成的恢复路径与定向测试已存在,本轮测试成绩另行记录。 +- 仍保留的范围边界:**历史 Markdown-only 缓存没有结构化快照,不回填、不重算**;旧报告不会自动补齐事实表、16 张结构化盘或 Task 5 新适用性说明。不是新生成结构化恢复未实现,也不因此扩大本单范围。 ## BUG-1000:聊天人物选择缺少受控登录态(2026-09-22) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e581b3..468bce8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ - 原始附录独立下载,保留原始表格,清理工程用语;普通 Markdown 下载不变。 - 新生成长报告附带服务端事实表与最多 16 张真实分盘;旧缓存不重算、不升级。 - 辅助大运说明仅供交叉核对;Sade Sati 三轮日期仍缺源数据,未宣称完成。 -- Skill 版本不变。尚未提交、推送或部署;完整限制见本轮 PROGRESS。 +- 原实现 `bbd96d3b` 已提交并进入 staging,前轮验收发现可读性、隐私门与打印问题;2026-09-23 正按修复单补修,不能视为已部署或验收通过。 +- Skill 版本不变。新生成已有结构化快照恢复,历史 Markdown-only 缓存不回填。浏览器 / PDF 证据仍缺,真人清单新建被工具边界阻止、尚未生成;进度与完整限制见 `docs/tasks/PROGRESS-report-density-20260922.md`、`BLOCKED.md`。 ## 2026-09-22 — 今日星语和生时校正入口边框变轻 diff --git a/contracts/personal-report/report-document.v2.schema.json b/contracts/personal-report/report-document.v2.schema.json index b7c1e987..c1afd565 100644 --- a/contracts/personal-report/report-document.v2.schema.json +++ b/contracts/personal-report/report-document.v2.schema.json @@ -495,6 +495,100 @@ "maxLength": 80 } }, + "rows": { + "type": "array", + "maxItems": 2000, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "sourcePath", + "cells" + ], + "properties": { + "sourcePath": { + "type": "string", + "minLength": 1, + "maxLength": 400, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$" + }, + "cells": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 300 + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "subtables": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/definitions/factSubtable" + } + } + } + }, + "factSubtable": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "sourcePath", + "note", + "columns", + "rows" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "sourcePath": { + "type": "string", + "minLength": 1, + "maxLength": 400, + "pattern": "^[A-Za-z0-9_.\\[\\]-]+$" + }, + "note": { + "type": "string", + "maxLength": 500 + }, + "columns": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, "rows": { "type": "array", "maxItems": 2000, diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2c71261a..1cae2087 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13296,6 +13296,86 @@ - 复发自:无 - 修复版本:本分支,尚未部署 +## BUG-1003 | 对外原始附录带出工程状态词与第三方页码 + +- 状态:investigating(原实现已合入 staging,本轮清洗一致性修复待验证,未确认部署) +- 首次发现:2026-09-22 +- 最近更新:2026-09-23 +- 影响面:`/api/professional_report_reference` 的对外 Markdown、报告独立原始附录下载;内部审计与 CLI 不变。 +- 用户现象:附录出现内部函数名、工程状态词、第三方引擎名与产品页码。 +- 触发条件:原始引擎导出作为读者附录返回,而非普通正文投影。 +- 根因:内部审计与读者副本未分开;BUG-999 的普通正文 allowlist 不覆盖附录。初版 Python / TS 两份规则又因 Unicode 单词边界不同漏掉 Python 第三方页码。 +- 修复:`bbd96d3b` 增加读者副本清洗;本轮 F4 将 Python / TS 规则收敛为 scripts 下共享 JSON,正则采用相同 ASCII 边界,保留行数、原始字段、英文散文与图盘围栏,不以整行删除代替翻译。 +- 验证:本轮主会话报告定向 165/165、Python 定向 101/101 通过,含完整虚构 golden 两端逐字一致、页码归零及行/表/数字/图盘字节保留。前轮相差 36 行的问题已有回归覆盖;完整门和部署仍未通过,详见 PROGRESS。 +- 防复发:保留 `test_reader_appendix_language.py` 与前端原始附录清洗 / golden 回归;F4 必须覆盖同一真实引擎虚构 fixture 两侧逐字一致及页码归零。普通正文投影与安全边界不变。 +- 相关记录:BUG-999、BUG-1004;`TASK-report-density-fix-20260923.md` F4 +- 复发自:BUG-999 同现象族的未覆盖通道,不是撤销普通正文限制。 +- 修复版本:原实现 `bbd96d3b`;本轮工作树修复待验证,未确认部署。 + +## BUG-1004 | 读者没有独立完整原始附录入口 + +- 状态:investigating(入口已实现,受控浏览器与部署待验) +- 首次发现:2026-09-22 +- 最近更新:2026-09-23 +- 影响面:报告下载动作、`POST /api/reports/[reportId]/raw-appendix`、新长报告计算快照。 +- 用户现象:普通下载只提供净化正文,完整计算表不能作为独立附录取得。 +- 触发条件:用户需要核对原始表格,但只有 BUG-999 建立的普通正文下载通道。 +- 根因:缺独立附录产物与入口;不能把完整表格塞回普通正文投影作为补救。 +- 修复:`bbd96d3b` 新增独立附录路由和下载动作,普通 Markdown 仍走原投影。新计算通过既有 section RPC 持久 snapshot,重试从快照恢复结构化数据;快照行从章节列表及错误聚合排除。旧 Markdown-only 缓存不回填。 +- 验证:前轮验收入口与快照读写静态核对通过;`personal-report-density-retry.test.ts` 已含中断、并发、损坏绑定、旧缓存等用例。测试存在不等于本轮通过;登录态下载与重试真人证据仍缺,见 PROGRESS。 +- 防复发:`personal-report-raw-appendix.test.ts`、`personal-report-density-retry.test.ts` 与 `report-public-projection.test.ts` 分别守住独立附录、快照恢复和普通下载不变;不能让 snapshot 成为 writer 章节,不能借附录授权放宽 XSS / 危险 URL 合同。 +- 相关记录:BUG-999、BUG-1003、BUG-1005 +- 复发自:无;为经产品授权新增的独立通道,普通下载限制仍有效。 +- 修复版本:`bbd96d3b` 已合入 staging;未确认部署。 + +## BUG-1005 | 个人报告没有可承载引擎事实表的结构化层 + +- 状态:investigating(事实层已实现,可读性与打印补修待验) +- 首次发现:2026-09-22 +- 最近更新:2026-09-23 +- 影响面:报告契约、服务端装配、报告页事实表与 writer 边界。 +- 用户现象:引擎已有大运、力量、功能吉凶等表,但报告只有叙述与图盘;初版补表后又把路径清单显示给读者,见 BUG-1009。 +- 触发条件:生成包含上述已计算层的个人报告。 +- 根因:原报告没有 `factTables`;初版未规定用户列结构,writer 拒绝表格与打印闭合也缺回归覆盖。 +- 修复:`bbd96d3b` 增加 8 组服务端事实层,不给 writer 可写字段;保留证据状态。F1 补真正列结构,F5 补 writer 提示与拒绝测试,F6 补全折叠时打印。Sade Sati 只展示源中已有框架,三轮日期计算范围外。 +- 验证:本轮报告定向 165/165、Python 定向 101/101 通过;writer 去拦截变异使 4 个拒绝用例全部变红,正常对照仍绿。Chrome 153 隔离真实组件 28/28,全部折叠与混合状态 PDF 各 5 页、各 130/130 行完整,打印后恢复;不是受控登录 E2E。完整构建受 Windows symlink EPERM 阻塞,尚未提交或部署。 +- 防复发:`report-fact-tables.test.ts` 真实 golden 与逐行来源回查不可削弱;writer 的 Markdown / HTML 表格或 `factTables` 输出必须 fail closed;打印测试必须从全折叠状态开始并核对打印后恢复。 +- 相关记录:BUG-1009、BUG-153、BUG-999;修复单 F1/F5/F6 +- 复发自:BUG-153 的打印合同遗漏新增事实表 disclosure;旧静态合同不能证明关闭的 details 进入 PDF。 +- 修复版本:原实现 `bbd96d3b`;本轮补修待验证,未确认部署。 + +## BUG-1006 | 报告契约分盘种类上限不足 + +- 状态:investigating(16 种契约已实现,本轮构建与真机待验) +- 首次发现:2026-09-22 +- 最近更新:2026-09-23 +- 影响面:`CHART_IDS`、长报告分盘装配、报告页布局。 +- 用户现象:引擎可提供更多分盘,但报告契约只接受 9 种,无法交付约定的 16 张。 +- 触发条件:新生成完整个人报告并查看分盘。 +- 根因:契约枚举和服务端交付层未承载完整目标分盘集合。 +- 修复:`bbd96d3b` 将枚举扩为任务书指定 16 种,装配真实宫位与星体,沿用 BUG-616 grid/card 和 BUG-617 memoized 文章树;旧 v1 枚举兼容保留。 +- 验证:前轮验收记录 `/` Static、gzip +0.007%,golden 覆盖 16 盘完整宫位;本轮未重跑,不作为修复版通过。16 张盘不重叠、滚动不重建及旧报告读取待受控浏览器复核。 +- 防复发:`report-fact-tables.test.ts` 检查图盘完整性;`personal-report-markdown-view.test.tsx` 中 grid、禁止 float / 负 margin 与文章无滚动态合同仍在,不得只检查是否存在 SVG。 +- 相关记录:BUG-616、BUG-617、BUG-1005 +- 复发自:无;分盘扩容须持续遵守旧布局与性能防线。 +- 修复版本:`bbd96d3b` 已合入 staging;未确认部署。 + +## BUG-1007 | 辅助大运族缺少读者可理解的适用性说明 + +- 状态:investigating(新生成路径已实现,回归与真人待验) +- 首次发现:2026-09-22 +- 最近更新:2026-09-23 +- 影响面:新报告原始附录的辅助时间轴适用性说明。 +- 用户现象:辅助大运只剩状态词,不能看出是否适用、缺什么,以及只能交叉核对的边界。 +- 触发条件:读取辅助大运族数据,包括引擎已算出但仍限制可断言程度的层。 +- 根因:对外附录缺读者语言说明,未把引擎适用性和使用边界一同交付。 +- 修复:`bbd96d3b` 对 16 个大运族提供逐族说明,读取引擎 `applicable` 而不自行放宽;标明第二时间轴、不单独生成应期。新快照保存说明,旧 Markdown-only 缓存不补算、不回填。 +- 验证:前轮验收记录 16 族各有白话说明、模块不自行判定;本轮清洗变化后的回归与受控下载待验。 +- 防复发:真实 golden 的附录 / durable retry 回归须保住适用性与限制;普通正文投影不能接纳这些替代时间轴成为断言,旧缓存缺证据必须如实降级。 +- 相关记录:BUG-1003、BUG-1004、BUG-999 +- 复发自:无;不把展示授权当作证据等级升级。 +- 修复版本:`bbd96d3b` 已合入 staging;未确认部署。 + ## BUG-1008 | 任务书写入真实个人出生资料并推到 staging - 状态:mitigated(staging 历史已重写,不再含该资料;旧提交在 Gitea 上仍可按 SHA 匿名访问,待服务器垃圾回收) @@ -13313,3 +13393,35 @@ - 相关记录:BUG-999 - 复发自:无(与 2026-09-20 的本仓个人案例清除属同一类资料) - 修复版本:`abf28321`(HEAD 清除);历史重写见上 + +## BUG-1009 | 普通报告事实表显示引擎字段路径而不是可读列 + +- 状态:investigating(F1 修复执行中,待定向与独立验收) +- 首次发现:2026-09-23 +- 最近更新:2026-09-23 +- 影响面:`assembleReportFactTables`、事实表渲染、普通报告可见单元格。 +- 用户现象:8 组表为“原始字段 / 数值或状态”两列键值清单;出现 snake_case、数组下标、内部时间字段,主运年数还可能被出生时剩余量替代。 +- 触发条件:查看原报告密度实现新增的普通报告事实表。 +- 根因:原任务书只要求可追溯,未明确用户列结构;实现把来源路径直接当行名。普通正文的 BUG-999 投影不覆盖这块新增结构化渲染。 +- 修复:F1 按修复单逐组改成主运 / 分运 / 小运、SAV / BAV、六分量、功能吉凶、行星状态、特殊点、阶段框架、年度与 Saham 表。来源保留在不可见 `sourcePath`,数字不补算,主运年数与出生剩余量分开。 +- 验证:前轮 948 个叶子行的缺陷证据不再作为新表行数合同。本轮真实 golden 的 8 组、11 张实际表、130 行、545 单元格在 Chrome 完整渲染;可见 snake_case、数组下标、超两位小数均为零;Node 回归核对来源路径、原始数值与缺值不补算。首段主运直接取 full_years 显示 18.00,余额 0.72 仅说明;年度源无行星位置,保留空表,不冒用本命数据。构建与登录 E2E 缺口见 PROGRESS,尚未部署。 +- 防复发:使用真实引擎虚构 golden,同时测试可见内容与来源追溯;不能只通过“值来自引擎”就宣称面向读者可读,不能削弱 BUG-999 普通正文安全合同。 +- 相关记录:BUG-1005、BUG-999;`TASK-report-density-fix-20260923.md` F1 +- 复发自:BUG-999 同现象族的新增表格通道,旧正文测试未覆盖。 +- 修复版本:缺陷引入 `bbd96d3b`;修复在 `codex/report-density-fix-20260923` 工作树实施,待验证,未确认部署。 + +## BUG-1010 | 新虚构 fixture 的隐私扫描数值碰撞未登记 + +- 状态:investigating(F2 按精确数值碰撞机制处理,待本轮验证) +- 首次发现:2026-09-23 +- 最近更新:2026-09-23 +- 影响面:`tests/test_repo_privacy_markers.py` 与 `frontend/tests/fixtures/report-density-fictional-reader.json`。 +- 用户现象:无用户界面现象;质量门隐私扫描命中虚构引擎时间戳的数值片段,阻断发货。 +- 触发条件:新增真实引擎虚构 golden 后运行仓库隐私门。 +- 根因:时间戳数值与私有标记发生碰撞,但新 fixture 未登记到既有已审阅数值碰撞机制;不是 fixture 包含真实个人资料。任务书真实泄漏另见 BUG-1008,不得混称误报。 +- 修复:F2 只登记已审阅的单处数值碰撞,说明来自虚构输入的引擎时间戳;不更改私有标记、扫描规则,也不豁免整份 fixture 或目录。 +- 验证:前轮独立重跑已复现该虚构时间戳,本轮没有重跑引擎。主会话转述 F2:精确登记及反例 62 passed / 1 deselected;完整专项用 audit hook 尊重 `pending_packets` 禁读,62 pass / 1 fail(17 个保护文件 READ_ERROR),其余 4,647 文件无命中。完整隐私门未绿,不能标 resolved;不复制碰撞数值或私有 marker。 +- 防复发:新增 golden 必须保留虚构来源声明并跑隐私扫描;数值碰撞逐处核实、精确登记,真实泄漏必须分别清除,不能靠扩大 allowlist 隐藏。 +- 相关记录:BUG-1008、BUG-1009;`TASK-report-density-fix-20260923.md` F2 +- 复发自:无;既有扫描正确触发,缺的是已核实碰撞的精确登记。 +- 修复版本:缺陷引入 `bbd96d3b`;F2 工作树修复待验证,未确认部署。 diff --git a/docs/tasks/PROGRESS-report-density-20260922.md b/docs/tasks/PROGRESS-report-density-20260922.md new file mode 100644 index 00000000..bad9734e --- /dev/null +++ b/docs/tasks/PROGRESS-report-density-20260922.md @@ -0,0 +1,132 @@ +# PROGRESS-report-density-20260922 · 报告密度实现与验收修复 + +## 当前状态(2026-09-23) + +**代码补修与可执行的独立验证已完成;用户在获知下列缺口后明确要求 push 到 staging。** 本次按授权提交并尝试快进推送,不代表整体验收通过或部署成功。F3 清单写入被拒,完整隐私门、全量门槛、构建与数据库缺口仍保留,Gitea 门禁和部署结果须另外核实。原实现已进入 staging;本轮按 [修复单](TASK-report-density-fix-20260923.md) 执行 F1~F6。原任务书:[TASK-report-density-20260922.md](TASK-report-density-20260922.md)。 + +| 项 | 已核实事实 | +| --- | --- | +| 原实现 | 脱敏历史 `bbd96d3b`,已进入 staging;原验收未通过 | +| 本轮基线 | 主会话已 fetch 并创建 worktree;本工作树 HEAD 核实为 `0d37bec15a456329880b45810c63812d29162f29` | +| 工作树 / 分支 | `.worktrees/report-density-fix-20260923` / `codex/report-density-fix-20260923` | +| 当前交付 | 用户明确要求 push 到 staging;主会话提交本轮指定文件并快进推送,远端 SHA 与门禁结果另行核实;不使用 stash、不强推 | +| 部署 | 原修复单记录门禁 run 2854 失败、health 停在 `1bc6a954`;本轮未重新查询,不能当作当前线上核验 | +| Bug 编号 | 开工核对最大已记编号 BUG-1008;按既有预留补 BUG-1003~1007 与 BUG-1009/1010,不改 BUG-1008 | + +## 实现事实与分工 + +| 范围 | 基线已有实现 / 本轮工作 | 当前结论 | +| --- | --- | --- | +| BUG-1003 / F4 | Python / TS 共用一份 JSON 清洗规则 | 本轮完整虚构 golden 逐字一致,页码归零 | +| BUG-1004 | 独立 `raw-appendix` 入口;普通正文仍走原投影;新计算有持久 snapshot | 定向回归通过;受控浏览器下载待验证 | +| BUG-1005 / BUG-1009 / F1 | 8 组可读领域表、可选子表、隐藏来源路径 | 单测与 Chrome 隔离组件验证通过;年度行星源缺失如实留空 | +| BUG-1006 | 16 种分盘契约与装配已有;复用 grid / memoized Markdown | golden 与布局合同通过;Static、gzip、真机布局待验 | +| BUG-1007 | 新计算的辅助大运适用性说明已有;不提升证据等级 | 本轮定向回归通过;旧 Markdown-only 缓存仍不回填 | +| BUG-1010 / F2 | 虚构时间戳碰撞按精确 span 登记 | 反扩大豁免用例通过;完整隐私门因保护文件 READ_ERROR 未绿 | +| F3 | 补 Bug 历史、本进度、索引与真实状态,更正旧缺口 | 已有文档补齐;新建真人清单被工具拒绝,文件不存在,F3 尚未完全满足 | +| F5 | writer 补提示及真实校验路径拒表回归 | 主会话回归通过;代理去 guard 变异证据已收到 | +| F6 | beforeprint 展开、afterprint 恢复、打印解除裁切 | 单测与 Chrome 原生 PDF 通过;真人打印对话框与登录 E2E 未验证 | + +F1/F2/F4/F5 各代理只改自己的代码范围,F3 仅改授权文档,F6 和最终验收由主会话负责。P3 可选项没有完成证据前不记为已修。 + +## 已有防复发措施核对 + +开始记录前已按报告模块、原始附录接口、字段泄漏、打印、折叠、快照与相关 BUG 编号检索 Bug 历史,并读完 BUG-153、616、617、999、1008。本轮只读核对结果如下,不等于执行测试通过。 + +| 历史记录 | 仍在的措施 | 此次未被拦住的边界 | +| --- | --- | --- | +| BUG-999 | `report-public-projection.test.ts` 保留旧缓存再投影、普通下载、HTML / 危险 URL / 图盘围栏合同 | 新增事实表是独立结构化可见层,旧正文投影测试不检查表格单元格 | +| BUG-616 | `personal-report-markdown-view.test.tsx` 保留 grid/card 结构及禁止 float / 负 margin | 源码或静态 DOM 不能代替 16 张盘真实布局 | +| BUG-617 | 同文件保留文章无滚动态、`renderMarkdown` 在 `useMemo` 内的合同 | 滚动无整树重建仍需浏览器核对 | +| BUG-153 | 既有打印入口与报告样式合同保留 | 新增默认关闭的事实表 disclosure 未被旧打印范围完整覆盖,F6 补齐 | +| BUG-1008 | 状态与历史清除记录原样保留 | 服务器垃圾回收不属于本轮;不访问旧泄漏历史、不同步镜像 | + +## 结构化重试与范围边界 + +- 基线 `personal-report-longform-snapshot.ts` 已通过既有 `ensure_personal_report_section` / `complete_personal_report_section` RPC 写入服务端快照;不新增表或迁移。 +- `generatePersonalReportLongform` 先持久完整快照,再写 Markdown 缓存与最终文档。重试从快照恢复 `factTables`、`charts` 与适用性说明;检查用户 / 请求 / 报告绑定和 Markdown 哈希;损坏快照失败闭合,不冒充旧缓存。 +- `personal-report-density-retry.test.ts` 已含 snapshot-only、appendix-only、final-ready、legacy、corrupt、mismatch、pending-with-markdown、late-pending、late-ready、parallel-ready 用例。这里确认的是测试存在,不是本轮测试成绩。 +- 因此旧 BLOCKED 中“结构化层的持久重试尚未实现”已过时。**真正保留的范围边界**是没有结构化快照的历史 Markdown-only 缓存不回填、不重算;不得承诺旧报告自动获得 8 组事实表、16 张结构化分盘或新适用性说明。 +- Sade Sati 源数据只有三阶段框架与当前状态,没有三轮起止日期;新增日期计算在本修复单范围外,不靠渲染层推算。 + +## 既有 API 断言变更(三栏说明) + +证据取自脱敏历史 `git diff f77a1b5a bbd96d3b -- frontend/tests/personal-report-api.test.ts`,未读取旧泄漏提交或私有输入。目标用例为 `failed report reads aggregate existing section error codes without importing generation`。 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `assert.match(createRoute, /select\("request_id, status, last_error_code"\)/)` | `assert.match(createRoute, /select\("request_id, section_id, status, last_error_code"\)/)` | 列表聚合必须读取 `section_id`,识别并排除服务端计算快照;保留请求、状态、错误码三列,不删既有检查目的 | +| 无专门的快照行过滤断言 | 新增 `assert.match(createRoute, /row\.section_id === LONGFORM_SNAPSHOT_SECTION_ID\) continue/)` | 快照不是 writer 章节,不得计入章节进度或错误聚合;这是新增约束,不是放宽断言 | + +### 本轮 F1 既有断言调整 + +五个既有 Node 测试名称全部保留;F4/F5/F6 没有修改既有断言。 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 每个顶层表必须有行 | 顶层或子表有真实行 | 年度源没有行星位置,但有年度 Saham 子表;不得伪造顶层行 | +| `cells[1]` 等于来源路径的标量 | 每行来源可回查;逐列比较 packet 原值及两位格式 | 一行改为领域对象,原始数值不改变 | +| 六分量 42 个叶子行 | 7 个行星行,六分量与引擎合计逐列核对 | 从路径清单变为矩阵,不自行求和 | +| 八分力 108 个叶子行 | SAV 12 行、BAV 8 行 × 12 星座 | 不再列内部索引或重复总分 | +| 年度混含 `advanced_systems.sahams` | 年度位置 0 行,年度 Saham 36 行,来源 `annual_tajika_pack.sahams.data` | 本命数据不能冒充年度位置;真实缺值保留 | +| Sade Sati 6 个叶子行 | 3 个阶段行,当前状态移到说明 | 隐藏内部字段,日期缺口保留 | + +新增断言包括完整主运年数与余额分离、删除 full_years 后不得补算、blocked 不升级、旧快照兼容、子表列数/来源/重复 id 拒绝。Python 新增子表合同兼容回归。共享规则打包测试修正 CWD 隔离,不改业务或断言。 + +## 验证账本 + +### 前轮独立验收(引用,不是本轮重跑) + +来源为 [修复单的验收结论](TASK-report-density-fix-20260923.md#验收结论)。以下用于追踪前轮基线,不将前轮通过移植为修复版通过。 + +| 检查 | 前轮记载 | +| --- | --- | +| Node 22 全量前端 | 基线 3,698 条 / 26 失败;原实现 3,730 条 / 26 失败;失败名单逐条一致 | +| tsc / lint | 0 错 / 0 error | +| Python 定向 | 88 passed,含 API server 增长合同 | +| 构建 | `/` Static;首屏 gzip 623,288 → 623,329 B(+0.007%) | +| Python 快速门 | 隐私门新增失败;不是通过 | + +### 本轮修复验证(主会话独立复验,2026-09-23) + +| 检查 | 状态 / 待补证据 | +| --- | --- | +| F1 可见格、来源回查、主运年数 | 报告整批 Node 定向 165/165;最终 facts/打印/普通投影/布局安全复验 29/29。完整年数直接取 full_years;缺年度位置留空。没有修改普通投影实现 | +| F2 隐私扫描 | F2 执行报告:精确登记及反例 62 passed / 1 deselected;完整专项使用 audit hook 禁读 `pending_packets`,62 pass / 1 fail(17 个保护文件 READ_ERROR),其余 4,647 文件无命中。**完整门未绿**;本轮未重跑引擎,独立复现依据来自修复单已有验收 | +| F4 双语言一致性与页码归零 | 主会话 Node/Python 定向通过,完整真实引擎虚构 fixture 两端逐字一致、页码归零;普通投影安全复验通过 | +| F5 writer 拒绝与变异 | 主会话独立回归包含 Markdown、HTML、顶层及嵌套 factTables 拒绝与正常文字。执行代理隔离去 guard 的变异:4 个负例全红,2 个正常对照仍绿;未在共享产品文件上变异 | +| F6 打印行为 | 4 条单测通过;Chrome 153 实际组件隔离页 28/28,通过原生 printToPDF 验证打印前展开与打印后恢复。两份 A4 PDF 各 5 页、各 130/130 行完整;不是登录 E2E,也未真人操作取消对话框 | +| tsc / lint | 最终集成 tsc 0 错,lint 0 error / 119 warning;本轮新增 warning 已清理,不顺修旧 warning | +| 全量前端 | Node 22 / Windows 基线 3,686 tests / 3,595 pass / 91 fail;修复版已执行,名称及失败原因比对另列。部分文件组受 symlink 失败影响;不能拿前轮 Linux 3,730 当本轮达标证据 | +| build / Static / gzip | 本轮 webpack 编译和构建内 TypeScript 通过;收集 /api/consult、/api/birth-time-guide 配置时因 SKILL.md symlink EPERM 退出 1。完整构建未通过,Static 与 gzip 未验证,不沿用前轮结果 | +| 数据库 | 两项实际执行均在 Docker 地址池耗尽处停止,未到 SQL;不能记数据库通过,未清其他会话网络 | +| Python 定向 / quick | 主会话最终 101/101 通过。首次定向 98 通过/2 失败为新增打包测试依赖 CWD,修正 monkeypatch.chdir 后复验。F1 代理额外启动一次 quick,GBK UnicodeEncodeError 退出 1;已禁止重跑。public/commercial 未运行,完整门不能记绿 | +| 受控登录态浏览器 | 环境缺口;拟建 `docs/testing/report-density-20260922.md` 被工具拒绝,文件尚未生成,不能视为清单齐备 | +| staging 推送 / 门禁 / health | 未执行;不预写 SHA、门禁绿或部署成功 | + +### 同机全量 Node 名称与失败原因对比 + +| 指标 | 基线 | 修复版 | +| --- | ---: | ---: | +| tests | 3,686 | 3,704 | +| pass | 3,595 | 3,613 | +| fail | 91 | 91 | +| 消失的既有测试名称 | — | 0 | +| 新增测试名称 | — | 18(全部通过) | +| 新增失败 | — | 0 | + +只读核对两份本轮日志,包含缩进嵌套 TAP 名称多重集合;91 个既有失败的完整诊断逐项一致。仅归一工作树前缀、临时随机目录、Docker 项目 PID/时间、时长和临时 alias 路径段,不删除断言值、错误码、栈行或 Skill 哈希。4 个只报 test failed 的文件组另查前置 stderr,两侧同为 Skill alias EPERM。**3,704 仍低于修复单要求的 3,730;只能证明本机无名称丢失和新增失败,不能宣布完整门达标。** + +本轮 `pre_work_check` 远端 verified;focused 仍有一条既有 workbuddy 镜像路径断言失败,不记预检全绿。 + +### 打印证据与执行边界 + +证据在本轮忽略目录 `scratch/browser-f1-f6-20260923-acceptance/`:`browser-evidence.json`、`pdf-evidence.json`、`facts-default-collapsed.pdf`、`facts-mixed-state.pdf`。真实组件与完整产品 CSS,独立新建 Chrome profile,没有账号或 Cookie。8 组、11 张实际网格、130 行、545 单元格;两份 PDF 全行核对、无页面外裁切、无可见工程键/下标/超精度。非阻断排版细节:SAV 标题留在第 1 页末尾,表从第 2 页继续;未缺内容。隔离页不等于登录后完整报告或真人打印对话框验收。 + +F1 子代理额外运行 quick 时没有继承禁读 audit hook,输出有保护目录路径的清单枚举;其在 `validate_bphs_invariants.py` GBK 编码错误处停止,没有 pytest/隐私扫描启动证据。**无法保证此前子进程未读取保护目录,不能宣称这次全程禁读已获证实。** 主会话收到报告即禁止重跑,代理确认没有启动 UTF-8 重跑。不得为补门禁读取这些文件、修改隐私规则或静默跳过后记通过。 + +F3 新建真人清单时收到工具拒绝:子代理不得写 report 文件。未重试、未换工具绕过,已通知主会话;授权要求与工具边界由产品处理。拟验收项目为:新报告及刷新恢复、16 盘桌面 / 移动布局、8 组逐表与来源核对、独立附录及普通下载与修复前逐字比较、全部表折叠时按钮和 Ctrl+P 打印 / 保存 PDF、实际页数、打印完成 / 取消后恢复原开合状态、混合开合恢复,以及旧报告兼容 / 旧缓存不回填。当前无执行证据,不能勾选通过。 + +F3 文档自检已执行:限定授权文档的 `git diff --check` 通过;脚本比较确认 BUG-1008 段落与 HEAD 原样一致、BUG-1003~1010 各出现一次、BLOCKED / CHANGELOG 不再含过时的“未提交、推送”说法、PROGRESS 存在、真人清单确实仍缺。未读取 `pending_packets`,未运行完整隐私扫描、未改代码、未提交或推送。 + +上述自检不能替代代码、浏览器或部署验收;真人清单文件缺失意味着 F3 验收尚未完全满足。阻塞项统一见根目录 [BLOCKED.md](../../BLOCKED.md)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 9b5e9a2d..0b23b868 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -169,8 +169,8 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-report-density-20260922.md` | `PROGRESS-report-density-20260922.md` | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **验收未通过**(2026-09-23):任务 1/2/4/5 通过;任务 3 事实表在普通报告里显示 447 个引擎键路径(P1);隐私门新增失败(任务书自身写入真实出生资料 BUG-1008 已清 HEAD,fixture 数值碰撞待登记);BUG-1003~1007、PROGRESS、testing 清单缺失。Node 22 前端 0 新增失败、`/` Static、gzip +0.007% | 实现 `bbd96d3b`(原 `f968cb21`,09-23 为清除 BUG-1008 重写历史;已在 staging,未部署);修复单 `TASK-report-density-fix-20260923.md` | -| `TASK-report-density-fix-20260923.md` | — | **报告密度验收修复单(BUG-1009/1010 + 补写 1003~1007)**:F1 事实表改成真正的表(逐组规定列,普通报告可见格 0 个引擎键 / 0 个下标 / 最多 2 位小数,`sourcePath` 保留供回查;主运年数不得填出生剩余年数);F2 按既有机制登记 fixture 的数值碰撞(已独立重跑确认是虚构输入);F3 补 Bug 历史 / PROGRESS / 真人清单并更正「未提交推送」;F4 两份清洗规则只留一份(Python 版漏第三方页码);F5 writer 写表拦截补测试与提示;F6 事实表可打印。硬红线:不得弱化隐私扫描、不得强推或重写 staging(历史清除由产品负责人决定);Sade Sati 三轮日期是原任务书错误、不在本单 | **待领取** | — | +| `TASK-report-density-20260922.md` | `PROGRESS-report-density-20260922.md` | **报告信息密度与原始附录(BUG-1003~1007)**:与源头仓 PL9 全量报告同资料实测:对照 847,632 字符 / 8,044 表格行,我方 309,357 / 1,478。缺口不在引擎——20 张分盘全表、KP 四表、Ashtakavarga 四类、Shadbala 六分量、Avastha、Sahams、年度都已算得出,但 `personal-report-contract.ts` 的 `REPORT_SECTION_KINDS` **没有表格类型**、`CHART_IDS` 只有 9 种,这些表在产品页报告里没有落脚字段。另测出对外原始附录泄漏 925 种 / 3,797 处工程标识符(`parameter_sensitive` 697、`cmd_full_reading` 68、`PyJHora`/`JHora` 35、`PL9` 页码 59),对照物同项为 0。**产品 2026-09-22 三点拍板**:层 1 按 8 组表 + 分盘扩到 16 张;层 2 原始附录直接给 C 端且先全显示;B 类大运族要显示。**决策记录已写明推翻 BUG-999 的两条红线**(专业参考不得作普通下载 fallback;`parameter_sensitive`/「参数敏感」命中整行剔除)——**仅限附录通道**,普通正文投影不动。硬红线:表格层服务端装配、writer 不得写表;对照物的替代大运族日期与 Shadbala 分量在源头仍未闭环(506 行 date mismatch、`production_tuning_allowed=false`),不得照搬升级。让步顺序与串行依赖(任务 3 → 任务 4 同改契约)见任务书 | **验收未通过**(2026-09-23):任务 1/2/4/5 通过;任务 3 事实表在普通报告里显示 447 个引擎键路径(P1);隐私门新增失败(任务书自身写入真实出生资料 BUG-1008 已清 HEAD,fixture 数值碰撞待登记);当时 BUG-1003~1007、PROGRESS、testing 清单缺失;F3 现已补前两项,testing 新建受工具边界阻塞仍缺。Node 22 前端 0 新增失败、`/` Static、gzip +0.007% | 实现 `bbd96d3b`(原 `f968cb21`,09-23 为清除 BUG-1008 重写历史;已在 staging,未部署);修复单 `TASK-report-density-fix-20260923.md` | +| `TASK-report-density-fix-20260923.md` | `PROGRESS-report-density-20260922.md` | **报告密度验收修复单(BUG-1009/1010 + 补写 1003~1007)**:F1 事实表改成真正的表(逐组规定列,普通报告可见格 0 个引擎键 / 0 个下标 / 最多 2 位小数,`sourcePath` 保留供回查;主运年数不得填出生剩余年数);F2 按既有机制登记 fixture 的数值碰撞(已独立重跑确认是虚构输入);F3 补 Bug 历史 / PROGRESS / 真人清单并更正「未提交推送」;F4 两份清洗规则只留一份(Python 版漏第三方页码);F5 writer 写表拦截补测试与提示;F6 事实表可打印。硬红线:不得弱化隐私扫描、不得强推或重写 staging(历史清除由产品负责人决定);Sade Sati 三轮日期是原任务书错误、不在本单 | **执行中**:F1/F2/F4/F5 分工实施,F6 与独立验收由主会话负责;F3 已补 Bug / 进度 / API 断言三栏,真人清单新建被工具拒绝,文件仍缺。隐私专项因禁读保护文件尚未全绿;未写浏览器或部署通过 | `codex/report-density-fix-20260923`,基线 `0d37bec15`;BUG-1008 原样保留 | | `TASK-report-sectioned-generation-20260830.md` | `PROGRESS-report-sectioned-20260830.md` | 分章节生成 | 已合入 | 见 PROGRESS | | `TASK-report-skill-parity-20260901.md` | `PROGRESS-report-skill-parity-20260901.md` | 内容对齐 skill 解读深度 | 已验收 | `90bad10d`、`ef1bd6df` | | `TASK-report-blocked-repairs-20260902.md` | `PROGRESS-report-blocked-repairs-20260902.md` | 全主题 blocked 修复 | 已验收 | `7faf8555` | diff --git a/docs/tasks/TASK-report-density-20260922.md b/docs/tasks/TASK-report-density-20260922.md index 3bda12c6..39856af5 100644 --- a/docs/tasks/TASK-report-density-20260922.md +++ b/docs/tasks/TASK-report-density-20260922.md @@ -6,6 +6,12 @@ - 开发分支:`codex/report-density-20260922`,worktree `.worktrees/report-density-20260922` - 对照物:用户在本机提供的一份对照报告 Markdown(不入仓,路径与文件名含个人信息,不在此记录),由**源头 skill 仓** `/workspace/yinduzhanxing` 的 `scripts/pl9_bilingual_batch_export.py` → `jyotish_engine.py` 生成。它对标商业软件 Parashara's Light 9 的 204 页报告。 +## 执行状态(2026-09-23) + +原实现 `bbd96d3b` 已提交并进入 staging,但原验收未通过;当前从 `0d37bec15` 在独立工作树执行 [验收修复单](TASK-report-density-fix-20260923.md)。实施与验证见 [进度记录](PROGRESS-report-density-20260922.md)。F3 新建真人清单被工具拒绝、文件仍缺,不能记为文档齐备;无受控浏览器或本轮部署通过结论。 + +范围更正:任务 3 的 Sade Sati 源数据只有阶段框架和当前状态,没有三轮起止日期;原“已逐项确认存在”的日期部分有误,新增日期计算按修复单明确列为范围外。下文保留原事故与规格历史,不作为该能力已完成的证据。 + ## 事故实证 ### 一、对照物与我方同一组出生资料实测 diff --git a/docs/tasks/TASK-report-density-fix-20260923.md b/docs/tasks/TASK-report-density-fix-20260923.md index b804f4dc..ef74d7fe 100644 --- a/docs/tasks/TASK-report-density-fix-20260923.md +++ b/docs/tasks/TASK-report-density-fix-20260923.md @@ -7,7 +7,13 @@ - 修复分支:`codex/report-density-fix-20260923`,从 `origin/staging` 起 - 验收基线:`f77a1b5a`(实现的父提交) -## 验收结论 +## 执行状态(2026-09-23) + +- **执行中**:已 fetch 并从 `0d37bec15` 创建独立工作树 `.worktrees/report-density-fix-20260923`,分支 `codex/report-density-fix-20260923`。下方验收结论是对原实现 `bbd96d3b` 的历史验收,不是本轮修复通过。 +- F1/F2/F4/F5 分工实施;F6 与独立验收由主会话负责。F3 补 Bug 历史、进度、索引和 API 既有断言三栏;**真人清单新建被工具拒绝,文件仍不存在,F3 尚未全部完成**。不换工具绕过。 +- 实际结果与缺口见 `docs/tasks/PROGRESS-report-density-20260922.md`、`BLOCKED.md`。没有受控登录 / Chrome 证据,不预写测试成功或部署;BUG-1008 不改。 + +## 验收结论(原实现) | 项 | 结论 | 证据 | |---|---|---| diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 98aafd26..83e6a842 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -6,6 +6,8 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J 个人报告保留原 Markdown 阅读、目录隔离与 memo 边界;文后独立渲染服务端分盘和事实表。分盘复用 VedicChartSvg 与 chart-grid/chart-card,不使用 float 或负 margin。事实表默认折叠、可键盘展开,横向溢出仅在表格区域滚动,缺数据不补写。Sade Sati 明确缺三轮日期。操作区增加「下载原始附录」,有独立忙态和简短错误提示,不接普通下载 fallback、不增加 spinner。旧缓存不升级、不重算;没有结构化层就不显示占位盘。文案遵守 VOICE:不把可计算写成已验证。 +事实表用中文列名和领域行列呈现,不把引擎路径当行名。主运、分运与小运、SAV 与 BAV、年度位置与 Saham 分为同组子表;缺失的年度行星位置明确说明,不用本命盘替代。机器溯源路径保留在数据中、不渲染。打印或保存 PDF 前自动展开此核对层全部折叠组,完成或取消后恢复原状态;打印解除表格横向滚动裁剪、重复表头,宽 BAV 表仍保留十二星座列。不新增打印入口或等待动画。 + ## 校正跨午夜日期与采用(2026-09-20) 复用现有时段选择卡追问一次午夜前/后;不知道或跳过保留填报当天凌晨与深夜两段,不补白昼或次日。选项用中文钟点,继续遵守题卡禁止暴露数字钟点的现有校验,不新增入口、状态或等待动画。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3dc9523f..9eae09b7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -6,7 +6,15 @@ .personal-report-fact-tables summary { cursor: pointer; min-height: 2.75rem; } .personal-report-fact-tables table { width: 100%; border-collapse: collapse; } .personal-report-fact-tables th, .personal-report-fact-tables td { padding: 0.5rem; text-align: start; border-bottom: 1px solid var(--color-border); overflow-wrap: anywhere; } -@media print { .personal-report-fact-tables details { break-inside: auto; } } +@media print { + .personal-report-fact-tables details { break-inside: auto; padding: 0; border: 0; } + .personal-report-fact-tables summary, .personal-report-fact-tables h3 { break-after: avoid-page; } + .personal-report-fact-table-scroll { overflow: visible !important; max-width: none !important; } + .personal-report-fact-tables table { min-width: 0; table-layout: fixed; font-size: 8pt; } + .personal-report-fact-tables th, .personal-report-fact-tables td { padding: 1mm; } + .personal-report-fact-tables thead { display: table-header-group; } + .personal-report-fact-tables tr { break-inside: avoid; } +} @theme inline { diff --git a/frontend/src/components/personal-report/report-fact-tables.tsx b/frontend/src/components/personal-report/report-fact-tables.tsx index c602f55c..ac41ea76 100644 --- a/frontend/src/components/personal-report/report-fact-tables.tsx +++ b/frontend/src/components/personal-report/report-fact-tables.tsx @@ -1,7 +1,8 @@ "use client"; -import { memo } from "react"; +import { memo, useEffect, useRef } from "react"; import type { ReportFactTable } from "@/lib/report-fact-table-schema"; +import { bindFactTablePrint } from "@/lib/report-fact-table-print"; const STATUS_LABELS = { multi_system_consensus: "多系统共识", @@ -12,28 +13,45 @@ const STATUS_LABELS = { blocked: "暂不可用", } as const; +function FactTable({ table }: { table: Pick }) { + if (!table.rows.length) return null; + return ( +
+ + + {table.columns.map((column) => )} + {table.rows.map((row) => ( + {row.cells.map((cell, index) => ( + + ))} + ))} +
{table.title}
{column}
{cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : typeof cell === "number" ? String(Number(cell.toFixed(2))) : cell}
+
+ ); +} + /** Server-owned facts, separate from the ordinary Markdown and its TOC state. */ export const ReportFactTables = memo(function ReportFactTables({ tables }: { tables: readonly ReportFactTable[] }) { + const root = useRef(null); + useEffect(() => { + if (root.current) return bindFactTablePrint(root.current, window); + }, []); + return ( -
+

计算数据核对表

{tables.map((table) => (
{table.title} · {STATUS_LABELS[table.claimStatus]}

{table.note}

- {table.rows.length > 0 && ( -
- - - {table.columns.map((column) => )} - {table.rows.map((row) => ( - {row.cells.map((cell, index) => ( - - ))} - ))} -
{table.title}
{column}
{cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : String(cell)}
-
- )} + + {table.subtables?.map((subtable) => ( +
+

{subtable.title}

+ {subtable.note &&

{subtable.note}

} + +
+ ))}
))}
diff --git a/frontend/src/lib/reader-appendix-language.ts b/frontend/src/lib/reader-appendix-language.ts index ceca5483..db96f4d6 100644 --- a/frontend/src/lib/reader-appendix-language.ts +++ b/frontend/src/lib/reader-appendix-language.ts @@ -1,27 +1,32 @@ -/** Cache compatibility for the Python reader-only vocabulary projection. */ -const STATUS_LABELS: Readonly> = { - parameter_sensitive: "参数敏感", - pyjhora_behavior_only: "仅单一外部参照,未做多引擎核对", - not_multiengine_parity: "仅单一外部参照,未做多引擎核对", - unresolved_external_tuple_boundary: "外部边界未对齐", - partial_verified: "部分核验", - raw_appendix_only: "仅原始附录可见", - missing_in_local: "本地暂无", - internal_reference_omitted: "内部参照已省略", -}; -const CELL_LABELS: Readonly> = { - blocked: "暂不可用", executed: "已执行", available: "可用", computed: "已计算", -}; +import vocabulary from "../../../scripts/reader_appendix_language.rules.json"; + +type VocabularyRule = Readonly<{ + pattern: string; + ignoreCase?: boolean; + replacement?: string; + labels?: Readonly>; + labelGroup?: number; + prefix?: string; + beforeGroups?: readonly number[]; + afterGroups?: readonly number[]; +}>; + +// One rule source for Python and TS; the JSON ships in both existing images. +const chartFence = new RegExp(vocabulary.chartFencePattern, "m"); +const replacements = (vocabulary.rules as readonly VocabularyRule[]).map((rule) => ({ + rule, + pattern: new RegExp(rule.pattern, rule.ignoreCase ? "gi" : "g"), +})); export function cleanReaderAppendixMarkdown(markdown: string): string { - return markdown.split(/(^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$))/m) - .map((part, index) => index % 2 ? part : part - .replace(new RegExp(`\\b(${Object.keys(STATUS_LABELS).join("|")})\\b`, "g"), (value) => STATUS_LABELS[value]) - .replace(/\b(?:jyotish_engine\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\b/g, "本地计算") - .replace(/\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\.v\d+)?\b/gi, "外部参照资料") - .replace(/\bPL9\b(?:\.pdf)?(?:[ \t]*(?:第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页|(?:pages?|p)[ \t-]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*))?/gi, "外部参照资料") - .replace(/\b(?:PyJHora|JHora)\b/gi, "外部参照引擎") - .replace(/\|([ \t]*)(`?)(blocked|executed|available|computed)\2([ \t]*)(?=\|)/g, - (_match, before: string, quote: string, value: string, after: string) => `|${before}${quote}${CELL_LABELS[value]}${quote}${after}`)) - .join(""); + return markdown.split(chartFence).map((part, index) => { + if (index % 2) return part; + return replacements.reduce((value, { pattern, rule }) => value.replace(pattern, (...match) => { + if (rule.replacement !== undefined) return rule.replacement; + return (rule.prefix ?? "") + + (rule.beforeGroups ?? []).map((group) => match[group]).join("") + + rule.labels![match[rule.labelGroup!]] + + (rule.afterGroups ?? []).map((group) => match[group]).join(""); + }), part); + }).join(""); } diff --git a/frontend/src/lib/report-fact-table-print.ts b/frontend/src/lib/report-fact-table-print.ts new file mode 100644 index 00000000..b86b4521 --- /dev/null +++ b/frontend/src/lib/report-fact-table-print.ts @@ -0,0 +1,21 @@ +/** Expand only this report's disclosures; restore the reader's state after print/cancel. */ +export function bindFactTablePrint(root: HTMLElement, target: Pick): () => void { + const states = new Map(); + const beforePrint = () => { + root.querySelectorAll("details").forEach((details) => { + if (!states.has(details)) states.set(details, details.open); + details.open = true; + }); + }; + const afterPrint = () => { + states.forEach((open, details) => { details.open = open; }); + states.clear(); + }; + target.addEventListener("beforeprint", beforePrint); + target.addEventListener("afterprint", afterPrint); + return () => { + target.removeEventListener("beforeprint", beforePrint); + target.removeEventListener("afterprint", afterPrint); + afterPrint(); + }; +} diff --git a/frontend/src/lib/report-fact-table-schema.ts b/frontend/src/lib/report-fact-table-schema.ts index c9cf61b1..f07b7af3 100644 --- a/frontend/src/lib/report-fact-table-schema.ts +++ b/frontend/src/lib/report-fact-table-schema.ts @@ -2,12 +2,7 @@ import { z } from "zod"; export const FACT_TABLE_GROUPS = ["vimshottari", "ashtakavarga", "shadbala", "functional_roles", "avasthas", "special_points", "sade_sati", "annual"] as const; const sourcePath = z.string().min(1).max(400).regex(/^[A-Za-z0-9_.\[\]-]+$/); - -/** Immutable server facts; never part of a writer output schema. */ -export const reportFactTableSchema = z.object({ - id: z.enum(FACT_TABLE_GROUPS), - title: z.string().min(1).max(120), - claimStatus: z.enum(["multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked"]), +const gridShape = { sourcePath, note: z.string().max(500), columns: z.array(z.string().min(1).max(80)).min(1).max(16), @@ -15,13 +10,37 @@ export const reportFactTableSchema = z.object({ sourcePath, cells: z.array(z.union([z.string().max(300), z.number().finite(), z.boolean(), z.null()])).min(1).max(16), }).strict()).max(2000), -}).strict().superRefine((table, context) => { +}; +const within = (child: string, parent: string) => child === parent || child.startsWith(`${parent}.`) || child.startsWith(`${parent}[`); +function validateRows(table: { sourcePath: string; columns: string[]; rows: { sourcePath: string; cells: unknown[] }[] }, context: z.RefinementCtx) { table.rows.forEach((row, index) => { if (row.cells.length !== table.columns.length) context.addIssue({ code: "custom", path: ["rows", index], message: "fact_table_cell_count" }); - if (row.sourcePath !== table.sourcePath && !row.sourcePath.startsWith(`${table.sourcePath}.`) && !row.sourcePath.startsWith(`${table.sourcePath}[`)) context.addIssue({ code: "custom", path: ["rows", index, "sourcePath"], message: "fact_table_source_mismatch" }); + if (!within(row.sourcePath, table.sourcePath)) context.addIssue({ code: "custom", path: ["rows", index, "sourcePath"], message: "fact_table_source_mismatch" }); + }); +} +export const reportFactSubtableSchema = z.object({ + id: z.string().min(1).max(80).regex(/^[a-z][a-z0-9_-]*$/), + title: z.string().min(1).max(120), + ...gridShape, +}).strict().superRefine(validateRows); + +/** Optional subtables are additive: persisted v2 snapshots keep their original rows. */ +export const reportFactTableSchema = z.object({ + id: z.enum(FACT_TABLE_GROUPS), + title: z.string().min(1).max(120), + claimStatus: z.enum(["multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked"]), + ...gridShape, + subtables: z.array(reportFactSubtableSchema).max(8).optional(), +}).strict().superRefine((table, context) => { + validateRows(table, context); + const subtables = table.subtables ?? []; + if (new Set(subtables.map(subtable => subtable.id)).size !== subtables.length) context.addIssue({ code: "custom", path: ["subtables"], message: "duplicate_fact_subtable" }); + subtables.forEach((subtable, index) => { + if (!within(subtable.sourcePath, table.sourcePath)) context.addIssue({ code: "custom", path: ["subtables", index, "sourcePath"], message: "fact_table_source_mismatch" }); }); }); export const reportFactTablesSchema = z.array(reportFactTableSchema).max(8).superRefine((tables, context) => { if (new Set(tables.map((table) => table.id)).size !== tables.length) context.addIssue({ code: "custom", message: "duplicate_fact_table" }); }); export type ReportFactTable = z.infer; +export type ReportFactSubtable = z.infer; diff --git a/frontend/src/lib/report-fact-table-values.ts b/frontend/src/lib/report-fact-table-values.ts new file mode 100644 index 00000000..b3a8faf7 --- /dev/null +++ b/frontend/src/lib/report-fact-table-values.ts @@ -0,0 +1,36 @@ +import { findForbiddenContent } from "./personal-report-contract"; +import { PLANET_ZH, SIGN_ORDER, SIGN_ZH, type PlanetName, type SignName } from "./chart-view-labels"; + +export const record = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value); +export const entries = (value: unknown) => Object.entries(record(value)).filter(([key]) => /^[A-Za-z0-9_-]+$/.test(key)); +export const list = (value: unknown): unknown[] => Array.isArray(value) ? value : []; +export const decimal = (value: unknown): string | null => typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : null; +export const yesNo = (value: unknown): string | null => typeof value === "boolean" ? value ? "是" : "否" : null; +/** Display-only allowlist: unknown/internal labels never become prose. */ +export const readable = (value: unknown): string | null => typeof value === "string" && value.length > 0 && value.length <= 300 + && !/[_<>\[\]{}\/\\]|https?:|\b\w+\.\w+\b/.test(value) && !findForbiddenContent(value).length ? value : null; +export const date = (value: unknown): string | null => typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value) ? value.slice(0, 10) : null; +export function planet(value: unknown, chinese?: unknown): string | null { + const label = readable(chinese); + if (label && /[㐀-鿿]/.test(label)) return label; + return typeof value === "string" ? PLANET_ZH[value as PlanetName] ?? (value === "Lagna" ? "上升" : null) : null; +} +export function sign(value: unknown, chinese?: unknown, index?: unknown): string | null { + const label = readable(chinese); + if (label && /[㐀-鿿]/.test(label)) return label; + if (typeof value === "string") { + const mapped = SIGN_ZH[value as SignName]; + if (mapped) return mapped; + if (Object.values(SIGN_ZH).some(name => value === name || value === `${name}座`)) return value; + } + return typeof index === "number" && Number.isInteger(index) && index >= 0 && index < 12 ? SIGN_ZH[SIGN_ORDER[index]] : null; +} +export const position = (value: unknown) => { + const raw = record(value); + return [sign(raw.sign, raw.sign_cn, raw.sign_idx), decimal(raw.degree_in_sign ?? raw.sign_degree)] as const; +}; +export const isBlocked = (value: unknown) => { + const raw = record(value); + return raw.status === "blocked" || raw.status === "missing_in_local" || raw.claimStatus === "blocked" || raw.claim_status === "blocked"; +}; diff --git a/frontend/src/lib/report-fact-tables.ts b/frontend/src/lib/report-fact-tables.ts index 26b78179..c57d9225 100644 --- a/frontend/src/lib/report-fact-tables.ts +++ b/frontend/src/lib/report-fact-tables.ts @@ -1,128 +1,138 @@ -import { findForbiddenContent } from "./personal-report-contract"; -import { reportFactTablesSchema, type ReportFactTable } from "./report-fact-table-schema"; +import { reportFactTablesSchema, type ReportFactTable, type ReportFactSubtable } from "./report-fact-table-schema"; +import { SIGN_ORDER, SIGN_ZH } from "./chart-view-labels"; +import { at, date, decimal, entries, isBlocked, list, planet, position, readable, record, sign, yesNo } from "./report-fact-table-values"; -type RecordValue = Record; -const record = (value: unknown): RecordValue => value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; -const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value); -const safeCell = (value: unknown): string | number | boolean | null => { - if (typeof value === "number") return Number.isFinite(value) ? value : null; - if (typeof value === "boolean") return value; - if (typeof value === "string" && value.length <= 300 && !findForbiddenContent(value).length) return value; - return null; -}; const SOURCES = [ - ["vimshottari", "Vimshottari 主运与分运", "timing_and_predictive_systems.dasha", "dasha"], - ["ashtakavarga", "SAV / BAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga"], - ["shadbala", "Shadbala 六分量", "strengths_and_scores.shadbala", "shadbala"], - ["functional_roles", "功能吉凶与宫主", "strengths_and_scores.functional_benefic_malefic", "functional_benefic_malefic"], - ["avasthas", "Avastha 行星状态", "advanced_systems.avasthas", "avasthas"], - ["special_points", "附属星点与特殊上升点", "divisional_and_special_charts", ""], - ["sade_sati", "Sade Sati 阶段框架", "advanced_systems.yogas_doshas.sade_sati", "yogas_doshas.sade_sati"], - ["annual", "年度回归与 Saham", "timing_and_predictive_systems.annual_tajika_pack", "annual_tajika_pack"], + ["vimshottari", "Vimshottari 主运", "timing_and_predictive_systems.dasha", "dasha", ["主运", "起", "止", "年数", "当前"]], + ["ashtakavarga", "SAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga", ["星座", "SAV 点数"]], + ["shadbala", "Shadbala 六分量", "strengths_and_scores.shadbala", "shadbala", ["行星", "位置力", "方向力", "时间力", "动态力", "自然力", "相位力", "合计"]], + ["functional_roles", "功能吉凶", "strengths_and_scores.functional_benefic_malefic", "functional_benefic_malefic", ["类别", "行星"]], + ["avasthas", "Avastha 行星状态", "advanced_systems.avasthas", "avasthas", ["行星", "Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"]], + ["special_points", "附属星点与特殊上升点", "divisional_and_special_charts", "", ["名称", "星座", "度数"]], + ["sade_sati", "Sade Sati 阶段框架", "advanced_systems.yogas_doshas.sade_sati", "yogas_doshas.sade_sati", ["阶段", "星座"]], + ["annual", "年度盘", "timing_and_predictive_systems.annual_tajika_pack", "annual_tajika_pack", ["行星", "星座", "度数"]], ] as const; +const NOTE = "原始计算供核对,不增加结论的确定性。"; +const EMPTY = "本次没有可展示的数据,不补算、不补写。"; +const POINT_NAMES: Record = { + Kaala: "Kaala", Mrityu: "Mrityu", Artha_Praharaka: "Artha Praharaka", Yama_Ghantaka: "Yama Ghantaka", + Gulika: "Gulika", Maandi: "Maandi", Dhuma: "Dhuma", Vyatipata: "Vyatipata", Parivesha: "Parivesha", Indrachapa: "Indrachapa", Upaketu: "Upaketu", + Bhava_Lagna: "Bhava Lagna", Hora_Lagna: "Hora Lagna", Ghati_Lagna: "Ghati Lagna", Arudha_Lagna: "Arudha Lagna", Upapada_Lagna: "Upapada Lagna", A10_Karma_Pada: "A10 Karma Pada", bhrigu_bindu: "Bhrigu Bindu", +}; +type Grid = Pick; +const add = (grid: Grid, sourcePath: string, cells: (string | null)[]) => grid.rows.push({ sourcePath, cells }); +const subtable = (id: string, title: string, sourcePath: string, columns: string[]): ReportFactSubtable => ({ id, title, sourcePath, columns, rows: [], note: NOTE }); -/** Explicit leaves only: no interpretations, remedies, profile, audit or producer text. */ -const SCALAR_KEYS = new Set([ - "longitude", "degree", "degree_in_sign", "sign_degree", "sign", "sign_idx", "sign_index", "house", - "state", "degree_range", "strength", "lord", "start", "end", "is_current", "years", - "dig_bala", "chesta_bala", "naisargika_bala", "drik_bala", "total", "total_virupas", "total_rupas", - "is_active", "phase", "moon_sign", "rising", "peak", "setting", "jd_ut", "dt_ut", "dt_local", "sun_lon", -]); - +/** Server-owned, explicit presentation columns; provenance is never a visible cell. */ export function assembleReportFactTables(packet: unknown): ReportFactTable[] { const root = record(packet); const worksheets = record(root.worksheets); const modules = record(record(root.chart).modules ?? root.modules); const tables: ReportFactTable[] = []; - for (const [id, title, key, moduleKey] of SOURCES) { + for (const [id, title, key, moduleKey, columns] of SOURCES) { const worksheetValue = at(worksheets, key); const rootValue = at(root, key); const source = worksheetValue ?? rootValue ?? (moduleKey ? at(modules, moduleKey) : modules); - if (!source || !Object.keys(record(source)).length) continue; - const sourcePath = worksheetValue !== undefined ? `worksheets.${key}` : rootValue !== undefined ? key + if (!Object.keys(record(source)).length) continue; + const sourcePath = worksheetValue != null ? `worksheets.${key}` : rootValue != null ? key : `${root.chart ? "chart." : ""}modules${moduleKey ? `.${moduleKey}` : ""}`; const raw = record(source); const declared = raw.claimStatus ?? raw.claim_status; - // No new certainty: retain declared limitations; unspecified calculations stay sensitive. - const claimStatus = raw.status === "blocked" || raw.status === "missing_in_local" || declared === "blocked" ? "blocked" + const claimStatus = isBlocked(raw) ? "blocked" : declared === "unclosed_divisional_chart" || declared === "user_history_verification_required" || declared === "single_system_inference" || declared === "multi_system_consensus" ? declared : "parameter_sensitive"; - const rows: ReportFactTable["rows"] = []; - const add = (path: string, label: string, value: unknown) => { - const cell = safeCell(value); - if (cell !== null) rows.push({ sourcePath: path, cells: [label, cell] }); - }; - const walk = (value: unknown, path: string, label: string, allScalars = false, depth = 0) => { - if (depth > 10) return; - if (Array.isArray(value)) { - value.forEach((entry, index) => walk(entry, `${path}[${index}]`, `${label}[${index}]`, allScalars, depth + 1)); - } else if (value && typeof value === "object") { - Object.entries(record(value)).forEach(([name, entry]) => { - if (!/^[A-Za-z0-9_-]+$/.test(name)) return; - if (SCALAR_KEYS.has(name) || allScalars || name === "avasthas" || ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi", "sthana_bala", "kala_bala"].includes(name)) { - walk(entry, `${path}.${name}`, `${label}.${name}`, allScalars, depth + 1); - } - }); - } else add(path, label, value); - }; - const pick = (path: string, allScalars = false) => { - const value = at(source, path); - if (value !== undefined) walk(value, `${sourcePath}.${path}`, path, allScalars); - }; + const table: ReportFactTable = { id, title, claimStatus, sourcePath, note: NOTE, columns: [...columns], rows: [] }; + const children: ReportFactSubtable[] = []; if (claimStatus !== "blocked") { if (id === "vimshottari") { - const timeline = Array.isArray(raw.timeline) ? raw.timeline : []; - timeline.forEach((period, index) => { + list(raw.timeline).forEach((item, index) => { + const period = record(item); const path = `${sourcePath}.timeline[${index}]`; - walk(period, path, `MD[${index}]`); - if (record(period).is_current === true) { - const ad = record(period).antardasha_timeline; - if (Array.isArray(ad)) ad.forEach((entry, ai) => { - walk(entry, `${path}.antardasha_timeline[${ai}]`, `AD[${ai}]`); - const pd = record(entry).pratyantar_dasha_timeline; - if (Array.isArray(pd)) pd.forEach((item, pi) => walk(item, `${path}.antardasha_timeline[${ai}].pratyantar_dasha_timeline[${pi}]`, `AD[${ai}].PD[${pi}]`)); + if (!Object.keys(period).length || isBlocked(period)) return; + // `years` can mean the birth balance. Never infer the full duration from dates. + add(table, path, [planet(period.lord, period.lord_cn), date(period.start), date(period.end), decimal(period.full_years), yesNo(period.is_current)]); + if (period.is_balance === true && decimal(period.balance_years) !== null) table.note += ` 出生时${planet(period.lord, period.lord_cn) ?? "首段主运"}剩余 ${decimal(period.balance_years)} 年。`; + if (period.is_current !== true) return; + const ad = subtable("antardasha", "当前主运下分运", path, ["分运", "起", "止", "当前"]); + list(period.antardasha_timeline).forEach((entry, ai) => { + const part = record(entry); + const partPath = `${path}.antardasha_timeline[${ai}]`; + if (!Object.keys(part).length || isBlocked(part)) return; + add(ad, partPath, [planet(part.lord, part.lord_cn), date(part.start), date(part.end), yesNo(part.is_current)]); + if (part.is_current !== true) return; + const pd = subtable("pratyantar", "当前分运下小运", partPath, ["小运", "起", "止", "当前"]); + list(part.pratyantar_dasha_timeline).forEach((value, pi) => { + const small = record(value); + if (Object.keys(small).length && !isBlocked(small)) add(pd, `${partPath}.pratyantar_dasha_timeline[${pi}]`, [planet(small.lord, small.lord_cn), date(small.start), date(small.end), yesNo(small.is_current)]); }); - } + children.push(pd); + }); + children.unshift(ad); }); } else if (id === "ashtakavarga") { - pick("sav.scores", true); - Object.keys(record(raw.bav)).forEach((planet) => pick(`bav.${planet}.bindus`, true)); + for (const [name, score] of entries(at(raw, "sav.scores"))) { + const label = sign(name); + if (label) add(table, `${sourcePath}.sav.scores.${name}`, [label, decimal(score)]); + } + const bav = subtable("bav", "BAV 八分力", `${sourcePath}.bav`, ["行星", ...SIGN_ORDER.map(name => SIGN_ZH[name])]); + for (const [name, value] of entries(raw.bav)) { + if (!planet(name) || isBlocked(value)) continue; + const bindus = list(record(value).bindus); + add(bav, `${sourcePath}.bav.${name}`, [planet(name), ...SIGN_ORDER.map((_, index) => decimal(bindus[index]))]); + } + if (raw.bav != null) children.push(bav); } else if (id === "shadbala") { - Object.keys(record(raw.planets)).forEach((planet) => { - for (const component of ["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala"]) pick(`planets.${planet}.${component}`); - }); + for (const [name, value] of entries(raw.planets)) { + if (!planet(name) || isBlocked(value)) continue; + add(table, `${sourcePath}.planets.${name}`, [planet(name), ...["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala", "total_virupas"].map(field => decimal(at(value, field)))]); + } } else if (id === "functional_roles") { - for (const bucket of ["functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses"]) pick(bucket, true); + for (const [bucket, label] of [["functional_benefics", "功能吉星"], ["functional_malefics", "功能凶星"], ["functional_neutrals", "中性"], ["yogakarakas", "Yogakaraka"]]) { + list(raw[bucket]).forEach((name, index) => { + if (planet(name)) add(table, `${sourcePath}.${bucket}[${index}]`, [label, planet(name)]); + }); + } } else if (id === "avasthas") { - Object.keys(raw).forEach((planet) => pick(`${planet}.avasthas`)); + for (const [name, value] of entries(raw)) { + if (!planet(name) || isBlocked(value)) continue; + add(table, `${sourcePath}.${name}`, [planet(name, record(value).planet_cn), ...["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => readable(at(value, `avasthas.${kind}.state`)))]); + } } else if (id === "special_points") { for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) { - Object.keys(record(at(source, kind))).forEach((name) => pick(`${kind}.${name}`)); + if (isBlocked(at(source, kind.split(".")[0]))) continue; + for (const [name, value] of entries(at(source, kind))) { + if (POINT_NAMES[name] && !isBlocked(value)) add(table, `${sourcePath}.${kind}.${name}`, [POINT_NAMES[name], ...position(value)]); + if (name === "sarpa_drekkana") for (const [body, point] of entries(value)) { + if (planet(body) && !isBlocked(point)) add(table, `${sourcePath}.${kind}.${name}.${body}`, [`Sarpa Drekkana · ${planet(body)}`, ...position(point)]); + } + } } } else if (id === "sade_sati") { - for (const field of ["is_active", "phase", "moon_sign", "phases"]) pick(field); - } else if (id === "annual") { - pick("solar_return.data"); - for (const field of ["asc_sign", "asc_sign_idx", "age"]) { - const path = `annual_chart.data.${field}`; - add(`${sourcePath}.${path}`, path, at(source, path)); + for (const [phase, label] of [["rising", "上升"], ["peak", "高峰"], ["setting", "下降"]]) { + if (at(raw, `phases.${phase}`) !== undefined) add(table, `${sourcePath}.phases.${phase}`, [label, sign(at(raw, `phases.${phase}`))]); } - Object.keys(record(at(source, "annual_chart.data.planets"))).forEach((planet) => pick(`annual_chart.data.planets.${planet}`)); - Object.keys(record(at(source, "sahams.data"))).forEach((point) => pick(`sahams.data.${point}`)); - const sahamPath = worksheetValue !== undefined ? "worksheets.advanced_systems.sahams" - : rootValue !== undefined ? "advanced_systems.sahams" : `${root.chart ? "chart." : ""}modules.sahams`; - const sahams = record(rootValue !== undefined && worksheetValue === undefined ? undefined : at(root, sahamPath)); - Object.entries(sahams).forEach(([name, point]) => { - if (/^[A-Za-z0-9_]+_saham$/.test(name)) walk(point, `${sahamPath}.${name}`, `sahams.${name}`); - }); + table.note = `当前是否在进行中:${yesNo(raw.is_active) ?? "本次未返回"}。这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。`; + } else if (id === "annual") { + const annual = record(at(raw, "annual_chart.data")); + if (!isBlocked(raw.annual_chart)) { + table.note += ` 年度上升:${sign(annual.asc_sign, annual.asc_sign_cn, annual.asc_sign_idx) ?? "本次未返回"};年龄:${decimal(annual.age) ?? "本次未返回"}。`; + for (const [name, value] of entries(annual.planets)) { + if (planet(name) && !isBlocked(value)) add(table, `${sourcePath}.annual_chart.data.planets.${name}`, [planet(name, record(value).name_cn), ...position(value)]); + } + } + if (!table.rows.length) table.note += " 本次未返回年度行星位置,不补算。"; + // Annual Sahams only: do not relabel natal advanced_systems.sahams as annual facts. + const saham = subtable("sahams", "年度 Saham", `${sourcePath}.sahams.data`, ["名称", "星座", "度数"]); + if (!isBlocked(raw.sahams)) for (const [name, value] of entries(at(raw, "sahams.data"))) { + const label = readable(record(value).name); + if (name.endsWith("_saham") && label && !isBlocked(value)) add(saham, `${saham.sourcePath}.${name}`, [label, ...position(value)]); + } + if (at(raw, "sahams.data") != null) children.push(saham); } } - const tableSourcePath = id === "annual" ? (worksheetValue !== undefined ? "worksheets" - : rootValue !== undefined ? "timing_and_predictive_systems" : `${root.chart ? "chart." : ""}modules`) : sourcePath; - tables.push({ id, title, claimStatus, sourcePath: tableSourcePath, - note: id === "sade_sati" ? "这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。" - : rows.length ? "原始计算供核对,不增加结论的确定性。" : "本次没有可展示的数据,不补算、不补写。", - columns: ["原始字段", "数值 / 状态"], rows }); + if (children.length) table.subtables = children.map(child => ({ ...child, note: child.rows.length ? child.note : EMPTY })); + if (claimStatus === "blocked" || (!table.rows.length && !children.length && id !== "sade_sati" && id !== "annual")) table.note = EMPTY; + tables.push(table); } return reportFactTablesSchema.parse(tables); } diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index 2c9019ed..fcfbf30e 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -186,6 +186,7 @@ Truth boundaries are hard output contracts: Structure rules: - Produce exactly the JSON object described by the requested output schema. No Markdown fences, no commentary, no hidden fields. +- 不要输出表格,表格由系统提供。正文只写散文,不要输出 Markdown 表格、HTML table 或 factTables 字段。 - executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims. - Write only thematic sections whose plan disposition is write. Section id, theme, and evidenceRefs must exactly match the plan entry. Every narrative conclusion must trace to the matching claimCard. Never output a blocked plan entry; the server creates those disclosures deterministically. - Keep actions concrete and cautious; caveats must state limits honestly. diff --git a/frontend/tests/personal-report-writer-table-guard.test.ts b/frontend/tests/personal-report-writer-table-guard.test.ts new file mode 100644 index 00000000..8855ab18 --- /dev/null +++ b/frontend/tests/personal-report-writer-table-guard.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Agent } from "@mastra/core/agent"; +import { validatePersonalReportAgentOutputAgainstPlan } from "../src/lib/personal-report-generation.ts"; +import type { PersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts"; +import type { ReportEvidenceBundleV2 } from "../src/lib/report-evidence-bundle-v2.ts"; +import { createPersonalReportAgent, type PersonalReportAgentOutput } from "../src/mastra/personal-report.ts"; + +// Writer-boundary unit inputs contain no astrological facts or fabricated engine payload. +const plan: PersonalReportSectionPlan = { schemaVersion: "personal_report_section_plan.v1", depth: "standard", sections: [] }; +const bundle = { claimCards: [] } as unknown as ReportEvidenceBundleV2; +function prose(): PersonalReportAgentOutput { + return { + executiveSummary: { headline: "先把已有安排理清", summary: "把要做的事写下来,再安排下一步。", priorities: ["核对现有安排"] }, + thematicNarrative: [], + }; +} + +const forbiddenCases = { + "Markdown table": () => ({ ...prose(), executiveSummary: { ...prose().executiveSummary, summary: "| 项目 | 说明 |\n| --- | --- |\n| 安排 | 待核对 |" } }), + "HTML table in nested array": () => ({ ...prose(), executiveSummary: { ...prose().executiveSummary, priorities: ['
待核对
'] } }), + "factTables top-level key": () => ({ ...prose(), factTables: [] }), + "factTables nested key": () => ({ ...prose(), executiveSummary: { ...prose().executiveSummary, factTables: [] } }), +}; +for (const [name, output] of Object.entries(forbiddenCases)) { + test(`writer rejects ${name} with the exact fact-table error code`, () => { + assert.throws(() => validatePersonalReportAgentOutputAgainstPlan(output(), plan, bundle), { + name: "Error", message: "report_writer_fact_tables_forbidden", + }); + }); +} + +test("writer accepts normal prose unchanged, including a non-table inline pipe", () => { + const output = { ...prose(), executiveSummary: { + ...prose().executiveSummary, + summary: prose().executiveSummary.summary + "安排 A | 安排 B 只是两种写法,不是表格。", + } }; + assert.strictEqual(validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle), output); +}); + +test("actual writer agent instructions prohibit tables and assign them to the system", async (t) => { + let calls = 0; + t.mock.method(Agent.prototype, "generate", async function (this: Agent) { + calls++; + const instructions = await this.getInstructions(); + assert.match(String(instructions), /不要输出表格,表格由系统提供/); + assert.match(String(instructions), /Markdown 表格、HTML table 或 factTables 字段/); + return { object: prose(), usage: { inputTokens: 1, outputTokens: 1 }, finishReason: "stop" }; + }); + const writer = createPersonalReportAgent({ + id: "writer-table-contract", label: "Writer test", description: "Writer test", creditCost: 0, + isDefault: false, mode: "openai", + model: { specificationVersion: "v2", provider: "writer-test", modelId: "writer-test" } as never, + }); + assert.deepEqual(await writer.generate(bundle, plan), prose()); + assert.equal(calls, 1); +}); diff --git a/frontend/tests/reader-appendix-language-parity.test.ts b/frontend/tests/reader-appendix-language-parity.test.ts new file mode 100644 index 00000000..be613a1e --- /dev/null +++ b/frontend/tests/reader-appendix-language-parity.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { cleanReaderAppendixMarkdown } from "../src/lib/reader-appendix-language.ts"; + +const golden = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8")); +// Vocabulary-only cases, not invented calculation fixtures. +const pageCases = [ + "PL9 第 42 页", "PL9第42页", "PL9.pdf 第 42 页", "PL9.pdf第42页后文", + "参见PL9 第 42 页。", "PL9 第 43–44 页", "PL9 第 40 / 48 页", + "PL9 pages 99-100", "PL9 p-42", "PL9第42页中的原始字段", +]; +const chartFence = ' ```jyotish-chart\r\n{"title":"PL9 第 42 页 parameter_sensitive","id":"D1"}\r\n ```\r\n'; +const copyCases = [ + ...pageCases, + "中文parameter_sensitive中文 PyJHora中文 cmd_full_reading后文", + "some_parameter_sensitive_field PL99 XPL9 PL9_suffix", + "The blocked / executed / available / computed result is not a claim.", + "| blocked | `executed` | available | computed | sign_cn | 12.50 |\n", + `parameter_sensitive\r\n${chartFence}| parameter_sensitive |\r\n`, +]; + +function pythonClean(inputs: string[]): string[] { + const script = [ + "import json, sys", + "from scripts.reader_appendix_language import clean_reader_appendix_markdown", + "sys.stdout.write(json.dumps([clean_reader_appendix_markdown(value) for value in json.load(sys.stdin)], ensure_ascii=False))", + ].join("\n"); + const result = spawnSync(process.env.PYTHON ?? (process.platform === "win32" ? "python" : "python3"), ["-c", script], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + encoding: "utf8", input: JSON.stringify(inputs), maxBuffer: 16 * 1024 * 1024, + env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONDONTWRITEBYTECODE: "1" }, + }); + assert.equal(result.status, 0, `${result.error ?? ""}\n${result.stderr}`); + return JSON.parse(result.stdout); +} + +test("appendix Python and TS share byte-identical golden and Chinese-boundary output", () => { + assert.equal(golden.fixtureProvenance.fictional, true); + const inputs = [golden.markdown, golden.reader_dasha_applicability, ...copyCases] as string[]; + const python = pythonClean(inputs); + for (const [index, input] of inputs.entries()) { + const ts = cleanReaderAppendixMarkdown(input); + assert.deepEqual(Buffer.from(python[index], "utf8"), Buffer.from(ts, "utf8"), `case ${index}`); + assert.equal(cleanReaderAppendixMarkdown(ts), ts, `idempotent case ${index}`); + } + assert.doesNotMatch(python[0], /第\s*\d+(?:\s*[–—/-]\s*\d+)*\s*页/); + assert.doesNotMatch(cleanReaderAppendixMarkdown(golden.markdown), /第\s*\d+(?:\s*[–—/-]\s*\d+)*\s*页/); +}); + +test("appendix page references disappear without swallowing adjacent Chinese prose", () => { + for (const source of pageCases) { + const output = cleanReaderAppendixMarkdown(source); + assert.doesNotMatch(output, /PL9|第\s*\d+|pages?\s*\d+|p-42/); + } + assert.equal(cleanReaderAppendixMarkdown("参见PL9第42页中的原始字段"), "参见外部参照资料中的原始字段"); + assert.equal(cleanReaderAppendixMarkdown("PL9.pdf第42页后文"), "外部参照资料后文"); + assert.equal(cleanReaderAppendixMarkdown("some_parameter_sensitive_field PL99 XPL9"), "some_parameter_sensitive_field PL99 外部参照资料"); +}); + +test("appendix golden preserves line and table shape, field names, numeric cells and chart bytes", () => { + const raw = golden.markdown as string; + const clean = cleanReaderAppendixMarkdown(raw); + const sourceLines = raw.split("\n"); + const cleanLines = clean.split("\n"); + assert.equal(cleanLines.length, sourceLines.length); + for (const [index, line] of sourceLines.entries()) { + if (!line.startsWith("|")) continue; + const cells = line.split("|"); + const outputCells = cleanLines[index].split("|"); + assert.equal(outputCells.length, cells.length, `table columns at line ${index}`); + cells.forEach((cell, column) => { + if (/^\s*-?\d+(?:\.\d+)?\s*$/.test(cell)) assert.equal(outputCells[column], cell); + }); + } + for (const field of ["sign_cn", "degree_in_sign", "nakshatra_lord"]) { + assert.equal(clean.split(field).length, raw.split(field).length); + } + const fences = (text: string) => [...text.matchAll(/^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$)/gm)].map(match => Buffer.from(match[0])); + assert.deepEqual(fences(clean), fences(raw)); + assert.deepEqual(fences(cleanReaderAppendixMarkdown(copyCases.at(-1)!)), [Buffer.from(chartFence)]); +}); diff --git a/frontend/tests/report-fact-table-print.test.ts b/frontend/tests/report-fact-table-print.test.ts new file mode 100644 index 00000000..60c495d9 --- /dev/null +++ b/frontend/tests/report-fact-table-print.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { bindFactTablePrint } from "../src/lib/report-fact-table-print"; + +function setup() { + const details = [{ open: false }, { open: true }, { open: false }]; + const outside = { open: false }; + const events = new EventTarget(); + const root = { querySelectorAll(selector: string) { assert.equal(selector, "details"); return details; } }; + const cleanup = bindFactTablePrint(root as unknown as HTMLElement, events); + return { details, outside, events, cleanup }; +} + +test("fact table printing expands all report groups and restores mixed disclosure state", () => { + const { details, outside, events, cleanup } = setup(); + assert.deepEqual(details.map(item => item.open), [false, true, false]); + events.dispatchEvent(new Event("beforeprint")); + assert.deepEqual(details.map(item => item.open), [true, true, true]); + assert.equal(outside.open, false); + events.dispatchEvent(new Event("afterprint")); + assert.deepEqual(details.map(item => item.open), [false, true, false]); + cleanup(); +}); + +test("fact table printing preserves the first snapshot across repeated beforeprint events", () => { + const { details, events, cleanup } = setup(); + events.dispatchEvent(new Event("beforeprint")); + events.dispatchEvent(new Event("beforeprint")); + events.dispatchEvent(new Event("afterprint")); + assert.deepEqual(details.map(item => item.open), [false, true, false]); + details[0].open = true; + events.dispatchEvent(new Event("beforeprint")); + events.dispatchEvent(new Event("afterprint")); + assert.deepEqual(details.map(item => item.open), [true, true, false]); + cleanup(); +}); + +test("fact table print cleanup restores pending state and removes both event listeners", () => { + const { details, events, cleanup } = setup(); + events.dispatchEvent(new Event("afterprint")); + events.dispatchEvent(new Event("beforeprint")); + cleanup(); + assert.deepEqual(details.map(item => item.open), [false, true, false]); + events.dispatchEvent(new Event("beforeprint")); + assert.deepEqual(details.map(item => item.open), [false, true, false]); +}); + +test("fact table print styles remove horizontal clipping and repeat table headers", () => { + const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + const print = css.slice(css.indexOf("@media print"), css.indexOf("@theme inline")); + assert.match(print, /\.personal-report-fact-table-scroll\s*\{[^}]*overflow: visible !important/); + assert.match(print, /\.personal-report-fact-tables table\s*\{[^}]*table-layout: fixed/); + assert.match(print, /\.personal-report-fact-tables thead\s*\{[^}]*display: table-header-group/); +}); diff --git a/frontend/tests/report-fact-tables.test.ts b/frontend/tests/report-fact-tables.test.ts index bdbd49c1..3ddecb08 100644 --- a/frontend/tests/report-fact-tables.test.ts +++ b/frontend/tests/report-fact-tables.test.ts @@ -12,6 +12,7 @@ import { projectOrdinaryReportMarkdown } from "../src/lib/report-public-projecti const packet = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8")); const resolve = (path: string) => path.replace(/\[(\d+)\]/g, ".$1").split(".").reduce((value, key) => value?.[key], packet); +const fixed = (value: number) => value.toFixed(2); test("density golden: sixteen real charts have complete houses and nine occupants", () => { const charts = assembleLongformCharts(packet, "ev-cover-longform"); @@ -43,18 +44,35 @@ test("density golden: eight server groups contain only unchanged engine leaves", const tables = assembleReportFactTables(packet); assert.equal(tables.length, 8); for (const table of tables) { - assert.ok(table.rows.length > 0, table.id); - for (const row of table.rows) assert.deepEqual(row.cells[1], resolve(row.sourcePath), row.sourcePath); + assert.ok(table.rows.length > 0 || table.subtables?.some(child => child.rows.length > 0), table.id); + for (const grid of [table, ...(table.subtables ?? [])]) { + assert.notEqual(resolve(grid.sourcePath), undefined, grid.sourcePath); + for (const row of grid.rows) assert.notEqual(resolve(row.sourcePath), undefined, row.sourcePath); + } } - assert.equal(tables.find(t => t.id === "shadbala")?.rows.length, 42); - assert.equal(tables.find(t => t.id === "ashtakavarga")?.rows.length, 108); - assert.ok(tables.find(t => t.id === "annual")?.rows.some(row => row.sourcePath.includes("advanced_systems.sahams"))); + const shadbala = tables.find(t => t.id === "shadbala")!; + assert.equal(shadbala.rows.length, 7); + for (const row of shadbala.rows) { + const raw = resolve(row.sourcePath); + assert.deepEqual(row.cells.slice(1), [raw.sthana_bala.total, raw.dig_bala, raw.kala_bala.total, raw.chesta_bala, raw.naisargika_bala, raw.drik_bala, raw.total_virupas].map(fixed)); + } + const ashtakavarga = tables.find(t => t.id === "ashtakavarga")!; + assert.equal(ashtakavarga.rows.length, 12); + for (const row of ashtakavarga.rows) assert.equal(row.cells[1], resolve(row.sourcePath).toFixed(2)); + assert.equal(ashtakavarga.subtables?.[0].rows.length, 8); + for (const row of ashtakavarga.subtables![0].rows) assert.deepEqual(row.cells.slice(1), resolve(row.sourcePath).bindus.map(fixed)); + const annual = tables.find(t => t.id === "annual")!; + assert.equal(annual.rows.length, 0, "golden annual chart has no planet positions"); + assert.ok(annual.subtables?.[0].rows.every(row => row.sourcePath.includes("annual_tajika_pack.sahams.data"))); + assert.equal(annual.subtables?.[0].rows.length, 36); }); test("density golden: Sade Sati reports missing cycle dates without inventing them", () => { const table = assembleReportFactTables(packet).find(t => t.id === "sade_sati")!; assert.match(table.note, /尚无三轮起止日期/); - assert.equal(table.rows.length, 6); + assert.equal(table.rows.length, 3); + assert.deepEqual(table.rows.map(row => row.cells[0]), ["上升", "高峰", "下降"]); + assert.match(table.note, /当前是否在进行中:否/); assert.equal(table.claimStatus, "parameter_sensitive"); assert.ok(table.rows.every(row => !/start|end|date/.test(row.sourcePath))); }); @@ -68,3 +86,93 @@ test("density facts preserve declared limitations and reject malformed provenanc assert.equal(reportFactTablesSchema.safeParse(tables).success, false); assert.deepEqual(assembleReportFactTables(undefined), []); }); + +test("density visible columns never expose engine paths or excessive precision", () => { + for (const table of assembleReportFactTables(packet)) { + for (const grid of [table, ...(table.subtables ?? [])]) { + const visible = [grid.title, grid.note, ...grid.columns, ...grid.rows.flatMap(row => row.cells)].join("\n"); + assert.doesNotMatch(visible, /[A-Za-z]+_[A-Za-z]+|\[\d+\]|\d+\.\d{3,}|jd_ut|dt_ut|\.data\./); + assert.ok(grid.rows.every(row => row.cells.every(cell => cell === null || typeof cell === "string"))); + } + } +}); + +test("density golden: full years and birth balance have distinct sourced meanings", () => { + const table = assembleReportFactTables(packet).find(t => t.id === "vimshottari")!; + assert.equal(table.rows.length, 9); + assert.deepEqual(table.columns, ["主运", "起", "止", "年数", "当前"]); + assert.equal(resolve(table.rows[0].sourcePath).full_years, 18); + assert.equal(resolve(table.rows[0].sourcePath).balance_years, 0.72); + assert.equal(table.rows[0].cells[3], "18.00"); + assert.match(table.note, /出生时.*剩余 0\.72 年/); + for (const row of table.rows) { + const raw = resolve(row.sourcePath); + assert.deepEqual(row.cells, [raw.lord_cn, raw.start, raw.end, fixed(raw.full_years), raw.is_current ? "是" : "否"]); + } + assert.deepEqual(table.subtables?.map(child => child.id), ["antardasha", "pratyantar"]); + for (const child of table.subtables!) { + assert.equal(resolve(child.sourcePath).is_current, true); + assert.equal(child.rows.length, 9); + for (const row of child.rows) { + const raw = resolve(row.sourcePath); + assert.deepEqual(row.cells, [raw.lord_cn, raw.start, raw.end, raw.is_current ? "是" : "否"]); + } + } + const altered = structuredClone(packet); + delete altered.worksheets.timing_and_predictive_systems.dasha.timeline[0].full_years; + assert.equal(assembleReportFactTables(altered).find(t => t.id === "vimshottari")!.rows[0].cells[3], null, "neither dates nor balance nor standard constants may supply missing duration"); +}); + +test("density golden: state and position cells retain actual source values", () => { + const tables = assembleReportFactTables(packet); + const states = tables.find(t => t.id === "avasthas")!; + for (const row of states.rows) { + const raw = resolve(row.sourcePath); + assert.deepEqual(row.cells.slice(1), ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(key => raw.avasthas[key].state)); + } + for (const grid of [tables.find(t => t.id === "special_points")!, ...tables.find(t => t.id === "annual")!.subtables!]) { + for (const row of grid.rows) { + const raw = resolve(row.sourcePath); + assert.equal(row.cells[2], fixed(raw.degree_in_sign ?? raw.sign_degree)); + } + } + const altered = structuredClone(packet); + const strengths = altered.worksheets.strengths_and_scores; + delete strengths.shadbala.planets.Sun.total_virupas; + delete altered.worksheets.divisional_and_special_charts.upagrahas.raw.Kaala.degree_in_sign; + const output = assembleReportFactTables(altered); + assert.equal(output.find(t => t.id === "shadbala")!.rows[0].cells[7], null, "do not sum components"); + assert.equal(output.find(t => t.id === "special_points")!.rows[0].cells[2], null, "do not derive sign degrees from longitude"); +}); + +test("density fact subtable schema accepts legacy snapshots and rejects malformed new grids", () => { + const tables = assembleReportFactTables(packet); + const legacy = tables.map(source => { + const table = { ...source }; + delete table.subtables; + return { ...table, columns: ["原始字段", "数值 / 状态"], rows: [{ sourcePath: table.sourcePath, cells: ["legacy", 1] }] }; + }); + assert.equal(reportFactTablesSchema.safeParse(legacy).success, true); + const invalid = structuredClone(tables); + invalid[0].subtables![0].rows[0].cells.pop(); + assert.equal(reportFactTablesSchema.safeParse(invalid).success, false); + const wrongSource = structuredClone(tables); + wrongSource[0].subtables![0].sourcePath = "worksheets.other"; + assert.equal(reportFactTablesSchema.safeParse(wrongSource).success, false); + const duplicates = structuredClone(tables); + duplicates[0].subtables!.push(duplicates[0].subtables![0]); + assert.equal(reportFactTablesSchema.safeParse(duplicates).success, false); +}); + +test("density blocked sources stay blocked and missing annual positions are not invented", () => { + const altered = structuredClone(packet); + altered.worksheets.strengths_and_scores.shadbala.claimStatus = "blocked"; + altered.worksheets.timing_and_predictive_systems.annual_tajika_pack.sahams.status = "blocked"; + const tables = assembleReportFactTables(altered); + assert.equal(tables.find(t => t.id === "shadbala")!.claimStatus, "blocked"); + assert.deepEqual(tables.find(t => t.id === "shadbala")!.rows, []); + assert.deepEqual(tables.find(t => t.id === "annual")!.rows, []); + assert.deepEqual(tables.find(t => t.id === "annual")!.subtables![0].rows, []); + assert.match(tables.find(t => t.id === "annual")!.note, /年度上升:天蝎;年龄:26\.00/); + assert.match(tables.find(t => t.id === "annual")!.note, /未返回年度行星位置,不补算/); +}); diff --git a/scripts/reader_appendix_language.py b/scripts/reader_appendix_language.py index cfa9287d..b800cd14 100644 --- a/scripts/reader_appendix_language.py +++ b/scripts/reader_appendix_language.py @@ -2,29 +2,30 @@ from __future__ import annotations +import json +from pathlib import Path import re -STATUS_LABELS = { - "parameter_sensitive": "参数敏感", - "pyjhora_behavior_only": "仅单一外部参照,未做多引擎核对", - "not_multiengine_parity": "仅单一外部参照,未做多引擎核对", - "unresolved_external_tuple_boundary": "外部边界未对齐", - "partial_verified": "部分核验", - "raw_appendix_only": "仅原始附录可见", - "missing_in_local": "本地暂无", - "internal_reference_omitted": "内部参照已省略", -} -CELL_LABELS = {"blocked": "暂不可用", "executed": "已执行", "available": "可用", "computed": "已计算"} -_TOKEN = re.compile(r"\b(" + "|".join(STATUS_LABELS) + r")\b") -_PRODUCT = re.compile( - r"\bPL9(?:\.pdf)?(?:[ \t]*(?:第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页|" - r"(?:pages?|p)[ \t-]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*))?\b|" - r"\bPL9[ \t]*第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页", - re.IGNORECASE, -) -_FUNCTION = re.compile(r"\b(?:jyotish_engine\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\b") -_CELL = re.compile(r"(?<=\|)([ \t]*)(`?)(blocked|executed|available|computed)\2([ \t]*)(?=\|)") +# Both runtimes consume this file. ASCII regex semantics deliberately match JS: +# Python's default Unicode \b would leave Chinese page references untranslated. +_RULES = json.loads(Path(__file__).with_suffix(".rules.json").read_text(encoding="utf-8")) +_CHART_FENCE = re.compile(_RULES["chartFencePattern"], re.MULTILINE | re.ASCII) +_REPLACEMENTS = [ + (re.compile(rule["pattern"], re.ASCII | (re.IGNORECASE if rule.get("ignoreCase") else 0)), rule) + for rule in _RULES["rules"] +] + + +def _replace(match: re.Match[str], rule: dict) -> str: + if "replacement" in rule: + return rule["replacement"] + return ( + rule.get("prefix", "") + + "".join(match[group] for group in rule.get("beforeGroups", [])) + + rule["labels"][match[rule["labelGroup"]]] + + "".join(match[group] for group in rule.get("afterGroups", [])) + ) def clean_reader_appendix_markdown(markdown: str) -> str: @@ -33,13 +34,10 @@ def clean_reader_appendix_markdown(markdown: str) -> str: Ambiguous English words are changed only as complete pipe-table cells. Chart fences are immutable: their JSON is a separate renderer contract. """ - parts = re.split(r"(^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$))", markdown, flags=re.MULTILINE) + parts = _CHART_FENCE.split(markdown) for index in range(0, len(parts), 2): - value = _TOKEN.sub(lambda match: STATUS_LABELS[match[0]], parts[index]) - value = _FUNCTION.sub("本地计算", value) - value = re.sub(r"\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\.v\d+)?\b", "外部参照资料", value, flags=re.IGNORECASE) - value = _PRODUCT.sub("外部参照资料", value) - value = re.sub(r"\b(?:PyJHora|JHora)\b", "外部参照引擎", value, flags=re.IGNORECASE) - value = _CELL.sub(lambda match: f"{match[1]}{match[2]}{CELL_LABELS[match[3]]}{match[2]}{match[4]}", value) + value = parts[index] + for pattern, rule in _REPLACEMENTS: + value = pattern.sub(lambda match: _replace(match, rule), value) parts[index] = value return "".join(parts) diff --git a/scripts/reader_appendix_language.rules.json b/scripts/reader_appendix_language.rules.json new file mode 100644 index 00000000..240c0b3c --- /dev/null +++ b/scripts/reader_appendix_language.rules.json @@ -0,0 +1,46 @@ +{ + "chartFencePattern": "(^[ \\t]*```jyotish-chart[^\\n]*\\n[\\s\\S]*?^[ \\t]*```[^\\n]*(?:\\n|$))", + "rules": [ + { + "pattern": "\\b(parameter_sensitive|pyjhora_behavior_only|not_multiengine_parity|unresolved_external_tuple_boundary|partial_verified|raw_appendix_only|missing_in_local|internal_reference_omitted)\\b", + "labels": { + "parameter_sensitive": "参数敏感", + "pyjhora_behavior_only": "仅单一外部参照,未做多引擎核对", + "not_multiengine_parity": "仅单一外部参照,未做多引擎核对", + "unresolved_external_tuple_boundary": "外部边界未对齐", + "partial_verified": "部分核验", + "raw_appendix_only": "仅原始附录可见", + "missing_in_local": "本地暂无", + "internal_reference_omitted": "内部参照已省略" + }, + "labelGroup": 0 + }, + { + "pattern": "\\b(?:jyotish_engine\\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\\b", + "replacement": "本地计算" + }, + { + "pattern": "\\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\\.v\\d+)?\\b", + "ignoreCase": true, + "replacement": "外部参照资料" + }, + { + "pattern": "\\bPL9\\b(?:\\.pdf)?(?:[ \\t]*(?:第[ \\t]*\\d+(?:[ \\t]*[–—/-][ \\t]*\\d+)*[ \\t]*页|(?:pages?|p)[ \\t-]*\\d+(?:[ \\t]*[–—/-][ \\t]*\\d+)*))?", + "ignoreCase": true, + "replacement": "外部参照资料" + }, + { + "pattern": "\\b(?:PyJHora|JHora)\\b", + "ignoreCase": true, + "replacement": "外部参照引擎" + }, + { + "pattern": "\\|([ \\t]*)(`?)(blocked|executed|available|computed)\\2([ \\t]*)(?=\\|)", + "labels": { "blocked": "暂不可用", "executed": "已执行", "available": "可用", "computed": "已计算" }, + "labelGroup": 3, + "prefix": "|", + "beforeGroups": [1, 2], + "afterGroups": [2, 4] + } + ] +} diff --git a/scripts/report_fact_table_contract.py b/scripts/report_fact_table_contract.py index 8b02a2d4..812253ba 100644 --- a/scripts/report_fact_table_contract.py +++ b/scripts/report_fact_table_contract.py @@ -12,42 +12,31 @@ def validate_fact_tables(value, add): def text(item, maximum, minimum=0): return isinstance(item, str) and minimum <= len(item) <= maximum - if not isinstance(value, list) or len(value) > 8: - add("factTables", "must be array with at most eight groups") - return - seen = set() - for index, table in enumerate(value): - path = f"factTables[{index}]" - if not isinstance(table, dict) or set(table) != {"id", "title", "claimStatus", "sourcePath", "note", "columns", "rows"}: - add(path, "invalid table keys") - continue - group = table["id"] - if not isinstance(group, str) or group not in GROUPS or group in seen: - add(path, "invalid or duplicate group") - else: - seen.add(group) - source = table["sourcePath"] + def within(child, parent): + return isinstance(child, str) and bool(PATH.fullmatch(child)) and (child == parent or child.startswith(parent + ".") or child.startswith(parent + "[")) + + def grid(table, path, parent=None): + source = table.get("sourcePath") if not isinstance(source, str) or not PATH.fullmatch(source): add(path, "invalid source path") - continue - if not text(table["title"], 120, 1) or not text(table["note"], 500): + return + if parent is not None and not within(source, parent): + add(path, "fact_table_source_mismatch") + if not text(table.get("title"), 120, 1) or not text(table.get("note"), 500): add(path, "invalid text") - if table["claimStatus"] not in STATUSES: - add(path, "invalid claim status") - columns = table["columns"] + columns = table.get("columns") if not isinstance(columns, list) or not 1 <= len(columns) <= 16 or not all(text(c, 80, 1) for c in columns): add(path, "invalid columns") - continue - rows = table["rows"] + return + rows = table.get("rows") if not isinstance(rows, list) or len(rows) > 2000: add(path, "invalid rows") - continue + return for row in rows: if not isinstance(row, dict) or set(row) != {"sourcePath", "cells"}: add(path, "invalid row keys") continue - origin = row["sourcePath"] - if not isinstance(origin, str) or not PATH.fullmatch(origin) or not (origin == source or origin.startswith(source + ".") or origin.startswith(source + "[")): + if not within(row["sourcePath"], source): add(path, "fact_table_source_mismatch") cells = row["cells"] if not isinstance(cells, list) or len(cells) != len(columns): @@ -59,3 +48,43 @@ def validate_fact_tables(value, add): if isinstance(cell, (int, float)) and math.isfinite(cell): continue add(path, "invalid cell") + + if not isinstance(value, list) or len(value) > 8: + add("factTables", "must be array with at most eight groups") + return + seen = set() + required = {"id", "title", "claimStatus", "sourcePath", "note", "columns", "rows"} + subkeys = required - {"claimStatus"} + for index, table in enumerate(value): + path = f"factTables[{index}]" + if not isinstance(table, dict) or not required <= set(table) or set(table) - required - {"subtables"}: + add(path, "invalid table keys") + continue + group = table["id"] + if not isinstance(group, str) or group not in GROUPS or group in seen: + add(path, "invalid or duplicate group") + else: + seen.add(group) + if not isinstance(table["claimStatus"], str) or table["claimStatus"] not in STATUSES: + add(path, "invalid claim status") + grid(table, path) + if "subtables" not in table: + continue + children = table["subtables"] + if not isinstance(children, list) or len(children) > 8: + add(path, "invalid subtables") + continue + child_ids = set() + for child_index, child in enumerate(children): + child_path = f"{path}.subtables[{child_index}]" + if not isinstance(child, dict) or set(child) != subkeys: + add(child_path, "invalid subtable keys") + continue + child_id = child["id"] + if not text(child_id, 80, 1) or not re.fullmatch(r"[a-z][a-z0-9_-]*", child_id) or child_id in child_ids: + add(child_path, "invalid or duplicate subtable") + else: + child_ids.add(child_id) + parent = table["sourcePath"] + if isinstance(parent, str): + grid(child, child_path, parent) diff --git a/tests/test_reader_appendix_shared_rules.py b/tests/test_reader_appendix_shared_rules.py new file mode 100644 index 00000000..0d8a3dab --- /dev/null +++ b/tests/test_reader_appendix_shared_rules.py @@ -0,0 +1,52 @@ +"""Shared vocabulary regressions; no generated engine facts in these unit inputs.""" + +from pathlib import Path + +import pytest + +from scripts.reader_appendix_language import clean_reader_appendix_markdown +from scripts import skill_release_package + + +ROOT = Path(__file__).resolve().parents[1] +RULE_PATH = "scripts/reader_appendix_language.rules.json" + + +@pytest.mark.parametrize("source,expected", [ + ("PL9 第 42 页", "外部参照资料"), + ("PL9第42页", "外部参照资料"), + ("PL9.pdf 第 42 页", "外部参照资料"), + ("PL9.pdf第42页后文", "外部参照资料后文"), + ("参见PL9第43–44页中的字段", "参见外部参照资料中的字段"), + ("PL9 第 40 / 48 页", "外部参照资料"), + ("PL9 pages 99-100", "外部参照资料"), +]) +def test_chinese_page_boundary(source, expected): + assert clean_reader_appendix_markdown(source) == expected + + +@pytest.mark.parametrize("edition", ["basic_git", "premium_cloud_drive"]) +def test_shared_rules_are_included_by_skill_package_selection(monkeypatch, edition): + # Simulate the tracked file inventory without mutating this worktree's index. + # Exercise the real release selector: scripts assets must not become .py-only. + inventory = ["scripts/reader_appendix_language.py", RULE_PATH] + # Release manifest references are relative to the repository, not the caller. + monkeypatch.chdir(ROOT) + monkeypatch.setattr(skill_release_package, "_git_files", lambda: inventory) + selected = skill_release_package._edition_files(edition) + assert all(path in selected for path in inventory) + assert (ROOT / RULE_PATH).is_file() + + +@pytest.mark.parametrize("dockerfile", ["railway-api.Dockerfile", "railway-web.Dockerfile"]) +def test_shared_rules_live_inside_existing_image_copy_boundary(dockerfile): + source = (ROOT / "deploy" / dockerfile).read_text(encoding="utf-8") + assert any(line.startswith("COPY scripts ") for line in source.splitlines()) + + +def test_crlf_chart_fence_is_byte_preserved(): + fence = ' ```jyotish-chart\r\n{"title":"PL9第42页 parameter_sensitive","id":"D1"}\r\n ```\r\n' + source = "PL9第42页\r\n" + fence + "| parameter_sensitive |\r\n" + assert clean_reader_appendix_markdown(source).encode("utf-8") == ( + "外部参照资料\r\n" + fence + "| 参数敏感 |\r\n" + ).encode("utf-8") diff --git a/tests/test_repo_privacy_markers.py b/tests/test_repo_privacy_markers.py index ae81bfc9..04026e60 100644 --- a/tests/test_repo_privacy_markers.py +++ b/tests/test_repo_privacy_markers.py @@ -192,12 +192,49 @@ def _collision_spans(path: str, source: str, rules: tuple[Rule, ...]) -> list[tu return [] service_path = "tests/test_rectification_v5_services.py" replay_path = "references/oracle/pyjhora_multi_case_panchanga_gulika_replay_2026_07_23.json" - if path not in {service_path, replay_path}: + report_path = "frontend/tests/fixtures/report-density-fictional-reader.json" + if path not in {service_path, replay_path, report_path}: return [] try: tree = ast.parse(source) except SyntaxError: return [] + if path == report_path: + # BUG-1010: engine timestamp from fictional input, independently replayed + # during acceptance (TASK-report-density-fix-20260923, P1-B; BUG-1008). + # Pin provenance, JSON node, source line and complete computed row; allow + # only the colliding timestamp span, not the large markdown string. + try: + data = json.loads(source) + if data["fixtureProvenance"] != { + "fictional": True, + "generator": "cmd_full_reading -> build_professional_report_reference_packet", + "input": { + "year": 2000, "month": 1, "day": 1, "hour": 12, + "minute": 0, "second": 0, "lat": 0.0, "lon": 0.0, + "tz": 0, "ayanamsa": "lahiri", "node_mode": "mean", + "house_system": "whole_sign", "today": "2026-09-22", + "target_year": 2026, + }, + } or data["fixtureProvenance"]["fictional"] is not True: + return [] + nodes = [v for k, v in zip(tree.body[0].value.keys, tree.body[0].value.values) + if isinstance(k, ast.Constant) and k.value == "markdown"] + if len(nodes) != 1 or not isinstance(nodes[0], ast.Constant) or nodes[0].lineno != 22: + return [] + row = (f"| 5 | 4 | 5 | 2028-01-24 {marker}:39 (unresolved_external_tuple_boundary) " + "| 0.019276268255984996 | pyjhora_behavior_only / not_multiengine_parity |") + if data["markdown"].splitlines().count(row) != 1: + return [] + start, end = _node_span(source, nodes[0]) + literal = source[start:end] + encoded_row = r"\n" + json.dumps(row, ensure_ascii=False)[1:-1] + r"\n" + if literal.count(encoded_row) != 1: + return [] + offset = start + literal.index(encoded_row) + encoded_row.index(marker) + return [(offset, offset + len(marker))] + except (ValueError, KeyError, IndexError, AttributeError, TypeError): + return [] if path == service_path: name = "test_hour_window_keeps_all_seventeen_signature_clusters_including_late_tail" functions = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == name] @@ -559,6 +596,42 @@ def test_reviewed_numerical_collisions_are_exact_nodes_only(path: str, count: in assert len(scan_text(path, source.replace(marker + ":18", marker + ":19"), rules)[0]) == count +def test_report_fixture_collision_is_one_reviewed_timestamp_only() -> None: + __tracebackhide__ = True + path = "frontend/tests/fixtures/report-density-fictional-reader.json" + rules = build_rules((ROOT / GOLDEN_PATH).read_text(encoding="utf8")) + source = (ROOT / path).read_text(encoding="utf8") + marker = next(r.value for r in rules if r.code == "R003") + findings, permitted = scan_text(path, source, rules) + assert len(findings) == 0 and permitted == 1 + for dirty_path, dirty in [ + ("new/" + path, source), + (path, "\n" + source), + (path, source.replace('"fictional": true', '"fictional": false', 1)), + (path, source.replace('"year": 2000', '"year": 2001', 1)), + (path, source.replace('"generator": "cmd_full_reading', '"generator": "unreviewed', 1)), + (path, source.replace('"markdown":', '"other":', 1)), + (path, source.replace(marker + ":39", marker + ":40", 1)), + (path, source.replace("| 5 | 4 | 5 | 2028-01-24", "| 6 | 4 | 5 | 2028-01-24", 1)), + ]: + findings, permitted = scan_text(dirty_path, dirty, rules) + assert len(findings) == 1 and permitted == 0 + # Extra markers in the same markdown node or another field remain findings. + for dirty in [ + source.replace('"markdown": "', '"markdown": "' + marker + r"\n", 1), + source.replace('"markdown":', f'"extra": {json.dumps(marker)}, "markdown":', 1), + ]: + findings, permitted = scan_text(path, dirty, rules) + assert len(findings) == 1 and permitted == 1 + row = next(line for line in json.loads(source)["markdown"].splitlines() if marker in line) + encoded_row = json.dumps(row, ensure_ascii=False)[1:-1] + dirty = source.replace(encoded_row, encoded_row + r"\n" + encoded_row, 1) + findings, permitted = scan_text(path, dirty, rules) + assert len(findings) == 2 and permitted == 0 + findings, permitted = scan_text(path, source, rules + (Rule("LOCAL_001", marker),)) + assert [finding.rule for finding in findings] == ["LOCAL_001"] and permitted == 1 + + def test_local_rule_is_not_exempted_by_golden_declaration() -> None: source = 'FORBIDDEN_PRIVACY_MARKERS = ("synthetic",)' rules = build_rules(source, "synthetic") diff --git a/tests/test_report_density_facts.py b/tests/test_report_density_facts.py index 40f74b56..48e2729b 100644 --- a/tests/test_report_density_facts.py +++ b/tests/test_report_density_facts.py @@ -87,3 +87,36 @@ def test_optional_table_validator_fails_closed_on_bad_shape(): errors.clear() validate_fact_tables([], lambda *error: errors.append(error)) assert not errors + + +def test_fact_subtable_contract_uses_golden_periods_and_keeps_legacy_snapshots(): + import jsonschema + + packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-engine.json").read_text(encoding="utf8")) + source = "worksheets.timing_and_predictive_systems.dasha" + timeline = packet["worksheets"]["timing_and_predictive_systems"]["dasha"]["timeline"] + rows = [{"sourcePath": f"{source}.timeline[{index}]", "cells": [period["lord_cn"], period["start"], period["end"], f'{period["full_years"]:.2f}', "是" if period["is_current"] else "否"]} for index, period in enumerate(timeline)] + table = {"id": "vimshottari", "title": "Vimshottari 主运", "claimStatus": "parameter_sensitive", "sourcePath": source, "note": "原始计算供核对。", "columns": ["主运", "起", "止", "年数", "当前"], "rows": rows} + current_index, current = next((index, period) for index, period in enumerate(timeline) if period["is_current"]) + child_source = f"{source}.timeline[{current_index}]" + child = {"id": "antardasha", "title": "当前主运下分运", "sourcePath": child_source, "note": "原始计算供核对。", "columns": ["分运", "起", "止", "当前"], "rows": [{"sourcePath": f"{child_source}.antardasha_timeline[{index}]", "cells": [period["lord_cn"], period["start"], period["end"], "是" if period["is_current"] else "否"]} for index, period in enumerate(current["antardasha_timeline"])]} + schema = json.loads((ROOT / "contracts/personal-report/report-document.v2.schema.json").read_text(encoding="utf8")) + contract = {"$ref": "#/definitions/factTable", "definitions": schema["definitions"]} + for candidate in [table, {**table, "subtables": [child]}]: + errors = [] + validate_fact_tables([candidate], lambda *error: errors.append(error)) + assert not errors + jsonschema.validate(candidate, contract) + for mutation in ("row_width", "origin", "duplicate", "unknown_key"): + invalid = copy.deepcopy({**table, "subtables": [child]}) + if mutation == "row_width": + invalid["subtables"][0]["rows"][0]["cells"].pop() + elif mutation == "origin": + invalid["subtables"][0]["sourcePath"] = "worksheets.other" + elif mutation == "duplicate": + invalid["subtables"].append(copy.deepcopy(child)) + else: + invalid["subtables"][0]["raw"] = True + errors = [] + validate_fact_tables([invalid], lambda *error: errors.append(error)) + assert errors, mutation