diff --git a/docs/README.md b/docs/README.md
index 6a38d0b..17dd527 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -32,6 +32,7 @@
1. 阅读 [Excel 模板与 Dicts 指南](../templates/README.md)。
2. 阅读 [表类型指南](guides/table-types.md)。
3. 根据使用的表类型阅读对应设计说明。
+4. 需要为配置增加合理性或跨表语义检查时,阅读 [表类型数据校验器设计](designs/table-validation-design.md)。
### 程序/工具链维护者
@@ -51,6 +52,7 @@ docs/
│ ├── graph-table-design.md
│ ├── kv-table-design.md
│ ├── localized-table-design.md
+│ ├── table-validation-design.md
│ └── tree-table-design.md
├── reference/ # 稳定协议、格式和 API 参考
│ └── binary-format-v3.md
diff --git a/docs/designs/table-validation-design.md b/docs/designs/table-validation-design.md
new file mode 100644
index 0000000..df90ebb
--- /dev/null
+++ b/docs/designs/table-validation-design.md
@@ -0,0 +1,432 @@
+# 表类型数据校验器设计
+
+> 状态:active(2026-07-29 开始实施首个 P0/P1 切片)
+> 初稿日期:2026-07-27
+> 范围:生成期/`validate` 命令的数据合理性与语义校验;不改变 v3 `.bytes` 运行时协议。
+
+## 实施进度
+
+首个实施切片已建立内部 `ITableDataValidator` 注册与统一调用点,并完成以下迁移:
+
+- `ValidateOnly` 与正常生成都会在渲染代码和写出 `.bytes` 前运行数据 validator;
+- `tree` 的 Id 唯一、ParentId 引用、循环与 Order 数字校验已从 serializer 迁出;
+- `graph` 的 EdgeId 唯一、端点必填与 Weight 数字校验已从 serializer 迁出;
+- 普通 `table` 的显式唯一 Index 会在生成期预校验,并报告首次行与重复行;
+- parser 的 supported/reserved 集合不再同时包含 `tree`。
+
+本切片尚未实现不可变 `TableDataSnapshot`、结构化诊断 v2、跨表 catalog 或声明式规则;当前 validator context 仍是内部过渡接口,不视为已冻结的公共 API。
+
+## 1. 问题与目标
+
+DataTables 已能校验字段、索引等 Schema 约束,也会在序列化时发现单元格格式错误;`tree` 和 `graph` 还在 `DataRowBinarySerializer` 中执行部分结构校验。但是这些能力分散在 parser、通用 Schema validator 和 writer 中,新增表类型时没有统一的校验扩展点。
+
+本设计希望形成一条可注册、可组合、可定位的生成期校验管线,使每种表既能复用基础规则,也能声明自己的业务语义:
+
+- **合理性**:必填、范围、长度、枚举集合、唯一性、顺序等单表规则;
+- **语义准确性**:外键存在、树无环、图约束、矩阵坐标和值域等结构或跨表规则;
+- **可操作诊断**:每个问题尽量包含文件、Sheet、逻辑表、字段、单元格和规则码;
+- **生成安全**:存在 Error 时不产出代码和 `.bytes`,Warning 可由严格模式升级;
+- **可扩展性**:新增表类型通过注册 validator 接入,不修改 `DataTableProcessor` 主流程。
+
+非目标:
+
+- 在运行时加载每张表时重复执行完整业务校验;
+- 在第一阶段实现任意 C# 表达式或脚本执行;
+- 为旧版 `.bytes` 增加兼容分支;
+- 猜测业务含义,例如看到字段名 `Price` 就自动要求其大于零。
+
+## 2. 当前能力与缺口
+
+当前流程大致为:
+
+```text
+ITableSchemaParser
+ -> TableSchemaValidator
+ -> DataRowBinarySerializer
+ -> DataTableBinaryWriter
+```
+
+主要缺口如下:
+
+1. `ITableSchemaValidator` 只接收 `firstDataRowIndex`,无法统一访问工作表数据、源单元格位置或其他逻辑表。
+2. `TableSchemaValidator` 同时承担通用前置检查和索引规范化,类型专属规则没有注册机制。
+3. `tree` / `graph` 的引用、环和数字规则位于 serializer;当前 `ValidateOnly` 不调用 `GenerateDataFile`,所以这些规则不会执行,而正常生成又要等到写出阶段才发现错误。
+4. 多数失败通过 `Exception` / `FormatException` 表达,只能报告第一个问题,策划需要反复修表、重跑。
+5. `table`、`matrix`、`column` 只有单元格能否被类型处理器解析的校验,没有声明式值域、唯一性或引用完整性规则。
+6. 跨 Sheet/跨文件语义必须在所有目标表完成解析之后执行,现有逐 Sheet 校验时机不足。
+
+### 2.1 深度评审结论:初稿仍不足以直接实施
+
+初稿正确识别了“按类型注册 validator”和“单表/项目两阶段校验”的方向,但接口草图和实施顺序仍有以下关键不足。若直接照初稿编码,容易新增另一套与 parser、serializer 并行且逐渐漂移的数据模型。
+
+#### 不足一:`ValidationRow` 复制数据,却没有定义唯一解析来源
+
+初稿同时保留 `RawText`、`object? ParsedValue`,并提出 serializer 共享解析结果,但没有回答由谁解析、失败值如何表达、复合类型如何避免可变对象泄漏。`object` 还会丢失 `DataTypeDescriptor`、空值状态和原始 Excel 类型,validator 最终仍需类型判断或再次转换。
+
+**修正建议:** 不把初稿代码片段视为冻结 API。先引入不可变的 `TableDataSnapshot`,其中每个值由以下信息组成:
+
+- 字段稳定 ID 与 `DataTypeDescriptor`;
+- 原始文本、源 Excel 类型和 `SourceSpan`;
+- `Missing` / `Empty` / `Parsed` / `Invalid` 四态解析结果;
+- 成功时的只读规范值,失败时的结构化转换诊断。
+
+类型转换只能由 reader/materializer 调用一次。validator 和 writer 消费同一个 snapshot,不允许再调用 `DataProcessor.WriteToStream` 来“顺便验证”。在该模型落地前,不应先公开 `ValidationRow` API。
+
+#### 不足二:只增加 validator registry 会制造多个不一致的注册表
+
+当前已经存在 parser 注册表,模板选择和 serializer 分支却在其他位置。再单独增加 validator registry,只能靠测试事后发现 parser、validator、writer、template 支持集合漂移;当前 reserved 与 supported 名称也可能出现重叠,说明分散注册已经有维护风险。
+
+**修正建议:** 以单一 `TableTypeDescriptor` 作为组合根:
+
+```csharp
+internal sealed class TableTypeDescriptor
+{
+ public string DataSetType { get; }
+ public ITableSchemaParser Parser { get; }
+ public ITableDataMaterializer Materializer { get; }
+ public IReadOnlyList ValidationPasses { get; }
+ public ITableSerializationPlan Serialization { get; }
+ public ITableCodeRenderer CodeRenderer { get; }
+}
+```
+
+默认 registry 一次性注册完整 descriptor,并在启动时验证类型名唯一、组件声明一致、reserved 与 supported 不相交。若暂时无法统一模板和 writer,过渡期 registry 也必须成为唯一支持类型清单,其他注册点由它派生,而不是各自维护字符串数组。
+
+#### 不足三:validator 生命周期、顺序和失败级联没有契约
+
+初稿给出了大致顺序,却未定义前序解析失败后哪些规则仍可运行。例如 Id 无法解析时继续做唯一性和外键检查会产生大量派生错误;项目 validator 在目标表本身失败时也无法判断引用是否真的缺失。
+
+**修正建议:** 每个 pass 明确 `Phase`、`Requires`、`Produces` 和 `CanRunOnInvalidInput`。建议阶段固定为:
+
+```text
+Schema -> Materialization -> LocalStructural -> LocalSemantic
+ -> ProjectReference -> ProjectSemantic -> OutputGate
+```
+
+规则依赖形成小型 DAG,但同一阶段按稳定的 rule ID 排序。转换失败的字段标记为 `Invalid`,依赖该字段的后续规则跳过并只保留根因;目标表失败时跨表引用报告“目标不可验证”,不能同时误报每个 key 不存在。
+
+#### 不足四:诊断设想超过现有模型,但缺少兼容迁移
+
+当前 `Diagnostic` 只有 severity、file、sheet、cell、message。初稿直接要求 code、field、related locations 等字段,没有说明 JSON 兼容、控制台格式和调用方迁移,也没有线程安全约束;而生成器会并发处理输入文件,collector 中普通 `List` 不能被项目级并行写入。
+
+**修正建议:** 先版本化诊断 DTO:
+
+- 保留现有字段和构造路径,新增可选 `Code`、`Table`、`Field`、`RuleId`、`SourceSpan`、`RelatedLocations`;
+- JSON 报告增加 `formatVersion`,新增字段只追加、不重命名旧字段;
+- collector 改为线程安全 sink,最终统一去重、限流和稳定排序;
+- 诊断 message 面向人,自动化只依赖 code 与结构字段,测试不再只匹配整段文案。
+
+#### 不足五:规则来源、优先级和版本没有闭环
+
+初稿同时允许内建规则、程序化规则、Rules Sheet、JSON/YAML,却没有定义同名 rule 合并、禁用或冲突时的语义,也没有说明规则是否影响生成 fingerprint 和 schema hash。这会使 CLI、MSBuild、Unity 使用不同规则集,甚至出现相同输入生成结果不同。
+
+**修正建议:** 第一版只支持两种来源:内建不可关闭规则,以及显式注册的项目规则。声明式格式在 ADR 冻结前只做实验,不同时支持 Excel、JSON 和 YAML。每条规则具有稳定 ID、规则实现版本、参数规范值和来源;它们共同计算 `ValidationFingerprint`。业务规则默认不进入运行时 `SchemaHash`,因为不改变二进制布局,但必须进入增量 manifest 和诊断报告。只有真正改变字段或编码布局的规则才进入 schema hash。
+
+#### 不足六:项目级校验没有定义“生成全集”
+
+跨表引用可能指向被 tag 过滤、增量跳过、命名子表、同名不同 namespace 或本次命令未选择的输入。初稿提出“目标不存在/未加载”区分,但缺少确定的表身份与解析范围,无法实现一致结果。
+
+**修正建议:** 使用规范 `TableId(namespace, className, childName)`,并在生成开始时先构造 project catalog。catalog 中表状态至少区分 `Selected`、`Filtered`、`Unavailable`、`Invalid` 和 `Valid`。默认严格模式只允许引用本次 catalog 中的 `Valid` 表;需要引用外部发布表时,必须显式提供带 schema/version 的 manifest,不能把“未找到”当作成功。
+
+#### 不足七:全量快照可能带来不可控内存成本
+
+初稿要求所有表完成后再运行项目规则,但未考虑大型 workbook、matrix 展开和并行生成。把所有解析对象常驻内存可能显著放大峰值,尤其复合值和稀疏矩阵。
+
+**修正建议:** snapshot 采用按表所有权和确定性释放:单表 pass 完成后仅保留项目规则声明所需的投影索引,例如外键目标 key set,而不是保留全部行。matrix 保持稀疏项,不物化默认值。增加预算指标(行数、值数、投影字节估算),超过可配置阈值时给出明确错误,不以未经测量的性能结论作为设计依据。
+
+#### 不足八:自定义 validator 的公共扩展边界过早
+
+如果直接把 `GenerationContext`、NPOI 或内部 `DataTypeDescriptor` 暴露给第三方程序集,未来重构会形成兼容包袱;动态加载 validator 还涉及依赖解析、异常隔离和 Unity 环境差异。
+
+**修正建议:** P0/P1 的接口保持 `internal`,先完成内建六类型并验证模型。公共扩展 API 单独做 ADR,使用只读、版本化 DTO,不暴露 NPOI;CLI/MSBuild 的插件加载和 Unity 的静态注册分别设计。自定义规则异常必须转换为单条基础设施诊断,并标记整个规则失败,不能中断其他无依赖规则。
+
+#### 不足九:没有明确兼容性基线和发布策略
+
+“有效 fixture 不变化”不足以覆盖错误行为变化。把原本生成时抛出的错误提前到 validate、一次报告多个错误、开始校验唯一索引,都可能让历史上能导出的表停止生成。
+
+**修正建议:** 建立规则兼容等级:
+
+- `Required`:现有格式/结构不变量,立即作为 Error;
+- `Recommended`:新合理性规则,首个版本默认 Warning;
+- `ProjectPolicy`:项目显式启用并选择 severity。
+
+每个新增内建规则必须记录旧行为、启用版本、默认 severity 和修复方式。发布说明提供审计模式,先输出报告但不阻止生成;一个迁移周期后才能将已公告规则升级为 Error。
+
+## 3. 校验分层
+
+校验应拆成四层,避免把“格式正确”误当成“业务正确”。
+
+| 层级 | 典型规则 | 执行时机 | 失败默认级别 |
+| --- | --- | --- | --- |
+| Schema | 必需字段、字段名、类型、索引声明 | 单表 Schema 解析后 | Error |
+| Cell | 值可解析、必填、范围、长度、正则、集合 | 数据行读取后 | Error |
+| Table semantics | 唯一性、顺序、tree/graph/matrix 结构约束 | 单表全部行读取后 | Error/Warning |
+| Project semantics | 外键、跨表唯一、成对配置、版本一致性 | 所有逻辑表读取后 | Error/Warning |
+
+各层共同遵守:
+
+- validator **只读** Schema 和数据,不修正、裁剪或补齐用户输入;
+- parser 负责解释布局,validator 负责判断其结果是否有效,writer 只负责确定性输出已验证的数据;
+- 可恢复的问题写入 `DiagnosticsCollector` 后继续收集,同一规则可设置每表最大问题数,防止错误洪泛;
+- 基础设施故障或内部不变量破坏才抛异常,用户数据错误不依赖异常作为正常控制流。
+
+## 4. 修订后的内部扩展点
+
+本节代码只描述职责,不承诺公共 API。P0/P1 先以 `internal` 实现;是否开放第三方 validator 必须等待 DTO、版本和加载模型 ADR 通过。
+
+### 4.1 单表 validator
+
+```csharp
+internal interface ITableValidationPass
+{
+ string RuleId { get; }
+ ValidationPhase Phase { get; }
+ ValidationPassResult Validate(TableValidationContext context);
+}
+
+internal sealed class TableValidationContext
+{
+ public TableSchemaSnapshot Schema { get; }
+ public TableDataSnapshot Data { get; }
+ public ValidationProfile Profile { get; }
+ public IDiagnosticSink Diagnostics { get; }
+}
+```
+
+`TableDataSnapshot` 包含布局无关的逻辑记录,而不是直接暴露 NPOI `IRow`:
+
+```csharp
+internal sealed class ValidationRow
+{
+ public int LogicalIndex { get; }
+ public IReadOnlyDictionary Values { get; }
+}
+
+internal sealed class ValidationValue
+{
+ public string RawText { get; }
+ public ParsedValueState State { get; }
+ public DataTypeDescriptor Type { get; }
+ public object? CanonicalValue { get; }
+ public SourceSpan Source { get; }
+}
+```
+
+这样 `table` 的 Excel 行、`column` 的 Excel 列、`matrix` 展开的稀疏坐标项以及 `kv` 的键值项都能进入相同规则引擎,同时保留类型、四态解析结果和精确位置。规范值由无状态 materializer 产生一次,validator 与 serializer 共享结果,避免相同文本被两套逻辑解释。具体实现应优先使用按类型封装的不可变值,`object?` 只是迁移期占位,不能作为公开契约。
+
+### 4.2 注册与组合
+
+校验 pass 不建立独立的顶层 registry,而是挂到统一 `TableTypeDescriptor`,键使用规范化后的 `DTGen` 类型。默认描述符包含:
+
+```text
+table -> RowMaterializer + RowTableValidator
+column -> ColumnMaterializer + ColumnTableValidator
+matrix -> MatrixMaterializer + MatrixTableValidator
+kv -> KvMaterializer + KvTableValidator
+tree -> RowMaterializer + TreeTableValidator
+graph -> RowMaterializer + GraphTableValidator
+```
+
+每个类型 validator 是组合入口,而不是复制全部规则。建议执行顺序为:
+
+1. `CommonCellRulesValidator`:类型可解析、必填和值约束;
+2. `CommonIndexValidator`:唯一索引、分组键与稳定顺序;
+3. 类型 validator:布局和结构语义;
+4. 用户声明的字段/表级规则。
+
+组合 registry 应拒绝重复类型,并列出支持类型。缺失 materializer、validator、serialization plan 或 renderer 都必须产生启动错误,不能静默回退到 `table`。过渡期若尚未迁移全部组件,架构测试必须核对旧注册点集合,且由组合 registry 输出唯一的 supported/reserved 清单。
+
+### 4.3 项目级 validator
+
+跨表引用需要第二个扩展点:
+
+```csharp
+internal interface IProjectValidationPass
+{
+ string RuleId { get; }
+ ValidationPassResult Validate(ProjectValidationContext context);
+}
+```
+
+`ProjectValidationContext` 包含生成开始时建立的 catalog、有效表的只读投影索引,以及按稳定 `TableId(namespace, class, child)` 建立的目录。项目规则仅在单表解析完成后运行;若被引用表被过滤、不可用或校验失败,诊断必须准确报告 catalog 状态,不能假定引用有效。
+
+## 5. 各表类型的第一阶段规则
+
+### 5.1 `table`
+
+- 所有非忽略字段的值均可按声明类型解析;
+- 显式 `required` 字段不接受空单元格;
+- 每个唯一 `Index` 的字段组合在有效行中唯一,错误同时指出首次出现行和重复行;
+- `Group` 只验证键可用,不要求唯一;
+- 可选字段规则:数值范围、字符串长度/正则、允许值集合、顺序连续或单调;
+- 显式外键规则验证目标表及目标唯一键存在。
+
+空白整行和 `#` 注释行沿用 reader 的有效行判定,所有 validator 与 writer 必须共享该判定结果。
+
+### 5.2 `column`
+
+逻辑记录按 Excel 列构造,复用 `table` 的字段和值规则,但诊断位置映射为字段所在行与记录所在列。另需验证:
+
+- 注释列不会进入唯一性或引用校验;
+- 非空逻辑记录缺失 required 字段时报对应交叉单元格;
+- 标题/类型列自身不被误识别为数据记录。
+
+### 5.3 `matrix`
+
+- Key1、Key2 坐标都能按声明类型解析且各自唯一;
+- 展开后的 `(Key1, Key2)` 组合唯一;
+- 值能按 Value 类型解析,并可应用范围/集合规则;
+- `MatrixDefaultValue` 只表示“跳过该项”,其文本若与合法业务值冲突应给出 Warning;
+- 可选规则可要求完整矩阵、方阵、对称、禁止对角项或指定稀疏度,但这些都必须显式声明,不能成为默认假设。
+
+### 5.4 `kv`
+
+- Key 必填、符合 C# 成员命名规则且唯一;
+- Type 必填且合法;Value 必填并能按该行 Type 解析;
+- 每个 Key 可附带数值范围、集合或正则规则;
+- 如果生成强类型成员名经过规范化,规范化后的冲突也必须报错;
+- JSON 只在声明了 `json` 时验证语法和目标类型结构,不把普通字符串猜测为 JSON。
+
+### 5.5 `tree`
+
+- Id 必填且唯一,ParentId 为空表示根;
+- 非空 ParentId 必须引用同表节点,节点不得引用自身;
+- 整棵树无环,环诊断给出完整路径和相关行;
+- Order 存在时必须可解析,并可选择在同一父节点下唯一或连续;
+- 根节点数量、最大深度、叶子要求属于可选项目规则,不默认限制。
+
+现有 `DataRowBinarySerializer.ValidateTreeRows` 的逻辑应迁移到 `TreeTableValidator`,writer 不再承担验证。
+
+### 5.6 `graph`
+
+- EdgeId 必填且唯一,From/To 必填;
+- Weight 存在时必须可解析,并可显式限制为非负或指定范围;
+- 是否允许自环、平行边、无向重复边、悬空节点必须由表级规则明确;
+- DAG 模式才执行有向环检测,普通 graph 不应默认禁止环;
+- 若节点来自外部节点表,则 From/To 使用项目级外键校验。
+
+现有 `DataRowBinarySerializer.ValidateGraphRows` 的逻辑应迁移到 `GraphTableValidator`。
+
+## 6. 规则声明方案
+
+建议分两阶段落地,先解决架构和内建语义,再引入稳定的声明语法。
+
+### 阶段一:现有元数据 + 程序化规则
+
+- 从 `Index`、`Group`、字段类型和 `DTGen` 推导无歧义的内建规则;
+- 内部实现内建 `ITableValidationPass` / `IProjectValidationPass`,暂不承诺公共插件 API;
+- 不根据字段名猜规则,不立即引入表达式语言。
+
+### 阶段二:声明式规则
+
+候选载体包括独立 `Rules` Sheet 或外部规则文件,但实现前必须通过 ADR 只选一种首发格式,而不是同时支持多套语法。无论最终载体如何,规则的规范模型可以表示为:
+
+| Table | Field | Rule | Arguments | Severity | Code |
+| --- | --- | --- | --- | --- | --- |
+| Item | Price | range | `min=0,max=999999` | error | ITEM_PRICE_RANGE |
+| Quest | RewardItemId | reference | `Item.Id` | error | QUEST_REWARD_REF |
+| SkillTree | Order | uniqueWithin | `ParentId` | warning | SKILL_ORDER_DUP |
+
+第一批规则限定为 `required`、`range`、`length`、`regex`、`oneOf`、`unique`、`reference`、`sequential`。规则参数需要自己的严格 parser、版本号和诊断;未知规则或参数必须报错。暂不支持任意表达式,以避免执行安全、跨语言一致性和错误定位问题。
+
+## 7. 诊断契约
+
+建议校验诊断码以 `DTV` 开头并保持稳定:
+
+| 范围 | 示例 | 含义 |
+| --- | --- | --- |
+| DTV1xxx | `DTV1001` | 单元格缺失或无法解析 |
+| DTV2xxx | `DTV2001` | 唯一性、范围、顺序等单表规则 |
+| DTV3xxx | `DTV3001` | tree/graph/matrix 类型语义 |
+| DTV4xxx | `DTV4001` | 跨表引用或项目规则 |
+| DTV9xxx | `DTV9001` | 规则配置错误或 validator 缺失 |
+
+每条诊断至少包含 `severity`、`code`、`message`、`file`、`sheet`;能定位时增加 `table`、`field`、`cell`、`logicalRow`、`rule` 和 `relatedLocations`。例如重复 Id 的主位置指向第二处,related location 指向第一处。
+
+聚合策略:
+
+- 同一规则默认最多报告 100 条,之后追加一条截断 Warning;
+- 输出按文件、Sheet、单元格、规则码稳定排序,保证 CI diff 可读;
+- `validate --diagnostics-json-output` 输出结构化数据,控制台输出简短修复提示;
+- `--warnings-as-errors`(后续选项)只改变退出结果,不改变原始 severity,便于工具消费。
+
+## 8. 建议生成流程
+
+```text
+1. 发现输入与逻辑表
+2. parser 生成 schema + layout
+3. schema validators 收集问题
+4. materializer 构造 TableDataSnapshot
+5. 单表 data validators 收集问题
+6. 单表完成后保留所需投影;catalog 就绪后运行 project validators
+7. 有 Error:输出报告并终止事务
+8. 无 Error:从同一份已解析值生成代码与 .bytes
+```
+
+`ValidateOnly` 与正常生成必须走 1–6 的完全相同路径;区别仅在第 8 步是否提交产物。增量生成不能仅凭输入未变就跳过项目级校验,因为被引用目标可能改变;manifest 需要记录 validation fingerprint、目标投影摘要与引用依赖,未实现这些记录时必须保守地重新运行项目规则。
+
+## 9. 分阶段实施与验收
+
+### P0:行为基线与观测(不改变生成结果)
+
+1. 用 characterization tests 固定六种类型当前的成功产物、失败时机和错误位置;
+2. 为现有 `Diagnostic` 设计向后兼容的 v2 DTO 与 JSON `formatVersion`;
+3. 建立唯一 supported/reserved 类型清单,并增加注册完整性架构测试;
+4. 记录生成阶段、峰值逻辑行/值数量和诊断数量,作为 snapshot 方案的预算基线;
+5. 建立 `ValidateOnly` 漏检 tree/graph 错误的失败测试,但此阶段不移动职责。
+
+验收:现有行为被自动化刻画;诊断格式可兼容演进;支持类型清单不存在重叠或漂移;没有新增默认 Error。
+
+### P1:统一 materialization 与既有规则迁移
+
+1. 引入内部 `TableDataSnapshot`、四态值、`SourceSpan` 和统一无状态类型转换;
+2. 让 serializer 消费规范值,先用双路径对照测试证明新旧 bytes 一致,再删除重复转换;
+3. 通过 `TableTypeDescriptor` 聚合 parser、materializer、validation passes、serialization 和 renderer;
+4. 将 tree/graph 既有规则从 serializer 迁出,使 `ValidateOnly` 与正常生成执行同一路径;
+5. 诊断 sink 支持根因抑制、限流、去重和稳定排序。
+
+验收:所有既有有效 fixture 的代码与 bytes 完全一致;tree/graph 的同一错误在两种生成模式产生相同 code 和位置;writer 中不再包含类型语义判断;快照峰值受预算保护。
+
+### P2:通用单表规则,以审计模式发布
+
+1. 增加 required、range、oneOf、regex、unique、sequential 的内部 rule definitions;
+2. 唯一索引报告首次位置与冲突位置,并抑制依赖无效值的派生错误;
+3. 新合理性规则默认 Warning/审计模式,记录规则兼容等级与启用版本;
+4. 计算 `ValidationFingerprint` 并写入增量 manifest 和诊断报告;
+5. 覆盖 table/column/matrix/kv/tree/graph 的成功、根因失败和诊断快照测试。
+
+验收:一次 validate 能报告多个独立根因;每个问题可定位到 Excel 单元格;审计模式不改变历史生成结果;显式严格模式下 Error 不提交任何产物。
+
+### P3:跨表语义与可选扩展 API
+
+1. 在输入发现阶段建立 `TableId` catalog 和表状态机;
+2. 只保留项目规则所需的唯一键/引用投影,实现跨 Sheet、跨文件 reference;
+3. 将标签、增量依赖、外部 manifest 和规则 fingerprint 纳入失效策略;
+4. 通过 ADR 冻结单一声明式格式;评估后再决定是否发布版本化插件 DTO;
+5. CLI、MSBuild、Unity 分别验证一致的规则发现、配置与诊断结果。
+
+验收:缺失、过滤、不可用、无效目标和缺失 key 产生不同诊断;规则或目标投影变化会使引用方重新校验;未启用插件时不存在动态程序集加载。
+
+## 10. 测试矩阵
+
+- **注册测试**:parser、validator、writer/template 支持类型集合一致,重复和未知注册失败;
+- **布局测试**:同一 required/unique 规则在 table 与 column 中映射到正确单元格;
+- **类型测试**:基础、enum、array、map、json、custom 的成功/失败解析与空值语义;
+- **结构测试**:matrix 重复坐标,tree 缺父节点/自引用/多节点环,graph 空端点/重复边/DAG 环;
+- **项目测试**:跨 Sheet/文件引用、标签过滤目标、目标生成失败、复合键;
+- **事务测试**:Error 不落盘,Warning 正常生成,ValidateOnly 无副作用;
+- **诊断快照**:文本与 JSON 的 code、位置、related locations 和稳定顺序;
+- **回归测试**:有效 fixture 生成的 C# 与 `.bytes` 保持确定性且成对更新。
+
+## 11. 待决策问题
+
+在进入对应 P1–P3 工作项前需要明确:
+
+1. 空字符串、空白字符串与缺失单元格在 `string` 和可空类型中是否等价;
+2. 数值范围使用源类型比较还是统一 decimal,比对浮点特殊值时如何处理;
+3. 跨表标识是否包含 namespace、命名表名和 tag 视图;
+4. Warning 严格化采用全局开关还是允许按规则码配置;
+5. 自定义 validator 的程序集加载、安全边界和 CLI/MSBuild/Unity 一致性;
+6. 声明式规则文件是否纳入生成 fingerprint,以及如何记录跨表依赖。
+
+在这些语义冻结前,P0 只建立行为与观测基线;P1 仅迁移已有、无歧义的规则,不引入会改变现有合法数据含义的默认限制。
diff --git a/docs/guides/table-types.md b/docs/guides/table-types.md
index 91e9cf4..d9e114f 100644
--- a/docs/guides/table-types.md
+++ b/docs/guides/table-types.md
@@ -2,6 +2,8 @@
本文档说明当前已支持和计划中的表格布局。表类型由 Sheet 元信息行中的 `DTGen` 值选择。
+各表类型的生成期数据合理性、结构语义与跨表校验扩展方案见 [表类型数据校验器设计](../designs/table-validation-design.md)。首个内部 validator 切片已经开始实施,但文中规划的声明式规则尚不是已发布语法。
+
## 已支持类型
当前代码中的默认解析器注册了 `table`、`matrix`、`column`、`kv`、`tree` 和 `graph`;代码模板也为这些类型提供生成器。
diff --git a/src/DataTables.GeneratorCore/DataTableGenerator.cs b/src/DataTables.GeneratorCore/DataTableGenerator.cs
index 4c773c1..5d364c4 100644
--- a/src/DataTables.GeneratorCore/DataTableGenerator.cs
+++ b/src/DataTables.GeneratorCore/DataTableGenerator.cs
@@ -688,6 +688,10 @@ private async Task GenerateExcel(string filePath, string usingNamespace, string
continue;
}
+ // Validate data semantics before rendering or writing either half of the generated pair.
+ // ValidateOnly intentionally runs the same validators as normal generation.
+ processor.ValidateData(sheet);
+
var codeContent = renderCode ? RenderCodeFile(context) : null;
var codeContentHash = codeContent == null ? null : ComputeContentHash(codeContent);
if (!outputClaims.TryReserve(context, filePath, codeContentHash, out var writeCode, out var conflict))
diff --git a/src/DataTables.GeneratorCore/DataTableProcessor.cs b/src/DataTables.GeneratorCore/DataTableProcessor.cs
index f0ccd77..6cefd53 100644
--- a/src/DataTables.GeneratorCore/DataTableProcessor.cs
+++ b/src/DataTables.GeneratorCore/DataTableProcessor.cs
@@ -7,6 +7,7 @@ namespace DataTables.GeneratorCore;
public sealed partial class DataTableProcessor : IDisposable
{
+ private static readonly TableDataValidatorRegistry s_TableDataValidatorRegistry = TableDataValidatorRegistry.CreateDefault();
private readonly GenerationContext m_Context;
private readonly IFormulaEvaluator m_FormulaEvaluator;
private readonly string m_Tags;
@@ -150,6 +151,21 @@ public bool ValidateGenerationContext()
return new TableSchemaValidator(m_Context, m_Options).Validate(m_FirstDataRowIndex);
}
+ internal void ValidateData(ISheet sheet)
+ {
+ if (!s_TableDataValidatorRegistry.TryGetValidator(m_Context.DataSetType, out var validator))
+ {
+ throw new InvalidOperationException($"DTGen={m_Context.DataSetType} 缺少数据校验器。");
+ }
+
+ validator.Validate(new TableDataValidationContext(
+ m_Context,
+ sheet,
+ m_FirstDataRowIndex,
+ GetCellString,
+ IgnoreDataRow));
+ }
+
private void ValidateFormulaCellString(ICell cell, string value)
{
if (!m_Options.ValidateFormulaConsistency || m_Options.FormulaPolicy == FormulaEvaluationPolicy.Off)
diff --git a/src/DataTables.GeneratorCore/Serialization/DataRowBinarySerializer.cs b/src/DataTables.GeneratorCore/Serialization/DataRowBinarySerializer.cs
index 0822053..f0af985 100644
--- a/src/DataTables.GeneratorCore/Serialization/DataRowBinarySerializer.cs
+++ b/src/DataTables.GeneratorCore/Serialization/DataRowBinarySerializer.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.IO;
using System.Linq;
using NPOI.SS.UserModel;
@@ -32,17 +31,7 @@ public int WriteDataRows(ISheet sheet, BinaryWriter writer)
{
int dataRowCount = 0;
- switch (m_Context.DataSetType)
- {
- case "tree":
- ValidateTreeRows(sheet);
- break;
- case "graph":
- ValidateGraphRows(sheet);
- break;
- case "kv":
- return WriteKvBytes(writer);
- }
+ if (m_Context.DataSetType == "kv") return WriteKvBytes(writer);
if (m_Context.DataSetType == "column")
{
@@ -213,83 +202,6 @@ private DataTableProcessor.DataProcessor GetDataProcessorWithDiagnostics(XField
}
}
- private void ValidateTreeRows(ISheet sheet)
- {
- var idField = m_Context.GetField("Id") ?? throw new InvalidOperationException("DTGen=tree 缺少 Id 字段");
- var parentField = m_Context.GetField("ParentId") ?? throw new InvalidOperationException("DTGen=tree 缺少 ParentId 字段");
- var orderField = m_Context.GetField("Order");
- var nodes = new Dictionary(StringComparer.Ordinal);
-
- for (int i = m_FirstDataRowIndex; i <= sheet.LastRowNum; i++)
- {
- var row = sheet.GetRow(i);
- if (!m_ShouldWriteDataRow(row)) continue;
- var id = m_GetCellString(row!.GetCell(idField.Index));
- var parentId = m_GetCellString(row.GetCell(parentField.Index));
- if (string.IsNullOrWhiteSpace(id)) throw new FormatException($"DTGen=tree Id 为空: row={i + 1}, cell={GetRowColString(i, idField.Index)}");
- if (!nodes.TryAdd(id, (parentId, i))) throw new FormatException($"DTGen=tree Id 重复: nodeId={id}, row={i + 1}, cell={GetRowColString(i, idField.Index)}");
- if (orderField != null)
- {
- var orderText = m_GetCellString(row.GetCell(orderField.Index));
- if (!string.IsNullOrWhiteSpace(orderText) && !decimal.TryParse(orderText, out _)) throw new FormatException($"DTGen=tree Order 不是合法数字: nodeId={id}, row={i + 1}, cell={GetRowColString(i, orderField.Index)}, value={orderText}");
- }
- }
-
- foreach (var (id, node) in nodes)
- {
- if (!string.IsNullOrEmpty(node.ParentId) && !nodes.ContainsKey(node.ParentId)) throw new FormatException($"DTGen=tree ParentId 引用不存在: nodeId={id}, parentId={node.ParentId}, row={node.Row + 1}");
- }
-
- var visited = new HashSet(StringComparer.Ordinal);
- var visiting = new HashSet(StringComparer.Ordinal);
- var path = new List();
- foreach (var id in nodes.Keys) Visit(id);
-
- void Visit(string id)
- {
- if (visited.Contains(id)) return;
- if (!visiting.Add(id))
- {
- var start = path.IndexOf(id);
- var cycle = start >= 0 ? path.Skip(start).Concat(new[] { id }) : new[] { id, id };
- throw new FormatException($"DTGen=tree 检测到循环引用: {string.Join(" -> ", cycle)}");
- }
- path.Add(id);
- var parentId = nodes[id].ParentId;
- if (!string.IsNullOrEmpty(parentId) && nodes.ContainsKey(parentId)) Visit(parentId);
- path.RemoveAt(path.Count - 1);
- visiting.Remove(id);
- visited.Add(id);
- }
- }
-
- private void ValidateGraphRows(ISheet sheet)
- {
- var edgeIdField = m_Context.GetField("EdgeId") ?? throw new InvalidOperationException("DTGen=graph 缺少 EdgeId 字段");
- var fromField = m_Context.GetField("From") ?? throw new InvalidOperationException("DTGen=graph 缺少 From 字段");
- var toField = m_Context.GetField("To") ?? throw new InvalidOperationException("DTGen=graph 缺少 To 字段");
- var weightField = m_Context.GetField("Weight");
- var edgeIds = new HashSet(StringComparer.Ordinal);
-
- for (int i = m_FirstDataRowIndex; i <= sheet.LastRowNum; i++)
- {
- var row = sheet.GetRow(i);
- if (!m_ShouldWriteDataRow(row)) continue;
- var edgeId = m_GetCellString(row!.GetCell(edgeIdField.Index));
- var from = m_GetCellString(row.GetCell(fromField.Index));
- var to = m_GetCellString(row.GetCell(toField.Index));
- if (string.IsNullOrWhiteSpace(edgeId)) throw new FormatException($"DTGen=graph EdgeId 为空: row={i + 1}, cell={GetRowColString(i, edgeIdField.Index)}");
- if (!edgeIds.Add(edgeId)) throw new FormatException($"DTGen=graph EdgeId 重复: edgeId={edgeId}, row={i + 1}, cell={GetRowColString(i, edgeIdField.Index)}");
- if (string.IsNullOrWhiteSpace(from)) throw new FormatException($"DTGen=graph From 为空: edgeId={edgeId}, row={i + 1}, cell={GetRowColString(i, fromField.Index)}");
- if (string.IsNullOrWhiteSpace(to)) throw new FormatException($"DTGen=graph To 为空: edgeId={edgeId}, row={i + 1}, cell={GetRowColString(i, toField.Index)}");
- if (weightField != null)
- {
- var weightText = m_GetCellString(row.GetCell(weightField.Index));
- if (!string.IsNullOrWhiteSpace(weightText) && !decimal.TryParse(weightText, out _)) throw new FormatException($"DTGen=graph Weight 不是合法数字: edgeId={edgeId}, row={i + 1}, cell={GetRowColString(i, weightField.Index)}, value={weightText}");
- }
- }
- }
-
private static string GetRowColString(int row, int col) => string.Format("{0}{1}", ConvertToDigit(col), row + 1);
private static string ConvertToDigit(int num)
diff --git a/src/DataTables.GeneratorCore/TableSchemaParserRegistry.cs b/src/DataTables.GeneratorCore/TableSchemaParserRegistry.cs
index 1438cc5..cfcf19c 100644
--- a/src/DataTables.GeneratorCore/TableSchemaParserRegistry.cs
+++ b/src/DataTables.GeneratorCore/TableSchemaParserRegistry.cs
@@ -9,7 +9,6 @@ public sealed class TableSchemaParserRegistry : ITableSchemaParserRegistry
private static readonly string[] s_ReservedDataSetTypes =
[
"localized",
- "tree",
"partitioned",
"versioned",
"patch"
diff --git a/src/DataTables.GeneratorCore/Validation/GraphTableDataValidator.cs b/src/DataTables.GeneratorCore/Validation/GraphTableDataValidator.cs
new file mode 100644
index 0000000..4828906
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/GraphTableDataValidator.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+
+namespace DataTables.GeneratorCore;
+
+internal sealed class GraphTableDataValidator : ITableDataValidator
+{
+ public string DataSetType => "graph";
+
+ public void Validate(TableDataValidationContext context)
+ {
+ var edgeIdField = context.Schema.GetField("EdgeId") ?? throw new InvalidOperationException("DTGen=graph 缺少 EdgeId 字段");
+ var fromField = context.Schema.GetField("From") ?? throw new InvalidOperationException("DTGen=graph 缺少 From 字段");
+ var toField = context.Schema.GetField("To") ?? throw new InvalidOperationException("DTGen=graph 缺少 To 字段");
+ var weightField = context.Schema.GetField("Weight");
+ var edgeIds = new HashSet(StringComparer.Ordinal);
+
+ for (int i = context.FirstDataRowIndex; i <= context.Sheet.LastRowNum; i++)
+ {
+ var row = context.Sheet.GetRow(i);
+ if (!context.ShouldValidateRow(row)) continue;
+ var edgeId = context.GetCellString(row!.GetCell(edgeIdField.Index));
+ var from = context.GetCellString(row.GetCell(fromField.Index));
+ var to = context.GetCellString(row.GetCell(toField.Index));
+ if (string.IsNullOrWhiteSpace(edgeId)) throw new FormatException($"DTGen=graph EdgeId 为空: row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, edgeIdField.Index)}");
+ if (!edgeIds.Add(edgeId)) throw new FormatException($"DTGen=graph EdgeId 重复: edgeId={edgeId}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, edgeIdField.Index)}");
+ if (string.IsNullOrWhiteSpace(from)) throw new FormatException($"DTGen=graph From 为空: edgeId={edgeId}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, fromField.Index)}");
+ if (string.IsNullOrWhiteSpace(to)) throw new FormatException($"DTGen=graph To 为空: edgeId={edgeId}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, toField.Index)}");
+ if (weightField != null)
+ {
+ var weightText = context.GetCellString(row.GetCell(weightField.Index));
+ if (!string.IsNullOrWhiteSpace(weightText) && !decimal.TryParse(weightText, out _))
+ {
+ throw new FormatException($"DTGen=graph Weight 不是合法数字: edgeId={edgeId}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, weightField.Index)}, value={weightText}");
+ }
+ }
+ }
+ }
+}
diff --git a/src/DataTables.GeneratorCore/Validation/ITableDataValidator.cs b/src/DataTables.GeneratorCore/Validation/ITableDataValidator.cs
new file mode 100644
index 0000000..226b766
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/ITableDataValidator.cs
@@ -0,0 +1,8 @@
+namespace DataTables.GeneratorCore;
+
+internal interface ITableDataValidator
+{
+ string DataSetType { get; }
+
+ void Validate(TableDataValidationContext context);
+}
diff --git a/src/DataTables.GeneratorCore/Validation/RowTableDataValidator.cs b/src/DataTables.GeneratorCore/Validation/RowTableDataValidator.cs
new file mode 100644
index 0000000..1c8fd58
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/RowTableDataValidator.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DataTables.GeneratorCore;
+
+internal sealed class RowTableDataValidator : ITableDataValidator
+{
+ public string DataSetType => "table";
+
+ public void Validate(TableDataValidationContext context)
+ {
+ foreach (var constraint in context.Schema.UniqueConstraints)
+ {
+ ValidateUniqueConstraint(context, constraint.Fields);
+ }
+ }
+
+ private static void ValidateUniqueConstraint(TableDataValidationContext context, IReadOnlyList fieldNames)
+ {
+ var fields = fieldNames.Select(name =>
+ context.Schema.GetField(name) ?? throw new InvalidOperationException($"唯一索引引用了不存在的字段: {name}"))
+ .ToArray();
+ var firstRows = new Dictionary(StringComparer.Ordinal);
+
+ for (var rowIndex = context.FirstDataRowIndex; rowIndex <= context.Sheet.LastRowNum; rowIndex++)
+ {
+ var row = context.Sheet.GetRow(rowIndex);
+ if (!context.ShouldValidateRow(row)) continue;
+
+ var values = fields.Select(field => context.GetCellString(row!.GetCell(field.Index))).ToArray();
+ var key = string.Join("\u001F", values.Select(value => $"{value.Length}:{value}"));
+ if (firstRows.TryGetValue(key, out var firstRow))
+ {
+ throw new FormatException(
+ $"唯一索引值重复: fields={string.Join("&", fieldNames)}, value=({string.Join(", ", values)}), " +
+ $"firstRow={firstRow + 1}, duplicateRow={rowIndex + 1}, " +
+ $"cell={TableDataValidationContext.GetCellReference(rowIndex, fields[0].Index)}");
+ }
+
+ firstRows.Add(key, rowIndex);
+ }
+ }
+}
diff --git a/src/DataTables.GeneratorCore/Validation/TableDataValidationContext.cs b/src/DataTables.GeneratorCore/Validation/TableDataValidationContext.cs
new file mode 100644
index 0000000..66d53b5
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/TableDataValidationContext.cs
@@ -0,0 +1,47 @@
+using System;
+using NPOI.SS.UserModel;
+
+namespace DataTables.GeneratorCore;
+
+internal sealed class TableDataValidationContext
+{
+ private readonly Func m_GetCellString;
+ private readonly Func m_ShouldWriteDataRow;
+
+ public TableDataValidationContext(
+ GenerationContext schema,
+ ISheet sheet,
+ int firstDataRowIndex,
+ Func getCellString,
+ Func shouldWriteDataRow)
+ {
+ Schema = schema;
+ Sheet = sheet;
+ FirstDataRowIndex = firstDataRowIndex;
+ m_GetCellString = getCellString;
+ m_ShouldWriteDataRow = shouldWriteDataRow;
+ }
+
+ public GenerationContext Schema { get; }
+
+ public ISheet Sheet { get; }
+
+ public int FirstDataRowIndex { get; }
+
+ public string GetCellString(ICell? cell) => m_GetCellString(cell);
+
+ public bool ShouldValidateRow(IRow? row) => m_ShouldWriteDataRow(row);
+
+ public static string GetCellReference(int row, int column)
+ {
+ return $"{ConvertToColumnName(column)}{row + 1}";
+ }
+
+ private static string ConvertToColumnName(int column)
+ {
+ if (column < 0) throw new ArgumentOutOfRangeException(nameof(column));
+ return column < 26
+ ? Convert.ToString((char)('A' + column))
+ : ConvertToColumnName(column / 26 - 1) + ConvertToColumnName(column % 26);
+ }
+}
diff --git a/src/DataTables.GeneratorCore/Validation/TableDataValidatorRegistry.cs b/src/DataTables.GeneratorCore/Validation/TableDataValidatorRegistry.cs
new file mode 100644
index 0000000..13219ef
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/TableDataValidatorRegistry.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DataTables.GeneratorCore;
+
+internal sealed class TableDataValidatorRegistry
+{
+ private readonly Dictionary m_Validators;
+
+ public TableDataValidatorRegistry(IEnumerable validators)
+ {
+ ArgumentNullException.ThrowIfNull(validators);
+ m_Validators = validators.ToDictionary(x => x.DataSetType, StringComparer.OrdinalIgnoreCase);
+ }
+
+ public static TableDataValidatorRegistry CreateDefault()
+ {
+ return new TableDataValidatorRegistry(
+ [
+ new RowTableDataValidator(),
+ new NoOpTableDataValidator("matrix"),
+ new NoOpTableDataValidator("column"),
+ new NoOpTableDataValidator("kv"),
+ new TreeTableDataValidator(),
+ new GraphTableDataValidator()
+ ]);
+ }
+
+ public IReadOnlyCollection SupportedDataSetTypes =>
+ m_Validators.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
+
+ public bool TryGetValidator(string dataSetType, out ITableDataValidator validator)
+ {
+ return m_Validators.TryGetValue(dataSetType, out validator!);
+ }
+
+ private sealed class NoOpTableDataValidator(string dataSetType) : ITableDataValidator
+ {
+ public string DataSetType { get; } = dataSetType;
+
+ public void Validate(TableDataValidationContext context)
+ {
+ }
+ }
+}
diff --git a/src/DataTables.GeneratorCore/Validation/TreeTableDataValidator.cs b/src/DataTables.GeneratorCore/Validation/TreeTableDataValidator.cs
new file mode 100644
index 0000000..46f4f23
--- /dev/null
+++ b/src/DataTables.GeneratorCore/Validation/TreeTableDataValidator.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace DataTables.GeneratorCore;
+
+internal sealed class TreeTableDataValidator : ITableDataValidator
+{
+ public string DataSetType => "tree";
+
+ public void Validate(TableDataValidationContext context)
+ {
+ var idField = context.Schema.GetField("Id") ?? throw new InvalidOperationException("DTGen=tree 缺少 Id 字段");
+ var parentField = context.Schema.GetField("ParentId") ?? throw new InvalidOperationException("DTGen=tree 缺少 ParentId 字段");
+ var orderField = context.Schema.GetField("Order");
+ var nodes = new Dictionary(StringComparer.Ordinal);
+
+ for (int i = context.FirstDataRowIndex; i <= context.Sheet.LastRowNum; i++)
+ {
+ var row = context.Sheet.GetRow(i);
+ if (!context.ShouldValidateRow(row)) continue;
+ var id = context.GetCellString(row!.GetCell(idField.Index));
+ var parentId = context.GetCellString(row.GetCell(parentField.Index));
+ if (string.IsNullOrWhiteSpace(id)) throw new FormatException($"DTGen=tree Id 为空: row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, idField.Index)}");
+ if (!nodes.TryAdd(id, (parentId, i))) throw new FormatException($"DTGen=tree Id 重复: nodeId={id}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, idField.Index)}");
+ if (orderField != null)
+ {
+ var orderText = context.GetCellString(row.GetCell(orderField.Index));
+ if (!string.IsNullOrWhiteSpace(orderText) && !decimal.TryParse(orderText, out _))
+ {
+ throw new FormatException($"DTGen=tree Order 不是合法数字: nodeId={id}, row={i + 1}, cell={TableDataValidationContext.GetCellReference(i, orderField.Index)}, value={orderText}");
+ }
+ }
+ }
+
+ foreach (var (id, node) in nodes)
+ {
+ if (!string.IsNullOrEmpty(node.ParentId) && !nodes.ContainsKey(node.ParentId))
+ {
+ throw new FormatException($"DTGen=tree ParentId 引用不存在: nodeId={id}, parentId={node.ParentId}, row={node.Row + 1}");
+ }
+ }
+
+ var visited = new HashSet(StringComparer.Ordinal);
+ var visiting = new HashSet(StringComparer.Ordinal);
+ var path = new List();
+ foreach (var id in nodes.Keys) Visit(id);
+
+ void Visit(string id)
+ {
+ if (visited.Contains(id)) return;
+ if (!visiting.Add(id))
+ {
+ var start = path.IndexOf(id);
+ var cycle = start >= 0 ? path.Skip(start).Concat(new[] { id }) : new[] { id, id };
+ throw new FormatException($"DTGen=tree 检测到循环引用: {string.Join(" -> ", cycle)}");
+ }
+
+ path.Add(id);
+ var parentId = nodes[id].ParentId;
+ if (!string.IsNullOrEmpty(parentId) && nodes.ContainsKey(parentId)) Visit(parentId);
+ path.RemoveAt(path.Count - 1);
+ visiting.Remove(id);
+ visited.Add(id);
+ }
+ }
+}
diff --git a/tests/DataTables.Tests/DataTableProcessorArchitectureTests.cs b/tests/DataTables.Tests/DataTableProcessorArchitectureTests.cs
index e5e6285..b3ea7d2 100644
--- a/tests/DataTables.Tests/DataTableProcessorArchitectureTests.cs
+++ b/tests/DataTables.Tests/DataTableProcessorArchitectureTests.cs
@@ -1,5 +1,6 @@
using System.IO;
using System.Linq;
+using DataTables.GeneratorCore;
using FluentAssertions;
using Xunit;
@@ -23,6 +24,26 @@ public void DataTableProcessor_Should_Remain_Orchestration_Only()
source.Should().NotContain("DataSetType == \"graph\"");
}
+ [Fact]
+ public void DataRowBinarySerializer_Should_Not_Own_Table_Semantic_Validation()
+ {
+ var source = File.ReadAllText(GetRepositoryPath(
+ "src", "DataTables.GeneratorCore", "Serialization", "DataRowBinarySerializer.cs"));
+
+ source.Should().NotContain("ValidateTreeRows");
+ source.Should().NotContain("ValidateGraphRows");
+ source.Should().NotContain("检测到循环引用");
+ source.Should().NotContain("ParentId 引用不存在");
+ }
+
+ [Fact]
+ public void ParserRegistry_Should_Not_Report_Supported_Types_As_Reserved()
+ {
+ var registry = TableSchemaParserRegistry.CreateDefault();
+
+ registry.SupportedDataSetTypes.Should().NotIntersectWith(registry.ReservedDataSetTypes);
+ }
+
private static string GetRepositoryPath(params string[] parts)
{
var current = Directory.GetCurrentDirectory();
diff --git a/tests/DataTables.Tests/GenerationTransactionTests.cs b/tests/DataTables.Tests/GenerationTransactionTests.cs
index c15fa7d..752bc27 100644
--- a/tests/DataTables.Tests/GenerationTransactionTests.cs
+++ b/tests/DataTables.Tests/GenerationTransactionTests.cs
@@ -281,6 +281,51 @@ public async Task ValidateOnlyGeneration_ShouldParseAndRenderWithoutWritingOutpu
logs.Should().Contain(message => message.Contains("数据表校验完成"));
}
+ [Theory]
+ [InlineData("tree", "ParentId 引用不存在")]
+ [InlineData("graph", "To 为空")]
+ public async Task ValidateOnlyGeneration_ShouldRunTableDataValidators(string dataSetType, string expectedError)
+ {
+ var root = CreateTempDirectory();
+ var input = Directory.CreateDirectory(Path.Combine(root, "input")).FullName;
+ var code = Directory.CreateDirectory(Path.Combine(root, "code")).FullName;
+ var data = Directory.CreateDirectory(Path.Combine(root, "data")).FullName;
+ await CreateWorkbookAsync(Path.Combine(input, $"{dataSetType}.xlsx"), workbook =>
+ {
+ if (dataSetType == "tree") AddInvalidTreeSheet(workbook);
+ else AddInvalidGraphSheet(workbook);
+ });
+
+ var result = await GenerateAsync(input, code, data, generationMode: GenerationMode.ValidateOnly);
+
+ result.Succeeded.Should().BeFalse();
+ result.Failures.Should().ContainSingle(x => x.Exception.Message.Contains(expectedError));
+ Directory.GetFiles(code, "*", SearchOption.AllDirectories).Should().BeEmpty();
+ Directory.GetFiles(data, "*", SearchOption.AllDirectories).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task ValidateOnlyGeneration_ShouldRejectDuplicateUniqueIndexBeforeWritingOutputs()
+ {
+ var root = CreateTempDirectory();
+ var input = Directory.CreateDirectory(Path.Combine(root, "input")).FullName;
+ var code = Directory.CreateDirectory(Path.Combine(root, "code")).FullName;
+ var data = Directory.CreateDirectory(Path.Combine(root, "data")).FullName;
+ await CreateWorkbookAsync(Path.Combine(input, "duplicates.xlsx"), workbook =>
+ {
+ AddTableSheet(workbook, "Items", "Item", 7);
+ workbook.GetSheet("Items").GetRow(0).GetCell(0).SetCellValue("dtgen=table, class=Item, index=Id");
+ workbook.GetSheet("Items").CreateRow(5).CreateCell(0, CellType.Numeric).SetCellValue(7);
+ });
+
+ var result = await GenerateAsync(input, code, data, generationMode: GenerationMode.ValidateOnly);
+
+ result.Succeeded.Should().BeFalse();
+ result.Failures.Should().ContainSingle(x => x.Exception.Message.Contains("唯一索引值重复"));
+ Directory.GetFiles(code, "*", SearchOption.AllDirectories).Should().BeEmpty();
+ Directory.GetFiles(data, "*", SearchOption.AllDirectories).Should().BeEmpty();
+ }
+
[Fact]
public async Task ValidateOnlyGeneration_ShouldReportTemplateAndSchemaConflictsWithoutTouchingOutputs()
{
@@ -602,6 +647,32 @@ private static void AddTableSheet(XSSFWorkbook workbook, string sheetName, strin
sheet.GetRow(4).CreateCell(1, CellType.String).SetCellValue("Item");
}
+ private static void AddInvalidTreeSheet(XSSFWorkbook workbook)
+ {
+ var sheet = workbook.CreateSheet("Tree");
+ sheet.CreateRow(0).CreateCell(0).SetCellValue("dtgen=tree, class=TreeNode");
+ SetRow(sheet, 1, "Identifier", "Parent");
+ SetRow(sheet, 2, "Id", "ParentId");
+ SetRow(sheet, 3, "string", "string");
+ SetRow(sheet, 4, "child", "missing-parent");
+ }
+
+ private static void AddInvalidGraphSheet(XSSFWorkbook workbook)
+ {
+ var sheet = workbook.CreateSheet("Graph");
+ sheet.CreateRow(0).CreateCell(0).SetCellValue("dtgen=graph, class=GraphEdge");
+ SetRow(sheet, 1, "Identifier", "Source", "Target");
+ SetRow(sheet, 2, "EdgeId", "From", "To");
+ SetRow(sheet, 3, "string", "string", "string");
+ SetRow(sheet, 4, "edge-1", "node-a", string.Empty);
+ }
+
+ private static void SetRow(ISheet sheet, int rowIndex, params string[] values)
+ {
+ var row = sheet.CreateRow(rowIndex);
+ for (var i = 0; i < values.Length; i++) row.CreateCell(i).SetCellValue(values[i]);
+ }
+
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "dt_generation_transaction_" + Guid.NewGuid().ToString("N"));
diff --git a/tests/DataTables.Tests/ParserTests.cs b/tests/DataTables.Tests/ParserTests.cs
index 5d1cc9f..e540f6b 100644
--- a/tests/DataTables.Tests/ParserTests.cs
+++ b/tests/DataTables.Tests/ParserTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using FluentAssertions;
using NPOI.XSSF.UserModel;
using Xunit;
@@ -190,7 +191,7 @@ public void CreateGenerationContext_Should_Report_Unknown_DTGen_Type()
diagnostic.Cell.Should().Be("A1");
diagnostic.Message.Should().Contain("声明值: unknown");
diagnostic.Message.Should().Contain("支持的类型: column, graph, kv, matrix, table, tree");
- diagnostic.Message.Should().Contain("预留类型: localized, tree, partitioned, versioned, patch");
+ diagnostic.Message.Should().Contain("预留类型: localized, partitioned, versioned, patch");
}
[Fact]
@@ -243,4 +244,3 @@ public void GraphTableTemplate_Should_Emit_Graph_Query_Api()
}
}
-