2026-07-27 00:18:42 +08:00
import fs from 'node:fs' ;
import path from 'node:path' ;
2026-07-27 09:44:08 +08:00
const contracts = [
[ 'dashboard' , 'overview' , '/dashboard/overview' , 'readonly' , 'dashboard' ] ,
[ 'gas' , 'gas_basic' , '/gas/gas_basic' , 'writable' , 'list' ] , [ 'gas' , 'gas_account' , '/gas/gas_account' , 'writable' , 'list' ] ,
[ 'delivery' , 'delivery_basic' , '/delivery/delivery_basic' , 'writable' , 'list' ] , [ 'delivery' , 'delivery_account' , '/delivery/delivery_account' , 'writable' , 'list' ] , [ 'delivery' , 'delivery_task' , '/delivery/delivery_task' , 'writable' , 'list' ] , [ 'delivery' , 'delivery_track' , '/delivery/delivery_track' , 'writable' , 'list' ] , [ 'delivery' , 'delivery_track_point' , '/delivery/delivery_track_point' , 'writable' , 'list' ] ,
[ 'staff' , 'staff_account' , '/staff/account' , 'writable' , 'list' ] , [ 'staff' , 'staff_credential' , '/staff/credential' , 'writable' , 'list' ] ,
[ 'user' , 'user_account' , '/user/account' , 'writable' , 'list' ] , [ 'user' , 'user_address' , '/user/address' , 'writable' , 'list' ] , [ 'user' , 'user_service_relation' , '/user/service_relation' , 'writable' , 'list' ] ,
[ 'device' , 'dev_smart_cylinder_valve' , '/device/dev_smart_cylinder_valve' , 'writable' , 'list' ] , [ 'device' , 'dev_device_binding' , '/device/dev_device_binding' , 'writable' , 'list' ] , [ 'device' , 'dev_telemetry' , '/device/dev_telemetry' , 'readonly' , 'list' ] ,
[ 'safety' , 'saf_rule' , '/safety/saf_rule' , 'writable' , 'list' ] , [ 'safety' , 'saf_event' , '/safety/saf_event' , 'writable' , 'list' ] , [ 'safety' , 'saf_inspection' , '/safety/saf_inspection' , 'writable' , 'list' ] ,
[ 'ec' , 'ec_category' , '/ec/ec_category' , 'writable' , 'list' ] , [ 'ec' , 'ec_product' , '/ec/ec_product' , 'writable' , 'list' ] , [ 'ec' , 'ec_product_attribute' , '/ec/ec_product_attribute' , 'writable' , 'list' ] , [ 'ec' , 'ec_product_image' , '/ec/ec_product_image' , 'writable' , 'list' ] , [ 'ec' , 'ec_cart' , '/ec/ec_cart' , 'writable' , 'list' ] , [ 'ec' , 'ec_order' , '/ec/ec_order' , 'writable' , 'list' ] , [ 'ec' , 'ec_order_item' , '/ec/ec_order_item' , 'writable' , 'list' ] , [ 'ec' , 'ec_review' , '/ec/ec_review' , 'writable' , 'list' ] ,
[ 'finance' , 'fin_payment' , '/finance/fin_payment' , 'writable' , 'list' ] , [ 'finance' , 'fin_settlement' , '/finance/fin_settlement' , 'writable' , 'list' ] , [ 'finance' , 'fin_reconciliation' , '/finance/fin_reconciliation' , 'writable' , 'list' ] ,
[ 'content' , 'cnt_content' , '/content/cnt_content' , 'writable' , 'list' ] , [ 'notification' , 'ntf_template' , '/notification/ntf_template' , 'writable' , 'list' ] , [ 'customer_service' , 'cs_ticket' , '/customer_service/cs_ticket' , 'writable' , 'list' ] ,
[ 'platform' , 'platfrom_account' , '/platform/platfrom_account' , 'writable' , 'list' ] , [ 'platform' , 'platform_role' , '/platform/platform_role' , 'writable' , 'list' ] , [ 'platform' , 'platform_menu' , '/platform/platform_menu' , 'writable' , 'tree' ] ,
[ 'wallet' , 'wallet' , '/wallet/wallet' , 'readonly' , 'list' ] , [ 'wallet' , 'wallet_ledger' , '/wallet/wallet_ledger' , 'readonly' , 'list' ] , [ 'wallet' , 'wallet_recharge' , '/wallet/wallet_recharge' , 'readonly' , 'list' ] , [ 'wallet' , 'wallet_withdrawal' , '/wallet/wallet_withdrawal' , 'readonly' , 'list' ] ,
[ 'report' , 'report' , '/report/report' , 'readonly' , 'list' ] , [ 'report' , 'report_item' , '/report/report_item' , 'readonly' , 'list' ] , [ 'report' , 'report_metric_snapshot' , '/report/report_metric_snapshot' , 'readonly' , 'list' ] ,
[ 'audit' , 'aud_operation_log' , '/audit/aud_operation_log' , 'readonly' , 'list' ] , [ 'audit' , 'aud_export_log' , '/audit/aud_export_log' , 'readonly' , 'list' ] , [ 'audit' , 'aud_approval' , '/audit/aud_approval' , 'readonly' , 'list' ] ,
2026-07-27 01:11:38 +08:00
] ;
2026-07-27 09:44:08 +08:00
const resourceSource = fs . readFileSync ( 'src/api/resources.ts' , 'utf8' ) ;
const resourceEntry = ( name , resource , mode , pageKind ) => new RegExp ( ` name: ' ${ name } ', resource: ' ${ resource } ', title: '[^']+', mode: ' ${ mode } ', pageKind: ' ${ pageKind } ' ` ) ;
const files = ( dir ) => fs . existsSync ( dir ) ? fs . readdirSync ( dir , { withFileTypes : true } ) . flatMap ( ( entry ) => entry . isDirectory ( ) ? files ( path . join ( dir , entry . name ) ) : [ path . join ( dir , entry . name ) ] ) : [ ] ;
function checkContracts ( ) {
const failures = [ ] ;
if ( contracts . length !== 46 ) failures . push ( ` catalogue: expected 46 resources, got ${ contracts . length } ` ) ;
for ( const [ domain , name , resource , mode , pageKind ] of contracts ) {
const label = ` ${ domain } / ${ name } ` ;
if ( ! resourceEntry ( name , resource , mode , pageKind ) . test ( resourceSource ) ) failures . push ( ` ${ label } : missing or incorrect api contract ` ) ;
const route = path . join ( 'src/router/routes/modules' , domain === 'dashboard' ? 'platform.ts' : ` ${ domain } .ts ` ) ;
if ( ! fs . existsSync ( route ) || ! fs . readFileSync ( route , 'utf8' ) . includes ( name ) ) failures . push ( ` ${ label } : missing route ` ) ;
const page = pageKind === 'dashboard' ? path . join ( 'src/views/dashboard/DashboardPage.vue' ) : path . join ( 'src/views' , domain , name , pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue' ) ;
if ( ! fs . existsSync ( page ) ) failures . push ( ` ${ label } : missing view ` ) ;
if ( mode === 'readonly' && pageKind !== 'dashboard' && ( ! fs . existsSync ( page ) || ! fs . readFileSync ( page , 'utf8' ) . includes ( 'ReadOnlyListPage' ) ) ) failures . push ( ` ${ label } : readonly page exposes write surface ` ) ;
2026-07-27 00:18:42 +08:00
}
2026-07-27 09:44:08 +08:00
const readonlyPage = fs . readFileSync ( 'src/views/shared/ReadOnlyListPage.vue' , 'utf8' ) ;
if ( /(CrudListPage|resourceApi\.(create|update|archive|updateStatus))/ . test ( readonlyPage ) ) failures . push ( 'readonly shared page: contains write surface' ) ;
const treePage = fs . readFileSync ( 'src/views/shared/TreePage.vue' , 'utf8' ) ;
if ( ! treePage . includes ( '<a-tree' ) || ! treePage . includes ( 'parent_identity' ) || treePage . includes ( 'CrudListPage' ) ) failures . push ( 'tree shared page: missing tree semantics' ) ;
const crudSource = fs . readFileSync ( 'src/views/shared/CrudListPage.vue' , 'utf8' ) ;
if ( ! crudSource . includes ( "key !== 'id'" ) || ! crudSource . includes ( "key.endsWith('_id')" ) ) failures . push ( 'detail view: does not remove internal identifiers' ) ;
const identityLeaks = files ( 'src/api' ) . concat ( files ( 'src/views' ) ) . filter ( ( file ) => / \ . ( ts | vue ) $ / . test ( file ) && ! file . endsWith ( 'CrudListPage.vue' ) && ! file . endsWith ( 'ReadOnlyListPage.vue' ) ) . filter ( ( file ) => / \ b [ A - Za - z ] [ A - Za - z0 - 9 ] * _id \ b | \ bid \ b / . test ( fs . readFileSync ( file , 'utf8' ) ) ) ;
if ( identityLeaks . length ) failures . push ( ` identity leak: ${ identityLeaks . join ( ', ' ) } ` ) ;
if ( resourceSource . includes ( 'saf_event_disposal' ) || files ( 'src/router/routes/modules' ) . some ( ( file ) => fs . readFileSync ( file , 'utf8' ) . includes ( 'saf_event_disposal' ) ) ) failures . push ( 'saf_event_disposal: must not be displayed as an independent resource' ) ;
const obsoletePaths = /\/(?:commerce|staff\/staff_|user\/user_)/ ;
const stalePathFiles = files ( 'src/api' ) . concat ( files ( 'src/views' ) ) . filter ( ( file ) => / \ . ( ts | vue ) $ / . test ( file ) && obsoletePaths . test ( fs . readFileSync ( file , 'utf8' ) ) ) ;
if ( stalePathFiles . length ) failures . push ( ` obsolete resource path: ${ stalePathFiles . join ( ', ' ) } ` ) ;
return failures ;
2026-07-27 00:18:42 +08:00
}
2026-07-27 09:44:08 +08:00
const failures = checkContracts ( ) ;
for ( const failure of failures ) console . log ( failure ) ;
if ( failures . length ) process . exitCode = 1 ;