Prohibit new assertions to unknown
禁止新增到 unknown 的类型断言
An assertion through `unknown` removes TypeScript's compatibility check between the original value and a subsequent asserted type. Production code can hide an incorrect interface, and tests can claim that an incomplete fixture satisfies a service. Existing uses also include legitimate widening of untyped parser results, which can use explicit `unknown` declarations instead. The repository contains assertions across m
English
Problem
An assertion through unknown removes TypeScript's compatibility check between the original value and a subsequent asserted type. Production code can hide an incorrect interface, and tests can claim that an incomplete fixture satisfies a service. Existing uses also include legitimate widening of untyped parser results, which can use explicit unknown declarations instead.
The repository contains assertions across many independent package and test owners. Removing all of them in one change would combine the enforcement decision with unrelated type and fixture redesigns, making the behavior of each replacement harder to review.
Decision
New direct assertions to unknown are prohibited in first-party JavaScript and TypeScript, including tests, fixtures, benchmarks, and scripts. Both as unknown and <unknown> syntax are covered, including parenthesized target types and union targets with a direct unknown member. An unknown declaration remains valid: external data can enter as const value: unknown = JSON.parse(text) and reach its declared parser before use. Typed in-process values retain the existing rule against unnecessary runtime validation.
The checker, exposed as pnpm run verify-no-unknown-casts, uses the TypeScript syntax tree. It scans tracked source files and non-ignored untracked source files; vendored sources and frozen archived Agent Notes retain their ownership exclusions. The checker ignores comments, strings, and declarations of unknown.
Existing assertions have an exact baseline keyed by repository-relative path, a SHA-256 digest of the assertion's syntax tokens, and occurrence count. Baseline edits may only delete entries or decrease counts; reviewers must reject added files, new fingerprints, and increased counts. New or changed assertions outside the baseline and stale baseline entries fail the check; a file-wide allowance or a count budget cannot authorize replacement debt. --prune mechanically removes entries or reduces counts and refuses to write while new violations exist. There is no command that adds new exceptions.
The checker participates in CI static and primary modes and the local hygiene and check-all profiles in the gate runner. Existing casts remain visible debt for their owners to remove with suitable type, parser, or fixture changes.
Alternatives considered
Remove every existing assertion before enforcing the rule. Rejected because the cleanup spans unrelated services, wire parsers, and test doubles. Exact existing exceptions let enforcement begin without treating a bulk substitution as a type-safety improvement.
Allow whole files or a repository-wide count. Rejected because either permits a new assertion to replace a removed one. The baseline identifies each existing assertion and rejects increased occurrence counts.
Search text for as unknown. Rejected because comments and strings can contain the phrase, while whitespace, comments between tokens, and angle-bracket syntax can conceal an actual assertion from a text pattern.
Consequences
Owners must resolve a cast before changing or moving its assertion into an unlisted form or file. Any change to an operand token, including an identifier rename or a quote, trailing comma, or parentheses edit, invalidates the fingerprint even when runtime behavior is unchanged. Formatting whitespace and comments alone do not change the recorded tokens. Removing a cast also requires pruning its baseline entry, so obsolete allowances cannot silently accumulate.
Passing this check establishes absence of unlisted direct assertions to unknown, including union targets absorbed by a direct unknown member. It does not establish general type safety or resolve type aliases or intersections. JavaScript JSDoc @type assertions, unknown[], Promise<unknown>, and other containing types are outside this syntactic rule. Replacing a cast with any, another unchecked assertion, or a helper that merely hides the conversion does not resolve the underlying typing problem.
中文
问题
经由 unknown 的类型断言会移除 TypeScript 对原始值与后续断言类型之间的兼容性检查。生产代码可能因此掩盖错误的接口,测试也可能声称不完整的 fixture(测试前置数据)满足某项服务类型。现有用法还包括合理地将无类型的解析结果拓宽为 unknown,这种情况可以改用显式的 unknown 声明。
仓库中的断言分布在许多相互独立的包和测试中。一次性全部移除会把执行规则的决策与无关的类型、fixture 重新设计混在一起,使每处替换的行为更难审查。
决策
第一方 JavaScript 和 TypeScript 禁止新增直接断言到 unknown 的表达式,范围包括测试、fixture、基准测试和脚本。规则覆盖 as unknown 和 <unknown> 两种语法,也覆盖带括号的目标类型,以及直接包含 unknown 成员的联合目标类型。unknown 声明仍然有效:外部数据可以通过 const value: unknown = JSON.parse(text) 进入,再交给其声明的解析器处理后使用。同进程内已有类型的值仍遵循不得添加不必要运行时校验的现有规则。
检查器通过 pnpm run verify-no-unknown-casts 运行,使用 TypeScript 语法树。它扫描已跟踪的源文件和未被忽略的未跟踪源文件;vendored 源码和已冻结的归档 Agent Notes 保留各自维护规则要求的排除项。检查器忽略注释、字符串和 unknown 声明。
现有断言使用精确基线记录,键由仓库相对路径、断言语法 token 的 SHA-256 摘要及出现次数组成。基线修改只能删除条目或减少次数;审查者必须拒绝新增文件、新增指纹和增加次数。基线之外新增或变化的断言以及陈旧的基线条目都会使检查失败;整个文件的放行或数量预算不能授权替换成新的技术债。--prune 机械地删除条目或减少次数,存在新增违规时拒绝写入。没有可添加新例外的命令。
检查器加入 CI 静态模式和主模式,以及门禁运行器中的本地 hygiene 和 check-all 配置。现有断言仍是明确记录的技术债,由所属模块通过适当的类型、解析器或 fixture 修改来移除。
考虑过的替代方案
先移除所有现有断言,再执行规则。 不采用,因为清理涉及互不相关的服务、协议解析器和测试替身。精确记录现有例外后即可开始执行规则,无须把批量替换视为类型安全的改善。
放行整个文件或设置仓库级数量限制。 不采用,因为两者都允许用新增断言替代已删除的断言。基线识别每个现有断言,并拒绝增加出现次数。
在文本中搜索 as unknown。 不采用,因为注释和字符串可能包含这段文字,而空白、token 之间的注释和尖括号语法可能让实际断言躲过文本模式。
影响
所属模块必须先解决类型转换问题,才能将断言改为未登记的形式或移至未登记的文件。操作数 token 的任何变化,包括标识符重命名或引号、尾随逗号、括号修改,即使不改变运行时行为,也会使指纹失效。仅修改格式空白和注释不会改变所记录的 token。删除断言后还须缩减其基线条目,防止无效例外悄然累积。
通过检查只说明没有未登记的、直接到 unknown 的类型断言,其中包括被直接 unknown 成员吸收的联合目标类型。它不证明整体类型安全,也不解析类型别名或交叉类型。JavaScript JSDoc @type 断言、unknown[]、Promise<unknown> 等包含该类型的类型不属于这条语法规则。将断言替换成 any、另一种未经检查的断言,或仅隐藏转换的辅助函数,都没有解决底层类型问题。