diff --git a/AGENTS.md b/AGENTS.md index 7eccdd8..8eac1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **a-share-analysis** (1372 symbols, 1907 relationships, 42 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **a-share-analysis** (1440 symbols, 2018 relationships, 44 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/package.json b/package.json index 15b9128..20b6b4a 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,14 @@ "schema:verify": "node tools/database/schema-baseline.mjs verify", "governance:start": "node tools/governance/start.mjs", "governance:finish": "node tools/governance/finish.mjs", + "check:contracts": "pnpm --filter @a-share-analysis/server openapi:generate && pnpm --filter @a-share-analysis/web api:generate && pnpm exec prettier --write server/openapi/openapi.json web/src/api/generated/schema.ts && node tools/boundaries/verify-project-boundaries.mjs", + "test:boundaries": "node --test tools/boundaries/verify-project-boundaries.test.mjs", "lint": "pnpm --filter @a-share-analysis/web --if-present lint && pnpm --filter @a-share-analysis/server --if-present lint", "format:check": "pnpm exec prettier --check .", "typecheck": "pnpm --filter @a-share-analysis/web --if-present typecheck && pnpm --filter @a-share-analysis/server --if-present typecheck", - "test": "pnpm test:runtime && pnpm test:governance && pnpm test:mysql-baseline && pnpm test:environment-baseline && pnpm test:schema-baseline && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test", + "test": "pnpm test:runtime && pnpm test:governance && pnpm test:mysql-baseline && pnpm test:environment-baseline && pnpm test:schema-baseline && pnpm test:boundaries && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test", "build": "pnpm --filter @a-share-analysis/web --if-present build && pnpm --filter @a-share-analysis/server --if-present build", - "verify": "pnpm check:runtime && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build" + "verify": "pnpm check:runtime && pnpm check:contracts && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build" }, "devDependencies": { "@eslint/js": "9.39.5", @@ -32,6 +34,7 @@ "eslint-plugin-vue": "9.33.0", "globals": "17.11.0", "prettier": "3.9.6", + "typescript": "5.9.3", "typescript-eslint": "8.46.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd61f75..c0a513c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: prettier: specifier: 3.9.6 version: 3.9.6 + typescript: + specifier: 5.9.3 + version: 5.9.3 typescript-eslint: specifier: 8.46.2 version: 8.46.2(eslint@9.39.5)(typescript@5.9.3) @@ -99,6 +102,9 @@ importers: naive-ui: specifier: 2.44.1 version: 2.44.1(vue@3.5.41(typescript@5.9.3)) + openapi-fetch: + specifier: 0.17.0 + version: 0.17.0 pinia: specifier: 3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) @@ -130,6 +136,9 @@ importers: katex: specifier: 0.18.4 version: 0.18.4 + openapi-typescript: + specifier: 7.13.0 + version: 7.13.0(typescript@5.9.3) sass: specifier: 1.102.0 version: 1.102.0 @@ -872,6 +881,16 @@ packages: engines: {node: '>=20'} hasBin: true + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.19': + resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rollup/rollup-android-arm-eabi@4.62.4': resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] @@ -1492,6 +1511,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -1546,6 +1568,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2140,6 +2165,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2227,6 +2256,10 @@ packages: js-cookie@3.0.8: resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2405,6 +2438,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -2507,6 +2544,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2534,6 +2583,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2907,6 +2960,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3074,6 +3131,10 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3181,6 +3242,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3420,6 +3484,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3642,7 +3709,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -3658,7 +3725,7 @@ snapshots: '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4076,6 +4143,29 @@ snapshots: dependencies: playwright: 1.62.1 + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.19(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true @@ -4157,7 +4247,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -4234,7 +4324,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: @@ -4244,7 +4334,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/types': 8.46.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4263,7 +4353,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/utils': 8.46.2(eslint@9.39.5)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.5 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -4278,7 +4368,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/types': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.9 @@ -4653,7 +4743,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -4744,6 +4834,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + chardet@2.2.0: {} check-error@2.1.3: {} @@ -4788,6 +4880,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4886,9 +4980,11 @@ snapshots: dayjs@1.11.21: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -5068,7 +5164,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5140,7 +5236,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -5210,7 +5306,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -5402,14 +5498,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5436,6 +5532,8 @@ snapshots: imurmurhash@0.1.4: {} + index-to-position@1.2.0: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -5444,7 +5542,7 @@ snapshots: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -5512,6 +5610,8 @@ snapshots: js-cookie@3.0.8: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5531,7 +5631,7 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.24 parse5: 7.3.0 @@ -5670,6 +5770,10 @@ snapshots: dependencies: brace-expansion: 1.1.18 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.4 @@ -5775,6 +5879,22 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5817,6 +5937,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -5985,7 +6111,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -6042,7 +6168,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -6189,7 +6315,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fast-safe-stringify: 2.1.1 form-data: 4.0.6 formidable: 3.5.4 @@ -6211,6 +6337,8 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6333,6 +6461,8 @@ snapshots: type-fest@0.20.2: {} + type-fest@4.41.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -6359,7 +6489,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dedent: 1.7.2 dotenv: 16.6.1 glob: 10.5.0 @@ -6407,6 +6537,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6425,7 +6557,7 @@ snapshots: vite-node@3.2.4(@types/node@22.20.0)(sass@1.102.0)(terser@5.50.0)(tsx@4.20.6): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.4.3(@types/node@22.20.0)(sass@1.102.0)(terser@5.50.0)(tsx@4.20.6) @@ -6469,7 +6601,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -6511,7 +6643,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@9.39.5): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) eslint: 9.39.5 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -6674,6 +6806,8 @@ snapshots: y18n@5.0.8: {} + yaml-ast-parser@0.0.43: {} + yargs-parser@21.1.1: {} yargs@17.7.3: diff --git a/tools/boundaries/verify-project-boundaries.mjs b/tools/boundaries/verify-project-boundaries.mjs new file mode 100644 index 0000000..29db38e --- /dev/null +++ b/tools/boundaries/verify-project-boundaries.mjs @@ -0,0 +1,125 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import ts from 'typescript'; + +const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const INTERNAL_MESSAGE_MARKERS = ['MessageEnvelope', 'message-contracts']; + +async function collectTypeScriptFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nestedFiles = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); + return TYPESCRIPT_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : []; + }), + ); + return nestedFiles.flat(); +} + +function collectModuleSpecifiers(sourceFile) { + const specifiers = []; + + function visit(node) { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteralLike(node.arguments[0]) + ) { + specifiers.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return specifiers; +} + +function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions) { + const serverRoot = path.join(repositoryRoot, 'server'); + const resolved = ts.resolveModuleName(specifier, sourcePath, compilerOptions, ts.sys) + .resolvedModule?.resolvedFileName; + + // 模块解析在 fixture 没有目标文件时可能失败,此时仍需按导入者目录规范化相对路径, + // 避免通过尚未生成的 Server 文件绕过工程边界。 + const candidate = + resolved ?? + (specifier.startsWith('.') ? path.resolve(path.dirname(sourcePath), specifier) : ''); + return candidate === serverRoot || candidate.startsWith(`${serverRoot}${path.sep}`); +} + +export async function verifyProjectBoundaries(repositoryRoot) { + const webSourceRoot = path.join(repositoryRoot, 'web', 'src'); + const configPath = path.join(repositoryRoot, 'web', 'tsconfig.json'); + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const compilerOptions = configFile.error + ? { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler } + : ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)).options; + const files = await collectTypeScriptFiles(webSourceRoot); + const violations = []; + + for (const sourcePath of files) { + const sourceText = await readFile(sourcePath, 'utf8'); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + for (const specifier of collectModuleSpecifiers(sourceFile)) { + if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) { + violations.push( + `Web 禁止依赖 Server:${path.relative(repositoryRoot, sourcePath)} -> ${specifier}`, + ); + } + } + } + + const generatedSchemaPath = path.join(webSourceRoot, 'api', 'generated', 'schema.ts'); + try { + const generatedSchema = await readFile(generatedSchemaPath, 'utf8'); + for (const marker of INTERNAL_MESSAGE_MARKERS) { + if (generatedSchema.includes(marker)) { + violations.push(`生成契约包含 Server 内部消息:${marker}`); + } + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + if (violations.length > 0) { + throw new Error(violations.join('\n')); + } +} + +function parseRepositoryRoot(argv) { + const rootIndex = argv.indexOf('--root'); + if (rootIndex >= 0 && argv[rootIndex + 1]) return path.resolve(argv[rootIndex + 1]); + return process.cwd(); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname) +) { + const repositoryRoot = parseRepositoryRoot(process.argv.slice(2)); + try { + const rootStats = await stat(repositoryRoot); + if (!rootStats.isDirectory()) throw new Error('仓库根路径不是目录'); + await verifyProjectBoundaries(repositoryRoot); + process.stdout.write('项目边界验证通过\n'); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/tools/boundaries/verify-project-boundaries.test.mjs b/tools/boundaries/verify-project-boundaries.test.mjs new file mode 100644 index 0000000..455bd9a --- /dev/null +++ b/tools/boundaries/verify-project-boundaries.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const checkerPath = path.resolve('tools/boundaries/verify-project-boundaries.mjs'); + +async function createFixture() { + const repositoryRoot = await mkdtemp(path.join(tmpdir(), 'a-share-boundary-')); + await mkdir(path.join(repositoryRoot, 'web/src/api/generated'), { recursive: true }); + await mkdir(path.join(repositoryRoot, 'server/libs'), { recursive: true }); + await writeFile( + path.join(repositoryRoot, 'web/src/api/generated/schema.ts'), + 'export interface paths {}\n', + ); + return repositoryRoot; +} + +async function runChecker(repositoryRoot) { + try { + await execFileAsync(process.execPath, [checkerPath, '--root', repositoryRoot]); + return { exitCode: 0, stderr: '' }; + } catch (error) { + return { + exitCode: error.code, + stderr: String(error.stderr), + }; + } +} + +test('拒绝 Web 通过静态、动态或导出语句越界依赖 Server', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/src/illegal.ts'), + [ + "import '../../server/libs/static';", + "export { value } from '../../server/libs/exported';", + "void import('../../server/libs/dynamic');", + ].join('\n'), + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /Web 禁止依赖 Server/); + assert.match(result.stderr, /illegal\.ts/); +}); + +test('拒绝生成契约泄漏 RocketMQ 内部消息结构', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/src/api/generated/schema.ts'), + 'export interface MessageEnvelope { messageId: string }\n', + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /生成契约包含 Server 内部消息/); +}); + +test('拒绝通过 tsconfig 路径别名依赖 Server', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/tsconfig.json'), + JSON.stringify({ + compilerOptions: { + baseUrl: '.', + paths: { '@server/*': ['../server/*'] }, + }, + }), + ); + await writeFile( + path.join(repositoryRoot, 'web/src/aliased.ts'), + "import type { Internal } from '@server/libs/internal';\nexport type Leaked = Internal;\n", + ); + await writeFile( + path.join(repositoryRoot, 'server/libs/internal.ts'), + 'export interface Internal { secret: string }\n', + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /Web 禁止依赖 Server/); +}); + +test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/src/legal.ts'), + "import type { paths } from './api/generated/schema';\nexport type ApiPaths = paths;\n", + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 0); + assert.equal(result.stderr, ''); +}); diff --git a/web/package.json b/web/package.json index 8466624..9a935f7 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", + "api:generate": "openapi-typescript ../server/openapi/openapi.json -o src/api/generated/schema.ts", "lint": "eslint .", "typecheck": "vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "test": "vitest run", @@ -14,6 +15,7 @@ }, "dependencies": { "naive-ui": "2.44.1", + "openapi-fetch": "0.17.0", "pinia": "3.0.4", "vue": "3.5.41", "vue-router": "4.6.4" @@ -26,6 +28,7 @@ "dotenv": "16.6.1", "jsdom": "26.1.0", "katex": "0.18.4", + "openapi-typescript": "7.13.0", "sass": "1.102.0", "typescript": "5.9.3", "vite": "6.4.3", diff --git a/web/src/api/adapters/health.adapter.test.ts b/web/src/api/adapters/health.adapter.test.ts new file mode 100644 index 0000000..b13dc22 --- /dev/null +++ b/web/src/api/adapters/health.adapter.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import type { components } from '../generated/schema'; +import { toHealthViewModel } from './health.adapter'; + +type HealthResponseDto = components['schemas']['HealthResponseDto']; + +const healthyDto: HealthResponseDto = { + status: 'healthy', + process: 'api', + checkedAt: '2026-08-13T09:30:00.000Z', + dependencies: { + mysql: { status: 'healthy', detail: 'MySQL 5.7 ready' }, + redis: { status: 'healthy', detail: 'PONG' }, + rocketMq: { status: 'healthy', detail: 'RocketMQ ready' }, + }, +}; + +describe('健康响应页面适配器', () => { + it('把全部可用的传输状态映射为健康页面模型', () => { + expect(toHealthViewModel(healthyDto)).toEqual({ + state: 'healthy', + stateLabel: '运行正常', + checkedAt: new Date('2026-08-13T09:30:00.000Z'), + services: [ + { key: 'redis', name: 'Redis', available: true, note: 'PONG' }, + { key: 'mysql', name: 'MySQL', available: true, note: 'MySQL 5.7 ready' }, + { key: 'rocketMq', name: 'RocketMQ', available: true, note: 'RocketMQ ready' }, + ], + }); + }); + + it('把可选能力停用映射为降级而不是后端枚举', () => { + const dto: HealthResponseDto = { + ...healthyDto, + dependencies: { + ...healthyDto.dependencies, + rocketMq: { status: 'disabled', detail: '当前版本未启用' }, + }, + }; + + const viewModel = toHealthViewModel(dto); + + expect(viewModel.state).toBe('degraded'); + expect(viewModel.stateLabel).toBe('部分能力停用'); + expect(viewModel.services[2]).toEqual({ + key: 'rocketMq', + name: 'RocketMQ', + available: false, + note: '当前版本未启用', + }); + }); + + it('把任一依赖不可用映射为不可用', () => { + const dto: HealthResponseDto = { + ...healthyDto, + status: 'unavailable', + dependencies: { + ...healthyDto.dependencies, + redis: { status: 'unavailable', detail: '检查失败' }, + }, + }; + + expect(toHealthViewModel(dto).state).toBe('unavailable'); + expect(toHealthViewModel(dto).stateLabel).toBe('服务异常'); + }); + + it('拒绝运行时出现的未知依赖状态', () => { + const malformedDto = structuredClone(healthyDto) as unknown as { + dependencies: { redis: { status: string } }; + }; + malformedDto.dependencies.redis.status = 'unknown'; + + expect(() => toHealthViewModel(malformedDto as never)).toThrow(/未知依赖状态/); + }); +}); diff --git a/web/src/api/adapters/health.adapter.ts b/web/src/api/adapters/health.adapter.ts new file mode 100644 index 0000000..9fc702d --- /dev/null +++ b/web/src/api/adapters/health.adapter.ts @@ -0,0 +1,72 @@ +import type { components } from '../generated/schema'; + +type HealthResponseDto = components['schemas']['HealthResponseDto']; +type DependencyHealthDto = components['schemas']['DependencyHealthDto']; + +export type HealthViewState = 'healthy' | 'degraded' | 'unavailable'; + +export interface HealthServiceViewModel { + key: 'redis' | 'mysql' | 'rocketMq'; + name: 'Redis' | 'MySQL' | 'RocketMQ'; + available: boolean; + note: string; +} + +export interface HealthViewModel { + state: HealthViewState; + stateLabel: '运行正常' | '部分能力停用' | '服务异常'; + checkedAt: Date; + services: HealthServiceViewModel[]; +} + +function assertDependencyStatus(status: string): asserts status is DependencyHealthDto['status'] { + if (status !== 'healthy' && status !== 'unavailable' && status !== 'disabled') { + throw new Error(`未知依赖状态:${status}`); + } +} + +function toServiceViewModel( + key: HealthServiceViewModel['key'], + name: HealthServiceViewModel['name'], + dependency: DependencyHealthDto, +): HealthServiceViewModel { + assertDependencyStatus(dependency.status); + return { + key, + name, + available: dependency.status === 'healthy', + note: dependency.detail, + }; +} + +export function toHealthViewModel(dto: HealthResponseDto): HealthViewModel { + const dependencies = Object.values(dto.dependencies); + for (const dependency of dependencies) assertDependencyStatus(dependency.status); + + const hasUnavailable = dependencies.some((dependency) => dependency.status === 'unavailable'); + const hasDisabled = dependencies.some((dependency) => dependency.status === 'disabled'); + const state: HealthViewState = + dto.status === 'unavailable' || hasUnavailable + ? 'unavailable' + : hasDisabled + ? 'degraded' + : 'healthy'; + + const stateLabel = { + healthy: '运行正常', + degraded: '部分能力停用', + unavailable: '服务异常', + } as const; + + // 页面只消费稳定的展示模型,后端枚举和依赖对象结构不会直接扩散到 Vue 组件。 + return { + state, + stateLabel: stateLabel[state], + checkedAt: new Date(dto.checkedAt), + services: [ + toServiceViewModel('redis', 'Redis', dto.dependencies.redis), + toServiceViewModel('mysql', 'MySQL', dto.dependencies.mysql), + toServiceViewModel('rocketMq', 'RocketMQ', dto.dependencies.rocketMq), + ], + }; +} diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..cbc6d0f --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { ApiRequestError, createApiClient } from './client'; + +describe('OpenAPI 请求客户端', () => { + it('使用调用方提供的 API 根地址请求公开路径', async () => { + let requestedUrl = ''; + const client = createApiClient('http://127.0.0.1:3000', async (request) => { + requestedUrl = request.url; + return Response.json({ + status: 'healthy', + process: 'api', + checkedAt: '2026-08-13T09:30:00.000Z', + dependencies: { + mysql: { status: 'healthy', detail: 'ready' }, + redis: { status: 'healthy', detail: 'ready' }, + rocketMq: { status: 'disabled', detail: 'disabled' }, + }, + }); + }); + + await client.GET('/api/v1/health'); + + expect(requestedUrl).toBe('http://127.0.0.1:3000/api/v1/health'); + }); + + it('把网络错误归一化为不泄漏底层连接信息的错误', async () => { + const client = createApiClient('http://127.0.0.1:3000', async () => { + throw new Error('connect ECONNREFUSED mysql://root:secret@internal'); + }); + + await expect(client.GET('/api/v1/health')).rejects.toEqual( + expect.objectContaining({ + name: 'ApiRequestError', + message: 'API 请求失败,请稍后重试', + }), + ); + await expect(client.GET('/api/v1/health')).rejects.toBeInstanceOf(ApiRequestError); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..c1a65f6 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,28 @@ +import createClient, { type Client } from 'openapi-fetch'; +import type { paths } from './generated/schema'; + +export class ApiRequestError extends Error { + constructor() { + super('API 请求失败,请稍后重试'); + this.name = 'ApiRequestError'; + } +} + +export function createApiClient( + baseUrl: string, + fetchImplementation: (request: Request) => Promise = globalThis.fetch, +): Client { + const client = createClient({ + baseUrl: baseUrl.replace(/\/$/, ''), + fetch: fetchImplementation, + }); + + client.use({ + onError() { + // 浏览器错误可能带出内网主机、代理或底层连接细节,统一转换为安全的用户提示。 + return new ApiRequestError(); + }, + }); + + return client; +} diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts new file mode 100644 index 0000000..e07eb90 --- /dev/null +++ b/web/src/api/generated/schema.ts @@ -0,0 +1,82 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/api/v1/health': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['HealthController_getHealth']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + DependencyHealthDto: { + detail: string; + /** @enum {string} */ + status: 'healthy' | 'unavailable' | 'disabled'; + }; + HealthDependenciesDto: { + mysql: components['schemas']['DependencyHealthDto']; + redis: components['schemas']['DependencyHealthDto']; + rocketMq: components['schemas']['DependencyHealthDto']; + }; + HealthResponseDto: { + /** Format: date-time */ + checkedAt: string; + dependencies: components['schemas']['HealthDependenciesDto']; + /** @enum {string} */ + process: 'api'; + /** @enum {string} */ + status: 'healthy' | 'unavailable'; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + HealthController_getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HealthResponseDto']; + }; + }; + 503: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HealthResponseDto']; + }; + }; + }; + }; +}