@oj-module/cli 0.2.2 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/devkit/README.md +14 -3
  2. package/devkit/SKILL.md +16 -4
  3. package/devkit/framework-dev.md +8 -6
  4. package/devkit/manual.md +243 -23
  5. package/package.json +2 -2
  6. package/shell-dist/assets/runtime.js +143 -8
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +3 -3
  9. package/src/args.ts +67 -0
  10. package/src/build.ts +11 -3
  11. package/src/bundle.ts +197 -0
  12. package/src/contract/__tests__/handler-scan.test.ts +82 -0
  13. package/src/contract/check.ts +2 -1
  14. package/src/contract/handler-scan.ts +32 -0
  15. package/src/deterministic-tar.ts +120 -0
  16. package/src/dev.ts +4 -2
  17. package/src/enable.ts +28 -0
  18. package/src/index.ts +63 -2
  19. package/src/info.ts +3 -2
  20. package/src/init.ts +189 -14
  21. package/src/install.ts +268 -0
  22. package/src/keygen.ts +88 -0
  23. package/src/layout.ts +31 -0
  24. package/src/lifecycle/__tests__/authoring.test.ts +109 -0
  25. package/src/lifecycle/__tests__/bundle.test.ts +150 -0
  26. package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
  27. package/src/lifecycle/__tests__/fixtures/README.md +59 -0
  28. package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
  29. package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
  30. package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
  31. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
  32. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
  33. package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
  36. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
  37. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
  38. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
  39. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
  40. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
  41. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
  42. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
  43. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
  44. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
  45. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
  46. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
  47. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
  48. package/src/lifecycle/__tests__/helpers.ts +78 -0
  49. package/src/lifecycle/__tests__/install.test.ts +514 -0
  50. package/src/lifecycle/__tests__/keygen.test.ts +61 -0
  51. package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
  52. package/src/lifecycle/__tests__/publish.test.ts +80 -0
  53. package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
  54. package/src/lifecycle/__tests__/sign.test.ts +131 -0
  55. package/src/lifecycle/__tests__/smoke.test.ts +61 -0
  56. package/src/lifecycle/__tests__/trust.test.ts +177 -0
  57. package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
  58. package/src/lifecycle/keygen.ts +40 -0
  59. package/src/lifecycle/sign.ts +74 -0
  60. package/src/manifest-contract.ts +696 -0
  61. package/src/oj-cert.ts +19 -0
  62. package/src/oj-cmd.ts +3 -3
  63. package/src/preview.ts +2 -2
  64. package/src/publish.ts +150 -0
  65. package/src/sign.ts +128 -0
  66. package/src/site.ts +146 -0
  67. package/src/trust.ts +116 -0
  68. package/src/uninstall.ts +29 -0
  69. package/src/usage.ts +35 -1
  70. package/templates/README.md +1 -1
  71. package/templates/api/config.yaml +47 -0
  72. package/templates/api/seed.mysql.sql +1 -1
  73. package/templates/api/seed.pg.sql +1 -1
  74. package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
  75. package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
  76. package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
  77. package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
  78. package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
  79. package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
  80. package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
  81. package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
  82. package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
  83. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
  84. package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
  85. package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
  86. package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
  87. package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
  88. package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
  89. package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
  90. package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
  91. package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
  92. package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
  93. package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
  94. package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
  95. package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
  96. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
  97. package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
  98. package/templates/api/src/_platform/_shared/service/access.ts +2 -2
  99. package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
  100. package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
  101. package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
  102. package/templates/api/src/_platform/manifest.yaml +1 -0
  103. package/templates/api/src/_platform/market/api.ts +883 -0
  104. package/templates/api/src/_platform/market/bootstrap.ts +39 -0
  105. package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
  106. package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
  107. package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
  108. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
  109. package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
  110. package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
  111. package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
  112. package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
  113. package/templates/api/src/_platform/schema.yaml +21 -4
  114. package/templates/api/src/_platform/seed.sql +2 -2
  115. package/templates/api/src/_platform/solutions/api.ts +212 -0
  116. package/templates/api/src/demo/manifest.yaml +5 -0
  117. package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
  118. package/templates/api/src/demo/schema.yaml +11 -0
  119. package/templates/api/src/demo/seed.sql +5 -0
  120. package/templates/api/src/demo/todos/api.ts +59 -0
  121. package/templates/api/src/home/line/api.ts +57 -0
  122. package/templates/api/src/home/manifest.yaml +6 -0
  123. package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
  124. package/templates/api/src/home/pie/api.ts +40 -0
  125. package/templates/api/src/home/schema.yaml +14 -0
  126. package/templates/api/src/home/seed.sql +70 -0
  127. package/templates/api/src/system/oplog/api.ts +44 -2
  128. package/templates/api/tests/fixture.ts +16 -0
  129. package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
  130. package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
  131. package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
  132. package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
  133. package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
  134. package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
  135. package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
  136. package/templates/api/tests/lifecycle/register.spec.ts +194 -0
  137. package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
  138. package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
  139. package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
  140. package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
  141. package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
  142. package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
  143. package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
  144. package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
  145. package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
  146. package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
  147. package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
  148. package/templates/api/tests/rbac-settings.spec.ts +8 -0
  149. package/templates/init.include.yaml +62 -0
  150. package/templates/openapi.json +527 -0
  151. package/templates/package.json +52 -0
  152. package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
  153. package/templates/web/src/_platform/__client/api.ts +335 -0
  154. package/templates/web/src/about/entry.ts +42 -0
  155. package/templates/web/src/about/locales/en-US.json +16 -0
  156. package/templates/web/src/about/locales/zh-CN.json +16 -0
  157. package/templates/web/src/about/pages/constants.ts +20 -0
  158. package/templates/web/src/about/pages/index.tsx +135 -0
  159. package/templates/web/src/access/entry.ts +74 -0
  160. package/templates/web/src/access/locales/en-US.json +46 -0
  161. package/templates/web/src/access/locales/zh-CN.json +46 -0
  162. package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
  163. package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
  164. package/templates/web/src/access/pages/button-control/index.tsx +181 -0
  165. package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
  166. package/templates/web/src/access/pages/page-control/index.tsx +142 -0
  167. package/templates/web/src/demo/__client/api.schemas.ts +20 -0
  168. package/templates/web/src/demo/__client/api.ts +89 -0
  169. package/templates/web/src/exception/entry.ts +80 -0
  170. package/templates/web/src/exception/locales/en-US.json +13 -0
  171. package/templates/web/src/exception/locales/zh-CN.json +13 -0
  172. package/templates/web/src/exception/pages/403/index.tsx +30 -0
  173. package/templates/web/src/exception/pages/404/index.tsx +30 -0
  174. package/templates/web/src/exception/pages/500/index.tsx +30 -0
  175. package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
  176. package/templates/web/src/home/__client/api.schemas.ts +22 -0
  177. package/templates/web/src/home/__client/api.ts +113 -0
  178. package/templates/web/src/login/locales/en-US.json +6 -0
  179. package/templates/web/src/login/locales/zh-CN.json +6 -0
  180. package/templates/web/src/login/pages/login.tsx +67 -18
  181. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
  182. package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
  183. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
  184. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
  185. package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
  186. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
  187. package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
  188. package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
  189. package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
  190. package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
  191. package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
  192. package/templates/web/src/oj-market/api/client.ts +135 -0
  193. package/templates/web/src/oj-market/api/context.ts +11 -0
  194. package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
  195. package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
  196. package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
  197. package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
  198. package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
  199. package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
  200. package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
  201. package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
  202. package/templates/web/src/oj-market/entry.ts +27 -0
  203. package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
  204. package/templates/web/src/oj-market/locales/en-US.json +100 -0
  205. package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
  206. package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
  207. package/templates/web/src/oj-market/types.ts +84 -0
  208. package/templates/web/src/oj-market/utils/poll.ts +36 -0
  209. package/templates/web/src/oj-market/utils/semver.ts +14 -0
  210. package/templates/web/src/outside/entry.ts +80 -0
  211. package/templates/web/src/outside/locales/en-US.json +10 -0
  212. package/templates/web/src/outside/locales/zh-CN.json +10 -0
  213. package/templates/web/src/personal-center/entry.ts +1 -10
  214. package/templates/web/src/personal-center/locales/en-US.json +1 -5
  215. package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
  216. package/templates/web/src/route-nest/entry.ts +73 -0
  217. package/templates/web/src/route-nest/locales/en-US.json +9 -0
  218. package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
  219. package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
  220. package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
  221. package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
  222. package/templates/web/src/system/__client/api.schemas.ts +12 -0
  223. package/templates/web/src/system/__client/api.ts +25 -1
  224. package/templates/web/src/system/pages/oplog/index.tsx +1 -1
  225. package/templates/web/src/system/pages/roles/index.tsx +1 -0
  226. package/templates/web/src/system/pages/settings/index.tsx +1 -1
  227. package/templates/web/src/system/pages/users/index.tsx +1 -1
  228. package/templates/web.config.ts +32 -40
  229. package/vendor/host-versions.json +3 -3
  230. package/templates/pnpm-workspace.yaml +0 -14
  231. package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
@@ -0,0 +1,696 @@
1
+ // bundle 级 manifest.yaml 契约校验(P1-11,`ojm build --bundle` 构建期执行点)。
2
+ // 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33 构建列);
3
+ // 期望真源:src/lifecycle/__tests__/fixtures/(fixture-valid 零违规 / fixture-invalid 七码齐报)。
4
+ // 双端一致:本实现与 isolate 侧 preflight(templates/api/.../steps/preflight.ts)共享同一组夹具,
5
+ // 正则与规则**逐字一致**(§4.1 矩阵构建列 vs 安装列的执行点差异见各规则注释)——
6
+ // 改任一端正则前必须先改契约文档对应条款,再同步两端与夹具(契约 §0 唯一真源纪律)。
7
+ // 实现纪律(契约 §0):收集全部违规后统一报出,不首条即停——否则夹具断言集被截断。
8
+
9
+ import { parse } from "yaml";
10
+
11
+ /** bundle 级 manifest(动态校验对象;字段口径见契约 §2) */
12
+ export type BundleManifest = Record<string, unknown>;
13
+
14
+ // 单行书写:eslint indent-binary-ops 与 no-mixed-spaces-and-tabs 对 tab 续行互斥,拆行必撞规则
15
+ export type ParseResult = { ok: true, manifest: BundleManifest } | { ok: false, error: string };
16
+
17
+ // ── 契约 §2/§4 正则与清单(与 preflight.ts 逐字对应;改动须先改契约再同步双端与夹具)──
18
+
19
+ /** C2-01:name 白名单 */
20
+ const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
21
+
22
+ /** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留) */
23
+ const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
24
+
25
+ /** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
26
+ const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
27
+
28
+ /** §2.2 范围算子语法(peer 四字段/api.deps 值共用) */
29
+ const RANGE_RE = /^(>=|<=|[><^=])?([0-9A-Z.-]+)$/i;
30
+
31
+ /** C2-26①:anonymous 相对路径语法(无前导斜杠、无通配符;首字符 [a-z0-9] 使 _system/_platform 天然禁入) */
32
+ const ANONYMOUS_RE = /^[a-z0-9][a-z0-9\-_.]*(?:\/[a-z0-9][a-z0-9\-_.]*)*$/;
33
+
34
+ /** C2-17:路由 path 语法(首段首字符为字母;无参数段/通配符/尾斜杠) */
35
+ const ROUTE_PATH_RE = /^\/[a-z][a-z0-9\-_]*(?:\/[a-z0-9\-_]+)*$/;
36
+
37
+ /** C2-26③:登录语义端点禁令(R8/S1 纵深防御;契约 (?i) 正则逐字) */
38
+ const LOGIN_ENDPOINT_RE = /(?:^|\/)(?:[\w.-]*-)?(?:login|logout|token|session|signin|signup|auth)(?:\/|$)/i;
39
+
40
+ /** C2-17:D4 三值(PRD §5.2 settings/personal 两值废弃) */
41
+ const ROUTE_CATEGORIES = new Set(["main", "admin", "hidden"]);
42
+
43
+ /** C2-24:设置项类型枚举 */
44
+ const SETTING_TYPES = new Set(["string", "bool", "int", "float", "enum", "text"]);
45
+
46
+ /** C2-28:web.integrity 形态 */
47
+ const INTEGRITY_RE = /^sha384-[A-Za-z0-9+/]{64}$/;
48
+
49
+ /** 契约 §1:顶层键白名单——未列出的一律拒绝(防拼写漂移静默失效) */
50
+ const TOP_LEVEL_KEYS = new Set([
51
+ "name",
52
+ "version",
53
+ "title",
54
+ "summary",
55
+ "description",
56
+ "icon",
57
+ "author",
58
+ "license",
59
+ "homepage",
60
+ "keywords",
61
+ "changelog",
62
+ "peerRuntime",
63
+ "peerOj",
64
+ "peerFramework",
65
+ "peerPlatform",
66
+ "api",
67
+ "web",
68
+ "permissions",
69
+ "roles",
70
+ "routes",
71
+ "settings",
72
+ "health",
73
+ "archive",
74
+ ]);
75
+
76
+ function escapeRe(s: string): string {
77
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
79
+
80
+ function isObj(v: unknown): v is Record<string, unknown> {
81
+ return typeof v === "object" && v !== null && !Array.isArray(v);
82
+ }
83
+
84
+ /** i18n key 命名空间正则(C2-25 语法层):`<name>:<ns>.<rest>` */
85
+ function nameKeyRe(name: string, ns: string): RegExp {
86
+ return new RegExp(`^${escapeRe(name)}:${ns}\\.[a-zA-Z0-9_.-]+$`);
87
+ }
88
+
89
+ /** C2-02 语义检查(与 preflight.versionProblem 逐字一致)。返回违规描述,合法返回 null */
90
+ function versionProblem(s: string): string | null {
91
+ if (!VERSION_CHARS_RE.test(s)) {
92
+ return "含非法字符(允许 [0-9A-Za-z.-];+ 被 oj validate_version 拒装(附录 B-1)、_ 为契约收紧排除)";
93
+ }
94
+ if (s.startsWith(".") || s.endsWith(".")) {
95
+ return "首尾不得为点";
96
+ }
97
+ if (s.includes("..")) {
98
+ return "不得含连续点";
99
+ }
100
+ const hyphen = s.indexOf("-");
101
+ const core = hyphen === -1 ? s : s.slice(0, hyphen);
102
+ if (!/^\d+(?:\.\d+)*$/.test(core)) {
103
+ return `主版本段「${core}」必须为数字段(如 1.0.0);字母仅可用于 - 之后的 prerelease 标识`;
104
+ }
105
+ if (hyphen !== -1) {
106
+ const pre = s.slice(hyphen + 1);
107
+ if (pre === "" || pre.startsWith(".") || pre.endsWith(".") || pre.includes("..")) {
108
+ return "prerelease 标识不得为空或含空段";
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /** §2.2 范围算子语法 + P1 语义集(精确/=、>=、^;</<=/> 构建期即拒,§2.2「暂不支持的范围算子」) */
115
+ function rangeProblem(range: string): string | null {
116
+ const m = RANGE_RE.exec(range);
117
+ if (m === null) {
118
+ return "不符合范围算子语法 ^(>=|<=|>|<|^|=)?<version>(version 遵守 C2-02)";
119
+ }
120
+ const p = versionProblem(m[2]);
121
+ if (p !== null)
122
+ return p;
123
+ if (m[1] === "<" || m[1] === "<=" || m[1] === ">") {
124
+ return `使用了暂不支持的范围算子「${m[1]}」(P1 语义集 = 精确/=、>=、^;「<」「<=」构建期即拒)`;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /** 单条违规格式:编号在人话前,bundle 构建失败时逐条列出 */
130
+ function v(clause: string, detail: string): string {
131
+ return `【${clause}】${detail}`;
132
+ }
133
+
134
+ /**
135
+ * 解析 bundle 级 manifest.yaml 文本。
136
+ * 语法错误/顶层非映射 → ok:false + 可读错误(不抛异常,调用方统一决定 fail-fast 形态)。
137
+ */
138
+ export function parseManifest(text: string): ParseResult {
139
+ let doc: unknown;
140
+ try {
141
+ doc = parse(text);
142
+ }
143
+ catch (e) {
144
+ return { ok: false, error: `manifest.yaml 不是合法 YAML:${e instanceof Error ? e.message : String(e)}` };
145
+ }
146
+ if (!isObj(doc)) {
147
+ return { ok: false, error: `manifest.yaml 顶层必须是键值映射(实为 ${doc === null ? "null" : Array.isArray(doc) ? "数组" : typeof doc})` };
148
+ }
149
+ return { ok: true, manifest: doc };
150
+ }
151
+
152
+ /**
153
+ * 契约构建列全量校验(§4.1 矩阵「构建期」列的 manifest 内在规则)。
154
+ * 返回违规串列表(【编号】人话),空数组 = 通过;文件面规则(tables 对照 schema.yaml、
155
+ * locales 齐备、tgz 布局核对)分别在 checkTablesAgainstSchema / checkLocalesComplete / bundle.ts。
156
+ */
157
+ export function validateContract(m: unknown): string[] {
158
+ const v0: string[] = [];
159
+ if (!isObj(m)) {
160
+ return [v("C2", `manifest 不是键值对象(实为 ${m === null ? "null" : Array.isArray(m) ? "数组" : typeof m}),无法校验`)];
161
+ }
162
+ // 身份兜底:name/version 缺失/非字符串时,以占位串派生后续命名空间正则(衍生违规允许,内在违规照报)
163
+ const name = typeof m.name === "string" ? m.name : "";
164
+ const version = typeof m.version === "string" ? m.version : "";
165
+
166
+ // ── §1 顶层键白名单 ──
167
+ for (const k of Object.keys(m)) {
168
+ if (!TOP_LEVEL_KEYS.has(k)) {
169
+ v0.push(v("§1", `未知顶层键「${k}」(未列出的顶层键一律拒绝,防拼写漂移静默失效;白名单见契约 §1)`));
170
+ }
171
+ }
172
+
173
+ // ── C2-01 name:白名单 + 保留名 ──
174
+ if (typeof m.name !== "string" || !NAME_RE.test(m.name)) {
175
+ v0.push(v("C2-01", `name「${String(m.name)}」不匹配 ^[a-z][a-z0-9-]{0,63}$(小写字母开头,仅小写字母/数字/连字符)`));
176
+ }
177
+ if (RESERVED_NAMES.has(name)) {
178
+ v0.push(v("C2-01", `name「${name}」是保留名(§4.2:_platform/system/auth/market/framework/health/plugins/_system),禁用于方案(R9)`));
179
+ }
180
+
181
+ // ── C2-02 version:字符集 + oj 相容排除 ──
182
+ if (typeof m.version !== "string") {
183
+ v0.push(v("C2-02", "version 必须是字符串"));
184
+ }
185
+ else {
186
+ const p = versionProblem(m.version);
187
+ if (p !== null)
188
+ v0.push(v("C2-02", `version「${m.version}」${p}`));
189
+ }
190
+
191
+ // ── C2-03~08 身份可选字段 + changelog(构建列全量;安装列仅 summary 非空)──
192
+ if (m.title !== undefined && (typeof m.title !== "string" || m.title.length > 100)) {
193
+ v0.push(v("C2-03", `title「${String(m.title)}」必须是字符串且 ≤100 字符(缺省 = name)`));
194
+ }
195
+ const summary = m.summary;
196
+ if (typeof summary !== "string" || summary.trim() === "" || summary.length > 200 || summary.includes("\n")) {
197
+ v0.push(v("C2-04", "summary 必须是 1..200 字符的单行非空字符串(安装器取其写入模块 manifest 的 desc)"));
198
+ }
199
+ if (m.description !== undefined && (typeof m.description !== "string" || m.description.length > 2000)) {
200
+ v0.push(v("C2-05", "description 必须是字符串且 ≤2000 字符"));
201
+ }
202
+ if (m.icon !== undefined && (typeof m.icon !== "string" || m.icon.length > 500 || !/^(?:https?:\/\/|\/)/.test(m.icon))) {
203
+ v0.push(v("C2-06", `icon「${String(m.icon)}」必须是字符串(≤500,^https?:// 或 / 开头的绝对路径)`));
204
+ }
205
+ if (m.author !== undefined && typeof m.author !== "string") {
206
+ v0.push(v("C2-07", "author 必须是字符串"));
207
+ }
208
+ if (m.license !== undefined && (typeof m.license !== "string" || !/^[A-Z0-9.\-+ ]{1,64}$/i.test(m.license))) {
209
+ v0.push(v("C2-07", `license「${String(m.license)}」必须匹配 ^[A-Za-z0-9.\\-+ ]{1,64}$(SPDX 简化式)`));
210
+ }
211
+ if (m.homepage !== undefined && (typeof m.homepage !== "string" || !/^https?:\/\//.test(m.homepage))) {
212
+ v0.push(v("C2-07", `homepage「${String(m.homepage)}」必须以 http(s):// 开头`));
213
+ }
214
+ if (m.keywords !== undefined) {
215
+ const ks = m.keywords;
216
+ if (!Array.isArray(ks) || ks.length > 10 || !ks.every(x => typeof x === "string" && /^[a-z0-9-]{1,32}$/.test(x))) {
217
+ v0.push(v("C2-07", "keywords 每项必须匹配 ^[a-z0-9-]{1,32}$ 且总数 ≤10"));
218
+ }
219
+ }
220
+ const changelog = m.changelog;
221
+ if (typeof changelog === "string") {
222
+ if (changelog.trim() === "" || changelog.length > 2000)
223
+ v0.push(v("C2-08", "changelog 字符串形态必须非空且 ≤2000 字符"));
224
+ }
225
+ else if (Array.isArray(changelog)) {
226
+ if (changelog.length > 50 || !changelog.every(x => typeof x === "string" && x.trim() !== "" && x.length <= 500))
227
+ v0.push(v("C2-08", "changelog 数组形态必须 ≤50 条且每条非空 ≤500 字符"));
228
+ }
229
+ else {
230
+ v0.push(v("C2-08", "changelog 必填(R27 市场卡片版本对比):非空字符串或字符串数组"));
231
+ }
232
+
233
+ // ── C2-09/10/11 peer 四字段:范围算子语法(P1 语义集;peerRuntime 安装期另对宿主强制比对)──
234
+ for (const [key, clause] of [["peerRuntime", "C2-09"], ["peerOj", "C2-10"], ["peerFramework", "C2-10"], ["peerPlatform", "C2-11"]] as const) {
235
+ const val = m[key];
236
+ if (val === undefined)
237
+ continue;
238
+ if (typeof val !== "string" || rangeProblem(val) !== null) {
239
+ v0.push(v(clause, `${key}「${String(val)}」不符合范围算子语法 ^(>=|<=|>|<|^|=)?<version>(version 遵守 C2-02;P1 语义集 = 精确/=、>=、^)`));
240
+ }
241
+ }
242
+
243
+ // ── api 段(C2-12/12a/12b/13/26)──
244
+ let apiSeg = name;
245
+ if (m.api !== undefined) {
246
+ const api = m.api;
247
+ if (!isObj(api)) {
248
+ v0.push(v("C2-12", "api 段必须是键值对象"));
249
+ }
250
+ else {
251
+ if (api.module !== undefined && api.module !== name) {
252
+ v0.push(v("C2-12", `api.module「${String(api.module)}」必须等于 name「${name}」`));
253
+ }
254
+ if (api.prefix !== undefined) {
255
+ const p = api.prefix;
256
+ if (typeof p !== "string" || !/^\/api\/[a-z][a-z0-9-]{0,63}$/.test(p)) {
257
+ v0.push(v("C2-13", `api.prefix「${String(p)}」必须匹配 ^/api/[a-z][a-z0-9-]{0,63}$(缺省 /api/<name>)`));
258
+ }
259
+ else {
260
+ apiSeg = p.slice("/api/".length);
261
+ if (RESERVED_NAMES.has(apiSeg)) {
262
+ v0.push(v("C2-13", `api.prefix 模块段「${apiSeg}」是保留名(§4.2),禁用`));
263
+ }
264
+ }
265
+ }
266
+ if (api.tables !== undefined) {
267
+ const t = api.tables;
268
+ if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(x))) {
269
+ v0.push(v("C2-12a", "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,63}$ 的字符串(与 api/src/<name>/schema.yaml 表集逐一相等另行对照)"));
270
+ }
271
+ }
272
+ if (api.deps !== undefined) {
273
+ if (!isObj(api.deps)) {
274
+ v0.push(v("C2-12b", "api.deps 必须是键值对象"));
275
+ }
276
+ else {
277
+ for (const [k, val] of Object.entries(api.deps)) {
278
+ if (k !== "_platform" && !NAME_RE.test(k)) {
279
+ v0.push(v("C2-12b", `api.deps 键「${k}」必须匹配 C2-01 name 语法或为 _platform`));
280
+ }
281
+ if (typeof val !== "string" || rangeProblem(val) !== null) {
282
+ v0.push(v("C2-12b", `api.deps["${k}"] 值「${String(val)}」不符合范围算子语法(§2.2;P1 语义集 = 精确/=、>=、^)`));
283
+ }
284
+ }
285
+ }
286
+ }
287
+ // C2-26:anonymous ①语法 ②段内收敛(R8)③登录端点禁令 ④唯一
288
+ if (api.anonymous !== undefined) {
289
+ const list = api.anonymous;
290
+ if (!Array.isArray(list) || !list.every(x => typeof x === "string")) {
291
+ v0.push(v("C2-26", "api.anonymous 必须是字符串数组"));
292
+ }
293
+ else {
294
+ const seen = new Set<string>();
295
+ for (const a of list) {
296
+ if (!ANONYMOUS_RE.test(a)) {
297
+ v0.push(v("C2-26", `anonymous「${a}」不匹配相对路径语法 ^[a-z0-9][a-z0-9\\-_.]*(/[a-z0-9][a-z0-9\\-_.]*)*$(无前导斜杠/通配符;_system/_platform 段禁入)`));
298
+ continue;
299
+ }
300
+ if (a.split("/")[0] !== apiSeg) {
301
+ v0.push(v("C2-26", `anonymous「${a}」越段:首段必须 = 本方案段「${apiSeg}」(R8 段内收敛;_system/_platform 等保留段因此禁入)`));
302
+ }
303
+ if (LOGIN_ENDPOINT_RE.test(a)) {
304
+ v0.push(v("C2-26", `anonymous「${a}」命中登录语义端点禁令(R8/S1 纵深防御:login/logout/token/session/signin/signup/auth)`));
305
+ }
306
+ if (seen.has(a)) {
307
+ v0.push(v("C2-26", `anonymous「${a}」在本 manifest 内重复`));
308
+ }
309
+ seen.add(a);
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ // ── web 段(C2-14/27/28;integrity 重算比对属安装 deployWeb→smoke 段)──
317
+ if (m.web !== undefined) {
318
+ const web = m.web;
319
+ if (!isObj(web)) {
320
+ v0.push(v("C2-27", "web 段必须是键值对象"));
321
+ }
322
+ else {
323
+ if (web.route_prefix !== undefined) {
324
+ const rp = web.route_prefix;
325
+ if (typeof rp !== "string" || !/^\/[a-z][a-z0-9-]{0,63}$/.test(rp)) {
326
+ v0.push(v("C2-14", `web.route_prefix「${String(rp)}」必须匹配 ^/[a-z][a-z0-9-]{0,63}$(缺省 /<name>)`));
327
+ }
328
+ else if (RESERVED_NAMES.has(rp.slice(1))) {
329
+ v0.push(v("C2-14", `web.route_prefix 首段「${rp.slice(1)}」是保留名(§4.2),禁用`));
330
+ }
331
+ }
332
+ const base = `modules/${escapeRe(name)}/${escapeRe(version)}/[a-z0-9._/-]+`;
333
+ if (typeof web.entry !== "string" || !new RegExp(`^${base}\\.js$`).test(web.entry)) {
334
+ v0.push(v("C2-27", `web.entry「${String(web.entry)}」必须匹配 ^modules/${name}/${version}/…\\.js$(web 段存在时必填;版本段必须 === version;POSIX 分隔符)`));
335
+ }
336
+ if (web.css !== undefined) {
337
+ const css = web.css;
338
+ if (!Array.isArray(css) || !css.every(x => typeof x === "string" && new RegExp(`^${base}\\.css$`).test(x))) {
339
+ v0.push(v("C2-27", `web.css 每项必须匹配 ^modules/${name}/${version}/…\\.css$(版本段 === version)`));
340
+ }
341
+ }
342
+ if (web.integrity !== undefined && (typeof web.integrity !== "string" || !INTEGRITY_RE.test(web.integrity))) {
343
+ v0.push(v("C2-28", `web.integrity「${String(web.integrity)}」必须匹配 ^sha384-[A-Za-z0-9+/]{64}$(构建器对 entry 产物计算写入;安装器重算比对,不一致拒装)`));
344
+ }
345
+ }
346
+ }
347
+
348
+ // ── C2-15 permissions:code 强制 <name>. 前缀 + title_key 必填 + 唯一 ──
349
+ const permCodes = new Set<string>();
350
+ if (m.permissions !== undefined) {
351
+ const list = m.permissions;
352
+ if (!Array.isArray(list)) {
353
+ v0.push(v("C2-15", "permissions 必须是数组"));
354
+ }
355
+ else {
356
+ const seen = new Set<string>();
357
+ const codeRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9_]+(\\.[a-z0-9_]+)*$`);
358
+ for (const p of list) {
359
+ if (!isObj(p)) {
360
+ v0.push(v("C2-15", "permissions 每项必须是键值对象"));
361
+ continue;
362
+ }
363
+ const code = p.code;
364
+ if (typeof code !== "string" || !codeRe.test(code)) {
365
+ v0.push(v("C2-15", `permission code「${String(code)}」必须匹配 ^${name}\\.[a-z0-9_]+(\\.[a-z0-9_]+)*$(强制 <name>. 前缀)`));
366
+ }
367
+ const tk = p.title_key;
368
+ if (typeof tk !== "string" || !nameKeyRe(name, "perm").test(tk)) {
369
+ v0.push(v("C2-15", `permission「${String(code)}」title_key 必填且须匹配 ${name}:perm.*(i18n 命名空间)`));
370
+ }
371
+ if (typeof code === "string") {
372
+ if (seen.has(code))
373
+ v0.push(v("C2-15", `permission code「${code}」在本 manifest 内重复`));
374
+ seen.add(code);
375
+ permCodes.add(code);
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ // ── C2-16 roles:<name>.<role> + title_key + permissions ⊆ permissions[].code(悬空双端拒绝)──
382
+ if (m.roles !== undefined) {
383
+ const list = m.roles;
384
+ if (!Array.isArray(list)) {
385
+ v0.push(v("C2-16", "roles 必须是数组"));
386
+ }
387
+ else {
388
+ const seen = new Set<string>();
389
+ const nameRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9-]{1,64}$`);
390
+ for (const r of list) {
391
+ if (!isObj(r)) {
392
+ v0.push(v("C2-16", "roles 每项必须是键值对象"));
393
+ continue;
394
+ }
395
+ const rn = r.name;
396
+ if (typeof rn !== "string" || !nameRe.test(rn)) {
397
+ v0.push(v("C2-16", `role name「${String(rn)}」必须匹配 ${name}.<role>(写入 sys_role.module=${name})`));
398
+ }
399
+ const tk = r.title_key;
400
+ if (typeof tk !== "string" || !nameKeyRe(name, "role").test(tk)) {
401
+ v0.push(v("C2-16", `role「${String(rn)}」title_key 必填且须匹配 ${name}:role.*(i18n 命名空间)`));
402
+ }
403
+ const perms = r.permissions;
404
+ if (!Array.isArray(perms) || !perms.every(x => typeof x === "string")) {
405
+ v0.push(v("C2-16", `role「${String(rn)}」permissions 必须是字符串数组`));
406
+ }
407
+ else {
408
+ for (const code of perms) {
409
+ if (!permCodes.has(code)) {
410
+ v0.push(v("C2-16", `role「${String(rn)}」引用了未声明的权限「${code}」(悬空引用:须先在 permissions[].code 声明)`));
411
+ }
412
+ }
413
+ }
414
+ if (typeof rn === "string") {
415
+ if (seen.has(rn))
416
+ v0.push(v("C2-16", `role name「${rn}」在本 manifest 内重复`));
417
+ seen.add(rn);
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ // ── C2-17~21 routes:三值/path/order/title_key/required_permission ──
424
+ const routePaths: string[] = [];
425
+ if (m.routes !== undefined) {
426
+ const list = m.routes;
427
+ if (!Array.isArray(list)) {
428
+ v0.push(v("C2-17", "routes 必须是数组"));
429
+ }
430
+ else {
431
+ const seen = new Set<string>();
432
+ for (const r of list) {
433
+ if (!isObj(r)) {
434
+ v0.push(v("C2-17", "routes 每项必须是键值对象"));
435
+ continue;
436
+ }
437
+ const cat = r.category;
438
+ if (typeof cat !== "string" || !ROUTE_CATEGORIES.has(cat)) {
439
+ v0.push(v("C2-17", `route「${String(r.path)}」category「${String(cat)}」必须是 main|admin|hidden(D4 三值;settings/personal 已废弃)`));
440
+ }
441
+ const path = r.path;
442
+ if (typeof path !== "string" || !ROUTE_PATH_RE.test(path)) {
443
+ v0.push(v("C2-17", `route path「${String(path)}」必须匹配 ^/[a-z][a-z0-9\\-_]*(/[a-z0-9\\-_]+)*$(首段首字符字母;无参数段/通配符/尾斜杠)`));
444
+ }
445
+ else {
446
+ if (seen.has(path))
447
+ v0.push(v("C2-17", `route path「${path}」在本 manifest 内重复`));
448
+ seen.add(path);
449
+ routePaths.push(path);
450
+ }
451
+ if (!Number.isInteger(r.order)) {
452
+ v0.push(v("C2-17", `route「${String(path)}」order 必填整数(建议 ≥200,框架保留 <200 间隔带——越带仅构建期警告不阻断)`));
453
+ }
454
+ const tk = r.title_key;
455
+ if (cat === "main" || cat === "admin") {
456
+ if (typeof tk !== "string" || !nameKeyRe(name, "menu").test(tk)) {
457
+ v0.push(v("C2-17", `route「${String(path)}」(${String(cat)})title_key 必填且须匹配 ${name}:menu.*`));
458
+ }
459
+ }
460
+ else if (tk !== undefined && (typeof tk !== "string" || !nameKeyRe(name, "menu").test(tk))) {
461
+ v0.push(v("C2-25", `route「${String(path)}」(hidden)title_key 可省略,给出则须匹配 ${name}:menu.*`));
462
+ }
463
+ // C2-21:required_permission ∈ permissions[].code(R22:仅供后端深检比对,前端不下发)
464
+ const rp = r.required_permission;
465
+ if (rp !== undefined && (typeof rp !== "string" || !permCodes.has(rp))) {
466
+ v0.push(v("C2-21", `route「${String(path)}」required_permission「${String(rp)}」未在 permissions[].code 声明(悬空引用)`));
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // ── C2-22~24 settings:组标识/label_key/items 非空 + 键所有权(R24)+ 类型枚举/enum options ──
473
+ if (m.settings !== undefined) {
474
+ const s = m.settings;
475
+ if (!isObj(s) || !Array.isArray(s.groups)) {
476
+ v0.push(v("C2-22", "settings.groups 必须是数组"));
477
+ }
478
+ else {
479
+ const seenGroups = new Set<string>();
480
+ const seenKeys = new Set<string>();
481
+ const groupRe = /^[a-z][a-z0-9-]{0,63}$/;
482
+ const keyRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9_-]+(\\.[a-z0-9_-]+)+$`);
483
+ for (const g of s.groups) {
484
+ if (!isObj(g)) {
485
+ v0.push(v("C2-22", "settings.groups 每项必须是键值对象"));
486
+ continue;
487
+ }
488
+ const gn = g.group;
489
+ if (typeof gn !== "string" || !groupRe.test(gn)) {
490
+ v0.push(v("C2-22", `设置组标识「${String(gn)}」必须匹配 ^[a-z][a-z0-9-]{0,63}$`));
491
+ }
492
+ else if (seenGroups.has(gn)) {
493
+ v0.push(v("C2-22", `设置组「${gn}」在本 manifest 内重复(组标识 = sys_setting.group 值)`));
494
+ }
495
+ else {
496
+ seenGroups.add(gn);
497
+ }
498
+ const glk = g.label_key;
499
+ if (typeof glk !== "string" || !nameKeyRe(name, "settings.group").test(glk)) {
500
+ v0.push(v("C2-22", `设置组「${String(gn)}」label_key 必填且须匹配 ${name}:settings.group.*(系统设置页组标题)`));
501
+ }
502
+ if (!Array.isArray(g.items) || g.items.length === 0) {
503
+ v0.push(v("C2-22", `设置组「${String(gn)}」items 必须是非空数组`));
504
+ continue;
505
+ }
506
+ for (const item of g.items) {
507
+ if (!isObj(item)) {
508
+ v0.push(v("C2-23", `设置组「${String(gn)}」items 每项必须是键值对象`));
509
+ continue;
510
+ }
511
+ // C2-23(R24 键所有权):moduleOf(key) === name——正则强制 <name>. 前缀即首段所有权
512
+ const key = item.key;
513
+ if (typeof key !== "string" || !keyRe.test(key)) {
514
+ v0.push(v("C2-23", `设置键「${String(key)}」必须匹配 ^${name}\\.[a-z0-9_-]+(\\.[a-z0-9_-]+)+$(键所有权:moduleOf(key) === ${name},R24)`));
515
+ }
516
+ else if (seenKeys.has(key)) {
517
+ v0.push(v("C2-23", `设置键「${key}」在本 manifest 内重复`));
518
+ }
519
+ else {
520
+ seenKeys.add(key);
521
+ }
522
+ const type = item.type;
523
+ if (typeof type !== "string" || !SETTING_TYPES.has(type)) {
524
+ v0.push(v("C2-24", `设置项「${String(key)}」type「${String(type)}」必须是 string|bool|int|float|enum|text`));
525
+ }
526
+ else if (type === "enum") {
527
+ const opts = item.options;
528
+ if (!Array.isArray(opts) || opts.length < 1 || !opts.every(x => typeof x === "string")) {
529
+ v0.push(v("C2-24", `设置项「${String(key)}」type=enum 时 options 必填(string[] ≥1 项)`));
530
+ }
531
+ }
532
+ if (item.default !== undefined && typeof item.default !== "string") {
533
+ v0.push(v("C2-24", `设置项「${String(key)}」default 必须是字符串(值恒以字符串存 sys_setting.value)`));
534
+ }
535
+ if (item.secret !== undefined && typeof item.secret !== "boolean") {
536
+ v0.push(v("C2-24", `设置项「${String(key)}」secret 必须是布尔(缺省 false;true 读取面掩码 ***)`));
537
+ }
538
+ const ilk = item.label_key;
539
+ if (ilk !== undefined && (typeof ilk !== "string" || !nameKeyRe(name, "settings").test(ilk))) {
540
+ v0.push(v("C2-24", `设置项「${String(key)}」label_key 须匹配 ${name}:settings.*`));
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+
547
+ // ── C2-33 health:path 语法 + 存在于 routes[](本方案路由或其子路径)──
548
+ if (m.health !== undefined) {
549
+ const h = m.health;
550
+ if (typeof h !== "string" || !ROUTE_PATH_RE.test(h)) {
551
+ v0.push(v("C2-33", `health「${String(h)}」必须匹配路由 path 语法 ^/[a-z][a-z0-9\\-_]*(/[a-z0-9\\-_]+)*$`));
552
+ }
553
+ else if (!routePaths.some(p => h === p || h.startsWith(`${p}/`))) {
554
+ v0.push(v("C2-33", `health「${h}」必须是本方案路由或其子路径(routes[] 中存在 r 使 health === r.path 或以 r.path + "/" 开头)`));
555
+ }
556
+ }
557
+
558
+ // ── C2-29 archive Form A:可整体省略或仅含 file;禁 sha256/size/signature(附录 B-2 自引用悖论)──
559
+ if (m.archive !== undefined) {
560
+ const a = m.archive;
561
+ if (!isObj(a)) {
562
+ v0.push(v("C2-29", "archive 必须是键值对象(Form A:tgz 内占位仅 file;Form B 由 publish 填充于 CDN manifests.yaml)"));
563
+ }
564
+ else {
565
+ for (const banned of ["sha256", "size", "signature"]) {
566
+ if (a[banned] !== undefined) {
567
+ v0.push(v("C2-29", `archive.${banned} 在 tgz 内禁止携带(Form A 对 tgz 自身摘要不可自洽,附录 B-2;由 P3 publish 填充于 Form B)`));
568
+ }
569
+ }
570
+ for (const k of Object.keys(a)) {
571
+ if (k !== "file") {
572
+ v0.push(v("C2-29", `archive 未知键「${k}」(Form A 仅允许 file)`));
573
+ }
574
+ }
575
+ if (a.file !== undefined && (typeof a.file !== "string" || a.file !== `${name}-${version}.tgz`)) {
576
+ v0.push(v("C2-29", `archive.file「${String(a.file)}」必须为 <name>-<version>.tgz(即 ${name}-${version}.tgz)`));
577
+ }
578
+ }
579
+ }
580
+
581
+ return v0;
582
+ }
583
+
584
+ /** 收集 manifest 引用的全部 i18n key(C2-25 locales 齐备检查的引用面;bundle.ts 以此判定 locales 文件是否必查) */
585
+ export function referencedI18nKeys(m: BundleManifest): string[] {
586
+ const keys: string[] = [];
587
+ const push = (x: unknown) => {
588
+ if (typeof x === "string")
589
+ keys.push(x);
590
+ };
591
+ for (const r of Array.isArray(m.routes) ? m.routes : []) {
592
+ if (isObj(r))
593
+ push(r.title_key);
594
+ }
595
+ for (const p of Array.isArray(m.permissions) ? m.permissions : []) {
596
+ if (isObj(p))
597
+ push(p.title_key);
598
+ }
599
+ for (const r of Array.isArray(m.roles) ? m.roles : []) {
600
+ if (isObj(r))
601
+ push(r.title_key);
602
+ }
603
+ if (isObj(m.settings) && Array.isArray(m.settings.groups)) {
604
+ for (const g of m.settings.groups) {
605
+ if (!isObj(g))
606
+ continue;
607
+ push(g.label_key);
608
+ for (const item of Array.isArray(g.items) ? g.items : []) {
609
+ if (isObj(item))
610
+ push(item.label_key);
611
+ }
612
+ }
613
+ }
614
+ return keys;
615
+ }
616
+
617
+ /** 嵌套对象按点路径取值;嵌套未命中回退顶层扁平键(i18next 两态) */
618
+ function lookupKey(obj: Record<string, unknown>, nsKey: string): unknown {
619
+ let cur: unknown = obj;
620
+ for (const seg of nsKey.split(".")) {
621
+ if (!isObj(cur))
622
+ return undefined;
623
+ cur = cur[seg];
624
+ }
625
+ return cur !== undefined ? cur : obj[nsKey];
626
+ }
627
+
628
+ /**
629
+ * C2-25 locales 齐备(构建期缺 key fail-fast;安装期不重复校验文件内容)。
630
+ * 入参为两份 locale 文件文本(fs 在调用方);任何被引用 key 缺失/非字符串值即违规。
631
+ */
632
+ export function checkLocalesComplete(m: unknown, zhText: string, enText: string): string[] {
633
+ if (!isObj(m))
634
+ return [v("C2-25", "manifest 不是键值对象,无法核对 locales 引用")];
635
+ const locales: [string, Record<string, unknown> | null][] = [["zh-CN", parseJson(zhText)], ["en-US", parseJson(enText)]];
636
+ const out: string[] = [];
637
+ for (const key of referencedI18nKeys(m)) {
638
+ const nsKey = key.includes(":") ? key.slice(key.indexOf(":") + 1) : key;
639
+ for (const [lang, tree] of locales) {
640
+ if (tree === null) {
641
+ out.push(v("C2-25", `${lang} locale 不是合法 JSON 对象,无法核对引用 key`));
642
+ continue;
643
+ }
644
+ const val = lookupKey(tree, nsKey);
645
+ if (typeof val !== "string" || val === "") {
646
+ out.push(v("C2-25", `${lang} 缺少 manifest 引用的 key「${key}」(双语齐备,D4;构建期 fail-fast)`));
647
+ }
648
+ }
649
+ }
650
+ return out;
651
+ }
652
+
653
+ /**
654
+ * C2-12a api.tables 与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源,不一致 fail-fast)。
655
+ * 入参为 schema.yaml 文本(fs 在调用方)。
656
+ */
657
+ export function checkTablesAgainstSchema(m: unknown, schemaYamlText: string): string[] {
658
+ if (!isObj(m) || !isObj(m.api) || m.api.tables === undefined)
659
+ return [];
660
+ const declared = m.api.tables;
661
+ if (!Array.isArray(declared))
662
+ return []; // 语法违规归 validateContract(C2-12a 语法列),此处只对照表集
663
+ let schemaTables: unknown;
664
+ try {
665
+ const doc = parse(schemaYamlText);
666
+ schemaTables = isObj(doc) ? doc.tables : undefined;
667
+ }
668
+ catch {
669
+ return [v("C2-12a", "schema.yaml 不是合法 YAML,无法对照 api.tables 表集")];
670
+ }
671
+ if (!isObj(schemaTables)) {
672
+ return [v("C2-12a", "schema.yaml 缺 tables 映射,无法对照 api.tables 表集(oj 冗余校验同源)")];
673
+ }
674
+ const schemaKeys = new Set(Object.keys(schemaTables));
675
+ const declaredKeys = new Set(declared.filter(x => typeof x === "string"));
676
+ const out: string[] = [];
677
+ const missingInSchema = [...declaredKeys].filter(k => !schemaKeys.has(k));
678
+ const undeclaredInManifest = [...schemaKeys].filter(k => !declaredKeys.has(k));
679
+ if (missingInSchema.length > 0) {
680
+ out.push(v("C2-12a", `api.tables 声明了 schema.yaml 不存在的表:${missingInSchema.join("、")}(表集须逐一相等,oj 冗余校验同源)`));
681
+ }
682
+ if (undeclaredInManifest.length > 0) {
683
+ out.push(v("C2-12a", `schema.yaml 存在 api.tables 未声明的表:${undeclaredInManifest.join("、")}(表集须逐一相等,oj 冗余校验同源)`));
684
+ }
685
+ return out;
686
+ }
687
+
688
+ function parseJson(text: string): Record<string, unknown> | null {
689
+ try {
690
+ const p = JSON.parse(text);
691
+ return isObj(p) ? p : null;
692
+ }
693
+ catch {
694
+ return null;
695
+ }
696
+ }