feat(infra): prohibit new assertions to unknown

This commit is contained in:
Tianyi Cui committed 2026-09-19 20:13:58 +08:00
1 parent bc45bd821a
commit c5d775d437
10 files changed
+3288

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
@@ -0,0 +1,35 @@
# Agent Note: Prohibit new assertions to unknown
Status: implemented
English | [中文](2026-09-19-no-unknown-casts.zh.md)
## 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 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.
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.
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.
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.
## 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. 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.
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.
@@ -0,0 +1,35 @@
# Agent Note: 禁止新增到 unknown 的类型断言
Status: implemented
[English](2026-09-19-no-unknown-casts.md) | 中文
## 问题
经由 `unknown` 的类型断言会移除 TypeScript 对原始值与后续断言类型之间的兼容性检查。生产代码可能因此掩盖错误的接口,测试也可能声称不完整的 fixture(测试前置数据)满足某项服务类型。现有用法还包括合理地将无类型的解析结果拓宽为 `unknown`,这种情况可以改用显式的 `unknown` 声明。
仓库中的断言分布在许多相互独立的包和测试中。一次性全部移除会把执行规则的决策与无关的类型、fixture 重新设计混在一起,使每处替换的行为更难审查。
## 决策
第一方 JavaScript 和 TypeScript 禁止新增直接断言到 `unknown` 的表达式,范围包括测试、fixture、基准测试和脚本。规则覆盖 `as 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` 声明不属于类型断言。
现有断言使用精确基线记录,键由仓库相对路径、断言语法 token 的 SHA-256 摘要及出现次数组成。维护者只能缩减这份基线。新增或变化的断言以及陈旧的基线条目都会使检查失败;整个文件的放行或数量预算不能授权替换成新的技术债。`--prune` 只删除条目或减少次数,存在新增违规时拒绝写入。没有可添加新例外的命令。
检查器加入 CI 静态模式和主模式,以及[门禁运行器](../../../../scripts/run-gates.ts)中的本地 `hygiene` 和 `check-all` 配置。现有断言仍是明确记录的技术债,由所属模块通过适当的类型、解析器或 fixture 修改来移除。
## 考虑过的替代方案
**先移除所有现有断言,再执行规则。** 不采用,因为清理涉及互不相关的服务、协议解析器和测试替身。精确记录现有例外后即可开始执行规则,无须把批量替换视为类型安全的改善。
**放行整个文件或设置仓库级数量限制。** 不采用,因为两者都允许用新增断言替代已删除的断言。基线识别每个现有断言,并拒绝增加出现次数。
**在文本中搜索 `as unknown`。** 不采用,因为注释和字符串可能包含这段文字,而空白、token 之间的注释和尖括号语法可能让实际断言躲过文本模式。
## 影响
所属模块必须先解决类型转换问题,才能将断言改为未登记的形式或移至未登记的文件。格式空白和注释不会改变所记录的 token。删除断言后还须缩减其基线条目,防止无效例外悄然累积。
通过检查只说明没有未登记的、直接到 `unknown` 的类型断言。它不证明整体类型安全,也不追踪类型别名;`unknown[]`、`Promise<unknown>` 等包含该类型的类型不属于这条语法规则。将断言替换成 `any`、另一种未经检查的断言,或仅隐藏转换的辅助函数,都没有解决底层类型问题。
+1
View File
@@ -140,6 +140,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent.
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
- **Trust TypeScript at typed same-process boundaries.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries.
- **No new assertions to `unknown`** (`as unknown` or `<unknown>`). Preserve or reduce the exact legacy baseline; use typed values or validation for replacements ([rule](.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md)).
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
- **An empty `catch` names the error** and why; keep its `try` to one statement.
+1
View File
@@ -99,6 +99,7 @@
"verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts",
"verify-repository-references": "tsx scripts/verify-repository-references.ts",
"verify-concrete-terms": "tsx scripts/verify-concrete-terms.ts",
"verify-no-unknown-casts": "tsx scripts/verify-no-unknown-casts.ts",
"verify-doc-refs": "tsx scripts/verify-doc-refs.ts",
"verify-subsystem-pages": "tsx scripts/verify-subsystem-pages.ts",
"verify-package-paths": "tsx scripts/verify-package-paths.ts",
File diff suppressed because it is too large. Load diff
+11
View File
@@ -319,6 +319,7 @@ describe('gate graph validation', () => {
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'no-unknown-casts',
'cordis-config', 'runtime-closure',
])
})
@@ -360,6 +361,16 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'rejects new unknown casts in %s',
(mode) => {
const gate = withPnpmEntrypoint(() => gatesForMode(mode).find(subject => subject.id === 'no-unknown-casts'))
expect(gate?.args).toContain('verify-no-unknown-casts')
expect(gate?.allowFailure).not.toBe(true)
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'keeps package dependency enforcement in %s',
(mode) => {
+1
View File
@@ -363,6 +363,7 @@ function sharedHygieneGates(): Gate[] {
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('no-unknown-casts', 'verify-no-unknown-casts', { label: 'no new unknown casts' }),
]
}
+236
View File
@@ -0,0 +1,236 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { countUnknownCasts, findUnknownCasts, scanUnknownCasts, verifyNoUnknownCasts } from './verify-no-unknown-casts.ts'
const roots: string[] = []
const baselinePath = 'scripts/no-unknown-casts.baseline.json'
const sourcePath = 'packages/example/src/index.ts'
const assertion = 'const result = value as unknown\n'
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function write(root: string, file: string, contents: string): void {
const path = join(root, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, contents)
}
function git(root: string, ...args: string[]): void {
execFileSync('git', args, { cwd: root, stdio: 'pipe' })
}
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-unknown-casts-'))
roots.push(root)
git(root, 'init', '--quiet')
for (const area of ['packages', 'apps', 'scripts', 'website']) {
write(root, `${area}/seed.ts`, 'export {}\n')
}
write(root, baselinePath, '{}\n')
git(root, 'add', '.')
return root
}
function recordBaseline(root: string): string {
const text = `${JSON.stringify(countUnknownCasts(scanUnknownCasts(root)), null, 2)}\n`
write(root, baselinePath, text)
return text
}
describe('unknown assertion syntax', () => {
it.each([
['standalone', 'input.ts', 'value as unknown', 1],
['double assertion', 'input.ts', 'value as unknown as Result', 1],
['nested assertions', 'input.ts', '(value as unknown) as unknown', 2],
['multiline', 'input.ts', 'value as\nunknown', 1],
['comment-separated', 'input.ts', 'value as /* explanation */ unknown', 1],
['parenthesized type', 'input.ts', 'value as ((unknown))', 1],
['angle assertion', 'input.ts', '<unknown>value', 1],
['parenthesized angle type', 'input.ts', '<(unknown)>value', 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) => {
expect(findUnknownCasts(file, text)).toHaveLength(count)
})
it.each([
'// value as unknown\n/* <unknown>value */',
'const text = "value as unknown"',
'const text = `value as unknown`',
'const result = value as unknown[]',
'const result = value as Record<string, unknown>',
'const result = <unknown[]>value',
'const result: unknown = value',
'type Input = unknown; const result = value as Input',
])('accepts source without a direct assertion: %s', (text) => {
expect(findUnknownCasts('input.ts', text)).toEqual([])
})
it('accepts JSX text without ignoring assertions in expressions', () => {
expect(findUnknownCasts('input.tsx', '<View>as unknown</View>')).toEqual([])
expect(findUnknownCasts('input.tsx', '<View>{value as unknown}</View>')).toHaveLength(1)
})
it('reports normalized paths and the asserted type line', () => {
const casts = findUnknownCasts('packages\\example\\index.ts', '\nvalue as\nunknown')
expect(casts).toHaveLength(1)
expect(casts[0]).toMatchObject({ file: 'packages/example/index.ts', line: 3 })
expect(casts[0]?.fingerprint).toMatch(/^[a-f0-9]{64}$/u)
})
it('keeps syntax fingerprints through whitespace and comment edits', () => {
const original = findUnknownCasts('input.ts', 'call(value, "text") as unknown')
const formatted = findUnknownCasts('input.ts', '\ncall /* call */ (value,\n "text") as /* type */ unknown')
expect(formatted.map(cast => cast.fingerprint)).toEqual(original.map(cast => cast.fingerprint))
expect(findUnknownCasts('input.ts', 'call(value, "other") as unknown').map(cast => cast.fingerprint))
.not.toEqual(original.map(cast => cast.fingerprint))
})
})
describe('unknown assertion inventory', () => {
it('accepts recorded assertions after formatting without rewriting the baseline', () => {
const root = fixture()
write(root, sourcePath, assertion)
const baseline = recordBaseline(root)
write(root, sourcePath, 'const result = value as\n /* reason */ unknown\n')
expect(verifyNoUnknownCasts(root)).toBe(1)
expect(verifyNoUnknownCasts(root, true)).toBe(1)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(baseline)
})
it.each([
['additional assertion', `${assertion}const other = input as unknown\n`],
['duplicate assertion', `${assertion}${assertion}`],
['replacement assertion', 'const result = other as unknown\n'],
])('rejects an %s without changing the baseline', (_name, text) => {
const root = fixture()
write(root, sourcePath, assertion)
const baseline = recordBaseline(root)
write(root, sourcePath, text)
expect(() => verifyNoUnknownCasts(root)).toThrow(/new assertions to unknown/u)
expect(() => verifyNoUnknownCasts(root, true)).toThrow(/new assertions to unknown/u)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(baseline)
})
it('rejects a new assertion when the inventory is empty', () => {
const root = fixture()
write(root, sourcePath, assertion)
expect(() => verifyNoUnknownCasts(root)).toThrow(`${sourcePath}:1`)
})
it('requires pruning retired counts while preserving the remaining assertion', () => {
const root = fixture()
write(root, sourcePath, `${assertion}${assertion}`)
write(root, 'apps/retired.ts', 'const retired = input as unknown\n')
recordBaseline(root)
write(root, sourcePath, assertion)
unlinkSync(join(root, 'apps/retired.ts'))
expect(() => verifyNoUnknownCasts(root)).toThrow(/remove retired baseline entries/u)
expect(verifyNoUnknownCasts(root, true)).toBe(1)
expect(JSON.parse(readFileSync(join(root, baselinePath), 'utf8'))).toEqual(
countUnknownCasts(findUnknownCasts(sourcePath, assertion)),
)
expect(verifyNoUnknownCasts(root)).toBe(1)
})
it('prunes a deleted tracked file without treating it as a discovery failure', () => {
const root = fixture()
write(root, sourcePath, assertion)
git(root, 'add', sourcePath)
recordBaseline(root)
unlinkSync(join(root, sourcePath))
expect(() => verifyNoUnknownCasts(root)).toThrow(/remove retired baseline entries/u)
expect(verifyNoUnknownCasts(root, true)).toBe(0)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe('{}\n')
})
it.each([
['not an object', '[]'],
['null', 'null'],
['invalid JSON', '{'],
['non-source path', '{"input.json":{"HASH":1}}'],
['absolute path', '{"/input.ts":{"HASH":1}}'],
['parent path', '{"../input.ts":{"HASH":1}}'],
['backslash path', JSON.stringify({ 'packages\\input.ts': { HASH: 1 } })],
['excluded path', '{"vendor/input.ts":{"HASH":1}}'],
['empty file counts', '{"input.ts":{}}'],
['array counts', '{"input.ts":[]}'],
['invalid fingerprint', '{"input.ts":{"invalid":1}}'],
['zero count', '{"input.ts":{"HASH":0}}'],
['negative count', '{"input.ts":{"HASH":-1}}'],
['fractional count', '{"input.ts":{"HASH":1.5}}'],
['unsafe count', '{"input.ts":{"HASH":9007199254740992}}'],
['string count', '{"input.ts":{"HASH":"1"}}'],
])('rejects a malformed baseline: %s', (_name, text) => {
const root = fixture()
const contents = text.replaceAll('HASH', 'a'.repeat(64))
write(root, baselinePath, contents)
expect(() => verifyNoUnknownCasts(root, true)).toThrow()
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(contents)
})
})
describe('unknown assertion source discovery', () => {
it('checks tracked source across areas, extensions, tests and root configuration', () => {
const root = fixture()
const files = [
'packages/example/src/input.ts',
'apps/example/input.tsx',
'scripts/input.mts',
'website/input.cts',
'packages/example/tests/input.js',
'apps/example/input.jsx',
'scripts/input.mjs',
'website/input.cjs',
'config.ts',
]
for (const file of files) write(root, file, assertion)
git(root, 'add', '.')
expect(scanUnknownCasts(root).map(cast => cast.file)).toEqual(files.toSorted())
expect(() => verifyNoUnknownCasts(root)).toThrow(/new assertions to unknown/u)
})
it('includes untracked source while excluding ignored files, vendor, archives and other extensions', () => {
const root = fixture()
write(root, '.gitignore', 'ignored/\n')
for (const file of ['ignored/input.ts', 'vendor/input.ts', '.agents/notes/archived/input.ts', 'docs/input.md']) {
write(root, file, assertion)
}
git(root, 'add', '.')
write(root, 'scripts/new.ts', assertion)
expect(scanUnknownCasts(root).map(cast => cast.file)).toEqual(['scripts/new.ts'])
expect(() => verifyNoUnknownCasts(root)).toThrow('scripts/new.ts:1')
unlinkSync(join(root, 'scripts/new.ts'))
expect(verifyNoUnknownCasts(root)).toBe(0)
})
it.each(['packages', 'apps', 'scripts', 'website'])('rejects discovery missing %s', (area) => {
const root = fixture()
unlinkSync(join(root, `${area}/seed.ts`))
expect(() => verifyNoUnknownCasts(root)).toThrow(`source discovery omitted ${area}/`)
})
it('rejects an empty source corpus', () => {
const root = fixture()
for (const area of ['packages', 'apps', 'scripts', 'website']) unlinkSync(join(root, `${area}/seed.ts`))
expect(() => verifyNoUnknownCasts(root)).toThrow(/source discovery omitted/u)
})
// Creating file symlinks requires privileges that Windows CI does not guarantee.
it.skipIf(process.platform === 'win32')('rejects a source symlink', () => {
const root = fixture()
write(root, 'target.txt', assertion)
const link = join(root, 'scripts/linked.ts')
symlinkSync(join(root, 'target.txt'), link)
try {
expect(() => verifyNoUnknownCasts(root)).toThrow('source symlink is unsupported: scripts/linked.ts')
} finally {
unlinkSync(link)
}
})
})
+183
View File
@@ -0,0 +1,183 @@
/** Reject new assertions to unknown while retiring the recorded existing assertions. */
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { lstatSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
const baselinePath = 'scripts/no-unknown-casts.baseline.json'
/** Existing assertion counts, keyed by repository path and syntax fingerprint. */
export type UnknownCastBaseline = Record<string, Record<string, number>>
/** One direct assertion to unknown in authored source. */
export interface UnknownCast {
/** Repository-relative path with forward slashes. */
file: string
/** One-based source line. */
line: number
/** SHA-256 of the assertion's syntax tokens, excluding comments and whitespace. */
fingerprint: string
}
function syntaxFingerprint(node: ts.Node, source: ts.SourceFile): string {
const tokens: [number, 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)
}
visit(node)
return createHash('sha256').update(JSON.stringify(tokens)).digest('hex')
}
/**
* Find direct assertions to unknown, including angle assertions and parenthesized types.
* @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.
*/
export function findUnknownCasts(file: string, text: string): UnknownCast[] {
const normalized = file.replaceAll('\\', '/')
const source = ts.createSourceFile(normalized, text, ts.ScriptTarget.Latest, true)
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) {
casts.push({
file: normalized,
line: source.getLineAndCharacterOfPosition(node.type.getStart(source)).line + 1,
fingerprint: syntaxFingerprint(node, source),
})
}
}
ts.forEachChild(node, visit)
}
visit(source)
return casts
}
function isSource(file: string): boolean {
return /\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file)
&& !file.startsWith('vendor/')
&& !file.startsWith('.agents/notes/archived/')
}
/**
* Scan tracked source and non-ignored new source, including tests and configuration files.
* @param repoRoot - repository root containing the Git index.
* @returns every direct unknown assertion in the current working tree.
* @throws if discovery omits a required source area or an eligible path is a symlink.
*/
export function scanUnknownCasts(repoRoot: string): UnknownCast[] {
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
}).split('\0').map(file => file.replaceAll('\\', '/')).filter(isSource))].sort()
const present = files.filter((file) => {
const stat = lstatSync(resolve(repoRoot, file), { throwIfNoEntry: false })
if (stat?.isSymbolicLink()) throw new Error(`verify-no-unknown-casts: source symlink is unsupported: ${file}`)
return stat?.isFile() === true
})
for (const area of ['packages/', 'apps/', 'scripts/', 'website/']) {
if (!present.some(file => file.startsWith(area))) {
throw new Error(`verify-no-unknown-casts: source discovery omitted ${area}`)
}
}
return present.flatMap(file => findUnknownCasts(file, readFileSync(resolve(repoRoot, file), 'utf8')))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readBaseline(repoRoot: string): UnknownCastBaseline {
const value: unknown = JSON.parse(readFileSync(resolve(repoRoot, baselinePath), 'utf8'))
if (!isRecord(value)) throw new Error('verify-no-unknown-casts: baseline must be a file-to-counts object')
const baseline: UnknownCastBaseline = {}
for (const [file, counts] of Object.entries(value)) {
if (!isSource(file) || file.startsWith('/') || file.includes('\\') || file.split('/').includes('..')
|| !isRecord(counts) || Object.keys(counts).length === 0) {
throw new Error(`verify-no-unknown-casts: invalid baseline file: ${file}`)
}
const parsed: Record<string, number> = {}
for (const [fingerprint, count] of Object.entries(counts)) {
if (!/^[a-f0-9]{64}$/u.test(fingerprint) || typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) {
throw new Error(`verify-no-unknown-casts: invalid baseline count: ${file}:${fingerprint}`)
}
parsed[fingerprint] = count
}
baseline[file] = parsed
}
return baseline
}
/**
* Count exact assertions without granting a file-wide allowance.
* @param casts - observed assertions.
* @returns path and fingerprint counts in stable order for comparison and pruning.
*/
export function countUnknownCasts(casts: readonly UnknownCast[]): UnknownCastBaseline {
const baseline: UnknownCastBaseline = {}
for (const { file, fingerprint } of casts) {
const counts = baseline[file] ??= {}
counts[fingerprint] = (counts[fingerprint] ?? 0) + 1
}
return Object.fromEntries(Object.entries(baseline).sort(([a], [b]) => a.localeCompare(b))
.map(([file, counts]) => [file, Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)))]))
}
/**
* Enforce the recorded assertion inventory, optionally deleting retired allowances.
* @param repoRoot - repository root to scan.
* @param prune - remove stale entries only after establishing that no new assertion exists.
* @returns the number of existing assertions still present.
* @throws for new assertions, malformed inventories, or stale entries without pruning.
*/
export function verifyNoUnknownCasts(repoRoot: string, prune = false): number {
const baseline = readBaseline(repoRoot)
const casts = scanUnknownCasts(repoRoot)
const current = countUnknownCasts(casts)
const remaining = structuredClone(baseline)
const added: UnknownCast[] = []
for (const cast of casts) {
const counts = remaining[cast.file]
const allowance = counts?.[cast.fingerprint] ?? 0
if (counts !== undefined && allowance > 0) counts[cast.fingerprint] = allowance - 1
else added.push(cast)
}
if (added.length > 0) {
throw new Error('verify-no-unknown-casts: new assertions to unknown are forbidden. Use a typed value or narrow the input.\n'
+ added.map(cast => ` ${cast.file}:${String(cast.line)} (${cast.fingerprint})`).join('\n'))
}
const stale = Object.entries(remaining).flatMap(([file, counts]) => Object.entries(counts)
.filter(([, count]) => count > 0).map(([fingerprint]) => ` ${file} (${fingerprint})`))
if (stale.length > 0) {
if (!prune) {
throw new Error('verify-no-unknown-casts: remove retired baseline entries with pnpm run verify-no-unknown-casts --prune.\n'
+ stale.join('\n'))
}
writeFileSync(resolve(repoRoot, baselinePath), `${JSON.stringify(current, null, 2)}\n`)
}
return casts.length
}
if (import.meta.filename === resolve(process.argv[1] ?? '')) {
try {
const args = process.argv.slice(2)
if (args.length > 1 || (args.length === 1 && args[0] !== '--prune')) {
throw new Error('Usage: pnpm run verify-no-unknown-casts [--prune]')
}
const count = verifyNoUnknownCasts(root, args[0] === '--prune')
console.log(`verify-no-unknown-casts: no new assertions; ${String(count)} existing assertions remain.`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}