🌳
pt0/deployF/gitF/checkImportBoundariesAI.mts
5import ts from 'typescript'
12import { execSync } from 'child_process'
22import { type absFileDirPath } from '../../ptDirF.mts'
27// Import boundary rules: [srcDirs, cannotImportDirs, nodeBuiltinsOk]
28// SINGLE SOURCE OF TRUTH - generates AGENTS.md section when run directly
29const importBoundaryRulesA: [string[], string[], boolean][] = [
30 [['sharedF', 'componentsF'], ['serverF', 'deployF'], false],
31 [['*'], ['tmp'], true], // nothing can import from tmp/
34// Path-pattern boundary rules: [srcPathRegex, forbiddenFilePathPatternRegex, description]
35// Forbids specific files from being imported into runtime resolver code (Next.js API routes).
36// Heavy deploy hub files transitively pull in 1000s of modules → webpack RangeError on bundle.
37// Allowed: small leaf files (AI-suffixed) in deployF, or specific util files.
38const heavyFileBoundaryRulesA: [RegExp, RegExp, string][] = [
39 [
40 /\/server\/(queries|lib|resolvers|type_resolvers|mutations)\//,
41 /\/deployF\/(eptMjsRunner|doSync|mkjob|.*Runner|ptDeployActions|dockerActions|cliF\/)/,
42 'runtime resolvers must not import heavy deployF/ hub files (transitively pulls in massive dep tree → webpack RangeError)'
43 ],
46// A file is "webpack-bundled" iff transitively imported by a source pages/ entry
47// (Next compiles pages/ into client+server bundles). Build artifacts excluded.
48let webpackBundleSetCache: Set<string> | null = null
49const pagesBuildArtifactRe = /\/(\.next-test|out|build|android)\//
51export const getWebpackBundleSet = async (): Promise<Set<string>> => {
52 if (webpackBundleSetCache) return webpackBundleSetCache
53 const allFiles = await lsFilePathsRec(ptDir)
54 const pagesSeeds = allFiles
55 .filter(f => /\/pages\/.*\.(js|ts|jsx|tsx)$/.test(f) && !pagesBuildArtifactRe.test(f))
56 .map(f => f.startsWith(ptDir + '/') ? f.slice(ptDir.length + 1) : f)
57 // ptMadge resets the acorn budget internally every 500 files (it's the sanctioned bulk-walk tool),
58 // so this ~1900-file pages closure no longer trips the OOM breaker.
59 const depH: Record<string, string[]> = await madgeDepFilterCtx.run({dependencyFilter: () => true}, () => ptMadge(pagesSeeds))
60 const closure = new Set<string>(pagesSeeds)
61 for (const [importer, deps] of Object.entries(depH)) {
62 closure.add(importer)
63 for (const d of deps) closure.add(d)
64 }
65 webpackBundleSetCache = closure
66 return closure
69const isWebpackBundled = async (ptPath: string): Promise<boolean> => (await getWebpackBundleSet()).has(ptPath)
71// Per-process memo of the ep import-tree path sets. buildEpPathSets runs two full ptMadge passes
72// (~12s) and is called on every refactorPtcheck; within a process the registered eps are stable, so
73// caching avoids re-walking the whole repo on each back-to-back check. Follows the webpackBundleSetCache
74// precedent. Invalidated implicitly: onlyEpA signature changes when eps are re-registered.
75let epPathSetsCache: {key: string, pt0EpPathsSet: Set<string>, allEpPathsSet: Set<string>} | null = null
76const getEpPathSetsCached = async (onlyEpA: string[]) => {
77 const key = onlyEpA.join('\n')
78 if (epPathSetsCache && epPathSetsCache.key === key) return epPathSetsCache
79 const { pt0EpPathsSet, allEpPathsSet } = await buildEpPathSets({onlyEpA})
80 epPathSetsCache = {key, pt0EpPathsSet, allEpPathsSet}
81 return epPathSetsCache
84// UPPER_SNAKE var names are forbidden (AGENTS.md: use camelCase). 3+ chars so PI/X don't match.
85const upperSnakeVarRe = /^[A-Z][A-Z0-9_]{2,}$/
87// AI suffix is for filenames only (e.g. fooAI.mts exports foo), not function/var/class names.
88const endsWithAiSuffixRe = /AI$/
90const pushAiSuffixViol = (violations: string[], ptPath: string, line: number, name: string | undefined, kind: string) => {
91 if (name && endsWithAiSuffixRe.test(name)) {
92 violations.push(`${ptPath}:${line}: ${kind} ${name} - AI suffix is for filenames only, not function/var names - no-fn-ai-suffix: rename to ${name.replace(/AI$/, '')}`)
93 }
96const isAcornFunctionValue = (n: any): boolean => n?.type === 'ArrowFunctionExpression' || n?.type === 'FunctionExpression'
98// Package boundary rules: [packageRegex, allowedDirs[], webpackUnsafe]
99// webpackUnsafe = package requires Node built-ins that webpack can't bundle
100const packageBoundaryRulesA: [RegExp, string[], boolean][] = [
101 [/^@kubernetes\/client-node/, ['k8sF'], true],
102 [/^next(\/|$)/, ['nextF', 'ocui'], false],
103 [/^@aws-sdk\//, ['bktF'], true],
104 [/^ethers(\/|$)/, ['ethersF'], false],
107// Directories where console.log/betLog are forbidden bc glitched (use mcpDebugLog instead)
108const forbiddenConsoleLogDirsA: string[] = []
110// Directories requiring F-suffix (bare names forbidden)
111const forbiddenBareDirsA = ['server', 'shared', 'components', 'deploy', 'deploy_mjs', 'bin', 'lib']
113const nodeBuiltinsA = ['fs', 'path', 'child_process', 'async_hooks', 'crypto', 'os', 'http', 'https', 'net', 'tls', 'dns', 'cluster', 'worker_threads', 'v8', 'vm', 'perf_hooks', 'inspector', 'readline', 'repl', 'tty', 'dgram']
115// Context calls that should not have 'action' destructured/accessed (use getAction() instead)
116const ctxCallNamesA = ['getAppCfg', 'getKlusterCtx']
117const ctxShimFilesA = ['actionCtxF', 'klusterCtxF', 'appCfgCtxF']
119type FileCheckCtx = {
120 ptPath: string
121 contents: string
122 rawContents: string
123 importPathsA: string[]
124 isEntrypoint: boolean
125 tsExt: boolean | string | undefined
126 ossHook: Awaited<ReturnType<typeof getOssHook>>
129const getFileCheckCtx = async (ptPath: string): Promise<FileCheckCtx> => {
130 const rawContents = await read1File(ptPathToAbsPath(toPtRelPath(ptPath)))
131 const ossHook = await getOssHook()
132 const contents = ossHook ? ossHook.ossReplace(rawContents) : rawContents
133 const importsH = await acornSingleFile(ptPath as any, { contents: rawContents })
134 const importPathsA = Object.keys(importsH)
135 const registeredEps = getRegistrySync() || []
136 const isEntrypoint = registeredEps.some((ep: { regEpPath: string }) => ep.regEpPath === ptPath)
137 const { tsExt } = await getTsExtContents({ ptPath: ptPath as any })
138 return { ptPath, contents, rawContents, importPathsA, isEntrypoint, tsExt, ossHook }
141// Check import boundaries only (cross-dir imports, package restrictions, Node built-ins)
142export const checkImportBoundariesForFile = async (ptPath: string, ctx?: FileCheckCtx): Promise<string[]> => {
143 const { contents, rawContents, importPathsA, ossHook } = ctx || await getFileCheckCtx(ptPath)
144 const violations: string[] = []
146 for (const [srcDirsA, forbiddenDirsA, nodeOk] of importBoundaryRulesA) {
147 const matchesSrc = srcDirsA.includes('*') || srcDirsA.some(d => ptPath.includes(`/${d}/`))
148 if (!matchesSrc) continue
150 for (const importPath of importPathsA) {
151 for (const forbidden of forbiddenDirsA) {
152 if (importPath.includes(`/${forbidden}/`) || importPath.startsWith(`${forbidden}/`)) {
153 violations.push(`${ptPath} imports ${importPath} (${forbidden}/ forbidden)`)
154 }
155 }
156 }
158 if (!nodeOk) {
159 for (const builtin of nodeBuiltinsA) {
160 const hasImport = [builtin, `node:${builtin}`, `${builtin}/`]
161 .flatMap(b => [`from '${b}'`, `from "${b}"`])
162 .some(p => contents.includes(p))
163 if (hasImport) violations.push(`${ptPath} imports Node built-in '${builtin}'`)
164 }
165 }
166 }
168 const rawSpecifiers = await getRawImportSpecifiers(ptPath as any, rawContents)
169 for (const spec of rawSpecifiers) {
170 if (spec.startsWith('.')) continue
171 for (const [pkgRe, allowedDirsA] of packageBoundaryRulesA) {
172 if (!pkgRe.test(spec)) continue
173 const inAllowedDir = allowedDirsA.some(d => ptPath.includes(`/${d}/`))
174 if (!inAllowedDir) {
175 violations.push(`${ptPath} imports '${spec}' (only allowed in ${allowedDirsA.join(', ')})`)
176 }
177 }
178 }
180 for (const [srcRe, forbiddenRe, description] of heavyFileBoundaryRulesA) {
181 if (!srcRe.test(ptPath)) continue
182 for (const importPath of importPathsA) {
183 if (forbiddenRe.test(importPath)) {
184 violations.push(`${ptPath} imports ${importPath} - ${description}`)
185 }
186 }
187 }
189 if (await isWebpackBundled(ptPath)) {
190 const webpackUnsafeRulesA = packageBoundaryRulesA.filter(([, , unsafe]) => unsafe)
191 violations.push(...await checkTransitivePackageImports(ptPath, webpackUnsafeRulesA))
192 }
194 if (ptPath.startsWith('pt0/') && ossHook) {
195 const hit = perFileGuardNeedle({needleStrA: ossHook.ossForbidStrA, path: ptPath, contents, raiseOnFind: false})
196 if (hit) {
197 violations.push(`${ptPath} contains forbidden '${hit.needleStr}' (${hit.containingWord}) — restructure (move file out of pt0/ or inject via param); do NOT encode to bypass`)
198 } else {
199 const decodedContents = decodeStrLiterals(contents)
200 if (decodedContents) {
201 const decHit = perFileGuardNeedle({needleStrA: ossHook.ossForbidStrA, path: ptPath, contents: decodedContents, raiseOnFind: false})
202 if (decHit) violations.push(`${ptPath} encodes forbidden '${decHit.needleStr}' via base64/hex literal — restructure (move file out of pt0/ or inject via param)`)
203 }
204 }
205 }
207 const dirForbidStrAH = await getDirForbidStrAH()
208 if (dirForbidStrAH) {
209 for (const [dirPrefix, needles] of Object.entries(dirForbidStrAH)) {
210 if (ptPath.startsWith(dirPrefix + '/')) {
211 const dirHit = perFileGuardNeedle({needleStrA: needles, path: ptPath, contents: rawContents, raiseOnFind: false})
212 if (dirHit) violations.push(`${ptPath} contains '${dirHit.needleStr}' — ${dirPrefix}/ must not reference high-level app packages (layering inversion)`)
213 }
214 }
215 }
217 return violations
220// Shared AST check context - abstracts over acorn/TS AST differences
221const assertFnNamesA = ['throwIf', 'assertDefined', 'assertExists', 'assertTruthy']
222const whereMethodNamesA = ['where', 'whereRaw', 'orWhere', 'whereNot', 'whereIn', 'whereNotIn', 'whereExists', 'whereBetween', 'having', 'havingRaw']
224type AstCheckContext = {
225 ptPath: string
226 isEntrypoint: boolean
227 skipTrivialThrowIf: boolean
228 line: number
229 isDynamicImport: boolean
230 importSource: string | null
231 isInsideFunction: boolean
232 isInGssp: boolean
233 isDevPcConditional: boolean
234 isInNextDynamic: boolean
235 isCallExpr: boolean
236 calleeName: string | null
237 hasConstantDebugCtx: boolean
238 firstArgThrowIfClassification: 'nullish' | 'falsy' | null
239 isDbFnNowCall: boolean
240 isInsideWhereClause: boolean
241 isDiffNowCall: boolean
244const runAstChecks = (ctx: AstCheckContext): string[] => {
245 const violations: string[] = []
247 // Check: dynamic import() in forbidden contexts
248 if (!ctx.isEntrypoint && ctx.isDynamicImport && ctx.isInsideFunction) {
249 const isImportingEp = !ctx.importSource || /\/ep[A-Z]/.test(ctx.importSource) || !ctx.importSource.startsWith('.')
250 if (!ctx.isInGssp && !ctx.isDevPcConditional && !ctx.isInNextDynamic && !isImportingEp) {
251 violations.push(`${ctx.ptPath}:${ctx.line}: dynamic import() inside functions only allowed in entrypoints or getServerSideProps`)
252 }
253 }
255 // Check: CallExpression patterns
256 if (ctx.isCallExpr && ctx.calleeName) {
257 // Check: constant debugCtx in assert functions
258 if (assertFnNamesA.includes(ctx.calleeName) && ctx.hasConstantDebugCtx) {
259 violations.push(`${ctx.ptPath}:${ctx.line}: debugCtx has constant value - use variables only`)
260 }
262 // Check: trivial throwIf patterns
263 if (!ctx.skipTrivialThrowIf && ctx.calleeName === 'throwIf' && ctx.firstArgThrowIfClassification) {
264 const suggestion = ctx.firstArgThrowIfClassification === 'nullish' ? 'assertDefined for TS narrowing' : 'assertTruthy/assertNonEmptyString/assertDefined for TS narrowing'
265 violations.push(`${ctx.ptPath}:${ctx.line}: throwIf(() => ${ctx.firstArgThrowIfClassification === 'nullish' ? 'x == null' : '!x'}) → ${suggestion}`)
266 }
267 }
269 if (ctx.isDbFnNowCall && ctx.isInsideWhereClause) {
270 violations.push(`${ctx.ptPath}:${ctx.line}: db.fn.now() in WHERE → use luxNow() for testable time override`)
271 }
273 if (ctx.isDiffNowCall) {
274 violations.push(`${ctx.ptPath}:${ctx.line}: .diffNow() → .diff(luxNow()) for testable time override`)
275 }
277 return violations
280// Acorn AST walker and accessors
281type AstNode = { type: string, id?: { name?: string }, key?: { name?: string }, test?: AstNode, source?: { value?: string }, loc?: { start?: { line?: number } }, [k: string]: unknown }
283const walkAcornAst = (ast: AstNode, onNode: (node: AstNode, ancestors: AstNode[]) => void, ancestors: AstNode[] = []) => {
284 if (!ast || typeof ast !== 'object') return
285 onNode(ast, ancestors)
286 const newAncestors = [ast, ...ancestors]
287 for (const key of Object.keys(ast)) {
288 if (key === 'loc' || key === 'parent') continue
289 const child = ast[key]
290 if (Array.isArray(child)) {
291 for (const item of child) walkAcornAst(item as AstNode, onNode, newAncestors)
292 } else if (child && typeof child === 'object' && (child as AstNode).type) {
293 walkAcornAst(child as AstNode, onNode, newAncestors)
294 }
295 }
298const acornIsConstant = (node: AstNode): boolean => node?.type === 'Literal' || node?.type === 'TemplateLiteral'
300const acornClassifyThrowIf = (node: AstNode): 'nullish' | 'falsy' | null => {
301 if (!node) return null
302 if (node.type === 'UnaryExpression' && (node as any).operator === '!' && (node as any).argument) {
303 const arg = (node as any).argument
304 if (arg.type === 'Identifier' || arg.type === 'MemberExpression') return 'falsy'
305 }
306 if (node.type === 'BinaryExpression') {
307 const op = (node as any).operator
308 if (op === '==' || op === '===') {
309 const right = (node as any).right
310 if (right?.type === 'Literal' && right?.value === null || right?.type === 'Identifier' && right?.name === 'undefined') return 'nullish'
311 }
312 }
313 return null
316const runAcornAstChecks = (ast: AstNode, ptPath: string, isEntrypoint: boolean): string[] => {
317 const violations: string[] = []
318 const skipTrivialThrowIf = ptPath.includes('/assertsF/')
320 walkAcornAst(ast, (node, ancestors) => {
321 const line = node.loc?.start?.line || 0
322 const callee = (node as any).callee
323 const calleeName = node.type === 'CallExpression' && callee?.type === 'Identifier' ? callee.name : null
324 const args = (node as any).arguments
326 const hasConstantDebugCtx = (() => {
327 if (!calleeName || !assertFnNamesA.includes(calleeName)) return false
328 const debugCtx = args?.[1]
329 if (debugCtx?.type !== 'ObjectExpression') return false
330 return (debugCtx.properties || []).some((prop: AstNode) => prop.type === 'Property' && acornIsConstant((prop as any).value))
331 })()
333 const firstArgThrowIfClassification = (() => {
334 if (calleeName !== 'throwIf') return null
335 const firstArg = args?.[0]
336 if (firstArg?.type !== 'ArrowFunctionExpression') return null
337 return acornClassifyThrowIf(firstArg.body)
338 })()
340 const isDbFnNowCall = (() => {
341 if (node.type !== 'CallExpression') return false
342 if (callee?.type !== 'MemberExpression' || callee.property?.name !== 'now') return false
343 const obj = callee.object
344 return obj?.type === 'MemberExpression' && obj.property?.name === 'fn'
345 })()
347 const isDiffNowCall = (() => {
348 if (node.type !== 'CallExpression') return false
349 return callee?.type === 'MemberExpression' && callee.property?.name === 'diffNow'
350 })()
352 const isInsideWhereClause = (() => {
353 if (!isDbFnNowCall) return false
354 for (const a of ancestors) {
355 if (a.type !== 'CallExpression') continue
356 const aCallee = (a as any).callee
357 if (aCallee?.type === 'MemberExpression' && aCallee.property?.name && whereMethodNamesA.includes(aCallee.property.name)) return true
358 break
359 }
360 return false
361 })()
363 violations.push(...runAstChecks({
364 ptPath, isEntrypoint, skipTrivialThrowIf, line,
365 isDynamicImport: node.type === 'ImportExpression',
366 importSource: node.source?.value ?? null,
367 isInsideFunction: ancestors.some(a => ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'MethodDefinition'].includes(a.type)),
368 isInGssp: ancestors.some(a => (a.type === 'VariableDeclarator' || a.type === 'FunctionDeclaration') && a.id?.name === 'getServerSideProps' || a.type === 'Property' && a.key?.name === 'getServerSideProps'),
369 isDevPcConditional: ancestors.some(a => a.type === 'ConditionalExpression' && JSON.stringify(a.test).includes('"name":"isDevPc"')),
370 isInNextDynamic: ancestors.some(a => a.type === 'CallExpression' && (a as any).callee?.type === 'Identifier' && (a as any).callee?.name === 'dynamic'),
371 isCallExpr: node.type === 'CallExpression',
372 calleeName,
373 hasConstantDebugCtx,
374 firstArgThrowIfClassification,
375 isDbFnNowCall,
376 isInsideWhereClause,
377 isDiffNowCall,
378 }))
380 // Check: empty catch block (catch {} or catch (e) {})
381 if (node.type === 'CatchClause') {
382 const body = (node as any).body
383 if (body?.type === 'BlockStatement' && !(body.body || []).length) {
384 violations.push(`${ptPath}:${line}: empty catch block - exceptions must be handled or logged, or add // catch:userapproved`)
385 }
386 }
388 // Check: enterWith without spread - must merge with getStore() or use // ctx:clear
389 if (node.type === 'CallExpression' && callee?.type === 'MemberExpression' && callee.property?.name === 'enterWith') {
390 const firstArg = args?.[0]
391 if (firstArg?.type === 'ObjectExpression') {
392 const hasSpread = (firstArg.properties || []).some((prop: AstNode) => prop.type === 'SpreadElement')
393 if (!hasSpread) {
394 violations.push(`${ptPath}:${line}: enterWith without spread - must include ...ctx.getStore() or add // ctx:clear`)
395 }
396 }
397 }
399 // Detect action destructured from context: const { action, ... } = getAppCfg()
400 if (!ctxShimFilesA.some(f => ptPath.includes(f)) && node.type === 'VariableDeclarator' && (node as any).init?.type === 'CallExpression') {
401 const init = (node as any).init
402 const calleeName = init.callee?.type === 'Identifier' ? init.callee.name : init.callee?.type === 'MemberExpression' ? init.callee.property?.name : null
403 if (ctxCallNamesA.includes(calleeName) && (node as any).id?.type === 'ObjectPattern') {
404 const hasAction = ((node as any).id.properties || []).some((p: any) => p.key?.name === 'action')
405 if (hasAction) violations.push(`${ptPath}:${line}: action destructured from context - use getAction() directly`)
406 }
407 }
409 // Check: UPPER_SNAKE variable names - use camelCase instead (AGENTS.md)
410 if (node.type === 'VariableDeclarator' && (node as any).id?.type === 'Identifier' && upperSnakeVarRe.test((node as any).id.name)) {
411 violations.push(`${ptPath}:${line}: UPPER_SNAKE variable ${(node as any).id.name} - use camelCase`)
412 }
414 // Check: function/var/class names ending in AI suffix - filename suffix only (AGENTS.md)
415 if (node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') pushAiSuffixViol(violations, ptPath, line, (node as any).id?.name, node.type === 'FunctionDeclaration' ? 'function' : 'class')
416 if (node.type === 'VariableDeclarator' && (node as any).id?.type === 'Identifier' && isAcornFunctionValue((node as any).init)) pushAiSuffixViol(violations, ptPath, line, (node as any).id.name, 'function')
417 if (node.type === 'MethodDefinition') pushAiSuffixViol(violations, ptPath, line, (node as any).key?.name, 'method')
418 if (node.type === 'Property' && isAcornFunctionValue((node as any).value)) pushAiSuffixViol(violations, ptPath, line, (node as any).key?.name, 'function')
420 // Detect action accessed from context: getAppCfg().action
421 if (!ctxShimFilesA.some(f => ptPath.includes(f)) && node.type === 'MemberExpression' && (node as any).property?.name === 'action' && (node as any).object?.type === 'CallExpression') {
422 const callExpr = (node as any).object
423 const calleeName = callExpr.callee?.type === 'Identifier' ? callExpr.callee.name : callExpr.callee?.type === 'MemberExpression' ? callExpr.callee.property?.name : null
424 if (ctxCallNamesA.includes(calleeName)) {
425 violations.push(`${ptPath}:${line}: action accessed from context - use getAction() directly`)
426 }
427 }
428 })
430 return violations
433// TypeScript AST accessors
434const tsIsConstant = (node: ts.Node): boolean =>
435 ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateExpression(node) ||
436 node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword
438const tsClassifyThrowIf = (node: ts.Node): 'nullish' | 'falsy' | null => {
439 if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) {
440 if (ts.isIdentifier(node.operand) || ts.isPropertyAccessExpression(node.operand)) return 'falsy'
441 }
442 if (ts.isBinaryExpression(node) && (node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken || node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken)) {
443 if (node.right.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(node.right) && node.right.text === 'undefined') return 'nullish'
444 }
445 return null
448const runTsAstChecks = (sourceFile: ts.SourceFile, ptPath: string, isEntrypoint: boolean): string[] => {
449 const violations: string[] = []
450 const skipTrivialThrowIf = ptPath.includes('/assertsF/')
452 const visit = (node: ts.Node, ancestors: ts.Node[] = []) => {
453 const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
454 const newAncestors = [node, ...ancestors]
456 const isDynamicImport = ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
457 const importSource = isDynamicImport && ts.isCallExpression(node) && node.arguments[0] && ts.isStringLiteral(node.arguments[0]) ? node.arguments[0].text : null
459 const isCallWithIdent = ts.isCallExpression(node) && ts.isIdentifier(node.expression)
460 const calleeName = isCallWithIdent ? (node as ts.CallExpression).expression.getText() : null
462 const hasConstantDebugCtx = (() => {
463 if (!isCallWithIdent || !calleeName || !assertFnNamesA.includes(calleeName)) return false
464 const debugCtx = (node as ts.CallExpression).arguments[1]
465 if (!debugCtx || !ts.isObjectLiteralExpression(debugCtx)) return false
466 return debugCtx.properties.some(prop => ts.isPropertyAssignment(prop) && tsIsConstant(prop.initializer))
467 })()
469 const firstArgThrowIfClassification = (() => {
470 if (calleeName !== 'throwIf' || !isCallWithIdent) return null
471 const firstArg = (node as ts.CallExpression).arguments[0]
472 if (!firstArg || !ts.isArrowFunction(firstArg)) return null
473 return tsClassifyThrowIf(firstArg.body)
474 })()
476 const isDbFnNowCall = (() => {
477 if (!ts.isCallExpression(node)) return false
478 const expr = node.expression
479 if (!ts.isPropertyAccessExpression(expr) || expr.name.text !== 'now') return false
480 return ts.isPropertyAccessExpression(expr.expression) && expr.expression.name.text === 'fn'
481 })()
483 const isDiffNowCall = (() => {
484 if (!ts.isCallExpression(node)) return false
485 return ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'diffNow'
486 })()
488 const isInsideWhereClause = (() => {
489 if (!isDbFnNowCall) return false
490 for (const a of ancestors) {
491 if (!ts.isCallExpression(a)) continue
492 if (ts.isPropertyAccessExpression(a.expression) && whereMethodNamesA.includes(a.expression.name.text)) return true
493 break
494 }
495 return false
496 })()
498 const isInsideFunction = ancestors.some(a => ts.isFunctionDeclaration(a) || ts.isFunctionExpression(a) || ts.isArrowFunction(a) || ts.isMethodDeclaration(a))
499 const isInGssp = ancestors.some(a =>
500 (ts.isVariableDeclaration(a) || ts.isFunctionDeclaration(a)) && a.name && ts.isIdentifier(a.name) && a.name.text === 'getServerSideProps' ||
501 ts.isPropertyAssignment(a) && ts.isIdentifier(a.name) && a.name.text === 'getServerSideProps'
502 )
503 const isDevPcConditional = ancestors.some(a => ts.isConditionalExpression(a) && a.condition.getText().includes('isDevPc'))
504 const isInNextDynamic = ancestors.some(a => ts.isCallExpression(a) && ts.isIdentifier(a.expression) && a.expression.text === 'dynamic')
506 violations.push(...runAstChecks({
507 ptPath, isEntrypoint, skipTrivialThrowIf, line,
508 isDynamicImport,
509 importSource,
510 isInsideFunction,
511 isInGssp,
512 isDevPcConditional,
513 isInNextDynamic,
514 isCallExpr: ts.isCallExpression(node),
515 calleeName,
516 hasConstantDebugCtx,
517 firstArgThrowIfClassification,
518 isDbFnNowCall,
519 isInsideWhereClause,
520 isDiffNowCall,
521 }))
523 // Check: empty catch block (catch {} or catch (e) {})
524 if (ts.isCatchClause(node) && node.block.statements.length === 0) {
525 violations.push(`${ptPath}:${line}: empty catch block - exceptions must be handled or logged, or add // catch:userapproved`)
526 }
528 // Check: enterWith without spread - must merge with getStore() or use // ctx:clear
529 if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'enterWith') {
530 const firstArg = (node as ts.CallExpression).arguments[0]
531 if (firstArg && ts.isObjectLiteralExpression(firstArg)) {
532 const hasSpread = firstArg.properties.some(prop => ts.isSpreadAssignment(prop))
533 if (!hasSpread) {
534 violations.push(`${ptPath}:${line}: enterWith without spread - must include ...ctx.getStore() or add // ctx:clear`)
535 }
536 }
537 }
539 // Detect action destructured from context: const { action, ... } = getAppCfg()
540 if (!ctxShimFilesA.some(f => ptPath.includes(f)) && ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
541 const callee = node.initializer.expression.getText()
542 if (ctxCallNamesA.some(n => callee === n || callee.endsWith('.' + n)) && node.name && ts.isObjectBindingPattern(node.name)) {
543 const hasAction = node.name.elements.some(el => ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === 'action')
544 if (hasAction) violations.push(`${ptPath}:${line}: action destructured from context - use getAction() directly`)
545 }
546 }
548 // Check: UPPER_SNAKE variable names - use camelCase instead (AGENTS.md)
549 if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && upperSnakeVarRe.test(node.name.text)) {
550 violations.push(`${ptPath}:${line}: UPPER_SNAKE variable ${node.name.text} - use camelCase`)
551 }
553 // Check: function/var/class names ending in AI suffix - filename suffix only (AGENTS.md)
554 if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) pushAiSuffixViol(violations, ptPath, line, node.name?.text, ts.isFunctionDeclaration(node) ? 'function' : 'class')
555 if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) pushAiSuffixViol(violations, ptPath, line, node.name.text, 'function')
556 if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) pushAiSuffixViol(violations, ptPath, line, node.name.text, 'method')
557 if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) pushAiSuffixViol(violations, ptPath, line, node.name.text, 'function')
559 // Detect action accessed from context: getAppCfg().action
560 if (!ctxShimFilesA.some(f => ptPath.includes(f)) && ts.isPropertyAccessExpression(node) && node.name.text === 'action' && ts.isCallExpression(node.expression)) {
561 const callee = node.expression.expression.getText()
562 if (ctxCallNamesA.some(n => callee === n || callee.endsWith('.' + n))) {
563 violations.push(`${ptPath}:${line}: action accessed from context - use getAction() directly`)
564 }
565 }
567 ts.forEachChild(node, child => visit(child, newAncestors))
568 }
570 visit(sourceFile)
571 return violations
574// Check code style rules (not import-related, can change when file moves)
575const isTrackedByGit = (ptPath: string) => {
576 try {
577 execSync(`git ls-files --error-unmatch "${toPtRelPath(ptPath)}"`, { cwd: ptDir, stdio: 'pipe' })
578 return true
579 } catch { return false } // catch:userapproved
582export const checkCodeStyleForFile = async (ptPath: string, ctx?: FileCheckCtx): Promise<string[]> => {
583 const { contents, importPathsA, isEntrypoint, tsExt } = ctx || await getFileCheckCtx(ptPath)
584 const violations: string[] = []
586 // console.log/betLog in forbidden directories
587 for (const forbiddenDir of forbiddenConsoleLogDirsA) {
588 if (!ptPath.includes(`/${forbiddenDir}/`)) continue
589 if (contents.includes('console.log')) violations.push(`${ptPath} uses console.log (forbidden in ${forbiddenDir}/, use mcpDebugLog)`)
590 if (contents.includes('betLog')) violations.push(`${ptPath} uses betLog (forbidden in ${forbiddenDir}/, use mcpDebugLog)`)
591 }
593 // _.chain() warning - breaks webpack tree-shaking, skip deployF/ (not webpack-bundled)
594 if (!ptPath.includes('/deployF/')) {
595 const chainMatches = contents.match(/_.chain\(/g)
596 if (chainMatches) {
597 violations.push(`${ptPath}: ${chainMatches.length}x _.chain() - convert to standalone lodash functions (e.g. _.map/_.filter) for webpack tree-shaking`)
598 }
599 }
601 // Forbidden bare directory names (should use *F suffix) - only for NEW files
602 if (!isTrackedByGit(ptPath) && ptPath.startsWith('pt0/')) {
603 for (const bare of forbiddenBareDirsA) {
604 if (ptPath.includes(`/${bare}/`)) {
605 const suggested = bare === 'deploy_mjs' ? 'deployF' : `${bare}F`
606 violations.push(`${ptPath}: /${bare}/ → /${suggested}/`)
607 }
608 }
609 }
611 // Detect action being passed to klusterCtx (should use actionCtx/getAction instead)
612 const importsKlusterCtx = importPathsA.some(p => p.includes('klusterCtxF'))
613 const importsGetDefaultAction = importPathsA.some(p => p.includes('getDefaultActionF'))
614 if (importsKlusterCtx && importsGetDefaultAction) {
615 violations.push(`${ptPath}: imports both klusterCtx and getDefaultAction - action should not flow through klusterCtx (use actionCtx/getAction)`)
616 }
618 // AST-based checks (includes action-from-context detection): dynamic imports and trivial throwIf
619 if (tsExt) {
620 const sourceFile = tsSrcFile({ ptPath: ptPath as any, contents })
621 violations.push(...runTsAstChecks(sourceFile, ptPath, isEntrypoint))
622 } else {
623 try {
624 const ast = acornParse({ contents, ptPath: ptPath as any }) as unknown as AstNode
625 violations.push(...runAcornAstChecks(ast, ptPath, isEntrypoint))
626 } catch { /* parse error - skip AST checks */ } // catch:userapproved
627 }
629 filterViolationsByLineComment(violations, 'empty catch block', '// catch:userapproved', contents)
630 filterViolationsByLineComment(violations, 'enterWith without spread', '// ctx:clear', contents)
632 return violations
635const filterViolationsByLineComment = (violations: string[], violationNeedle: string, commentMarker: string, contents: string) => {
636 if (!violations.some(v => v.includes(violationNeedle))) return
637 const lines = contents.split('\n')
638 const filtered = violations.filter(v => {
639 if (!v.includes(violationNeedle)) return true
640 const m = v.match(/:(\d+):/)
641 if (!m) return true
642 return !lines[parseInt(m[1]) - 1]?.includes(commentMarker)
643 })
644 violations.length = 0
645 violations.push(...filtered)
648// Check all rules for a file (boundaries + style)
649export const checkAllRulesForFile = async (ptPath: string): Promise<string[]> => {
650 const ctx = await getFileCheckCtx(ptPath)
651 const [boundaryViolations, styleViolations] = await Promise.all([
652 checkImportBoundariesForFile(ptPath, ctx),
653 checkCodeStyleForFile(ptPath, ctx),
654 ])
655 return [...boundaryViolations, ...styleViolations]
658// Check import boundaries for multiple files
659export const checkImportBoundaries = async (filesA: string[]): Promise<string> => {
660 resetRegistryCache() // fresh registry: the host process may have cached it before new files existed
661 const violations: string[] = []
662 for (const f of filesA) {
663 if (!isPtCodeFileExt(f)) continue
664 const fileViolations = await checkAllRulesForFile(f)
665 violations.push(...fileViolations)
666 }
667 const newPt0Files = filesA.filter(f => f.startsWith('pt0/') && isPtCodeFileExt(f) && !isTrackedByGit(f))
668 const pt0CodeFiles = filesA.filter(f => f.startsWith('pt0/') && isPtCodeFileExt(f))
669 if (newPt0Files.length > 0 || pt0CodeFiles.length > 0) {
670 const pt0EpA = getAllRegisteredEps().filter((p: string) => p.startsWith('pt0/'))
671 let { pt0EpPathsSet, allEpPathsSet } = await getEpPathSetsCached(pt0EpA)
672 if (newPt0Files.length > 0) {
673 // Targeted reachability: for each new pt0 file, check if any cached ep-path file imports it.
674 // Avoids nullifying epPathSetsCache → ~12s full madge recompute for the common case of a new
675 // helper file imported by existing ep-reachable code. Falls back to full recompute only if
676 // the targeted check can't confirm reachability (e.g. new-file chains).
677 let needsFullRecompute = false
678 for (const f of newPt0Files) {
679 if (pt0EpPathsSet.has(f)) continue
680 const {importedByPathsA} = await getPathsThatImport({importeePathA: [f]})
681 if ((importedByPathsA as string[]).some(p => pt0EpPathsSet.has(p))) {
682 pt0EpPathsSet.add(f)
683 } else {
684 needsFullRecompute = true
685 break
686 }
687 }
688 if (needsFullRecompute) {
689 epPathSetsCache = null
690 const fresh = await getEpPathSetsCached(pt0EpA)
691 pt0EpPathsSet = fresh.pt0EpPathsSet
692 allEpPathsSet = fresh.allEpPathsSet
693 }
694 }
695 for (const f of newPt0Files) {
696 if (!pt0EpPathsSet.has(f)) {
697 violations.push(`${f} is not reachable by any pt0 entrypoint — move to the consuming app's directory or add a pt0 ep that imports it`)
698 }
699 }
700 if (pt0CodeFiles.length > 0) {
701 const allEpCodePaths = [...allEpPathsSet].filter((p: string) => isPtCodeFileExt(p))
702 const pt0CodeFilesSet = new Set(pt0CodeFiles)
703 const pathsA = pt0CodeFiles.map(f => pathDownJoin(ptDir, f) as unknown as absFileDirPath)
704 const extraImportScanPathsA = allEpCodePaths
705 .filter((p: string) => !pt0CodeFilesSet.has(p))
706 .map((p: string) => pathDownJoin(ptDir, p) as unknown as absFileDirPath)
707 try {
708 const deadExportsByFile = await getUnusedExportsByFile({ pathsA, extraImportScanPathsA })
709 for (const [f, exports] of Object.entries(deadExportsByFile)) {
710 violations.push(`dead exports in ${f}: ${exports.join(', ')}`)
711 }
712 } catch { /* catch:userapproved */ } // catch:userapproved
713 }
714 }
715 if (violations.length > 0) {
716 return `Import boundary violations:\n${violations.map(e => ' ' + e).join('\n')}`
717 }
718 return `All ${filesA.length} file(s) pass import boundary check`
721// Generate AGENTS.md section from rules
722const genAgentsMdSection = () => {
723 const pkgSummary = packageBoundaryRulesA
724 .map(([re]) => re.source.replace(/\\/g, '').replace(/\(\/.?\)\$?/g, '').replace(/[^a-zA-Z0-9@/_-]/g, ''))
725 .join(', ')
726 const dirRuleParts = importBoundaryRulesA
727 .filter(([srcA]) => !srcA.includes('*'))
728 .map(([srcA, forbiddenA, nodeOk]) => {
729 const src = srcA.map(d => `\`${d}\``).join('/')
730 const forbidden = forbiddenA.map(d => `\`${d}\``).join('/')
731 return `${src} can't import ${forbidden}${nodeOk ? '' : '/Node built-ins'}`
732 })
733 return [
734 '## Import Boundaries',
735 '- Enforced by `pt_check`/`pt_commit` - violations block commit with details',
736 `- Key: ${dirRuleParts.join('; ')}; package restrictions exist for ${pkgSummary}`,
737 '- Runtime resolvers (`server/queries|lib|resolvers|type_resolvers|mutations/`) can\'t import heavy deployF hub files (eptMjsRunner, doSync*, mkjob, *Runner, ptDeployActions, dockerActions, cliF/) - pulls in massive dep tree → webpack RangeError on bundle. Use small leaf files (e.g. `*AI.mts`) instead.',
738 '- Webpack-bundled files (transitively imported by a `pages/` entry) are checked for Node-only packages (@kubernetes/client-node, @aws-sdk/) via import-graph BFS — type-only imports (`import type`) are excluded',
739 '- Package.json bloat: deps declared in a parent `package.json` but only imported in a single F-suffixed subdir are flagged by `findPkgJsonBloat` (in `pkgBloatAI.mts`); the `no-pkg-json-bloat-in-pt0` test locks this invariant. Move such deps to the subdir\'s own `package.json`.',
740 ].join('\n')
743// CLI: update AGENTS.md (guards uncommitted changes, auto-commits)
744if (isDirectlyRun(import.meta.url)) {
747 const agentsMdPath = ptPathToAbsPath(toPtRelPath('AGENTS.md'))
748 const contents = await read1File(agentsMdPath)
749 const newSection = genAgentsMdSection()
751 // Replace existing Import Boundaries section
752 const startMarker = '## Import Boundaries'
753 const endMarker = '\n## '
754 const startIdx = contents.indexOf(startMarker)
755 if (startIdx === -1) {
756 console.error('Could not find ## Import Boundaries in AGENTS.md')
757 process.exit(1)
758 }
759 const afterStart = contents.indexOf(endMarker, startIdx + startMarker.length)
760 const before = contents.slice(0, startIdx)
761 const after = afterStart === -1 ? '' : contents.slice(afterStart + 1) // +1 to keep the \n
763 const newContents = before + newSection + '\n\n' + after
764 if (newContents === contents) {
765 console.log('AGENTS.md Import Boundaries section already up to date')
766 } else {
767 await write1File(agentsMdPath, newContents)
768 execSync(`git add AGENTS.md && git commit -m 'Update AGENTS.md Import Boundaries (auto-generated)'`, { cwd: ptDir, encoding: 'utf8' })
769 console.log('Updated and committed AGENTS.md Import Boundaries section')
770 }