import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import ts from 'typescript'; 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; const resourceModule = { exports: {} }; vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); const resources = resourceModule.exports.resources; 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)]) : []; 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`); 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`); } } 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'); 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'); for (const failure of failures) console.log(failure); if (failures.length) process.exitCode = 1;