1import * as _ from 'lodash-es' 14import ts from 'typescript' 17import { Linter } from 'eslint' 20export const brokenImportSuffix = '(not found)' 21export const missingExportTsCodes = new Set([2305, 2306, 2307, 2724]) // TS2305: no exported member, TS2306: not a module, TS2307: cannot find module, TS2724: no exported member (did-you-mean). Cross-file: depend on exporter content, so the importer's own changed-line heuristic can't classify them. 23export type PtcheckSection = {label: string, header: string, viols: string[]} 24export type PtcheckResult = {ok: true, summary: string} | {ok: false, sections: PtcheckSection[]} 26export const formatPtcheckResult = (r: PtcheckResult): string => 27 r.ok ? r.summary : r.sections.map(s => `${s.header}\n${s.viols.map(v => ' ' + v).join('\n')}`).join('\n\n') 29export const refactorPtcheck = async (filesA: string[], {strictTypes, skipBoundaries, skipImporterCheck, baselineContent}: {strictTypes?: boolean, skipBoundaries?: boolean, skipImporterCheck?: boolean, baselineContent?: BaselineContentMap} = {}): Promise<PtcheckResult> => { 30 mcpDebugLog(`Checking ${filesA.length} files in import tree...${strictTypes ? ' [strictTypes]' : ''}${baselineContent ? ' [baseline]' : ''}`) 33 {label: 'syntax', run: () => refactorPtsyntaxcheck(filesA, baselineContent)}, 34 {label: 'eslint', run: () => refactorPteslintcheck(filesA, baselineContent)}, 35 {label: 'ts', run: async () => { tsResult = await refactorPttscheck(filesA, {strictTypes, skipImporterCheck, baselineContent}); return tsResult }}, 36 {label: 'imports', run: () => baselineContent ? 'All 0 file(s) skipped import check (baseline run)' : checkBrokenImports(filesA)}, 38 if (!skipBoundaries) { 41 const sectionsA: PtcheckSection[] = [] 42 for (const step of steps) { 44 const result = await step.run() 45 if (result.startsWith('All ')) continue 46 const [header, ...violLines] = result.split('\n') 47 sectionsA.push({label: step.label, header, viols: violLines.map(l => l.trim()).filter(Boolean)}) 49 if (sectionsA.length > 0) return {ok: false, sections: sectionsA} 50 return {ok: true, summary: tsResult} 53const checkBrokenImports = async (filesA: string[]) => { 54 const brokenA: string[] = [] 55 for (const rawPath of filesA) { 58 const onBrokenImport = (importPath: string, resolvedPtPath: string) => { 59 brokenA.push(`${ptPath} imports ${importPath} → ${resolvedPtPath} ${brokenImportSuffix}`) 63 if (brokenA.length === 0) return `All ${filesA.length} file(s) have valid import paths` 64 return `Broken import paths found:\n${brokenA.map(e => ' ' + e).join('\n')}` 67const refactorPtsyntaxcheck = async (filesA: string[], baselineContent?: BaselineContentMap) => { 68 const { validateOssMarkerSyntax } = {validateOssMarkerSyntax: () => []} 69 const errorsA: string[] = [], skippedA: string[] = [] 70 for (const rawPath of filesA) { 75 if (baselineContent) { 76 const headContent = baselineContent.get(absPath) 77 if (headContent == null) { skippedA.push(ptPath); continue } // new file: no baseline 78 contents = headContent 83 errorsA.push(`${ptPath} - file not found`) 89 const srcFile = ts.createSourceFile(ptPath, contents, ts.ScriptTarget.Latest, true) 90 for (const diag of (srcFile as any).parseDiagnostics || []) { 91 const {line, character} = srcFile.getLineAndCharacterOfPosition(diag.start) 92 const msg = ts.flattenDiagnosticMessageText(diag.messageText, ' ') 93 errorsA.push(`${ptPath}:${line + 1}:${character + 1} - ${msg}`) 99 errorsA.push(err.message || String(err)) 101 // Validate oss marker output produces valid syntax (catches stale/mis-scoped markers) 102 errorsA.push(...validateOssMarkerSyntax(ptPath, contents)) 104 const checkedCount = filesA.length - skippedA.length 105 if (errorsA.length === 0) return `All ${checkedCount} file(s) have valid syntax` + (skippedA.length ? ` (skipped ${skippedA.length} non-JS/TS)` : '') 106 return `Syntax errors found:\n${errorsA.map(e => ' ' + e).join('\n')}` 109const refactorPteslintcheck = async (filesA: string[], baselineContent?: BaselineContentMap) => { 110 const linter = new Linter({configType: 'flat'}) 114 const mod = await import(configPath) 117 return 'All 0 file(s) skipped ESLint (no config)' 119 const checkableA = filesA.filter(f => { 123 if (!checkableA.length) return `All 0 file(s) skipped ESLint (no checkable files)` 124 const errorsA: string[] = [] 125 for (const rawPath of checkableA) { 128 if (baselineContent) { 129 const headContent = baselineContent.get(absPath) 130 if (headContent == null) continue // new file: no baseline 131 contents = headContent 139 const messages = linter.verify(contents, config, {filename: toPtRelPath(rawPath)}) 140 for (const msg of messages) { 141 if (!['no-undef', 'react/jsx-no-undef', 'no-restricted-syntax'].includes(msg.ruleId!)) continue 143 errorsA.push(`${ptPath}:${msg.line}:${msg.column} - ${msg.ruleId}: ${msg.message}`) 146 if (errorsA.length === 0) return `All ${checkableA.length} file(s) pass ESLint checks` 147 return `ESLint errors found:\n${errorsA.map(e => ' ' + e).join('\n')}` 150let cachedParsedConfig: ts.ParsedCommandLine | null = null 151let cachedConfigMtimeMs: number | null = null 152let cachedProgram: ts.Program | null = null 153let cachedProgramOptsKey: string | null = null 155const getTsConfigMtimeMs = () => { 156 const configPath = ts.findConfigFile(ptDir, ts.sys.fileExists, 'tsconfig.json') 157 if (!configPath) return null 158 try { return fs.statSync(configPath).mtimeMs } catch { return null } 161const getParsedConfig = () => { 162 const mtimeMs = getTsConfigMtimeMs() 163 if (cachedParsedConfig && mtimeMs !== null && mtimeMs === cachedConfigMtimeMs) return cachedParsedConfig 164 const configPath = ts.findConfigFile(ptDir, ts.sys.fileExists, 'tsconfig.json') 165 if (!configPath) return null 166 const configFile = ts.readConfigFile(configPath, ts.sys.readFile) 167 if (configFile.error) return null 168 cachedParsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, ptDir) 169 cachedConfigMtimeMs = mtimeMs 171 return cachedParsedConfig 174// Reverse-dep expansion: find importers of edited files so removed/renamed exports are caught 175// (TS2305 "no exported member" fires on the importer, not the exporter) 176const getImporterPtPaths = async (editedPtPaths: string[]): Promise<string[]> => { 179 return [...new Set(importedByPathsA as string[])].filter(p => 181 !_.intersection(p.split('/'), globallyIgnoredPaths).length) 182 } catch { return [] } 185const refactorPttscheck = async (filesA: string[], {strictTypes, skipImporterCheck, baselineContent}: {strictTypes?: boolean, skipImporterCheck?: boolean, baselineContent?: BaselineContentMap} = {}) => { 186 const filteredA = filesA.filter((f: string) => { 188 if (ptPath.startsWith('.opencode/')) return false 190 if (_.intersection(ptPath.split('/'), globallyIgnoredPaths).length > 0) return false 195 if (filteredA.length === 0) return 'All 0 file(s) pass TypeScript type checking' 197 const ptPathsA = filteredA.map(toPtRelPath) 198 const importerPtPaths = skipImporterCheck ? [] : await getImporterPtPaths(ptPathsA) 199 const absPathsA = ptPathsA.map(ptPathToAbsPath) 201 const allAbsPathsA = [...absPathsA, ...importerAbsPaths] 202 const absPathsSet = new Set(absPathsA) 203 const importerAbsSet = new Set(importerAbsPaths) 205 const parsedConfig = getParsedConfig() 206 if (!parsedConfig) return 'Error: tsconfig.json not found or invalid' 208 const options = {...parsedConfig.options, checkJs: true, noImplicitAny: true, strictNullChecks: strictTypes ? true : parsedConfig.options.strictNullChecks, allowImportingTsExtensions: true} 209 const dtsFiles = parsedConfig.fileNames.filter(f => f.endsWith('.d.ts')) 211 // Reuse program across calls — TypeScript's incremental compilation (via oldProgram arg) handles 212 // root file changes efficiently. Invalidate only on tsconfig mtime change (via getParsedConfig setting cachedProgram=null) or strictTypes toggle. 213 // Previous rootsKey-based invalidation dropped the program on every file switch, causing 100s+ rebuilds per edit. 214 // Baseline runs use a HEAD-content overlay host and never touch/reuse the cached program. 215 let program: ts.Program 216 if (baselineContent) { 217 program = ts.createProgram([...allAbsPathsA, ...dtsFiles], options, createHeadOverlayHost(options, baselineContent)) 219 const optsKey = strictTypes ? 'strict' : 'default' 220 if (cachedProgramOptsKey !== optsKey) cachedProgram = null 221 program = ts.createProgram([...allAbsPathsA, ...dtsFiles], options, undefined, cachedProgram ?? undefined) 222 cachedProgram = program 223 cachedProgramOptsKey = optsKey 226 const baselineDiagKeys = !baselineContent && importerAbsPaths.length > 0 230 const errorsA: string[] = [] 231 for (const absPath of allAbsPathsA) { 232 const sourceFile = program.getSourceFile(absPath) 234 if (baselineContent) continue // new file (no HEAD blob) contributes no baseline viols 235 errorsA.push(`${toPtRelPath(absPath)} - File not found or not parseable`) 238 const isImporter = importerAbsSet.has(absPath as any) 240 const diagnostics = [ 241 ...program.getSyntacticDiagnostics(sourceFile), 242 ...program.getSemanticDiagnostics(sourceFile), 245 const isJsFile = ptJsFileExtA.some(ext => absPath.endsWith('.' + ext)) 246 const jsSuppressCodes = new Set([ 247 7006, 7019, 7031, 7053, 7005, 7034, 250 for (const diag of diagnostics) { 251 if (diag.category !== ts.DiagnosticCategory.Error) continue 252 if (diag.file && !absPathsSet.has(diag.file.fileName as any) && !importerAbsSet.has(diag.file.fileName as any)) continue 253 const msg = ts.flattenDiagnosticMessageText(diag.messageText, ' ') 255 // Importer (reverse-dep) files: surface only missing-export errors caused by this edit. 256 // Pre-existing errors (present with HEAD content) filtered via baselineDiagKeys. 257 // Emitted line-less so pt_commit's HEAD-baseline subtraction matches on the full string. 259 if (!missingExportTsCodes.has(diag.code)) continue 260 if (diag.file && diag.file.fileName !== absPath) continue 262 errorsA.push(`${toPtRelPath(absPath)} - TS${diag.code}: ${msg} (reverse-dep of edited file)`) 265 if (isJsFile && !strictTypes && jsSuppressCodes.has(diag.code)) continue 269 if (diag.file && diag.start !== undefined) { 270 const {line, character} = diag.file.getLineAndCharacterOfPosition(diag.start) 271 errorsA.push(`${ptPath}:${line + 1}:${character + 1} - TS${diag.code}: ${msg}`) 273 errorsA.push(`${ptPath} - TS${diag.code}: ${msg}`) 278 if (errorsA.length === 0) return `All ${filteredA.length} file(s)${importerPtPaths.length ? ` + ${importerPtPaths.length} importer(s)` : ''} pass TypeScript type checking` 279 return `TypeScript errors found:\n${errorsA.map(e => ' ' + e).join('\n')}`