diff --git a/tools/check-architecture-contract.test.ts b/tools/check-architecture-contract.test.ts new file mode 100644 index 00000000..1ba542d2 --- /dev/null +++ b/tools/check-architecture-contract.test.ts @@ -0,0 +1,164 @@ +import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0'; +import { + assertAllowedTypeEscapes, + assertDuplicateCounts, + assertMojibake, + assertStructuredMetadata, + failMatches, + isCurrentDocOrExample, + isProductionSource, + type Issue, + isTextPath, + normalizePath, + type TextFile, +} from './check-architecture-contract.ts'; + +const REPLACEMENT_CHAR = String.fromCharCode(0xFFFD); + +Deno.test('arch: failMatches flags a positive regex match with location', () => { + const files: TextFile[] = [{ path: 'README.md', text: 'Use rawHtml to inject.' }]; + const issues: Issue[] = []; + failMatches('trust-boundary', files, /\brawHtml\b|data-on-/, 'must use trustedHtml', issues); + assertEquals(issues.length, 1); + assertEquals(issues[0].check, 'trust-boundary'); + assertEquals(issues[0].file, 'README.md'); + assertEquals(issues[0].line, 1); + assertEquals(issues[0].message, 'must use trustedHtml'); +}); + +Deno.test('arch: failMatches ignores clean input', () => { + const files: TextFile[] = [{ path: 'README.md', text: 'Use trustedHtml instead.' }]; + const issues: Issue[] = []; + failMatches('trust-boundary', files, /\brawHtml\b|data-on-/, 'must use trustedHtml', issues); + assertEquals(issues, []); +}); + +Deno.test('arch: allowed `as unknown as` escape is not flagged as unapproved', () => { + const files: TextFile[] = [{ + path: 'packages/core/src/island.ts', + text: 'const x = el as unknown as Record;', + }]; + const issues: Issue[] = []; + assertAllowedTypeEscapes(files, issues); + const unapproved = issues.filter((i) => i.message.includes('not in the reviewed allowlist')); + assertEquals(unapproved, []); +}); + +Deno.test('arch: unallowed `as unknown as` escape is flagged', () => { + const files: TextFile[] = [{ + path: 'packages/core/src/foo.ts', + text: 'const y = a as unknown as Bar;', + }]; + const issues: Issue[] = []; + assertAllowedTypeEscapes(files, issues); + assert( + issues.some( + (i) => + i.message.includes('not in the reviewed allowlist') && + i.file === 'packages/core/src/foo.ts', + ), + ); +}); + +Deno.test('arch: single canonical CompatibilityClassification passes', () => { + const files: TextFile[] = [{ + path: 'packages/protocol/src/framework.ts', + text: 'export interface CompatibilityClassification { level: string; }', + }]; + const issues: Issue[] = []; + assertDuplicateCounts(files, issues); + assertEquals(issues.filter((i) => i.check === 'duplicate-type'), []); +}); + +Deno.test('arch: duplicate CompatibilityClassification is flagged', () => { + const files: TextFile[] = [ + { + path: 'packages/protocol/src/framework.ts', + text: 'interface CompatibilityClassification {}', + }, + { path: 'packages/element/src/other.ts', text: 'interface CompatibilityClassification {}' }, + ]; + const issues: Issue[] = []; + assertDuplicateCounts(files, issues); + assert( + issues.some((i) => i.check === 'duplicate-type' && i.file === 'packages/element/src/other.ts'), + ); +}); + +Deno.test('arch: missing canonical CompatibilityClassification is flagged', () => { + const files: TextFile[] = [{ path: 'packages/core/src/x.ts', text: 'interface Other {}' }]; + const issues: Issue[] = []; + assertDuplicateCounts(files, issues); + assert( + issues.some((i) => i.check === 'duplicate-type' && i.message.includes('missing canonical')), + ); +}); + +Deno.test('arch: route/nav scanner regex usage is flagged', () => { + const files: TextFile[] = [{ + path: 'packages/adapter-vite/src/route-scanner.ts', + text: 'exportMatch(/x/);', + }]; + const issues: Issue[] = []; + assertStructuredMetadata(files, issues); + assert(issues.some((i) => i.check === 'metadata-boundary')); +}); + +Deno.test('arch: route/nav scanner without regex passes', () => { + const files: TextFile[] = [{ + path: 'packages/adapter-vite/src/route-scanner.ts', + text: 'const x = 1;', + }]; + const issues: Issue[] = []; + assertStructuredMetadata(files, issues); + assertEquals(issues, []); +}); + +Deno.test('arch: mojibake is detected', () => { + const files: TextFile[] = [{ + path: 'packages/core/src/x.ts', + text: `const s = "${REPLACEMENT_CHAR}";`, + }]; + const issues: Issue[] = []; + assertMojibake(files, issues); + assert(issues.some((i) => i.check === 'encoding')); +}); + +Deno.test('arch: clean text has no mojibake', () => { + const files: TextFile[] = [{ path: 'packages/core/src/x.ts', text: 'const s = "hello";' }]; + const issues: Issue[] = []; + assertMojibake(files, issues); + assertEquals(issues, []); +}); + +Deno.test('arch: isProductionSource classifies correctly', () => { + assert(isProductionSource('packages/core/src/render.ts')); + assert(isProductionSource('tools/check-foo.ts')); + assert(isProductionSource('www/vite.config.ts')); + assert(!isProductionSource('tools/check-architecture-contract.ts')); + assert(!isProductionSource('README.md')); + assert(!isProductionSource('packages/core/__tests__/x.test.ts')); + assert(!isProductionSource('packages/foo/test/fixtures/x.ts')); +}); + +Deno.test('arch: isCurrentDocOrExample classifies correctly', () => { + assert(isCurrentDocOrExample('README.md')); + assert(isCurrentDocOrExample('README.zh.md')); + assert(isCurrentDocOrExample('docs/guide/x.md')); + assert(isCurrentDocOrExample('docs/arch/x.md')); + assert(isCurrentDocOrExample('packages/core/README.md')); + assert(!isCurrentDocOrExample('CHANGELOG.md')); + assert(!isCurrentDocOrExample('packages/core/__tests__/x.test.ts')); +}); + +Deno.test('arch: normalizePath converts backslashes to forward slashes', () => { + assertEquals(normalizePath('a\\b\\c.ts'), 'a/b/c.ts'); +}); + +Deno.test('arch: isTextPath recognizes text extensions only', () => { + assert(isTextPath('a.ts')); + assert(isTextPath('a.md')); + assert(isTextPath('a.json')); + assert(!isTextPath('a.png')); + assert(!isTextPath('a.png')); +}); diff --git a/tools/check-architecture-contract.ts b/tools/check-architecture-contract.ts index f642db40..1131b6a5 100644 --- a/tools/check-architecture-contract.ts +++ b/tools/check-architecture-contract.ts @@ -8,14 +8,14 @@ import { extname } from 'node:path'; -interface Issue { +export interface Issue { check: string; file: string; line?: number; message: string; } -interface TextFile { +export interface TextFile { path: string; text: string; } @@ -104,13 +104,17 @@ const TYPE_ESCAPE_ALLOWLIST: TypeEscapeAllow[] = [ }, ]; -const issues: Issue[] = []; - -function addIssue(check: string, file: string, message: string, line?: number): void { +function addIssue( + issues: Issue[], + check: string, + file: string, + message: string, + line?: number, +): void { issues.push({ check, file, line, message }); } -function normalizePath(path: string): string { +export function normalizePath(path: string): string { return path.replace(/\\/g, '/'); } @@ -129,11 +133,11 @@ async function gitFiles(): Promise { .map(normalizePath); } -function isTextPath(path: string): boolean { +export function isTextPath(path: string): boolean { return TEXT_EXTENSIONS.has(extname(path)); } -function isCurrentDocOrExample(path: string): boolean { +export function isCurrentDocOrExample(path: string): boolean { if (path.startsWith('docs/arch/')) return true; if (path.startsWith('docs/reference/')) return true; if (path.startsWith('docs/guide/')) return true; @@ -146,9 +150,13 @@ function isCurrentDocOrExample(path: string): boolean { return false; } -function isProductionSource(path: string): boolean { +export function isProductionSource(path: string): boolean { if (path.includes('/__tests__/') || path.includes('/test/fixtures/')) return false; if (path === 'tools/check-architecture-contract.ts') return false; + // Test files that exercise the architecture contract necessarily contain + // escape tokens, so they are excluded from production scanning. + if (path === 'tools/check-architecture-contract.test.ts') return false; + if (path === 'tools/check-type-safety.test.ts') return false; if (path.startsWith('packages/') && path.includes('/src/') && /\.(ts|tsx)$/.test(path)) { return true; } @@ -168,20 +176,21 @@ function eachLine(file: TextFile, fn: (line: string, lineNumber: number) => void file.text.split(/\r?\n/).forEach((line, index) => fn(line, index + 1)); } -function failMatches( +export function failMatches( check: string, files: TextFile[], re: RegExp, message: string, + issues: Issue[], ): void { for (const file of files) { eachLine(file, (line, lineNo) => { - if (re.test(line)) addIssue(check, file.path, message, lineNo); + if (re.test(line)) addIssue(issues, check, file.path, message, lineNo); }); } } -function assertAllowedTypeEscapes(files: TextFile[]): void { +export function assertAllowedTypeEscapes(files: TextFile[], issues: Issue[]): void { const found = new Set(); for (const file of files) { eachLine(file, (line, lineNo) => { @@ -191,6 +200,7 @@ function assertAllowedTypeEscapes(files: TextFile[]): void { ); if (!allow) { addIssue( + issues, 'type-escape', file.path, 'production as unknown as is not in the reviewed allowlist', @@ -206,6 +216,7 @@ function assertAllowedTypeEscapes(files: TextFile[]): void { const key = `${entry.file}\0${entry.fragment}`; if (!found.has(key)) { addIssue( + issues, 'type-escape', entry.file, `allowlist entry is stale: ${entry.reason}`, @@ -214,7 +225,7 @@ function assertAllowedTypeEscapes(files: TextFile[]): void { } } -function assertDuplicateCounts(files: TextFile[]): void { +export function assertDuplicateCounts(files: TextFile[], issues: Issue[]): void { const compatibilityHits: Array<{ file: string; line: number }> = []; for (const file of files.filter((f) => f.path.startsWith('packages/'))) { eachLine(file, (line, lineNo) => { @@ -228,6 +239,7 @@ function assertDuplicateCounts(files: TextFile[]): void { if (compatibilityHits.length !== 1 || compatibilityHits[0].file !== canonicalFile) { for (const hit of compatibilityHits) { addIssue( + issues, 'duplicate-type', hit.file, `CompatibilityClassification must have exactly one canonical interface in ${canonicalFile}`, @@ -236,6 +248,7 @@ function assertDuplicateCounts(files: TextFile[]): void { } if (compatibilityHits.length === 0) { addIssue( + issues, 'duplicate-type', canonicalFile, 'missing canonical CompatibilityClassification interface', @@ -244,7 +257,7 @@ function assertDuplicateCounts(files: TextFile[]): void { } } -function assertStructuredMetadata(files: TextFile[]): void { +export function assertStructuredMetadata(files: TextFile[], issues: Issue[]): void { const scannerFiles = files.filter((f) => f.path === 'packages/adapter-vite/src/route-scanner.ts' || f.path === 'packages/content/src/nav/scanner.ts' @@ -254,10 +267,11 @@ function assertStructuredMetadata(files: TextFile[]): void { scannerFiles, /exportMatch|splitOnCommas|parseValue\(raw/, 'route/nav metadata must use AST or structured data, not source regex parsing', + issues, ); } -function assertMojibake(files: TextFile[]): void { +export function assertMojibake(files: TextFile[], issues: Issue[]): void { const badChars = [ '\uFFFD', '\u951f', @@ -277,6 +291,7 @@ function assertMojibake(files: TextFile[]): void { const idx = file.text.indexOf(bad); if (idx !== -1) { addIssue( + issues, 'encoding', file.path, 'current source/doc contains replacement/mojibake text', @@ -292,6 +307,7 @@ async function main(): Promise { let totalBytes = 0; let readFailures = 0; const textFiles: TextFile[] = []; + const issues: Issue[] = []; for (const path of files) { try { @@ -306,6 +322,7 @@ async function main(): Promise { if (error instanceof Deno.errors.NotFound) continue; readFailures++; addIssue( + issues, 'byte-read', path, `could not read tracked file: ${error instanceof Error ? error.message : String(error)}`, @@ -322,31 +339,36 @@ async function main(): Promise { currentDocs, /render\(\):\s*(?:Promise<)?string\b|string\s*\|\s*VNode/, 'current source/docs must teach render(): VNode | null only', + issues, ); failMatches( 'trust-boundary', currentDocs, /\brawHtml\b|data-on-/, 'current source/docs must use trustedHtml and VNode event handlers', + issues, ); failMatches( 'core-render', coreSource, /wrongTypeErrorHtml|typeof\s+result\s*===\s*['"]string['"]|Components must return a string/, 'core must not carry the legacy string-render branch', + issues, ); failMatches( 'type-escape', production, /\bas\s+any\b/, 'production as any is forbidden', + issues, ); - assertAllowedTypeEscapes(production); + assertAllowedTypeEscapes(production, issues); failMatches( 'ts-suppression', production, /@ts-ignore|@ts-expect-error/, 'production TypeScript suppressions are forbidden', + issues, ); const taskMarkerPattern = new RegExp( `\\b${'TO' + 'DO'}\\b|\\b${'FIX' + 'ME'}\\b`, @@ -356,13 +378,14 @@ async function main(): Promise { production.filter((f) => !f.path.startsWith('www/app/data/')), taskMarkerPattern, 'production task markers must be removed or moved to classified SOP debt', + issues, ); - assertDuplicateCounts(textFiles); - assertStructuredMetadata(textFiles); - assertMojibake(production.concat(currentDocs)); + assertDuplicateCounts(textFiles, issues); + assertStructuredMetadata(textFiles, issues); + assertMojibake(production.concat(currentDocs), issues); if (readFailures > 0) { - addIssue('byte-read', '', `${readFailures} tracked files could not be read`); + addIssue(issues, 'byte-read', '', `${readFailures} tracked files could not be read`); } if (issues.length > 0) { @@ -379,4 +402,6 @@ async function main(): Promise { ); } -await main(); +if (import.meta.main) { + await main(); +} diff --git a/tools/check-type-safety.test.ts b/tools/check-type-safety.test.ts new file mode 100644 index 00000000..fc41cb00 --- /dev/null +++ b/tools/check-type-safety.test.ts @@ -0,0 +1,73 @@ +import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0'; +import { isCodeLine, type Issue, scanSourcesForAnyIssues } from './check-type-safety.ts'; + +const SAMPLE = 'packages/core/src/example.ts'; + +Deno.test('type-safety: detects `as any` cast escape', () => { + const issues = scanSourcesForAnyIssues([{ path: SAMPLE, text: 'const x = foo() as any;' }]); + assertEquals(issues.length, 1); + assertEquals(issues[0].line, 1); + assertEquals(issues[0].file, SAMPLE); + assert(issues[0].text.includes('as any')); +}); + +Deno.test('type-safety: detects `: any` annotation escape', () => { + const issues = scanSourcesForAnyIssues([{ path: SAMPLE, text: 'function f(x: any): void {}' }]); + assertEquals(issues.length, 1); + assert(issues[0].text.includes(': any')); +}); + +Deno.test('type-safety: detects `any[]` in a generic position', () => { + // `as any` and `: any` do not match here, isolating the any[] branch. + const issues = scanSourcesForAnyIssues([ + { path: SAMPLE, text: 'const m: Map = new Map();' }, + ]); + assertEquals(issues.length, 1); + assert(issues[0].text.includes('any[]')); +}); + +Deno.test('type-safety: allows `unknown` escapes', () => { + const issues = scanSourcesForAnyIssues([ + { + path: SAMPLE, + text: 'const v = x as unknown as Y;\nconst u: unknown = {};\nconst list: unknown[] = [];', + }, + ]); + assertEquals(issues, []); +}); + +Deno.test('type-safety: ignores any escapes inside comments', () => { + const issues = scanSourcesForAnyIssues([ + { path: SAMPLE, text: '// we must not use as any here\n/* const x = y as any; */' }, + ]); + assertEquals(issues, []); +}); + +Deno.test('type-safety: reports no issues for clean sources', () => { + const issues = scanSourcesForAnyIssues([ + { path: 'packages/core/src/a.ts', text: 'export const x = 1;' }, + { path: 'packages/core/src/b.ts', text: 'export function y(): number {\n return 2;\n}' }, + ]); + assertEquals(issues, []); +}); + +Deno.test('type-safety: reports the first matching escape per line only', () => { + // `as any` matches before `: any`, so only one issue is recorded. + const issues = scanSourcesForAnyIssues([{ path: SAMPLE, text: 'const x = y as any; // : any' }]); + assertEquals(issues.length, 1); + assert(issues[0].text.includes('as any')); +}); + +Deno.test('type-safety: isCodeLine skips comments but keeps code', () => { + assert(!isCodeLine('// foo as any')); + assert(!isCodeLine('* foo as any')); + assert(!isCodeLine('/* foo as any')); + assert(isCodeLine('const x = y as any;')); + assert(isCodeLine(' const x = y as any;')); +}); + +Deno.test('type-safety: returns typed Issue objects', () => { + const issues: Issue[] = scanSourcesForAnyIssues([{ path: SAMPLE, text: 'const x = y as any;' }]); + assertEquals(typeof issues[0].line, 'number'); + assertEquals(typeof issues[0].file, 'string'); +}); diff --git a/tools/check-type-safety.ts b/tools/check-type-safety.ts index 64889bb4..4619f00b 100644 --- a/tools/check-type-safety.ts +++ b/tools/check-type-safety.ts @@ -6,7 +6,7 @@ * Forbidden: \x60as any\x60, \x60: any\x60, \x60any[]\x60 in active code. */ -interface Issue { +export interface Issue { file: string; line: number; text: string; @@ -27,6 +27,10 @@ const ACTIVE_ROOTS = [ const EXCLUDED_FILES = new Set([ 'tools/check-type-safety.ts', 'tools/check-architecture-contract.ts', + // Test files that exercise the detector necessarily contain any-escape tokens, + // so they are excluded from scanning just like the gate tools themselves. + 'tools/check-type-safety.test.ts', + 'tools/check-architecture-contract.test.ts', ]); const EXTENSIONS = /\.(ts|tsx)$/; @@ -49,44 +53,63 @@ async function* walk(dir: string): AsyncGenerator { } } -function isCodeLine(line: string): boolean { +export function isCodeLine(line: string): boolean { const trimmed = line.trim(); if (trimmed.startsWith('//')) return false; if (trimmed.startsWith('*') || trimmed.startsWith('/*')) return false; return true; } -async function main(): Promise { +export interface SourceFile { + path: string; + text: string; +} + +/** + * Scan already-loaded source files for explicit `any` escapes. + * + * Pure: does not touch the filesystem and does not apply EXCLUDED_FILES, so it + * can be exercised directly in tests with synthetic inputs. + */ +export function scanSourcesForAnyIssues(files: SourceFile[]): Issue[] { const issues: Issue[] = []; - const files: string[] = []; + for (const file of files) { + const lines = file.text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isCodeLine(line)) continue; + for (const { re, name } of ANY_PATTERNS) { + if (re.test(line)) { + issues.push({ file: file.path, line: i + 1, text: name }); + break; + } + } + } + } + return issues; +} +/** Walk the active roots and read every scannable, non-excluded source file. */ +export async function collectActiveSourceFiles(): Promise { + const files: SourceFile[] = []; for (const root of ACTIVE_ROOTS) { try { for await (const path of walk(root)) { if (!EXTENSIONS.test(path)) continue; const normalized = normalize(path); if (EXCLUDED_FILES.has(normalized)) continue; - files.push(normalized); + files.push({ path: normalized, text: await Deno.readTextFile(normalized) }); } } catch { // Root may not exist in all contexts. } } + return files; +} - for (const file of files) { - const text = await Deno.readTextFile(file); - const lines = text.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!isCodeLine(line)) continue; - for (const { re, name } of ANY_PATTERNS) { - if (re.test(line)) { - issues.push({ file, line: i + 1, text: name }); - break; - } - } - } - } +async function main(): Promise { + const files = await collectActiveSourceFiles(); + const issues = scanSourcesForAnyIssues(files); if (issues.length > 0) { console.error(`Type-safety check failed: ${issues.length} explicit any escape(s) found.`); @@ -99,4 +122,6 @@ async function main(): Promise { console.log(`Type-safety check passed (${files.length} active TS/TSX files, 0 explicit any).`); } -await main(); +if (import.meta.main) { + await main(); +}