2026-03-16 10:33:01 -05:00
#!/usr/bin/env node
import { promises as fs } from "node:fs" ;
import path from "node:path" ;
import { fileURLToPath } from "node:url" ;
import ts from "typescript" ;
import { runAsScript , toLine } from "./lib/ts-guard-utils.mjs" ;
const repoRoot = path . resolve ( path . dirname ( fileURLToPath ( import . meta . url ) ) , ".." ) ;
const baselinePath = path . join ( repoRoot , "scripts" , "baselines" , "plugin-boundary-ratchet.json" ) ;
const sourceFilePattern = /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u ;
function isRelativeOrAbsoluteSpecifier ( specifier ) {
return specifier . startsWith ( "." ) || specifier . startsWith ( "/" ) || specifier . startsWith ( "file:" ) ;
}
function normalizeForCompare ( filePath ) {
return filePath . replaceAll ( "\\" , "/" ) ;
}
function isPathInside ( parentPath , childPath ) {
const relative = path . relative ( parentPath , childPath ) ;
return relative === "" || ( ! relative . startsWith ( ".." ) && ! path . isAbsolute ( relative ) ) ;
}
function resolveImportPath ( filePath , specifier ) {
if ( specifier . startsWith ( "file:" ) ) {
try {
return new URL ( specifier ) ;
} catch {
return null ;
}
}
return path . resolve ( path . dirname ( filePath ) , specifier ) ;
}
function resolvePluginRoot ( filePath , repo = repoRoot ) {
const relative = path . relative ( path . join ( repo , "extensions" ) , filePath ) ;
if ( ! relative || relative . startsWith ( ".." ) || path . isAbsolute ( relative ) ) {
return null ;
}
const [ pluginId ] = normalizeForCompare ( relative ) . split ( "/" ) ;
if ( ! pluginId ) {
return null ;
}
return path . join ( repo , "extensions" , pluginId ) ;
}
export function classifyPluginBoundaryImport ( specifier , filePath , options = { } ) {
const repo = options . repoRoot ? ? repoRoot ;
const normalizedSpecifier = specifier . trim ( ) ;
if ( ! normalizedSpecifier ) {
return null ;
}
if ( normalizedSpecifier === "openclaw/extension-api" ) {
return null ;
}
if ( normalizedSpecifier === "openclaw/plugin-sdk" ) {
return null ;
}
if ( normalizedSpecifier === "openclaw/plugin-sdk/compat" ) {
return null ;
}
if ( normalizedSpecifier . startsWith ( "openclaw/plugin-sdk/" ) ) {
return null ;
}
2026-03-16 11:24:46 -05:00
if (
normalizedSpecifier === "openclaw/plugin-sdk-internal" ||
normalizedSpecifier . startsWith ( "openclaw/plugin-sdk-internal/" )
) {
2026-03-16 10:33:01 -05:00
return {
kind : "plugin-sdk-internal" ,
reason : "imports non-public plugin-sdk-internal surface" ,
preferredReplacement : "Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily." ,
} ;
}
if ( ! isRelativeOrAbsoluteSpecifier ( normalizedSpecifier ) ) {
return null ;
}
const resolved = resolveImportPath ( filePath , normalizedSpecifier ) ;
const resolvedPath =
resolved instanceof URL
? path . resolve ( resolved . pathname )
: resolved
? path . resolve ( resolved )
: null ;
if ( ! resolvedPath ) {
return null ;
}
const importerPluginRoot = resolvePluginRoot ( filePath , repo ) ;
if ( importerPluginRoot && isPathInside ( path . join ( repo , "extensions" ) , resolvedPath ) ) {
if ( ! isPathInside ( importerPluginRoot , resolvedPath ) ) {
return {
kind : "cross-extension" ,
reason : "reaches into another extension via a relative import" ,
preferredReplacement :
"Keep relative imports within the same plugin root, or expose a public surface via openclaw/plugin-sdk/*, openclaw/extension-api, or a dedicated shared package." ,
} ;
}
return null ;
}
const internalSdkRoot = path . join ( repo , "src" , "plugin-sdk-internal" ) ;
if ( isPathInside ( internalSdkRoot , resolvedPath ) ) {
return {
kind : "plugin-sdk-internal" ,
reason : "reaches into non-public plugin-sdk-internal implementation" ,
preferredReplacement : "Use openclaw/plugin-sdk/* or openclaw/plugin-sdk/compat temporarily." ,
} ;
}
const coreSrcRoot = path . join ( repo , "src" ) ;
if ( isPathInside ( coreSrcRoot , resolvedPath ) ) {
return {
kind : "core-src" ,
reason : "reaches into core src/** from an extension" ,
preferredReplacement :
"Use openclaw/plugin-sdk/*, openclaw/extension-api, or openclaw/plugin-sdk/compat temporarily." ,
} ;
}
return null ;
}
function getImportLikeSpecifiers ( sourceFile ) {
const specifiers = [ ] ;
const push = ( node , specifierNode ) => {
if ( ! ts . isStringLiteralLike ( specifierNode ) ) {
return ;
}
specifiers . push ( {
specifier : specifierNode . text ,
line : toLine ( sourceFile , specifierNode ) ,
} ) ;
} ;
const visit = ( node ) => {
if ( ts . isImportDeclaration ( node ) && node . moduleSpecifier ) {
push ( node , node . moduleSpecifier ) ;
} else if ( ts . isExportDeclaration ( node ) && node . moduleSpecifier ) {
push ( node , node . moduleSpecifier ) ;
} else if (
ts . isCallExpression ( node ) &&
node . expression . kind === ts . SyntaxKind . ImportKeyword &&
node . arguments . length > 0
) {
push ( node , node . arguments [ 0 ] ) ;
} else if (
ts . isCallExpression ( node ) &&
node . arguments . length > 0 &&
ts . isStringLiteralLike ( node . arguments [ 0 ] ) &&
( ( ts . isIdentifier ( node . expression ) && node . expression . text === "require" ) ||
( ts . isPropertyAccessExpression ( node . expression ) &&
( ( ts . isIdentifier ( node . expression . expression ) &&
node . expression . expression . text === "vi" ) ||
( ts . isIdentifier ( node . expression . expression ) &&
node . expression . expression . text === "jest" ) ) &&
node . expression . name . text === "mock" ) )
) {
push ( node , node . arguments [ 0 ] ) ;
}
ts . forEachChild ( node , visit ) ;
} ;
visit ( sourceFile ) ;
return specifiers ;
}
export function findPluginBoundaryViolations ( content , filePath , options = { } ) {
const sourceFile = ts . createSourceFile ( filePath , content , ts . ScriptTarget . Latest , true ) ;
const violations = [ ] ;
for ( const entry of getImportLikeSpecifiers ( sourceFile ) ) {
const classification = classifyPluginBoundaryImport ( entry . specifier , filePath , options ) ;
if ( ! classification ) {
continue ;
}
violations . push ( {
line : entry . line ,
specifier : entry . specifier ,
kind : classification . kind ,
reason : classification . reason ,
preferredReplacement : classification . preferredReplacement ,
} ) ;
}
return violations ;
}
async function collectSourceFiles ( rootDir ) {
const files = [ ] ;
const stack = [ rootDir ] ;
while ( stack . length > 0 ) {
const current = stack . pop ( ) ;
if ( ! current ) {
continue ;
}
let entries = [ ] ;
try {
entries = await fs . readdir ( current , { withFileTypes : true } ) ;
} catch {
continue ;
}
for ( const entry of entries ) {
const fullPath = path . join ( current , entry . name ) ;
if ( entry . isDirectory ( ) ) {
if ( [ "node_modules" , "dist" , ".git" , "coverage" ] . includes ( entry . name ) ) {
continue ;
}
stack . push ( fullPath ) ;
continue ;
}
if ( ! entry . isFile ( ) || ! sourceFilePattern . test ( entry . name ) || fullPath . endsWith ( ".d.ts" ) ) {
continue ;
}
files . push ( fullPath ) ;
}
}
return files . toSorted ( ) ;
}
async function collectBundledPluginSourceFiles ( repo = repoRoot ) {
const entries = await fs . readdir ( path . join ( repo , "extensions" ) , { withFileTypes : true } ) ;
const filesToCheck = new Set ( ) ;
for ( const entry of entries ) {
if ( ! entry . isDirectory ( ) ) {
continue ;
}
const rootDir = path . join ( repo , "extensions" , entry . name ) ;
const manifestPath = path . join ( rootDir , "openclaw.plugin.json" ) ;
try {
await fs . access ( manifestPath ) ;
} catch {
continue ;
}
const entrySource = path . join ( rootDir , "index.ts" ) ;
try {
await fs . access ( entrySource ) ;
filesToCheck . add ( entrySource ) ;
} catch {
// Some plugins may be source-only under src/ without a root index.ts.
}
for ( const srcFile of await collectSourceFiles ( rootDir ) ) {
filesToCheck . add ( srcFile ) ;
}
}
for ( const sharedFile of await collectSourceFiles ( path . join ( repo , "extensions" , "shared" ) ) ) {
filesToCheck . add ( sharedFile ) ;
}
return [ ... filesToCheck ] . toSorted ( ( left , right ) => left . localeCompare ( right ) ) ;
}
export function toBaselineKey ( entry ) {
return ` ${ normalizeForCompare ( entry . path ) } :: ${ entry . specifier } ` ;
}
export function compareViolationBaseline ( current , baseline ) {
const baselineKeys = new Set ( baseline . map ( toBaselineKey ) ) ;
const currentKeys = new Set ( current . map ( toBaselineKey ) ) ;
const newViolations = current . filter ( ( entry ) => ! baselineKeys . has ( toBaselineKey ( entry ) ) ) ;
const resolvedViolations = baseline . filter ( ( entry ) => ! currentKeys . has ( toBaselineKey ( entry ) ) ) ;
return { newViolations , resolvedViolations } ;
}
export async function loadViolationBaseline ( filePath = baselinePath ) {
const raw = await fs . readFile ( filePath , "utf8" ) ;
const parsed = JSON . parse ( raw ) ;
if ( ! Array . isArray ( parsed ) ) {
throw new Error ( ` Baseline file must be an array: ${ filePath } ` ) ;
}
return parsed . map ( ( entry , index ) => {
if ( ! entry || typeof entry !== "object" ) {
throw new Error ( ` Baseline entry # ${ index + 1 } must be an object. ` ) ;
}
if ( typeof entry . path !== "string" || typeof entry . specifier !== "string" ) {
throw new Error ( ` Baseline entry # ${ index + 1 } must include string path and specifier. ` ) ;
}
return {
path : entry . path ,
specifier : entry . specifier ,
} ;
} ) ;
}
export async function collectCurrentViolations ( options = { } ) {
const repo = options . repoRoot ? ? repoRoot ;
const files = await collectBundledPluginSourceFiles ( repo ) ;
const violations = [ ] ;
for ( const filePath of files ) {
const content = await fs . readFile ( filePath , "utf8" ) ;
const fileViolations = findPluginBoundaryViolations ( content , filePath , { repoRoot : repo } ) ;
for ( const violation of fileViolations ) {
violations . push ( {
path : path . relative ( repo , filePath ) ,
specifier : violation . specifier ,
line : violation . line ,
kind : violation . kind ,
reason : violation . reason ,
preferredReplacement : violation . preferredReplacement ,
} ) ;
}
}
const deduped = [ ... new Map ( violations . map ( ( entry ) => [ toBaselineKey ( entry ) , entry ] ) ) . values ( ) ] ;
2026-03-16 11:24:46 -05:00
return {
files ,
violations : deduped . toSorted ( ( left , right ) => {
const pathCompare = left . path . localeCompare ( right . path ) ;
if ( pathCompare !== 0 ) {
return pathCompare ;
}
return left . specifier . localeCompare ( right . specifier ) ;
} ) ,
} ;
2026-03-16 10:33:01 -05:00
}
function printViolations ( header , violations ) {
if ( violations . length === 0 ) {
return ;
}
console . error ( header ) ;
for ( const violation of violations ) {
console . error ( ` - ${ violation . path } : ${ violation . line } ` ) ;
console . error ( ` import: ${ JSON . stringify ( violation . specifier ) } ` ) ;
console . error ( ` why: ${ violation . reason } ` ) ;
console . error ( ` prefer: ${ violation . preferredReplacement } ` ) ;
}
}
async function main ( ) {
2026-03-16 11:24:46 -05:00
const { files , violations : currentViolations } = await collectCurrentViolations ( { repoRoot } ) ;
2026-03-16 10:33:01 -05:00
const baseline = await loadViolationBaseline ( ) ;
const { newViolations , resolvedViolations } = compareViolationBaseline (
currentViolations ,
baseline ,
) ;
if ( newViolations . length > 0 ) {
printViolations (
"New extension boundary violations found. Bundled plugins should generally use public plugin SDK/runtime surfaces." ,
newViolations ,
) ;
if ( resolvedViolations . length > 0 ) {
console . error ( "" ) ;
console . error (
2026-03-16 11:24:46 -05:00
` Note: ${ resolvedViolations . length } baseline violation(s) are already resolved. While fixing the above, also remove them from ${ path . relative ( repoRoot , baselinePath ) } . ` ,
2026-03-16 10:33:01 -05:00
) ;
}
console . error ( "" ) ;
console . error (
"Allowed for now: openclaw/plugin-sdk/*, openclaw/plugin-sdk/compat, and openclaw/extension-api." ,
) ;
console . error (
"Reducing existing baseline entries is encouraged; only new violations should fail this ratchet." ,
) ;
process . exit ( 1 ) ;
}
if ( resolvedViolations . length > 0 ) {
console . log (
` OK: no new extension boundary violations ( ${ files . length } files checked). ${ resolvedViolations . length } baseline violation(s) are now gone; remove them from ${ path . relative ( repoRoot , baselinePath ) } when convenient. ` ,
) ;
return ;
}
console . log ( ` OK: no new extension boundary violations ( ${ files . length } files checked). ` ) ;
}
runAsScript ( import . meta . url , main ) ;