import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import type { Check } from './types'; /** * Returns true if the module has at least one TypeScript file under * `scripts/` (recursively). Used to decide whether `typescript-build` * has anything to check at all. */ async function hasTypeScriptSources(modulePath: string): Promise { const scriptsDir = join(modulePath, 'scripts'); if (!existsSync(scriptsDir)) return false; const stack: string[] = [scriptsDir]; while (stack.length > 0) { const dir = stack.pop(); if (!dir) continue; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { stack.push(join(dir, entry.name)); } else if (entry.name.endsWith('.ts')) { return true; } } } catch { // unreadable directory — ignore and continue } } return false; } /** * Runs `bunx tsc --noEmit` from the module directory. * * - Modules with no `tsconfig.json` AND no `.ts` files in `scripts/` * are silently skipped (returns ok). Pure YAML+shell modules don't * need a TS check. * - Modules with `.ts` sources but no tsconfig get a warn telling them * to add one. * - Otherwise we spawn tsc and report ok / fail with the captured * stderr+stdout truncated to a reasonable size. * * The whole check is skipped when `noBuild` is true; callers use that * for fast iteration. */ export async function checkTypeScriptBuild( modulePath: string, options: { noBuild?: boolean } = {}, ): Promise { if (options.noBuild) { return { category: 'typescript_build', name: 'tsc --noEmit', status: 'ok', message: 'skipped (--no-build)', }; } // tsconfig.json, package.json and node_modules all live in scripts/, not at // the module root: scripts/ is the standalone package that resolves // @celilo/capabilities the way the deployed module does. const scriptsDir = join(modulePath, 'scripts'); const hasTsConfig = existsSync(join(scriptsDir, 'tsconfig.json')); const hasTsFiles = await hasTypeScriptSources(modulePath); if (!hasTsConfig && !hasTsFiles) { return { category: 'typescript_build', name: 'tsc --noEmit', status: 'ok', message: 'no TypeScript surface to check', }; } if (!hasTsConfig) { return { category: 'typescript_build', name: 'tsc --noEmit', status: 'warn', message: 'module has .ts files but no scripts/tsconfig.json; add one (extending modules/tsconfig.scripts.base.json) so we can typecheck against current @celilo types', }; } if (!existsSync(join(scriptsDir, 'node_modules'))) { return { category: 'typescript_build', name: 'tsc --noEmit', status: 'fail', message: 'no node_modules — run `bun install` in the module scripts/ directory first', }; } const r = spawnSync('bunx', ['tsc', '--noEmit'], { cwd: scriptsDir, encoding: 'utf-8', }); if (r.status === 0) { return { category: 'typescript_build', name: 'tsc --noEmit', status: 'ok', message: 'TypeScript build clean', }; } const output = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim(); const truncated = output.length > 800 ? `${output.slice(0, 800)}\n…(truncated)` : output; return { category: 'typescript_build', name: 'tsc --noEmit', status: 'fail', message: truncated || 'tsc exited non-zero with no output', }; }