2026-07-27 10:25:04 +08:00
import { execFileSync } from 'node:child_process' ;
2026-07-27 00:18:42 +08:00
import fs from 'node:fs' ;
import path from 'node:path' ;
2026-07-27 09:56:33 +08:00
import vm from 'node:vm' ;
import ts from 'typescript' ;
2026-07-27 10:25:04 +08:00
const backendDirectory = path . resolve ( '../..' , 'backend/api' ) ;
const manifest = JSON . parse ( execFileSync ( 'go' , [ 'run' , './cmd/resource-contract' ] , { cwd : backendDirectory , encoding : 'utf8' } ) ) ;
let responseShapeVerified = true ;
try {
execFileSync ( 'go' , [ 'test' , '-count=1' , './internal/logic/platform' , '-run' , '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$' ] , { cwd : backendDirectory , stdio : 'pipe' } ) ;
} catch {
responseShapeVerified = false ;
}
const compiled = ts . transpileModule ( fs . readFileSync ( 'src/api/resources.ts' , 'utf8' ) , { compilerOptions : { module : ts . ModuleKind . CommonJS , target : ts . ScriptTarget . ES2020 } } ) . outputText ;
2026-07-27 09:56:33 +08:00
const resourceModule = { exports : { } } ;
vm . runInNewContext ( compiled , { module : resourceModule , exports : resourceModule . exports } ) ;
const resources = resourceModule . exports . resources ;
2026-07-27 09:44:08 +08:00
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 ) ] ) : [ ] ;
2026-07-27 10:25:04 +08:00
const failures = [ ] ;
if ( ! responseShapeVerified ) failures . push ( 'backend list response shape is not identity-only' ) ;
const expected = manifest . resources ;
if ( resources . length !== expected . length ) failures . push ( ` catalogue count: ${ resources . length } / ${ expected . length } ` ) ;
for ( const contract of expected ) {
const resource = resources . find ( ( item ) => item . name === contract . name ) ;
const pageKind = contract . name === 'ec_category' ? 'tree' : contract . pageKind ;
if ( ! resource || resource . mode !== contract . mode || resource . pageKind !== pageKind ) { failures . push ( ` ${ contract . name } : contract mismatch ` ) ; continue ; }
if ( ! /^[\u4e00-\u9fff]/ . test ( resource . title ) || resource . fields . some ( ( field ) => ! /^[\u4e00-\u9fff]/ . test ( field . label ) ) ) failures . push ( ` ${ contract . name } : missing Chinese business labels ` ) ;
if ( resource . fields . length === 0 || resource . fields . some ( ( field ) => field . key === 'id' || field . key . endsWith ( '_id' ) ) ) failures . push ( ` ${ contract . name } : invalid allowlist ` ) ;
const method = resource . mode === 'append_only' ? 'POST' : 'GET' ;
if ( ! manifest . routes . some ( ( route ) => route . method === method && route . path === resource . resource ) ) failures . push ( ` ${ contract . name } : resource route mismatch ` ) ;
2026-07-27 10:42:16 +08:00
if ( resource . mode === 'readonly' ) {
const view = files ( 'src/views' ) . find ( ( file ) => file . includes ( resource . name ) && file . endsWith ( 'ListPage.vue' ) ) ;
if ( ! view || ! fs . readFileSync ( view , 'utf8' ) . includes ( 'ReadOnlyListPage' ) ) failures . push ( ` ${ contract . name } : readonly UI ` ) ;
}
2026-07-27 00:18:42 +08:00
}
2026-07-27 10:25:04 +08:00
if ( JSON . stringify ( resources . map ( ( item ) => item . name ) . sort ( ) ) !== JSON . stringify ( expected . map ( ( item ) => item . name ) . sort ( ) ) ) failures . push ( 'catalogue names differ from backend ExpectedResources' ) ;
for ( const name of [ 'ec_category' , 'platform_menu' ] ) { const view = files ( 'src/views' ) . find ( ( file ) => file . includes ( name ) && file . endsWith ( 'TreePage.vue' ) ) ; if ( ! view ) failures . push ( ` ${ name } : tree page missing ` ) ; }
const tree = fs . readFileSync ( 'src/views/shared/TreePage.vue' , 'utf8' ) ;
if ( ! tree . includes ( 'parent_identity' ) || ! tree . includes ( 'canWrite' ) || ! tree . includes ( 'resourceApi.create' ) || ! tree . includes ( 'resourceApi.update' ) ) failures . push ( 'tree: writable identity actions missing' ) ;
2026-07-27 10:42:16 +08:00
if ( /(?:['"`]id['"`]|\bparent_id\b|\.id\b)/ . test ( tree ) ) failures . push ( 'tree: internal id key leaked' ) ;
const readOnlyPage = fs . readFileSync ( 'src/views/shared/ReadOnlyListPage.vue' , 'utf8' ) ;
if ( /resourceApi\.(?:create|update|archive)\b/ . test ( readOnlyPage ) ) failures . push ( 'readonly: mutation action exposed' ) ;
for ( const detailPage of [ 'src/views/shared/CrudListPage.vue' , 'src/views/shared/ReadOnlyListPage.vue' ] ) {
const source = fs . readFileSync ( detailPage , 'utf8' ) ;
if ( ! source . includes ( "key !== 'id'" ) || ! source . includes ( "key.endsWith('_id')" ) ) failures . push ( ` ${ detailPage } : internal ids shown in detail ` ) ;
}
const event = resources . find ( ( resource ) => resource . name === 'saf_event' ) ;
const disposal = resources . find ( ( resource ) => resource . name === 'saf_event_disposal' ) ;
if ( ! event ? . detailActions ? . some ( ( action ) => action . name === 'saf_event_disposal' && action . resource . includes ( ':identity/disposals' ) ) ) failures . push ( 'saf_event_disposal: detail action missing' ) ;
if ( ! disposal || disposal . resource !== '/safety/saf_event/:identity/disposals' || files ( 'src/views' ) . some ( ( file ) => file . includes ( 'saf_event_disposal' ) ) ) failures . push ( 'saf_event_disposal: independent page exposed' ) ;
2026-07-27 09:44:08 +08:00
for ( const failure of failures ) console . log ( failure ) ;
if ( failures . length ) process . exitCode = 1 ;