5import ts from 'typescript' 12import { execSync } from 'child_process' 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][] = [ 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)' 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 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)) { 63 for (const d of deps) closure.add(d) 65 webpackBundleSetCache = 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 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$/, '')}`) 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'] 123 importPathsA: string[] 124 isEntrypoint: boolean 125 tsExt: boolean | string | undefined 126 ossHook: Awaited<ReturnType<typeof getOssHook>> 129const getFileCheckCtx = async (ptPath: string): Promise<FileCheckCtx> => { 132 const contents = ossHook ? ossHook.ossReplace(rawContents) : rawContents 134 const importPathsA = Object.keys(importsH) 136 const isEntrypoint = registeredEps.some((ep: { regEpPath: string }) => ep.regEpPath === ptPath) 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)`) 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}'`) 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}/`)) 175 violations.push(`${ptPath} imports '${spec}' (only allowed in ${allowedDirsA.join(', ')})`) 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}`) 189 if (await isWebpackBundled(ptPath)) { 190 const webpackUnsafeRulesA = packageBoundaryRulesA.filter(([, , unsafe]) => unsafe) 194 if (ptPath.startsWith('pt0/') && ossHook) { 195 const hit = perFileGuardNeedle({needleStrA: ossHook.ossForbidStrA, path: ptPath, contents, raiseOnFind: false}) 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`) 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)`) 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)`) 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 = { 226 isEntrypoint: boolean 227 skipTrivialThrowIf: boolean 229 isDynamicImport: boolean 230 importSource: string | null 231 isInsideFunction: boolean 233 isDevPcConditional: boolean 234 isInNextDynamic: 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`) 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`) 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}`) 269 if (ctx.isDbFnNowCall && ctx.isInsideWhereClause) { 270 violations.push(`${ctx.ptPath}:${ctx.line}: db.fn.now() in WHERE → use luxNow() for testable time override`) 273 if (ctx.isDiffNowCall) { 274 violations.push(`${ctx.ptPath}:${ctx.line}: .diffNow() → .diff(luxNow()) for testable time override`) 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) 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' 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' 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)) 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) 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' 347 const isDiffNowCall = (() => { 348 if (node.type !== 'CallExpression') return false 349 return callee?.type === 'MemberExpression' && callee.property?.name === 'diffNow' 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 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', 374 firstArgThrowIfClassification, 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`) 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') 394 violations.push(`${ptPath}:${line}: enterWith without spread - must include ...ctx.getStore() or add // ctx:clear`) 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`) 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`) 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`) 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' 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' 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)) 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) 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' 483 const isDiffNowCall = (() => { 484 if (!ts.isCallExpression(node)) return false 485 return ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'diffNow' 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 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' 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, 514 isCallExpr: ts.isCallExpression(node), 517 firstArgThrowIfClassification, 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`) 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)) 534 violations.push(`${ptPath}:${line}: enterWith without spread - must include ...ctx.getStore() or add // ctx:clear`) 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`) 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`) 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`) 567 ts.forEachChild(node, child => visit(child, newAncestors)) 574// Check code style rules (not import-related, can change when file moves) 575const isTrackedByGit = (ptPath: string) => { 577 execSync(`git ls-files --error-unmatch "${toPtRelPath(ptPath)}"`, { cwd: ptDir, stdio: 'pipe' }) 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)`) 593 // _.chain() warning - breaks webpack tree-shaking, skip deployF/ (not webpack-bundled) 594 if (!ptPath.includes('/deployF/')) { 595 const chainMatches = contents.match(/_.chain\(/g) 597 violations.push(`${ptPath}: ${chainMatches.length}x _.chain() - convert to standalone lodash functions (e.g. _.map/_.filter) for webpack tree-shaking`) 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}/`) 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)`) 618 // AST-based checks (includes action-from-context detection): dynamic imports and trivial throwIf 620 const sourceFile = tsSrcFile({ ptPath: ptPath as any, contents }) 621 violations.push(...runTsAstChecks(sourceFile, ptPath, isEntrypoint)) 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 629 filterViolationsByLineComment(violations, 'empty catch block', '// catch:userapproved', contents) 630 filterViolationsByLineComment(violations, 'enterWith without spread', '// ctx:clear', contents) 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+):/) 642 return !lines[parseInt(m[1]) - 1]?.includes(commentMarker) 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), 655 return [...boundaryViolations, ...styleViolations] 658// Check import boundaries for multiple files 659export const checkImportBoundaries = async (filesA: string[]): Promise<string> => { 661 const violations: string[] = [] 662 for (const f of filesA) { 664 const fileViolations = await checkAllRulesForFile(f) 665 violations.push(...fileViolations) 667 const newPt0Files = filesA.filter(f => f.startsWith('pt0/') && isPtCodeFileExt(f) && !isTrackedByGit(f)) 669 if (newPt0Files.length > 0 || pt0CodeFiles.length > 0) { 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 681 if ((importedByPathsA as string[]).some(p => pt0EpPathsSet.has(p))) { 684 needsFullRecompute = true 688 if (needsFullRecompute) { 689 epPathSetsCache = null 690 const fresh = await getEpPathSetsCached(pt0EpA) 691 pt0EpPathsSet = fresh.pt0EpPathsSet 692 allEpPathsSet = fresh.allEpPathsSet 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`) 700 if (pt0CodeFiles.length > 0) { 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) 709 for (const [f, exports] of Object.entries(deadExportsByFile)) { 710 violations.push(`dead exports in ${f}: ${exports.join(', ')}`) 712 } catch { /* catch:userapproved */ } // catch:userapproved 715 if (violations.length > 0) { 716 return `Import boundary violations:\n${violations.map(e => ' ' + e).join('\n')}` 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, '')) 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'}` 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`.', 743// CLI: update AGENTS.md (guards uncommitted changes, auto-commits) 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') 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') 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')