diff --git a/package.json b/package.json index 9514bb1..acc697d 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "type": "git", "url": "git+https://github.com/hesprs/synthkernel.git" }, + "bin": "./dist/bin.js", "files": [ "./dist" ], @@ -48,9 +49,11 @@ "oxlint-tsgolint": "^0.23.0", "tsdown": "^0.22.1", "tslab": "^1.0.22", - "typescript": "^6.0.3", "vitest": "^4.1.7" }, + "peerDependencies": { + "typescript": "^6.0.3" + }, "devEngines": { "packageManager": { "name": "pnpm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1da8994..a33062e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,10 @@ settings: importers: .: + dependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 devDependencies: '@types/node': specifier: ^25.9.1 @@ -225,9 +229,6 @@ importers: tslab: specifier: ^1.0.22 version: 1.0.22 - typescript: - specifier: ^6.0.3 - version: 6.0.3 vitest: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)) diff --git a/skill/example/index.ts b/skill/example/index.ts index 4c88297..6eb38ed 100644 --- a/skill/example/index.ts +++ b/skill/example/index.ts @@ -1,4 +1,4 @@ -import type { MergeSingleKey, Context } from 'synthkernel'; +import type { MergeSingleKey, Context as ConstructContext } from 'synthkernel'; import { createContext } from 'synthkernel'; import type { Level, LogEntry } from './CoreLogging.ts'; import AlertDispatch from './AlertDispatch.ts'; @@ -8,12 +8,15 @@ export type BaseOptions = { appName: string; debug?: boolean; }; +type Context = ConstructContext; + +export type AllOptions = Context['options']; const allModules = [CoreLogging, AlertDispatch] as const; type AllModules = typeof allModules; class PolisAlert { - private ctx?: Context; + private ctx?: Context; dispatchAlert: (message: string) => Promise; log: (level: Level, message: string) => void; diff --git a/src/type-inspector.ts b/src/type-inspector.ts new file mode 100644 index 0000000..ce99f17 --- /dev/null +++ b/src/type-inspector.ts @@ -0,0 +1,213 @@ +#!/usr/bin/env node +// oxlint-disable import/no-nodejs-modules +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const TYPE_FORMAT_FLAGS = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias; +const IDENTIFIER_RE = /^[$A-Z_a-z][$0-9A-Z_a-z]*$/; + +function formatDiagnostic(error: ts.Diagnostic): string { + return ts.flattenDiagnosticMessageText(error.messageText, '\n'); +} + +function resolveFilePath(filePath: string): string { + const resolvedPath = path.resolve(process.cwd(), filePath); + if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) + throw new Error(`File not found: ${filePath}`); + + return resolvedPath; +} + +function loadProgram(filePath: string): ts.Program { + const configPath = ts.findConfigFile(path.dirname(filePath), ts.sys.fileExists); + if (!configPath) + return ts.createProgram([filePath], { + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + target: ts.ScriptTarget.ESNext, + }); + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) throw new Error(formatDiagnostic(configFile.error)); + + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath), + ); + const rootNames = new Set(parsed.fileNames.map((name) => path.resolve(name))); + rootNames.add(filePath); + return ts.createProgram([...rootNames], parsed.options); +} + +function findTypeAlias(sourceFile: ts.SourceFile, aliasName: string): ts.TypeAliasDeclaration { + const alias = sourceFile.statements.find( + (statement): statement is ts.TypeAliasDeclaration => + ts.isTypeAliasDeclaration(statement) && statement.name.text === aliasName, + ); + if (!alias) throw new Error(`Type alias not found: ${aliasName}`); + return alias; +} + +function formatPropertyName(name: string): string { + return IDENTIFIER_RE.test(name) ? name : JSON.stringify(name); +} + +function formatType( + type: ts.Type, + checker: ts.TypeChecker, + node: ts.Node, + seen = new Set(), +): string { + if (seen.has(type)) return checker.typeToString(type, node, TYPE_FORMAT_FLAGS); + + if (type.flags & ts.TypeFlags.StringLiteral) + return JSON.stringify((type as ts.StringLiteralType).value); + if (type.flags & ts.TypeFlags.NumberLiteral) + return String((type as ts.NumberLiteralType).value); + if (type.flags & ts.TypeFlags.BigIntLiteral) + return checker.typeToString(type, node, TYPE_FORMAT_FLAGS); + if (type.flags & ts.TypeFlags.BooleanLiteral) + return checker.typeToString(type, node, TYPE_FORMAT_FLAGS); + if (type.flags & ts.TypeFlags.Undefined) return 'undefined'; + if (type.flags & ts.TypeFlags.Null) return 'null'; + if (type.flags & ts.TypeFlags.Never) return 'never'; + if (type.flags & ts.TypeFlags.Unknown) return 'unknown'; + if (type.flags & ts.TypeFlags.Any) return 'any'; + if (type.flags & ts.TypeFlags.Void) return 'void'; + if (type.flags & ts.TypeFlags.String) return 'string'; + if (type.flags & ts.TypeFlags.Number) return 'number'; + if (type.flags & ts.TypeFlags.Boolean) return 'boolean'; + if (type.flags & ts.TypeFlags.BigInt) return 'bigint'; + if (type.flags & ts.TypeFlags.ESSymbol) return 'symbol'; + + if (type.flags & ts.TypeFlags.Union) { + const members = (type as ts.UnionType).types; + const hasUndefined = members.some((member) => member.flags & ts.TypeFlags.Undefined); + const hasNull = members.some((member) => member.flags & ts.TypeFlags.Null); + const nonNullableMembers = members.filter( + (member) => + !(member.flags & ts.TypeFlags.Undefined) && !(member.flags & ts.TypeFlags.Null), + ); + const hasTrue = nonNullableMembers.some( + (member) => + member.flags & ts.TypeFlags.BooleanLiteral && + checker.typeToString(member, node, TYPE_FORMAT_FLAGS) === 'true', + ); + const hasFalse = nonNullableMembers.some( + (member) => + member.flags & ts.TypeFlags.BooleanLiteral && + checker.typeToString(member, node, TYPE_FORMAT_FLAGS) === 'false', + ); + if ( + hasTrue && + hasFalse && + nonNullableMembers.every((member) => member.flags & ts.TypeFlags.BooleanLiteral) + ) { + const head = 'boolean'; + const tail = hasNull ? ['null'] : []; + if (hasUndefined) tail.push('undefined'); + return [head, ...tail].join(' | '); + } + + const parts = nonNullableMembers.map((subtype) => formatType(subtype, checker, node, seen)); + if (hasNull) parts.push('null'); + if (hasUndefined) parts.push('undefined'); + return parts.join(' | '); + } + + if (checker.isTupleType(type)) { + const tuple = type as ts.TypeReference; + const elements = checker + .getTypeArguments(tuple) + .map((elementType) => formatType(elementType, checker, node, seen)); + return `[${elements.join(', ')}]`; + } + + if (checker.isArrayType(type)) { + const arrayType = type as ts.TypeReference; + const [elementType] = checker.getTypeArguments(arrayType); + if (elementType) return `${formatType(elementType, checker, node, seen)}[]`; + } + + if (type.flags & ts.TypeFlags.Intersection) { + const props = checker.getPropertiesOfType(type); + if (props.length > 0) return formatObjectType(type, checker, node, seen); + return (type as ts.IntersectionType).types + .map((subtype) => formatType(subtype, checker, node, seen)) + .join(' & '); + } + + const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call); + const constructors = checker.getSignaturesOfType(type, ts.SignatureKind.Construct); + const props = checker.getPropertiesOfType(type); + if (props.length > 0 && signatures.length === 0 && constructors.length === 0) + return formatObjectType(type, checker, node, seen); + + return checker.typeToString(type, node, TYPE_FORMAT_FLAGS); +} + +function formatObjectType( + type: ts.Type, + checker: ts.TypeChecker, + node: ts.Node, + seen: Set, +): string { + seen.add(type); + try { + const lines = checker.getPropertiesOfType(type).map((symbol) => { + const optional = (symbol.flags & ts.SymbolFlags.Optional) !== 0; + const propType = checker.getTypeOfSymbolAtLocation(symbol, node); + return ` ${formatPropertyName(symbol.getName())}${optional ? '?' : ''}: ${formatType(propType, checker, node, seen)};`; + }); + if (lines.length === 0) return '{}'; + return `{ +${lines.join('\n')} +}`; + } finally { + seen.delete(type); + } +} + +export function inspectTypeAlias(filePath: string, aliasName: string): string { + const resolvedFilePath = resolveFilePath(filePath); + const program = loadProgram(resolvedFilePath); + const sourceFile = program.getSourceFile(resolvedFilePath); + if (!sourceFile) throw new Error(`Source file not found in program: ${filePath}`); + + const alias = findTypeAlias(sourceFile, aliasName); + if (alias.typeParameters?.length) throw new Error(`Type alias is generic: ${aliasName}`); + + const checker = program.getTypeChecker(); + const type = checker.getTypeFromTypeNode(alias.type); + return formatType(type, checker, alias); +} + +export function main(argv: Array = process.argv.slice(2)): number { + if (argv.length !== 2) { + console.error('Usage: synthkernel '); + return 1; + } + + try { + console.log(inspectTypeAlias(argv[0], argv[1])); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +function isMainEntryPoint() { + if (!process.argv[1]) return false; + try { + return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + } +} + +if (isMainEntryPoint()) process.exitCode = main(); diff --git a/test/type-inspector.test.ts b/test/type-inspector.test.ts new file mode 100644 index 0000000..568f408 --- /dev/null +++ b/test/type-inspector.test.ts @@ -0,0 +1,104 @@ +// oxlint-disable import/no-nodejs-modules +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { expect, test, vi } from 'vitest'; +import { inspectTypeAlias, main } from '../src/type-inspector'; + +function createFixture(source: string) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'synthkernel-type-inspector-')); + const filePath = path.join(dir, 'fixture.ts'); + writeFileSync( + path.join(dir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + target: 'ESNext', + }, + include: ['./*.ts'], + }, + null, + 2, + ), + ); + writeFileSync(filePath, source); + return { dir, filePath }; +} + +test('inspectTypeAlias resolves final type of alias', () => { + const fixture = createFixture('type Base = { count: number };\ntype Foo = Base;\n'); + try { + const result = inspectTypeAlias(fixture.filePath, 'Foo'); + expect(result).toContain('count: number'); + expect(result).not.toContain('Base'); + } finally { + rmSync(fixture.dir, { force: true, recursive: true }); + } +}); + +test('inspectTypeAlias expands merged context alias in example file', () => { + const result = inspectTypeAlias(path.resolve('skill/example/index.ts'), 'AllOptions'); + expect(result).toBe(`{ + logLevel: "DEBUG" | "ERROR" | "INFO" | "WARN"; + maxLogs?: number | undefined; + appName: string; + debug?: boolean | undefined; + minMessageLength: number; + maxMessageLength: number; +}`); +}); + +test('inspectTypeAlias rejects generic aliases', () => { + const fixture = createFixture('type Box = T[];\n'); + try { + expect(() => inspectTypeAlias(fixture.filePath, 'Box')).toThrow(/generic/i); + } finally { + rmSync(fixture.dir, { force: true, recursive: true }); + } +}); + +test('inspectTypeAlias fails when alias is missing', () => { + const fixture = createFixture('type Foo = number;\n'); + try { + expect(() => inspectTypeAlias(fixture.filePath, 'Missing')).toThrow( + 'Type alias not found: Missing', + ); + } finally { + rmSync(fixture.dir, { force: true, recursive: true }); + } +}); + +test('main prints resolved type and exits zero', () => { + const fixture = createFixture('type Foo = number;\n'); + const log = vi.spyOn(console, 'log').mockReturnValue(undefined); + const error = vi.spyOn(console, 'error').mockReturnValue(undefined); + + try { + const code = main([fixture.filePath, 'Foo']); + expect(code).toBe(0); + expect(log).toHaveBeenCalledWith('number'); + expect(error).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + error.mockRestore(); + rmSync(fixture.dir, { force: true, recursive: true }); + } +}); + +test('main writes stderr and exits non-zero for missing file', () => { + const error = vi.spyOn(console, 'error').mockReturnValue(undefined); + const log = vi.spyOn(console, 'log').mockReturnValue(undefined); + + try { + const code = main(['missing.ts', 'Foo']); + expect(code).toBe(1); + expect(error).toHaveBeenCalledWith('File not found: missing.ts'); + expect(log).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + error.mockRestore(); + } +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 7cccdd3..12a4664 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,7 +2,10 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ dts: true, - entry: ['src/index.ts'], + entry: { + bin: 'src/type-inspector.ts', + index: 'src/index.ts', + }, minify: true, outExtensions: () => ({ dts: '.d.ts', diff --git a/vitest.config.ts b/vitest.config.ts index f2fc970..132f4c6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,7 @@ import { defineConfig } from 'vitest/config'; -export default defineConfig({ test: {} }); +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +});