From 51c324e7514fc0962e54bccd348fd09d538be4d7 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 21 Dec 2025 16:26:53 -0600 Subject: [PATCH 1/2] fix: normalize generate-types specifiers via tsconfig context. --- package-lock.json | 4 +- packages/css/package.json | 2 +- packages/css/src/generateTypes.ts | 105 ++++++++++++++++++++++++ packages/css/test/generateTypes.test.ts | 81 ++++++++++++++++++ packages/playwright/package.json | 2 +- 5 files changed, 190 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6990bb2..3980d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11922,7 +11922,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.9", + "version": "1.0.0-rc.10", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -12222,7 +12222,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.9", + "@knighted/css": "1.0.0-rc.10", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index 45982e2..1032c9c 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.9", + "version": "1.0.0-rc.10", "description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.", "type": "module", "main": "./dist/css.js", diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index 92b49ba..c82da8e 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -7,6 +7,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url' import { init, parse } from 'es-module-lexer' import { moduleType } from 'node-module-type' +import { getTsconfig, type TsConfigResult } from 'get-tsconfig' +import { createMatchPath, type MatchPath } from 'tsconfig-paths' + import { cssWithMeta } from './css.js' import { determineSelectorVariant, @@ -34,12 +37,18 @@ interface DeclarationRecord { filePath: string } +interface TsconfigResolutionContext { + absoluteBaseUrl?: string + matchPath?: MatchPath +} + interface GenerateTypesInternalOptions { rootDir: string include: string[] outDir: string typesRoot: string stableNamespace?: string + tsconfig?: TsconfigResolutionContext } export interface GenerateTypesResult { @@ -115,6 +124,7 @@ export async function generateTypes( const include = normalizeIncludeOptions(options.include, rootDir) const outDir = path.resolve(options.outDir ?? DEFAULT_OUT_DIR) const typesRoot = path.resolve(options.typesRoot ?? DEFAULT_TYPES_ROOT) + const tsconfig = loadTsconfigResolutionContext(rootDir) await init await fs.mkdir(outDir, { recursive: true }) await fs.mkdir(typesRoot, { recursive: true }) @@ -125,6 +135,7 @@ export async function generateTypes( outDir, typesRoot, stableNamespace: options.stableNamespace, + tsconfig, } return generateDeclarations(internalOptions) @@ -162,6 +173,7 @@ async function generateDeclarations( resource, match.importer, options.rootDir, + options.tsconfig, ) if (!resolvedPath) { warnings.push( @@ -342,6 +354,7 @@ async function resolveImportPath( resourceSpecifier: string, importerPath: string, rootDir: string, + tsconfig?: TsconfigResolutionContext, ): Promise { if (!resourceSpecifier) return undefined if (resourceSpecifier.startsWith('.')) { @@ -350,6 +363,10 @@ async function resolveImportPath( if (resourceSpecifier.startsWith('/')) { return path.resolve(rootDir, resourceSpecifier.slice(1)) } + const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig) + if (tsconfigResolved) { + return tsconfigResolved + } const requireFromRoot = getProjectRequire(rootDir) try { return requireFromRoot.resolve(resourceSpecifier) @@ -491,6 +508,92 @@ async function fileExists(target: string): Promise { } } +async function resolveWithTsconfigPaths( + specifier: string, + tsconfig?: TsconfigResolutionContext, +): Promise { + if (!tsconfig) { + return undefined + } + if (tsconfig.matchPath) { + const matched = tsconfig.matchPath(specifier) + if (matched && (await fileExists(matched))) { + return matched + } + } + if (tsconfig.absoluteBaseUrl && isNonRelativeSpecifier(specifier)) { + const candidate = path.join( + tsconfig.absoluteBaseUrl, + specifier.split('/').join(path.sep), + ) + if (await fileExists(candidate)) { + return candidate + } + } + return undefined +} + +function loadTsconfigResolutionContext( + rootDir: string, +): TsconfigResolutionContext | undefined { + let result: TsConfigResult | null + try { + result = getTsconfig(rootDir) as TsConfigResult | null + } catch { + return undefined + } + if (!result) { + return undefined + } + const compilerOptions = result.config.compilerOptions ?? {} + const configDir = path.dirname(result.path) + const absoluteBaseUrl = compilerOptions.baseUrl + ? path.resolve(configDir, compilerOptions.baseUrl) + : undefined + const normalizedPaths = normalizeTsconfigPaths(compilerOptions.paths) + const matchPath = + absoluteBaseUrl && normalizedPaths + ? createMatchPath(absoluteBaseUrl, normalizedPaths) + : undefined + if (!absoluteBaseUrl && !matchPath) { + return undefined + } + return { absoluteBaseUrl, matchPath } +} + +function normalizeTsconfigPaths( + paths: Record | undefined, +): Record | undefined { + if (!paths) { + return undefined + } + const normalized: Record = {} + for (const [pattern, replacements] of Object.entries(paths)) { + if (!replacements) { + continue + } + const values = Array.isArray(replacements) ? replacements : [replacements] + if (values.length === 0) { + continue + } + normalized[pattern] = values + } + return Object.keys(normalized).length > 0 ? normalized : undefined +} + +function isNonRelativeSpecifier(specifier: string): boolean { + if (!specifier) { + return false + } + if (specifier.startsWith('.') || specifier.startsWith('/')) { + return false + } + if (/^[a-z][\w+.-]*:/i.test(specifier)) { + return false + } + return true +} + function createProjectPeerResolver(rootDir: string) { const resolver = getProjectRequire(rootDir) return async (name: string) => { @@ -650,6 +753,8 @@ export const __generateTypesInternals = { formatModuleDeclaration, formatSelectorType, normalizeIncludeOptions, + normalizeTsconfigPaths, + isNonRelativeSpecifier, parseCliArgs, printHelp, reportCliResult, diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index d1c9277..28a2c39 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -33,6 +33,41 @@ console.log(stableSelectors.demo) } } +async function setupBaseUrlFixture(): Promise<{ + root: string + cleanup: () => Promise +}> { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-tsconfig-')) + const srcDir = path.join(tmpRoot, 'src') + const stylesDir = path.join(srcDir, 'styles') + await fs.mkdir(stylesDir, { recursive: true }) + const cssPath = path.join(stylesDir, 'demo.css') + await fs.writeFile( + cssPath, + `.demo { color: rebeccapurple; } +.knighted-demo { color: teal; } +`, + ) + const specifier = 'styles/demo.css?knighted-css&types' + const entrySource = `import { stableSelectors } from '${specifier}' +console.log(stableSelectors.demo) +` + await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) + const tsconfig = { + compilerOptions: { + baseUrl: './src', + }, + } + await fs.writeFile( + path.join(tmpRoot, 'tsconfig.json'), + JSON.stringify(tsconfig, null, 2), + ) + return { + root: tmpRoot, + cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + } +} + test('generateTypes emits declarations and reuses cache', async () => { const project = await setupFixtureProject() try { @@ -67,6 +102,30 @@ test('generateTypes emits declarations and reuses cache', async () => { } }) +test('generateTypes resolves tsconfig baseUrl specifiers', async () => { + const project = await setupBaseUrlFixture() + try { + const outDir = path.join(project.root, '.knighted-css-test') + const typesRoot = path.join(project.root, '.knighted-css-types') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + typesRoot, + }) + assert.ok(result.written >= 1) + assert.equal(result.warnings.length, 0) + const manifestPath = path.join(outDir, 'manifest.json') + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< + string, + unknown + > + assert.ok(manifest['styles/demo.css?knighted-css&types']) + } finally { + await project.cleanup() + } +}) + test('generateTypes internals format selector-aware declarations', () => { const { stripInlineLoader, @@ -75,6 +134,8 @@ test('generateTypes internals format selector-aware declarations', () => { formatSelectorType, formatModuleDeclaration, normalizeIncludeOptions, + normalizeTsconfigPaths, + isNonRelativeSpecifier, parseCliArgs, printHelp, reportCliResult, @@ -138,8 +199,28 @@ test('generateTypes internals format selector-aware declarations', () => { assert.equal(parsed.stableNamespace, 'storybook') assert.throws(() => parseCliArgs(['--root']), /Missing value/) + assert.throws(() => parseCliArgs(['--include']), /Missing value/) + assert.throws(() => parseCliArgs(['--out-dir']), /Missing value/) + assert.throws(() => parseCliArgs(['--types-root']), /Missing value/) assert.throws(() => parseCliArgs(['--stable-namespace']), /Missing value/) assert.throws(() => parseCliArgs(['--wat']), /Unknown flag/) + const helpParsed = parseCliArgs(['--help']) + assert.equal(helpParsed.help, true) + + const normalizedPaths = normalizeTsconfigPaths({ + '@demo/*': ['src/demo/*', 'fallback/*'], + '@empty/*': [], + }) + assert.deepEqual(normalizedPaths, { + '@demo/*': ['src/demo/*', 'fallback/*'], + }) + assert.equal(normalizeTsconfigPaths(undefined), undefined) + assert.equal(normalizeTsconfigPaths({ foo: [] }), undefined) + + assert.equal(isNonRelativeSpecifier('pkg/component'), true) + assert.equal(isNonRelativeSpecifier('./local'), false) + assert.equal(isNonRelativeSpecifier('/absolute/path'), false) + assert.equal(isNonRelativeSpecifier('http://example.com/style.css'), false) const printed: string[] = [] const logged = console.log diff --git a/packages/playwright/package.json b/packages/playwright/package.json index f8765ad..c16cd65 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -14,7 +14,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.9", + "@knighted/css": "1.0.0-rc.10", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", From 974c4ea0991f657f4439db87171f4eac6aa6c9a0 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 21 Dec 2025 16:50:22 -0600 Subject: [PATCH 2/2] test: bump patch coverage. --- packages/css/src/generateTypes.ts | 43 +- packages/css/test/generateTypes.test.ts | 528 +++++++++++++++++++++++- 2 files changed, 565 insertions(+), 6 deletions(-) diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index c82da8e..be3f659 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -42,6 +42,10 @@ interface TsconfigResolutionContext { matchPath?: MatchPath } +type CssWithMetaFn = typeof cssWithMeta + +let activeCssWithMeta: CssWithMetaFn = cssWithMeta + interface GenerateTypesInternalOptions { rootDir: string include: string[] @@ -93,12 +97,17 @@ const SUPPORTED_EXTENSIONS = new Set([ '.cjs', ]) +type ModuleTypeDetector = () => ReturnType + +let moduleTypeDetector: ModuleTypeDetector = moduleType +let importMetaUrlProvider: () => string | undefined = getImportMetaUrl + function resolvePackageRoot(): string { - const detectedType = moduleType() + const detectedType = moduleTypeDetector() if (detectedType === 'commonjs' && typeof __dirname === 'string') { return path.resolve(__dirname, '..') } - const moduleUrl = getImportMetaUrl() + const moduleUrl = importMetaUrlProvider() if (moduleUrl) { return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..') } @@ -186,7 +195,7 @@ async function generateDeclarations( let selectorMap = selectorCache.get(cacheKey) if (!selectorMap) { try { - const { css } = await cssWithMeta(resolvedPath, { + const { css } = await activeCssWithMeta(resolvedPath, { cwd: options.rootDir, peerResolver, }) @@ -535,10 +544,11 @@ async function resolveWithTsconfigPaths( function loadTsconfigResolutionContext( rootDir: string, + loader: typeof getTsconfig = getTsconfig, ): TsconfigResolutionContext | undefined { let result: TsConfigResult | null try { - result = getTsconfig(rootDir) as TsConfigResult | null + result = loader(rootDir) as TsConfigResult | null } catch { return undefined } @@ -746,15 +756,40 @@ function reportCliResult(result: GenerateTypesResult): void { } } +function setCssWithMetaImplementation(impl?: CssWithMetaFn): void { + activeCssWithMeta = impl ?? cssWithMeta +} + +function setModuleTypeDetector(detector?: ModuleTypeDetector): void { + moduleTypeDetector = detector ?? moduleType +} + +function setImportMetaUrlProvider(provider?: () => string | undefined): void { + importMetaUrlProvider = provider ?? getImportMetaUrl +} + export const __generateTypesInternals = { + writeTypesIndex, stripInlineLoader, splitResourceAndQuery, + findSpecifierImports, + resolveImportPath, + resolvePackageRoot, buildDeclarationFileName, formatModuleDeclaration, formatSelectorType, + relativeToRoot, + collectCandidateFiles, normalizeIncludeOptions, normalizeTsconfigPaths, + setCssWithMetaImplementation, + setModuleTypeDetector, + setImportMetaUrlProvider, isNonRelativeSpecifier, + createProjectPeerResolver, + getProjectRequire, + loadTsconfigResolutionContext, + resolveWithTsconfigPaths, parseCliArgs, printHelp, reportCliResult, diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 28a2c39..e231d02 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -3,10 +3,11 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { generateTypes, + runGenerateTypesCli, __generateTypesInternals, type ParsedCliArgs, } from '../src/generateTypes.ts' @@ -126,16 +127,245 @@ test('generateTypes resolves tsconfig baseUrl specifiers', async () => { } }) -test('generateTypes internals format selector-aware declarations', () => { +test('generateTypes removes stale manifest entries when declarations are missing', async () => { + const project = await setupFixtureProject() + try { + const outDir = path.join(project.root, '.knighted-css-test') + const typesRoot = path.join(project.root, '.knighted-css-types') + const options = { rootDir: project.root, include: ['src'], outDir, typesRoot } + await generateTypes(options) + + const manifestPath = path.join(outDir, 'manifest.json') + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< + string, + { file: string; hash: string } + > + manifest['./ghost.css?knighted-css'] = { file: 'ghost.d.ts', hash: 'ghost-hash' } + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + + const result = await generateTypes(options) + assert.equal(result.removed, 0) + const updatedManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< + string, + { file: string; hash: string } + > + assert.ok(!updatedManifest['./ghost.css?knighted-css']) + } finally { + await project.cleanup() + } +}) + +test('generateTypes reports warnings when specifiers cannot be resolved', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-missing-spec-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import 'missing-package/style.css?knighted-css&types'\n", + ) + const outDir = path.join(root, '.knighted-css-out') + const typesRoot = path.join(root, '.knighted-css-types') + const result = await generateTypes({ + rootDir: root, + include: ['src'], + outDir, + typesRoot, + }) + assert.equal(result.written, 0) + assert.ok(result.warnings.some(w => w.includes('Unable to resolve'))) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('generateTypes surfaces css extraction failures', async () => { + const project = await setupFixtureProject() + const { setCssWithMetaImplementation } = __generateTypesInternals + try { + const outDir = path.join(project.root, '.knighted-css-test') + const typesRoot = path.join(project.root, '.knighted-css-types') + setCssWithMetaImplementation(async () => { + throw new Error('css failure') + }) + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + typesRoot, + }) + assert.equal(result.written, 0) + assert.ok(result.warnings.some(w => w.includes('Failed to extract CSS'))) + } finally { + setCssWithMetaImplementation() + await project.cleanup() + } +}) + +test('generateTypes completes with no declarations when no matching imports exist', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-empty-spec-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile(path.join(srcDir, 'entry.ts'), 'console.log("noop")\n') + const outDir = path.join(root, '.knighted-css-out') + const typesRoot = path.join(root, '.knighted-css-types') + const result = await generateTypes({ + rootDir: root, + include: ['src'], + outDir, + typesRoot, + }) + assert.equal(result.written, 0) + assert.equal(result.declarations.length, 0) + assert.equal(result.warnings.length, 0) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('generateTypes ignores specifiers lacking the types flag', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-missing-flag-')) + try { + const srcDir = path.join(root, 'src') + const stylesDir = path.join(srcDir, 'styles') + await fs.mkdir(stylesDir, { recursive: true }) + await fs.writeFile(path.join(stylesDir, 'demo.css'), '.demo { color: green; }\n') + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import './styles/demo.css?knighted-css'\n", + ) + const outDir = path.join(root, '.knighted-css-out') + const typesRoot = path.join(root, '.knighted-css-types') + const result = await generateTypes({ + rootDir: root, + include: ['src'], + outDir, + typesRoot, + }) + assert.equal(result.written, 0) + assert.equal(result.declarations.length, 0) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('generateTypes dedupes repeated specifiers', async () => { + const project = await setupFixtureProject() + try { + const srcDir = path.join(project.root, 'src') + const fixtureSource = path.join( + __dirname, + 'fixtures', + 'dialects', + 'basic', + 'entry.js', + ) + const relativeImport = path.relative(srcDir, fixtureSource).split(path.sep).join('/') + const specifier = `${relativeImport}?knighted-css&types` + const entrySource = `import { stableSelectors as firstSelectors } from '${specifier}' +import { stableSelectors as secondSelectors } from '${specifier}' +console.log(firstSelectors.demo, secondSelectors.demo) +` + await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) + + const outDir = path.join(project.root, '.knighted-css-out') + const typesRoot = path.join(project.root, '.knighted-css-types') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + typesRoot, + }) + assert.equal(result.written, 1) + assert.equal(result.warnings.length, 0) + } finally { + await project.cleanup() + } +}) + +test('runGenerateTypesCli executes generation and reports summaries', async () => { + const project = await setupFixtureProject() + try { + const outDir = path.join(project.root, '.knighted-css-cli') + const typesRoot = path.join(project.root, '.knighted-css-types-cli') + const args = [ + '--root', + project.root, + '--include', + 'src', + '--out-dir', + outDir, + '--types-root', + typesRoot, + ] + const logs: string[] = [] + const warns: string[] = [] + const originalLog = console.log + const originalWarn = console.warn + try { + console.log = (message: string) => logs.push(String(message)) + console.warn = (message: string) => warns.push(String(message)) + await runGenerateTypesCli(args) + await runGenerateTypesCli(args) + } finally { + console.log = originalLog + console.warn = originalWarn + } + assert.ok(logs.some(log => log.includes('[knighted-css] Updated 1 declaration(s)'))) + assert.ok( + logs.some(log => + log.includes( + 'No changes to ?knighted-css&types declarations (cache is up to date).', + ), + ), + ) + assert.equal(warns.length, 0) + const manifestPath = path.join(outDir, 'manifest.json') + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< + string, + unknown + > + assert.equal(Object.keys(manifest).length, 1) + } finally { + await project.cleanup() + } +}) + +test('runGenerateTypesCli prints help output when requested', async () => { + const printed: string[] = [] + const originalLog = console.log + try { + console.log = (message: string) => printed.push(String(message)) + await runGenerateTypesCli(['--help']) + } finally { + console.log = originalLog + } + assert.ok(printed.some(line => line.includes('Usage: knighted-css-generate-types'))) +}) + +test('generateTypes internals format selector-aware declarations', async () => { const { stripInlineLoader, splitResourceAndQuery, + findSpecifierImports, + resolveImportPath, + resolvePackageRoot, buildDeclarationFileName, formatSelectorType, formatModuleDeclaration, + writeTypesIndex, normalizeIncludeOptions, + collectCandidateFiles, normalizeTsconfigPaths, + setModuleTypeDetector, + setImportMetaUrlProvider, + relativeToRoot, isNonRelativeSpecifier, + createProjectPeerResolver, + getProjectRequire, + loadTsconfigResolutionContext, + resolveWithTsconfigPaths, parseCliArgs, printHelp, reportCliResult, @@ -150,6 +380,10 @@ test('generateTypes internals format selector-aware declarations', () => { resource: './demo.css', query: '?knighted-css', }) + assert.deepEqual(splitResourceAndQuery('./demo.css'), { + resource: './demo.css', + query: '', + }) const selectorMap = new Map([ ['beta', 'knighted-beta'], @@ -160,6 +394,7 @@ test('generateTypes internals format selector-aware declarations', () => { const selectorType = formatSelectorType(selectorMap) assert.match(selectorType, /readonly "alpha": "knighted-alpha"/) assert.match(selectorType, /readonly "beta": "knighted-beta"/) + assert.equal(formatSelectorType(new Map()), 'Readonly>') const declaration = formatModuleDeclaration( './demo.css?knighted-css&combined', @@ -182,6 +417,50 @@ test('generateTypes internals format selector-aware declarations', () => { path.resolve('/tmp/demo', './src'), ]) + const nonFileEntry = path.join(os.tmpdir(), 'knighted-non-file-entry') + const resolvedNonFileEntry = path.resolve(nonFileEntry) + const originalStat = fs.stat + const fsModule = fs as typeof fs & { stat: typeof fs.stat } + try { + fsModule.stat = (async (...args) => { + const [target] = args + const normalizedTarget = path.resolve( + target instanceof URL ? fileURLToPath(target) : (target?.toString() ?? ''), + ) + if (normalizedTarget === resolvedNonFileEntry) { + return { + isDirectory: () => false, + isFile: () => false, + } as unknown as import('node:fs').Stats + } + return originalStat(...(args as Parameters)) + }) as typeof fs.stat + const collected = await collectCandidateFiles([nonFileEntry]) + assert.deepEqual(collected, []) + } finally { + fsModule.stat = originalStat + } + + const collectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-collect-files-')) + try { + const entryFile = path.join(collectRoot, 'entry.ts') + await fs.writeFile(entryFile, 'export {}\n') + const duplicateResult = await collectCandidateFiles([entryFile, entryFile]) + assert.equal(duplicateResult.length, 1) + + const missingResult = await collectCandidateFiles([ + path.join(collectRoot, 'missing.ts'), + ]) + assert.deepEqual(missingResult, []) + + const skipDir = path.join(collectRoot, 'node_modules') + await fs.mkdir(skipDir, { recursive: true }) + const skipResult = await collectCandidateFiles([skipDir]) + assert.deepEqual(skipResult, []) + } finally { + await fs.rm(collectRoot, { recursive: true, force: true }) + } + const parsed = parseCliArgs([ '--root', '/tmp/project', @@ -210,17 +489,26 @@ test('generateTypes internals format selector-aware declarations', () => { const normalizedPaths = normalizeTsconfigPaths({ '@demo/*': ['src/demo/*', 'fallback/*'], '@empty/*': [], + '@skip/*': undefined as unknown as string[], }) assert.deepEqual(normalizedPaths, { '@demo/*': ['src/demo/*', 'fallback/*'], }) assert.equal(normalizeTsconfigPaths(undefined), undefined) assert.equal(normalizeTsconfigPaths({ foo: [] }), undefined) + assert.equal( + normalizeTsconfigPaths({ foo: undefined as unknown as string[] }), + undefined, + ) assert.equal(isNonRelativeSpecifier('pkg/component'), true) assert.equal(isNonRelativeSpecifier('./local'), false) assert.equal(isNonRelativeSpecifier('/absolute/path'), false) assert.equal(isNonRelativeSpecifier('http://example.com/style.css'), false) + assert.equal(isNonRelativeSpecifier(''), false) + + const positionalParsed = parseCliArgs(['src', 'stories']) + assert.deepEqual(positionalParsed.include, ['src', 'stories']) const printed: string[] = [] const logged = console.log @@ -268,4 +556,240 @@ test('generateTypes internals format selector-aware declarations', () => { ) assert.ok(summaryLogs.some(log => log.includes('Updated 2 declaration(s)'))) assert.equal(summaryWarns.length, 1) + + const peerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-peer-resolver-')) + try { + await fs.writeFile(path.join(peerRoot, 'package.json'), '{}') + const modulePath = path.join(peerRoot, 'demo.mjs') + await fs.writeFile(modulePath, 'export const value = 42\n') + const resolver = createProjectPeerResolver(peerRoot) + const moduleNs = await resolver('./demo.mjs') + assert.equal(moduleNs.value, 42) + } finally { + await fs.rm(peerRoot, { recursive: true, force: true }) + } + + assert.doesNotThrow(() => { + const loader = getProjectRequire('relative-root') + loader.resolve('node:path') + }) + + const tsconfigRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-tsconfig-empty-'), + ) + try { + await fs.writeFile( + path.join(tsconfigRoot, 'tsconfig.json'), + JSON.stringify({ compilerOptions: {} }, null, 2), + ) + const context = loadTsconfigResolutionContext(tsconfigRoot) + assert.equal(context, undefined) + } finally { + await fs.rm(tsconfigRoot, { recursive: true, force: true }) + } + + const brokenTsconfigRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-tsconfig-bad-'), + ) + try { + await fs.writeFile(path.join(brokenTsconfigRoot, 'tsconfig.json'), '{ invalid') + const context = loadTsconfigResolutionContext(brokenTsconfigRoot) + assert.equal(context, undefined) + } finally { + await fs.rm(brokenTsconfigRoot, { recursive: true, force: true }) + } + + const aliasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-tsconfig-alias-')) + try { + const aliasFile = path.join(aliasRoot, 'alias.css') + await fs.writeFile(aliasFile, '.alias {}\n') + const matchResolved = await resolveWithTsconfigPaths('alias-entry', { + matchPath: () => aliasFile, + }) + assert.equal(matchResolved, aliasFile) + + const nestedDir = path.join(aliasRoot, 'nested') + await fs.mkdir(nestedDir, { recursive: true }) + const nestedFile = path.join(nestedDir, 'file.css') + await fs.writeFile(nestedFile, '.nested {}\n') + const baseUrlResolved = await resolveWithTsconfigPaths('nested/file.css', { + absoluteBaseUrl: aliasRoot, + }) + assert.equal(baseUrlResolved, nestedFile) + + const unresolved = await resolveWithTsconfigPaths('alias-missing', { + matchPath: () => path.join(aliasRoot, 'missing.css'), + }) + assert.equal(unresolved, undefined) + } finally { + await fs.rm(aliasRoot, { recursive: true, force: true }) + } + + assert.equal(await resolveWithTsconfigPaths('standalone'), undefined) + + const tsconfigWithPathsRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-tsconfig-combo-'), + ) + try { + await fs.mkdir(path.join(tsconfigWithPathsRoot, 'src'), { recursive: true }) + await fs.writeFile( + path.join(tsconfigWithPathsRoot, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + baseUrl: './src', + paths: { + '@alias/*': ['@alias/*'], + }, + }, + }, + null, + 2, + ), + ) + const context = loadTsconfigResolutionContext(tsconfigWithPathsRoot) + assert.ok(context?.absoluteBaseUrl) + assert.ok(context?.matchPath) + } finally { + await fs.rm(tsconfigWithPathsRoot, { recursive: true, force: true }) + } + + const specifierRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-specifier-imports-'), + ) + try { + const plainFile = path.join(specifierRoot, 'plain.ts') + await fs.writeFile(plainFile, 'console.log("no selectors here")\n') + const noMatches = await findSpecifierImports(plainFile) + assert.deepEqual(noMatches, []) + + const requireFile = path.join(specifierRoot, 'require.js') + await fs.writeFile( + requireFile, + "const styles = require('./demo.css?knighted-css&types')\n", + ) + const requireMatches = await findSpecifierImports(requireFile) + assert.equal(requireMatches.length, 1) + assert.equal(requireMatches[0]?.specifier, './demo.css?knighted-css&types') + assert.equal(requireMatches[0]?.importer, requireFile) + const missingMatches = await findSpecifierImports( + path.join(specifierRoot, 'missing.js'), + ) + assert.deepEqual(missingMatches, []) + } finally { + await fs.rm(specifierRoot, { recursive: true, force: true }) + } + + const fakeMetaDir = path.join(os.tmpdir(), 'knighted-meta', 'esm') + const fakeModuleUrl = pathToFileURL(path.join(fakeMetaDir, 'index.js')).href + try { + setModuleTypeDetector(() => 'module') + setImportMetaUrlProvider(() => fakeModuleUrl) + const resolvedRoot = resolvePackageRoot() + assert.equal(resolvedRoot, path.resolve(fakeMetaDir, '..')) + } finally { + setModuleTypeDetector() + setImportMetaUrlProvider() + } + + const resolveRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-resolve-import-')) + try { + await fs.mkdir(path.join(resolveRoot, 'src'), { recursive: true }) + await fs.writeFile( + path.join(resolveRoot, 'package.json'), + JSON.stringify({ name: 'resolve-fixture', version: '1.0.0' }, null, 2), + ) + const importer = path.join(resolveRoot, 'src', 'entry.ts') + const absoluteResult = await resolveImportPath( + '/styles/demo.css', + importer, + resolveRoot, + ) + assert.equal(absoluteResult, path.join(resolveRoot, 'styles', 'demo.css')) + const missingResult = await resolveImportPath( + 'non-existent-module', + importer, + resolveRoot, + ) + assert.equal(missingResult, undefined) + } finally { + await fs.rm(resolveRoot, { recursive: true, force: true }) + } + + const loaderErrorContext = loadTsconfigResolutionContext('/tmp/project', () => { + throw new Error('tsconfig failure') + }) + assert.equal(loaderErrorContext, undefined) + + const rooted = relativeToRoot( + path.join('/tmp/project', 'src', 'demo.css'), + '/tmp/project', + ) + assert.equal(rooted, path.join('src', 'demo.css')) + const outside = path.join(os.tmpdir(), 'outside.css') + const outsideRelative = path.relative('/tmp/project', outside) + assert.equal(relativeToRoot(outside, '/tmp/project'), outsideRelative) + + const indexRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-types-index-')) + try { + const outDir = path.join(indexRoot, 'out') + await fs.mkdir(outDir, { recursive: true }) + const indexPath = path.join(indexRoot, 'index.d.ts') + await writeTypesIndex(indexPath, {}, outDir) + const indexContent = await fs.readFile(indexPath, 'utf8') + assert.ok(indexContent.includes('Generated by @knighted/css/generate-types')) + assert.ok(!indexContent.includes(' { + const errors: string[] = [] + const originalError = console.error + const previousExitCode = process.exitCode + let observedExitCode: number | undefined + try { + console.error = (message: string) => errors.push(String(message)) + process.exitCode = undefined + await runGenerateTypesCli(['--root']) + observedExitCode = process.exitCode as number | undefined + } finally { + console.error = originalError + process.exitCode = previousExitCode + } + assert.equal(errors.length >= 1, true) + assert.ok(errors[0]?.includes('Missing value for --root')) + assert.equal(observedExitCode, 1) +}) + +test('runGenerateTypesCli surfaces generator failures', async () => { + const project = await setupFixtureProject() + const errors: string[] = [] + const originalError = console.error + const previousExitCode = process.exitCode + let observedExitCode: number | undefined + try { + const outDirFile = path.join(project.root, 'conflict.txt') + await fs.writeFile(outDirFile, 'conflict') + console.error = (message: string) => errors.push(String(message)) + process.exitCode = undefined + await runGenerateTypesCli([ + '--root', + project.root, + '--include', + 'src', + '--out-dir', + outDirFile, + '--types-root', + path.join(project.root, '.cli-types-error'), + ]) + observedExitCode = process.exitCode as number | undefined + } finally { + console.error = originalError + process.exitCode = previousExitCode + await project.cleanup() + } + assert.ok(errors.some(line => line.includes('generate-types failed'))) + assert.equal(observedExitCode, 1) })