🌳
pt0/serverF/opencodeMcpF/refactorPtcheckAI.mts
1import * as _ from 'lodash-es'
10import { ptDir } from '../ptDirF.mts'
13import fs from 'fs'
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]' : ''}`)
31 let tsResult = ''
32 const steps = [
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)},
37 ]
38 if (!skipBoundaries) {
39 steps.push({label: 'boundaries', run: () => checkImportBoundaries(filesA.map(toPtRelPath))})
40 }
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)})
48 }
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) {
56 const ptPath = toPtRelPath(rawPath)
57 if (!isPtCodeFileExt(ptPath) || isPtcheckIgnoredPtPath(ptPath)) continue
58 const onBrokenImport = (importPath: string, resolvedPtPath: string) => {
59 brokenA.push(`${ptPath} imports ${importPath} → ${resolvedPtPath} ${brokenImportSuffix}`)
60 }
61 await acornSingleFile(ptPath as any, {onBrokenImport})
62 }
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) {
71 const ptPath = toPtRelPath(rawPath)
72 if (!isPtCodeFileExt(ptPath) || isPtcheckIgnoredPtPath(ptPath)) { skippedA.push(ptPath); continue }
73 const absPath = ptPathToAbsPath(ptPath)
74 let contents: string
75 if (baselineContent) {
76 const headContent = baselineContent.get(absPath)
77 if (headContent == null) { skippedA.push(ptPath); continue } // new file: no baseline
78 contents = headContent
79 } else {
80 try {
81 contents = await read1File(absPath)
82 } catch {
83 errorsA.push(`${ptPath} - file not found`)
84 continue
85 }
86 }
87 try {
88 if (getIsTsExt(ptPath as any)) {
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}`)
94 }
95 } else {
96 acornParse({contents, ptPath: ptPath as any})
97 }
98 } catch (err: any) {
99 errorsA.push(err.message || String(err))
100 }
101 // Validate oss marker output produces valid syntax (catches stale/mis-scoped markers)
102 errorsA.push(...validateOssMarkerSyntax(ptPath, contents))
103 }
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'})
111 const configPath = ptPathToAbsPath('eslint.config.mjs' as any)
112 let config: any
113 try {
114 const mod = await import(configPath)
115 config = mod.default
116 } catch {
117 return 'All 0 file(s) skipped ESLint (no config)'
118 }
119 const checkableA = filesA.filter(f => {
120 const ptPath = toPtRelPath(f)
121 return isPtCodeFileExt(ptPath) && !isProdPatched(ptPath) && !isPtcheckIgnoredPtPath(ptPath)
122 })
123 if (!checkableA.length) return `All 0 file(s) skipped ESLint (no checkable files)`
124 const errorsA: string[] = []
125 for (const rawPath of checkableA) {
126 const absPath = ptPathToAbsPath(toPtRelPath(rawPath))
127 let contents: string
128 if (baselineContent) {
129 const headContent = baselineContent.get(absPath)
130 if (headContent == null) continue // new file: no baseline
131 contents = headContent
132 } else {
133 try {
134 contents = await read1File(absPath)
135 } catch {
136 continue
137 }
138 }
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
142 const ptPath = toPtRelPath(rawPath)
143 errorsA.push(`${ptPath}:${msg.line}:${msg.column} - ${msg.ruleId}: ${msg.message}`)
144 }
145 }
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
170 cachedProgram = null
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[]> => {
177 try {
178 const {importedByPathsA} = await getPathsThatImport({importeePathA: editedPtPaths})
179 return [...new Set(importedByPathsA as string[])].filter(p =>
180 isPtCodeFileExt(p) && !editedPtPaths.includes(p) && !isProdPatched(p) && !isPtcheckIgnoredPtPath(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) => {
187 const ptPath = toPtRelPath(f)
188 if (ptPath.startsWith('.opencode/')) return false
189 if (isProdPatched(ptPath)) return false
190 if (_.intersection(ptPath.split('/'), globallyIgnoredPaths).length > 0) return false
191 if (isPtcheckIgnoredPtPath(ptPath)) return false
192 return isPtCodeFileExt(ptPath)
193 })
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)
200 const importerAbsPaths = importerPtPaths.map(p => ptPathToAbsPath(p as any))
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))
218 } else {
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
224 }
226 const baselineDiagKeys = !baselineContent && importerAbsPaths.length > 0
227 ? getBaselineDiagKeys(createBaselineProgram([...allAbsPathsA, ...dtsFiles], options, absPathsSet, program), importerAbsPaths, missingExportTsCodes)
228 : new Set<string>()
230 const errorsA: string[] = []
231 for (const absPath of allAbsPathsA) {
232 const sourceFile = program.getSourceFile(absPath)
233 if (!sourceFile) {
234 if (baselineContent) continue // new file (no HEAD blob) contributes no baseline viols
235 errorsA.push(`${toPtRelPath(absPath)} - File not found or not parseable`)
236 continue
237 }
238 const isImporter = importerAbsSet.has(absPath as any)
240 const diagnostics = [
241 ...program.getSyntacticDiagnostics(sourceFile),
242 ...program.getSemanticDiagnostics(sourceFile),
243 ]
245 const isJsFile = ptJsFileExtA.some(ext => absPath.endsWith('.' + ext))
246 const jsSuppressCodes = new Set([
247 7006, 7019, 7031, 7053, 7005, 7034,
248 ])
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.
258 if (isImporter) {
259 if (!missingExportTsCodes.has(diag.code)) continue
260 if (diag.file && diag.file.fileName !== absPath) continue
261 if (isPreExistingImporterViol(diag, baselineDiagKeys)) continue
262 errorsA.push(`${toPtRelPath(absPath)} - TS${diag.code}: ${msg} (reverse-dep of edited file)`)
263 continue
264 }
265 if (isJsFile && !strictTypes && jsSuppressCodes.has(diag.code)) continue
267 const ptPath = diag.file ? toPtRelPath(diag.file.fileName) : toPtRelPath(absPath)
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}`)
272 } else {
273 errorsA.push(`${ptPath} - TS${diag.code}: ${msg}`)
274 }
275 }
276 }
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')}`