fix(infra): stabilize cast fingerprints and reject unknown unions

This commit is contained in:
Tianyi Cui committed 2026-09-19 20:13:58 +08:00
1 parent 580bdc7258
commit fd0654e96b
6 files changed
+1283 -1253

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-19-no-unknown-casts.md
2026-09-19-no-unknown-casts.md: 7315f1d8a2620ce44c1ee4854686a1703a498dbb
2026-09-19-no-unknown-casts.zh.md: 240ade338424e54e00475f9a7e26f31f530f4a65
2026-09-19-no-unknown-casts.md: 2f90422d0980fddc2569951ef7f8dba34b488ce4
2026-09-19-no-unknown-casts.zh.md: 3b66236e586a60a61cdcc1f93b1c0c14ce27eb15
@@ -12,11 +12,11 @@ The repository contains assertions across many independent package and test owne
## 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 a parenthesized target type. 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.
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](../../../../scripts/verify-no-unknown-casts.ts), 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. Comments, strings, and declarations of `unknown` are not assertions.
The [checker](../../../../scripts/verify-no-unknown-casts.ts), 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. Maintainers may only reduce this baseline. New or changed assertions and stale baseline entries fail the check; a file-wide allowance or a count budget cannot authorize replacement debt. `--prune` only removes entries or reduces counts and refuses to write while new violations exist. There is no command that adds new exceptions.
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](../../../../scripts/run-gates.ts). Existing casts remain visible debt for their owners to remove with suitable type, parser, or fixture changes.
@@ -30,6 +30,6 @@ The checker participates in CI static and primary modes and the local `hygiene`
## Consequences
Owners must resolve a cast before changing or moving its assertion into an unlisted form or file. Formatting whitespace and comments do not change the recorded tokens. Removing a cast also requires pruning its baseline entry, so obsolete allowances cannot silently accumulate.
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`. It does not establish general type safety or follow type aliases; `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.
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.
@@ -12,11 +12,11 @@ Status: implemented
## 决策
第一方 JavaScript 和 TypeScript 禁止新增直接断言到 `unknown` 的表达式,范围包括测试、fixture、基准测试和脚本。规则覆盖 `as unknown` 和 `<unknown>` 两种语法,也覆盖带括号的目标类型。`unknown` 声明仍然有效:外部数据可以通过 `const value: unknown = JSON.parse(text)` 进入,再交给其声明的解析器处理后使用。同进程内已有类型的值仍遵循不得添加不必要运行时校验的现有规则。
第一方 JavaScript 和 TypeScript 禁止新增直接断言到 `unknown` 的表达式,范围包括测试、fixture、基准测试和脚本。规则覆盖 `as unknown` 和 `<unknown>` 两种语法,也覆盖带括号的目标类型,以及直接包含 `unknown` 成员的联合目标类型。`unknown` 声明仍然有效:外部数据可以通过 `const value: unknown = JSON.parse(text)` 进入,再交给其声明的解析器处理后使用。同进程内已有类型的值仍遵循不得添加不必要运行时校验的现有规则。
[检查器](../../../../scripts/verify-no-unknown-casts.ts)通过 `pnpm run verify-no-unknown-casts` 运行,使用 TypeScript 语法树。它扫描已跟踪的源文件和未被忽略的未跟踪源文件;vendored 源码和已冻结的归档 Agent Notes 保留各自维护规则要求的排除项。注释、字符串和 `unknown` 声明不属于类型断言。
[检查器](../../../../scripts/verify-no-unknown-casts.ts)通过 `pnpm run verify-no-unknown-casts` 运行,使用 TypeScript 语法树。它扫描已跟踪的源文件和未被忽略的未跟踪源文件;vendored 源码和已冻结的归档 Agent Notes 保留各自维护规则要求的排除项。检查器忽略注释、字符串和 `unknown` 声明。
现有断言使用精确基线记录,键由仓库相对路径、断言语法 token 的 SHA-256 摘要及出现次数组成。维护者只能缩减这份基线。新增或变化的断言以及陈旧的基线条目都会使检查失败;整个文件的放行或数量预算不能授权替换成新的技术债。`--prune` 只删除条目或减少次数,存在新增违规时拒绝写入。没有可添加新例外的命令。
现有断言使用精确基线记录,键由仓库相对路径、断言语法 token 的 SHA-256 摘要及出现次数组成。基线修改只能删除条目或减少次数;审查者必须拒绝新增文件、新增指纹和增加次数。基线之外新增或变化的断言以及陈旧的基线条目都会使检查失败;整个文件的放行或数量预算不能授权替换成新的技术债。`--prune` 机械地删除条目或减少次数,存在新增违规时拒绝写入。没有可添加新例外的命令。
检查器加入 CI 静态模式和主模式,以及[门禁运行器](../../../../scripts/run-gates.ts)中的本地 `hygiene` 和 `check-all` 配置。现有断言仍是明确记录的技术债,由所属模块通过适当的类型、解析器或 fixture 修改来移除。
@@ -30,6 +30,6 @@ Status: implemented
## 影响
所属模块必须先解决类型转换问题,才能将断言改为未登记的形式或移至未登记的文件。格式空白和注释不会改变所记录的 token。删除断言后还须缩减其基线条目,防止无效例外悄然累积。
所属模块必须先解决类型转换问题,才能将断言改为未登记的形式或移至未登记的文件。操作数 token 的任何变化,包括标识符重命名或引号、尾随逗号、括号修改,即使不改变运行时行为,也会使指纹失效。仅修改格式空白和注释不会改变所记录的 token。删除断言后还须缩减其基线条目,防止无效例外悄然累积。
通过检查只说明没有未登记的、直接到 `unknown` 的类型断言。它不证明整体类型安全,也不追踪类型别名;`unknown[]`、`Promise<unknown>` 等包含该类型的类型不属于这条语法规则。将断言替换成 `any`、另一种未经检查的断言,或仅隐藏转换的辅助函数,都没有解决底层类型问题。
通过检查只说明没有未登记的、直接到 `unknown` 的类型断言,其中包括被直接 `unknown` 成员吸收的联合目标类型。它不证明整体类型安全,也不解析类型别名或交叉类型。JavaScript JSDoc `@type` 断言、`unknown[]`、`Promise<unknown>` 等包含该类型的类型不属于这条语法规则。将断言替换成 `any`、另一种未经检查的断言,或仅隐藏转换的辅助函数,都没有解决底层类型问题。
File diff suppressed because it is too large. Load diff
+29 -2
View File
@@ -52,6 +52,12 @@ describe('unknown assertion syntax', () => {
['parenthesized type', 'input.ts', 'value as ((unknown))', 1],
['angle assertion', 'input.ts', '<unknown>value', 1],
['parenthesized angle type', 'input.ts', '<(unknown)>value', 1],
['union', 'input.ts', 'value as unknown | string', 1],
['union with trailing unknown', 'input.ts', 'value as string | unknown', 1],
['parenthesized union', 'input.ts', 'value as (unknown | never)', 1],
['nested union', 'input.ts', 'value as string | (unknown | number)', 1],
['angle union assertion', 'input.ts', '<unknown | string>value', 1],
['escaped keyword', 'input.ts', String.raw`value as \u0075nknown`, 1],
['TSX expression', 'input.tsx', '<View value={input as unknown} />', 1],
['template interpolation', 'input.ts', '`text ${value as unknown}`', 1],
])('finds %s', (_name, file, text, count) => {
@@ -64,9 +70,13 @@ describe('unknown assertion syntax', () => {
'const text = `value as unknown`',
'const result = value as unknown[]',
'const result = value as Record<string, unknown>',
'const result = value as unknown[] | string',
'const result = value as unknown & { id: string }',
'const result = value as string | (unknown & { id: string })',
'const result = <unknown[]>value',
'const result: unknown = value',
'type Input = unknown; const result = value as Input',
'type Input = unknown; const result = value as Input | string',
])('accepts source without a direct assertion: %s', (text) => {
expect(findUnknownCasts('input.ts', text)).toEqual([])
})
@@ -76,6 +86,10 @@ describe('unknown assertion syntax', () => {
expect(findUnknownCasts('input.tsx', '<View>{value as unknown}</View>')).toHaveLength(1)
})
it('excludes JavaScript JSDoc assertions', () => {
expect(findUnknownCasts('input.js', 'const result = /** @type {unknown} */ (value)')).toEqual([])
})
it('reports normalized paths and the asserted type line', () => {
const casts = findUnknownCasts('packages\\example\\index.ts', '\nvalue as\nunknown')
expect(casts).toHaveLength(1)
@@ -90,6 +104,13 @@ describe('unknown assertion syntax', () => {
expect(findUnknownCasts('input.ts', 'call(value, "other") as unknown').map(cast => cast.fingerprint))
.not.toEqual(original.map(cast => cast.fingerprint))
})
it.each([
['value as unknown', '6fe9024d30e0d4757a348b83d246925b3f5aeaf2e79a0eff4bf12f3c9fb5826b'],
['call() as unknown', '49f7ac9934e1a7fc08876cdf6f900bd05c74d7d25801b6ec2d4eed02c3221f8d'],
])('preserves the recorded fingerprint for %s', (text, fingerprint) => {
expect(findUnknownCasts('input.ts', text)[0]?.fingerprint).toBe(fingerprint)
})
})
describe('unknown assertion inventory', () => {
@@ -107,6 +128,7 @@ describe('unknown assertion inventory', () => {
['additional assertion', `${assertion}const other = input as unknown\n`],
['duplicate assertion', `${assertion}${assertion}`],
['replacement assertion', 'const result = other as unknown\n'],
['union assertion', 'const result = value as unknown | string\n'],
])('rejects an %s without changing the baseline', (_name, text) => {
const root = fixture()
write(root, sourcePath, assertion)
@@ -117,9 +139,14 @@ describe('unknown assertion inventory', () => {
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(baseline)
})
it('rejects a new assertion when the inventory is empty', () => {
it.each([
assertion,
'const result = value as (unknown | never)\n',
'const result = value as string | (unknown | number)\n',
String.raw`const result = value as \u0075nknown`,
])('rejects a new assertion when the inventory is empty: %s', (text) => {
const root = fixture()
write(root, sourcePath, assertion)
write(root, sourcePath, text)
expect(() => verifyNoUnknownCasts(root)).toThrow(`${sourcePath}:1`)
})
+12 -9
View File
@@ -23,22 +23,27 @@ export interface UnknownCast {
}
function syntaxFingerprint(node: ts.Node, source: ts.SourceFile): string {
const tokens: [number, string][] = []
const tokens: string[] = []
const visit = (child: ts.Node): void => {
if (ts.isJSDoc(child)) return
const children = child.getChildren(source)
if (children.length === 0) tokens.push([child.kind, child.getText(source)])
else children.forEach(visit)
if (ts.isToken(child)) tokens.push(child.getText(source))
else child.getChildren(source).forEach(visit)
}
visit(node)
return createHash('sha256').update(JSON.stringify(tokens)).digest('hex')
}
function assertsUnknown(type: ts.TypeNode): boolean {
if (ts.isParenthesizedTypeNode(type)) return assertsUnknown(type.type)
return type.kind === ts.SyntaxKind.UnknownKeyword
|| (ts.isUnionTypeNode(type) && type.types.some(assertsUnknown))
}
/**
* Find direct assertions to unknown, including angle assertions and parenthesized types.
* Find assertions to unknown, including angle syntax and unknown members of asserted unions.
* @param file - repository-relative source path; its extension selects TS or TSX parsing.
* @param text - source contents.
* @returns assertions in source order; comments, strings, aliases and unknown containers do not match.
* @returns assertions in AST traversal order; comments, strings, aliases and unknown containers do not match.
*/
export function findUnknownCasts(file: string, text: string): UnknownCast[] {
const normalized = file.replaceAll('\\', '/')
@@ -46,9 +51,7 @@ export function findUnknownCasts(file: string, text: string): UnknownCast[] {
const casts: UnknownCast[] = []
const visit = (node: ts.Node): void => {
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
let type = node.type
while (ts.isParenthesizedTypeNode(type)) type = type.type
if (type.kind === ts.SyntaxKind.UnknownKeyword) {
if (assertsUnknown(node.type)) {
casts.push({
file: normalized,
line: source.getLineAndCharacterOfPosition(node.type.getStart(source)).line + 1,