2026-05-14 15:23:25 +00:00
import { readFileSync } from 'node:fs'
import path from 'node:path'
type TreeEntry = {
type : 'file' | 'folder'
path : string
children? : TreeEntry [ ]
}
type Diagnostic = {
message : string
location : { path : string ; line? : number ; column? : number }
}
type RuleResult = { diagnostics : Diagnostic [ ] }
type Rule = { name : string ; check : ( root : TreeEntry ) = > RuleResult }
2026-05-14 17:35:23 +00:00
type FileRuleCheck = ( sourceRel : string ) = > string | null
2026-05-14 17:57:19 +00:00
const FILE_PREFIX_GROUP_ALLOWLIST = new Set ( [
'packages/core/src/lint/rules::no' ,
'tests/engine::visual'
] )
2026-05-14 15:23:25 +00:00
type ImportRef = {
specifier : string
line : number
column : number
}
const TEXT_EXTENSIONS = new Set ( [ '.ts' , '.tsx' , '.vue' , '.js' , '.jsx' , '.mjs' , '.mts' ] )
2026-05-14 17:51:59 +00:00
const ROOT_MARKDOWN_ALLOWLIST = new Set ( [
'AGENTS.md' ,
'CHANGELOG.md' ,
'CONTRIBUTING.md' ,
'README.md' ,
'SECURITY.md'
] )
2026-05-14 15:23:25 +00:00
const PACKAGE_ALIASES : Record < string , string > = {
'#core/' : 'packages/core/src/' ,
'#vue/' : 'packages/vue/src/' ,
'#cli/' : 'packages/cli/src/' ,
'#mcp/' : 'packages/mcp/src/'
}
2026-05-14 15:56:45 +00:00
const PACKAGE_ALIAS_OWNERS : Record < string , string > = {
'#core/' : 'packages/core/src/' ,
'#vue/' : 'packages/vue/src/' ,
'#cli/' : 'packages/cli/src/' ,
'#mcp/' : 'packages/mcp/src/'
}
2026-05-14 15:23:25 +00:00
function normalizePath ( filePath : string ) {
return filePath . split ( path . sep ) . join ( '/' )
}
2026-05-14 16:07:23 +00:00
function relativePath ( rootPath : string , filePath : string ) {
return normalizePath ( path . relative ( rootPath , filePath ) )
2026-05-14 15:23:25 +00:00
}
function collectFiles ( entry : TreeEntry , files : string [ ] = [ ] ) {
if ( entry . type === 'file' ) {
if ( TEXT_EXTENSIONS . has ( path . extname ( entry . path ) ) ) files . push ( entry . path )
return files
}
for ( const child of entry . children ? ? [ ] ) collectFiles ( child , files )
return files
}
2026-05-14 17:57:19 +00:00
function collectFolders ( entry : TreeEntry , folders : TreeEntry [ ] = [ ] ) {
if ( entry . type !== 'folder' ) return folders
folders . push ( entry )
for ( const child of entry . children ? ? [ ] ) collectFolders ( child , folders )
return folders
}
2026-05-14 15:23:25 +00:00
function importsIn ( content : string ) : ImportRef [ ] {
const imports : ImportRef [ ] = [ ]
const patterns = [
2026-05-14 17:35:23 +00:00
/^\s*(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s*)?['"]([^'"]+)['"]/gm ,
/^\s*import\(\s*['"]([^'"]+)['"]\s*\)/gm
2026-05-14 15:23:25 +00:00
]
for ( const pattern of patterns ) {
for ( const match of content . matchAll ( pattern ) ) {
const before = content . slice ( 0 , match . index )
const lines = before . split ( '\n' )
imports . push ( {
specifier : match [ 1 ] ,
line : lines.length ,
column : lines.at ( - 1 ) ? . length ? ? 0
} )
}
}
return imports
}
function resolveImport ( sourceRel : string , specifier : string ) : string | null {
if ( specifier . startsWith ( '@/' ) ) return ` src/ ${ specifier . slice ( 2 ) } `
for ( const [ alias , target ] of Object . entries ( PACKAGE_ALIASES ) ) {
if ( specifier . startsWith ( alias ) ) return ` ${ target } ${ specifier . slice ( alias . length ) } `
}
if ( specifier . startsWith ( '.' ) ) {
return normalizePath ( path . join ( path . dirname ( sourceRel ) , specifier ) )
}
return null
}
2026-05-14 17:35:23 +00:00
function createFileRule ( name : string , checkFile : FileRuleCheck ) : Rule {
return {
name ,
check ( root ) {
const diagnostics : Diagnostic [ ] = [ ]
for ( const file of collectFiles ( root ) ) {
const sourceRel = relativePath ( root . path , file )
const message = checkFile ( sourceRel )
if ( ! message ) continue
diagnostics . push ( { message , location : { path : file } } )
}
return { diagnostics }
}
}
}
2026-05-14 15:23:25 +00:00
function createImportRule (
name : string ,
checkImport : ( sourceRel : string , specifier : string , resolved : string | null ) = > string | null
) : Rule {
return {
name ,
check ( root ) {
const diagnostics : Diagnostic [ ] = [ ]
for ( const file of collectFiles ( root ) ) {
2026-05-14 16:07:23 +00:00
const sourceRel = relativePath ( root . path , file )
2026-05-14 15:23:25 +00:00
const content = readFileSync ( file , 'utf8' )
for ( const imported of importsIn ( content ) ) {
const resolved = resolveImport ( sourceRel , imported . specifier )
const message = checkImport ( sourceRel , imported . specifier , resolved )
if ( ! message ) continue
diagnostics . push ( {
message ,
location : { path : file , line : imported.line , column : imported.column }
} )
}
}
return { diagnostics }
}
}
}
2026-05-14 17:57:19 +00:00
function filePrefix ( filePath : string ) : string | null {
const name = path . basename ( filePath ) . replace ( /\.(test|spec|bench)?\.?[cm]?[tj]sx?$|\.vue$/ , '' )
const match = /^([a-z][a-z0-9]+)-[a-z0-9-]+$/ . exec ( name )
return match ? . [ 1 ] ? ? null
}
const preferDomainFoldersOverFilenamePrefixes : Rule = {
name : 'open-pencil/prefer-domain-folders-over-filename-prefixes' ,
check ( root ) {
const diagnostics : Diagnostic [ ] = [ ]
for ( const folder of collectFolders ( root ) ) {
const folderRel = relativePath ( root . path , folder . path )
const groups = new Map < string , string [ ] > ( )
for ( const child of folder . children ? ? [ ] ) {
if ( child . type !== 'file' || ! TEXT_EXTENSIONS . has ( path . extname ( child . path ) ) ) continue
const prefix = filePrefix ( child . path )
if ( ! prefix ) continue
const files = groups . get ( prefix ) ? ? [ ]
files . push ( child . path )
groups . set ( prefix , files )
}
for ( const [ prefix , files ] of groups ) {
if ( files . length < 3 ) continue
if ( FILE_PREFIX_GROUP_ALLOWLIST . has ( ` ${ folderRel } :: ${ prefix } ` ) ) continue
diagnostics . push ( {
message : ` Use a ${ prefix } / domain folder instead of ${ files . length } sibling files with the ${ prefix } - filename prefix. ` ,
location : { path : folder.path }
} )
}
}
return { diagnostics }
}
}
2026-05-14 17:35:23 +00:00
const strictTestFilePlacement = createFileRule ( 'open-pencil/strict-test-file-placement' , ( sourceRel ) = > {
if ( ! sourceRel . startsWith ( 'tests/' ) ) return null
if ( ! TEXT_EXTENSIONS . has ( path . extname ( sourceRel ) ) ) return null
2026-05-14 17:51:59 +00:00
const name = path . basename ( sourceRel )
if ( name . includes ( '.tmp.' ) || name . includes ( '.profile.' ) ) {
return 'Temporary/profile test files must not be committed. Move exploratory specs to scratch/ or delete them.'
}
2026-05-14 17:35:23 +00:00
if ( sourceRel . startsWith ( 'tests/e2e/' ) ) {
return sourceRel . endsWith ( '.spec.ts' ) ? null : 'E2E tests must live under tests/e2e/** and use *.spec.ts.'
}
if ( sourceRel . startsWith ( 'tests/figma/' ) ) {
return sourceRel . endsWith ( '.spec.ts' ) ? null : 'Figma Playwright tests must live under tests/figma/** and use *.spec.ts.'
}
if ( sourceRel . startsWith ( 'tests/engine/' ) ) {
if ( sourceRel . endsWith ( '.test.ts' ) ) return null
if ( sourceRel . endsWith ( '/helpers.ts' ) || sourceRel . endsWith ( '.bench.ts' ) ) return null
2026-05-16 09:30:27 +00:00
if ( /\/visual\/[^/]+\.ts$/ . test ( sourceRel ) ) return null
return 'Engine/unit tests must live under tests/engine/** and use *.test.ts; helpers.ts, *.bench.ts, and domain visual support scripts are allowed.'
2026-05-14 17:35:23 +00:00
}
if ( sourceRel . startsWith ( 'tests/helpers/' ) ) return null
return 'Tests must live under tests/e2e/** (*.spec.ts), tests/engine/** (*.test.ts), or tests/helpers/**.'
} )
2026-05-16 09:22:03 +00:00
const ENGINE_TEST_DOMAIN_REDIRECTS : Array < {
from : string
to : string
source : string
} > = [
{
from : 'tests/engine/fig/' ,
to : 'tests/engine/io/fig/' ,
source : 'packages/core/src/io/formats/fig/'
} ,
{
from : 'tests/engine/svg/' ,
to : 'tests/engine/io/svg/' ,
source : 'packages/core/src/io/formats/svg/'
} ,
{
from : 'tests/engine/fonts/' ,
to : 'tests/engine/text/fonts/' ,
source : 'packages/core/src/text/fonts.ts'
} ,
{
from : 'tests/engine/editor/clipboard/derived-text' ,
to : 'tests/engine/text/derived-text/' ,
source : 'packages/core/src/text/derived-text/'
2026-05-16 10:18:38 +00:00
} ,
{
from : 'tests/engine/images/' ,
to : 'tests/engine/figma/api/, tests/engine/tools/modify/, tests/engine/clipboard/openpencil/, or tests/engine/io/fig/export/' ,
source : 'the image API, tool, clipboard, or IO module under test'
} ,
{
from : 'tests/engine/figma-api/' ,
to : 'tests/engine/figma/api/' ,
source : 'packages/core/src/figma-api/'
} ,
{
from : 'tests/engine/scene-graph/mutation' ,
to : 'tests/engine/text/style-runs/, tests/engine/vector/blob/, or tests/engine/scene-graph/undo/' ,
source : 'the text, vector, or undo module under test'
} ,
{
from : 'tests/engine/editor/clipboard/bounds' ,
to : 'tests/engine/clipboard/figma/' ,
source : 'packages/core/src/clipboard.ts'
} ,
{
from : 'tests/engine/editor/clipboard/html' ,
to : 'tests/engine/clipboard/figma/' ,
source : 'packages/core/src/clipboard.ts'
} ,
{
from : 'tests/engine/editor/clipboard/fixture-roundtrip' ,
to : 'tests/engine/clipboard/fixtures/' ,
source : 'packages/core/src/clipboard.ts'
} ,
{
from : 'tests/engine/editor/clipboard/text-outlines' ,
to : 'tests/engine/text/opentype/' ,
source : 'packages/core/src/text/opentype.ts'
} ,
{
from : 'tests/engine/vector/basic' ,
to : 'tests/engine/vector/blob/, tests/engine/vector/normalize.test.ts, tests/engine/vector/validate.test.ts, or tests/engine/vector/bounds.test.ts' ,
source : 'the vector module under test'
2026-05-16 10:30:25 +00:00
} ,
{
from : 'tests/engine/geometry/basic' ,
to : 'tests/engine/geometry/angles.test.ts, tests/engine/geometry/rotation.test.ts, tests/engine/geometry/bounds.test.ts, or tests/engine/geometry/visual-bounds.test.ts' ,
source : 'the geometry module under test'
2026-05-16 11:15:04 +00:00
} ,
{
from : 'tests/engine/profiler/basic' ,
to : 'tests/engine/profiler/frame-stats.test.ts, draw-call-counter.test.ts, gpu-timer.test.ts, phase-timer.test.ts, capture-stack.test.ts, or speedscope.test.ts' ,
source : 'the profiler module under test'
2026-05-16 09:22:03 +00:00
}
]
const noMisplacedEngineTestDomainPaths = createFileRule (
'open-pencil/no-misplaced-engine-test-domain-paths' ,
2026-05-16 09:18:21 +00:00
( sourceRel ) = > {
2026-05-16 09:22:03 +00:00
const redirect = ENGINE_TEST_DOMAIN_REDIRECTS . find ( ( { from } ) = > sourceRel . startsWith ( from ) )
2026-05-16 09:30:27 +00:00
if ( redirect ) {
return ` Move tests from ${ redirect . from } under ${ redirect . to } to mirror ${ redirect . source } . `
}
if ( /^tests\/engine\/[^/]+\.test\.ts$/ . test ( sourceRel ) ) {
return 'Move root-level engine tests under a domain folder that mirrors the source module under test.'
}
return null
2026-05-16 09:18:21 +00:00
}
)
2026-05-14 17:35:23 +00:00
const noEngineOnlyAssertionsInE2E = createImportRule (
'open-pencil/no-engine-only-assertions-in-e2e' ,
( sourceRel , specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'tests/e2e/' ) ) return null
if ( specifier === 'bun:test' || resolved ? . startsWith ( 'tests/engine/' ) ) {
return 'E2E tests must drive the UI and visible behavior. Put engine/internal-state assertions in tests/engine/**.'
}
return null
}
)
const noE2EImportsInEngineTests = createImportRule (
'open-pencil/no-e2e-imports-in-engine-tests' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'tests/engine/' ) ) return null
if ( resolved ? . startsWith ( 'tests/e2e/' ) ) {
return 'Engine/unit tests must not import E2E tests or fixtures.'
}
return null
}
)
2026-05-14 17:51:59 +00:00
const noRootMarkdownClutter = createFileRule ( 'open-pencil/no-root-markdown-clutter' , ( sourceRel ) = > {
if ( sourceRel . includes ( '/' ) ) return null
if ( ! sourceRel . endsWith ( '.md' ) ) return null
if ( ROOT_MARKDOWN_ALLOWLIST . has ( sourceRel ) ) return null
return 'Do not add ad hoc root Markdown files. Put durable docs under packages/docs/** or update the root allowlist deliberately.'
} )
const noPrototypeOrGeneratedImports = createImportRule (
'open-pencil/no-prototype-or-generated-imports' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! resolved ) return null
if ( resolved . startsWith ( 'scratch/' ) ) {
return 'Committed code must not import scratch prototypes.'
}
if ( resolved . startsWith ( 'desktop/generated/' ) ) {
return 'Do not import generated desktop artifacts from TypeScript/app code.'
}
if (
resolved . startsWith ( 'packages/core/src/kiwi/kiwi-schema/' ) &&
! sourceRel . startsWith ( 'packages/core/src/kiwi/kiwi-schema/' )
) {
return 'Do not import vendored Kiwi schema internals directly; use the supported Kiwi APIs.'
}
return null
}
)
2026-05-14 15:23:25 +00:00
const noPropertyPanelImportsInCanvas = createImportRule (
'open-pencil/no-property-panel-imports-in-canvas' ,
( sourceRel , _specifier , resolved ) = > {
const isCanvasSurface =
sourceRel === 'src/components/EditorCanvas.vue' ||
sourceRel . startsWith ( 'src/app/editor/canvas/' ) ||
sourceRel . startsWith ( 'src/components/canvas/' ) ||
sourceRel . startsWith ( 'packages/vue/src/canvas/' )
if ( isCanvasSurface && resolved ? . startsWith ( 'src/components/properties/' ) ) {
return 'Canvas/editor overlay code must not import property-panel internals. Extract app-neutral UI or keep concerns local.'
}
return null
}
)
const noAppImportsInWorkspacePackages = createImportRule (
'open-pencil/no-app-imports-in-workspace-packages' ,
( sourceRel , specifier , resolved ) = > {
const isWorkspacePackage = /^packages\/[^/]+\/src\// . test ( sourceRel )
if ( isWorkspacePackage && ( specifier . startsWith ( '@/' ) || resolved ? . startsWith ( 'src/' ) ) ) {
return 'Workspace packages must not import app-layer src/ code.'
}
return null
}
)
2026-05-14 15:56:45 +00:00
const noPackageInternalsInApp = createImportRule (
'open-pencil/no-package-internals-in-app' ,
( sourceRel , specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'src/' ) ) return null
if ( specifier in PACKAGE_ALIASES || Object . keys ( PACKAGE_ALIASES ) . some ( ( alias ) = > specifier . startsWith ( alias ) ) ) {
return 'App code must use package public exports such as @open-pencil/core or @open-pencil/vue, not package-local aliases.'
}
if ( resolved ? . startsWith ( 'packages/' ) ) {
return 'App code must not import workspace package internals. Use package public exports instead.'
}
return null
}
)
const noForeignPackageLocalAliases = createImportRule (
'open-pencil/no-foreign-package-local-aliases' ,
( sourceRel , specifier ) = > {
2026-05-14 17:35:23 +00:00
if ( sourceRel . startsWith ( 'scripts/' ) || sourceRel . startsWith ( 'tests/' ) ) return null
2026-05-14 15:56:45 +00:00
for ( const [ alias , owner ] of Object . entries ( PACKAGE_ALIAS_OWNERS ) ) {
if ( specifier . startsWith ( alias ) && ! sourceRel . startsWith ( owner ) ) {
return ` Package-local alias ${ alias } can only be used inside ${ owner } . Use a public package export across package boundaries. `
}
}
return null
}
)
2026-05-14 16:07:23 +00:00
const noAppImportsComponentsOrViews = createImportRule (
'open-pencil/no-app-imports-components-or-views' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'src/app/' ) ) return null
const importsAppComponent =
resolved ? . startsWith ( 'src/components/' ) && ! resolved . startsWith ( 'src/components/ui/' )
if ( importsAppComponent || resolved ? . startsWith ( 'src/views/' ) ) {
return 'App service/domain code must not import app component or view layers. Pass data/actions through app-owned entrypoints instead.'
}
return null
}
)
const noComponentsImportViews = createImportRule (
'open-pencil/no-components-import-views' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'src/components/' ) ) return null
if ( resolved ? . startsWith ( 'src/views/' ) ) {
return 'Components must not import views. Views assemble components, not the other way around.'
}
return null
}
)
2026-05-14 17:35:23 +00:00
const noNonUiImportsInSharedUi = createImportRule (
'open-pencil/no-non-ui-imports-in-shared-ui' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'src/components/ui/' ) ) return null
if ( resolved ? . startsWith ( 'src/components/' ) && ! resolved . startsWith ( 'src/components/ui/' ) ) {
return 'Shared UI components must only import other shared UI modules from src/components/ui/**.'
}
return null
}
)
const noViewsImportedOutsideEntry = createImportRule (
'open-pencil/no-views-imported-outside-entry' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! resolved ? . startsWith ( 'src/views/' ) ) return null
if ( sourceRel === 'src/App.vue' || sourceRel === 'src/main.ts' || sourceRel === 'src/router.ts' ) return null
return 'Views are top-level composition entrypoints and must not be imported by app services or reusable components.'
}
)
2026-05-14 16:00:51 +00:00
const noAppImportsInSharedUi = createImportRule (
'open-pencil/no-app-imports-in-shared-ui' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! sourceRel . startsWith ( 'src/components/ui/' ) ) return null
if ( resolved ? . startsWith ( 'src/app/' ) ) {
return 'Shared UI components must not import app services or stores. Pass data/actions in or move app-specific wrappers outside src/components/ui.'
}
return null
}
)
const noPropertyPanelInternalsOutsidePanel = createImportRule (
'open-pencil/no-property-panel-internals-outside-panel' ,
( sourceRel , _specifier , resolved ) = > {
if ( ! resolved ? . startsWith ( 'src/components/properties/' ) ) return null
if ( sourceRel . startsWith ( 'src/components/properties/' ) ) return null
if ( sourceRel === 'src/components/DesignPanel.vue' ) return null
return 'Property-panel internals must stay inside the property panel. Extract app-neutral UI before reusing elsewhere.'
}
)
2026-05-14 15:23:25 +00:00
const noUiImportsInCore = createImportRule (
'open-pencil/no-ui-imports-in-core' ,
( sourceRel , specifier ) = > {
if ( ! sourceRel . startsWith ( 'packages/core/src/' ) ) return null
if (
specifier === 'vue' ||
specifier . startsWith ( '@vueuse/' ) ||
specifier === 'reka-ui' ||
specifier . startsWith ( '#vue/' ) ||
specifier . startsWith ( '@open-pencil/vue' )
) {
return 'Core must stay framework-agnostic and cannot import Vue/UI modules.'
}
return null
}
)
export const openPencilArchitecturePlugin = {
meta : { name : 'open-pencil-architecture' , version : '0.0.0' } ,
ruleDefinitions : [
2026-05-14 17:57:19 +00:00
preferDomainFoldersOverFilenamePrefixes ,
2026-05-14 17:35:23 +00:00
strictTestFilePlacement ,
2026-05-16 09:22:03 +00:00
noMisplacedEngineTestDomainPaths ,
2026-05-14 17:35:23 +00:00
noEngineOnlyAssertionsInE2E ,
noE2EImportsInEngineTests ,
2026-05-14 17:51:59 +00:00
noRootMarkdownClutter ,
noPrototypeOrGeneratedImports ,
2026-05-14 15:23:25 +00:00
noPropertyPanelImportsInCanvas ,
noAppImportsInWorkspacePackages ,
2026-05-14 15:56:45 +00:00
noPackageInternalsInApp ,
noForeignPackageLocalAliases ,
2026-05-14 16:07:23 +00:00
noAppImportsComponentsOrViews ,
noComponentsImportViews ,
2026-05-14 17:35:23 +00:00
noViewsImportedOutsideEntry ,
noNonUiImportsInSharedUi ,
2026-05-14 16:00:51 +00:00
noAppImportsInSharedUi ,
noPropertyPanelInternalsOutsidePanel ,
2026-05-14 15:23:25 +00:00
noUiImportsInCore
]
}