@oj-module/cli 0.2.8 → 0.2.15

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 (102) hide show
  1. package/devkit/README.md +70 -24
  2. package/devkit/framework-dev.md +14 -5
  3. package/devkit/manual.md +69 -6
  4. package/devkit/module-dev-and-cdn-playground.md +197 -0
  5. package/devkit/sql-builder-missing-capabilities-pr.md +38 -4
  6. package/package.json +2 -2
  7. package/shell-dist/assets/runtime.js +262 -45
  8. package/shell-dist/index.html +2 -2
  9. package/shell-dist/versions.json +3 -3
  10. package/src/__tests__/build-backend.test.ts +64 -0
  11. package/src/__tests__/versions.test.ts +26 -0
  12. package/src/build.ts +9 -0
  13. package/src/bundle.ts +8 -5
  14. package/src/contract/__tests__/market-client.integration.test.ts +13 -10
  15. package/src/index.ts +6 -0
  16. package/src/init.ts +22 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  18. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +2 -1
  19. package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +2 -1
  20. package/src/lifecycle/__tests__/manifest-contract.test.ts +70 -1
  21. package/src/manifest-contract.ts +25 -2
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +14 -0
  47. package/src/preview.ts +4 -3
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +19 -0
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +20 -0
  54. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  55. package/templates/api/src/_platform/_lifecycle/uninstall.ts +21 -53
  56. package/templates/api/src/_platform/_shared/service/domain/contacts.ts +15 -10
  57. package/templates/api/src/_platform/_shared/service/domain/solution-perms.ts +8 -6
  58. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +16 -23
  59. package/templates/api/src/_platform/_shared/service/infra/query.ts +18 -0
  60. package/templates/api/src/_platform/_shared/service/infra/rate.ts +4 -1
  61. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  62. package/templates/api/src/_platform/manifest.yaml +1 -0
  63. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  64. package/templates/api/src/_platform/schema.yaml +12 -0
  65. package/templates/api/src/auth/_shared/session.ts +2 -2
  66. package/templates/api/src/demo/todos/api.ts +8 -9
  67. package/templates/api/src/notification/_shared/notify.ts +64 -26
  68. package/templates/api/src/notification/api.ts +1 -1
  69. package/templates/api/src/notification/manifest.yaml +1 -0
  70. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  71. package/templates/api/src/notification/schema.yaml +7 -0
  72. package/templates/api/src/notification/seed.sql +5 -4
  73. package/templates/api/src/personal-center/profile/api.ts +63 -0
  74. package/templates/api/src/web/user-info/api.ts +3 -1
  75. package/templates/api/tests/fixture.ts +8 -16
  76. package/templates/api/tests/l1-builder.test.ts +91 -0
  77. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  78. package/templates/api/tests/lifecycle/market-api.spec.ts +1 -3
  79. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  80. package/templates/api/tests/lifecycle/uninstall.spec.ts +28 -88
  81. package/templates/api/tests/query-utils.spec.ts +85 -1
  82. package/templates/api/tests/rbac-settings.spec.ts +64 -19
  83. package/templates/cdn/README.md +48 -0
  84. package/templates/global.d.ts +36 -2
  85. package/templates/init.include.yaml +3 -2
  86. package/templates/openapi.json +55 -10
  87. package/templates/web/src/notification/entry.ts +46 -9
  88. package/templates/web/src/notification/locales/en-US.json +19 -0
  89. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  90. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  91. package/templates/web/src/oj-market/entry.ts +8 -2
  92. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  93. package/templates/web/src/personal-center/__client/api.ts +25 -1
  94. package/templates/web/src/personal-center/entry.ts +5 -0
  95. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  96. package/templates/web/src/system/entry.ts +2 -1
  97. package/templates/web.config.ts +10 -7
  98. package/vendor/host-versions.json +3 -3
  99. package/templates/api/src/notifications/api.ts +0 -29
  100. package/templates/api/src/notifications/manifest.yaml +0 -5
  101. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  102. package/templates/web/src/notifications/__client/api.ts +0 -80
@@ -5,9 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-GbU8577c6LFtg4pmT1NjGA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-+MhU9b9nCIgdxcVv98WPgQ=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
9
 
10
- <script type="importmap" nonce="GbU8577c6LFtg4pmT1NjGA==">{
10
+ <script type="importmap" nonce="+MhU9b9nCIgdxcVv98WPgQ==">{
11
11
  "imports": {
12
12
  "react": "/assets/react.js",
13
13
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.8",
11
- "@oj-module/runtime/contract": "0.2.8",
12
- "@oj-module/runtime/contract/errors": "0.2.8",
10
+ "@oj-module/runtime": "0.2.15",
11
+ "@oj-module/runtime/contract": "0.2.15",
12
+ "@oj-module/runtime/contract/errors": "0.2.15",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,64 @@
1
+ // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
2
+ //
3
+ // 安装器按 B-11 布局读 `<api>/dist/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
+ // 强制比对;而站点壳的 versions.json 落在 web/dist。两者不同源时,新工程 `ojm build`
5
+ // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { buildBackend } from "../build";
12
+
13
+ /**
14
+ * 假 oj 二进制:只需成功退出——本 spec 验的是 buildBackend 在 oj build 之后的收尾动作
15
+ * (versions.json 同源落地),oj build 自身的产物由用例预先放好模拟。
16
+ */
17
+ function fakeOj(projectRoot: string): void {
18
+ mkdirSync(join(projectRoot, "bin"), { recursive: true });
19
+ const bin = join(projectRoot, "bin", "oj");
20
+ writeFileSync(bin, "#!/bin/sh\nexit 0\n");
21
+ chmodSync(bin, 0o755);
22
+ }
23
+
24
+ function newProject(): string {
25
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-"));
26
+ mkdirSync(join(root, "api", "src", "demo"), { recursive: true });
27
+ writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
28
+ writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
+ // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
+ mkdirSync(join(root, "api", "dist"), { recursive: true });
31
+ writeFileSync(join(root, "api", "dist", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
+ fakeOj(root);
33
+ return root;
34
+ }
35
+
36
+ describe("given 宿主工程有 api/src When ojm build 后端", () => {
37
+ it("then api/dist/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
38
+ const root = newProject();
39
+
40
+ await buildBackend(root);
41
+
42
+ const hostVersions = join(root, "api", "dist", "versions.json");
43
+ expect(existsSync(hostVersions)).toBe(true);
44
+ const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
+ expect(typeof parsed["@oj-module/runtime"]).toBe("string");
46
+ });
47
+
48
+ it("then 既有 oj build 产物不被覆盖(manifests.yaml 原样保留)", async () => {
49
+ const root = newProject();
50
+
51
+ await buildBackend(root);
52
+
53
+ expect(readFileSync(join(root, "api", "dist", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
54
+ });
55
+ });
56
+
57
+ describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
+ it("then 跳过构建、不产出 api/dist", async () => {
59
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
+
61
+ expect(await buildBackend(root)).toBe(false);
62
+ expect(existsSync(join(root, "api", "dist"))).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { hostArtifactFreshnessError } from "../versions";
4
+
5
+ describe("hostArtifactFreshnessError(源码形态宿主产物过期守卫)", () => {
6
+ it("版本一致 → 通过", () => {
7
+ expect(hostArtifactFreshnessError("0.2.11", "0.2.11")).toBeUndefined();
8
+ });
9
+
10
+ it("任一侧缺失 → 跳过守卫", () => {
11
+ expect(hostArtifactFreshnessError(undefined, "0.2.11")).toBeUndefined();
12
+ expect(hostArtifactFreshnessError("0.2.11", undefined)).toBeUndefined();
13
+ });
14
+
15
+ it("宿主产物落后于 workspace runtime → 报真因并指向 build:shell", () => {
16
+ const err = hostArtifactFreshnessError("0.2.10", "0.2.11");
17
+ expect(err).toContain("shell-dist/versions.json)过期");
18
+ expect(err).toContain("0.2.10");
19
+ expect(err).toContain("0.2.11");
20
+ expect(err).toContain("build:shell");
21
+ });
22
+
23
+ it("宿主产物领先(跨仓消费旧版 runtime)→ 同样拦截", () => {
24
+ expect(hostArtifactFreshnessError("0.2.12", "0.2.11")).toContain("过期");
25
+ });
26
+ });
package/src/build.ts CHANGED
@@ -511,6 +511,15 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
511
511
  // SyntaxError: Invalid left-hand side in assignment(端点 500)。
512
512
  // 0.1.32 / 0.1.50 均复现。产物体积换正确性,等上游修好再撤这个旗标。
513
513
  execFileSync(oj, ["build", ...configArgs, "-d", apiSrc, "-o", apiDist, "--no-minify"], { stdio: "inherit" });
514
+ // 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 web/dist(mergeShellSite),
515
+ // 而方案安装器读的是 `<api>/dist/versions.json`(B-11:与 manifests.yaml 同层,用于
516
+ // peerRuntime 强制比对)。两处必须同源,否则新工程 `ojm build` 完仍装不上方案——
517
+ // preflight 直接 C2-09 fail-closed「无法读取宿主 runtime 版本」。
518
+ const versionsSrc = path.join(resolveShellDist(), "versions.json");
519
+ if (fs.existsSync(versionsSrc)) {
520
+ fs.mkdirSync(apiDist, { recursive: true });
521
+ fs.copyFileSync(versionsSrc, path.join(apiDist, "versions.json"));
522
+ }
514
523
  return true;
515
524
  }
516
525
 
package/src/bundle.ts CHANGED
@@ -116,8 +116,14 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
116
116
  }
117
117
  }
118
118
 
119
- /** C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version/desc 与 bundle 级逐一相等(oj 启动强校验同源) */
120
- function checkModuleManifest(built: string, name: string, version: string, summary: string): string[] {
119
+ /**
120
+ * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
121
+ * (oj 启动强校验同源)。
122
+ *
123
+ * 变更(2026-10-21):`desc === summary` 的等值要求已移除(与安装侧 extract.ts 同步)——
124
+ * 那是构建期映射的产物而不是安全边界,保留它会让 desc 与 summary 一字之差的包直接装不上。
125
+ */
126
+ function checkModuleManifest(built: string, name: string, version: string, _summary: string): string[] {
121
127
  const p = path.join(built, "manifest.yaml");
122
128
  let doc: unknown;
123
129
  try {
@@ -137,9 +143,6 @@ function checkModuleManifest(built: string, name: string, version: string, summa
137
143
  if (mm.version !== version) {
138
144
  out.push(`模块级 version「${String(mm.version)}」必须 === bundle version「${version}」`);
139
145
  }
140
- if (mm.desc !== summary) {
141
- out.push(`模块级 desc「${String(mm.desc)}」必须 === bundle summary「${summary}」(安装器写 desc 即取 summary)`);
142
- }
143
146
  return out;
144
147
  }
145
148
 
@@ -1,4 +1,6 @@
1
- import type { ScopedRequestLike } from "../../../templates/web/src/_platform/__client/api";
1
+ // ScopedRequestLike 真源在 runtime contract(生成物 __client/api.ts 也从这里 import,
2
+ // 自身不再 re-export——旧写法 type import 自 __client 会在再生后 TS2305)。
3
+ import type { ScopedRequestLike } from "@oj-module/runtime/contract/errors";
2
4
 
3
5
  // 集成回归:真实 httpMarketClient(统一后委托 ojm 生成 client)的委托行为。
4
6
  // 经 bindRequest 注入 mock request,验证每条 MarketClient 方法 → 正确 URL/body/query + 返回形状;
@@ -43,9 +45,8 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
43
45
  calls.push({ method, url, opts });
44
46
  const r = responder({ method, url, opts });
45
47
  if (r.status && r.status >= 400) {
46
- const err: { response?: { status?: number } } = new Error(`HTTP ${r.status}`);
47
- err.response = { status: r.status };
48
- throw err;
48
+ // ky 形态:HTTPError 携带 response.status(httpMarketClient.isUnconfigured 依赖它)
49
+ throw Object.assign(new Error(`HTTP ${r.status}`), { response: { status: r.status } });
49
50
  }
50
51
  return { json: () => Promise.resolve({ code: 0, data: r.data ?? null }) };
51
52
  };
@@ -61,14 +62,12 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
61
62
  }
62
63
 
63
64
  // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
65
+ // 桥接:占位方法零参签名 ↔ ScopedRequestLike 真实签名,经 any 双向打通(测试桩,无运行时差异)。
64
66
  function useMock(m: ReturnType<typeof mockRequest>) {
65
67
  bindRequest(m.req);
66
- requestImpl.get = m.req.get;
67
- requestImpl.post = m.req.post;
68
- requestImpl.put = m.req.put;
69
- requestImpl.delete = m.req.delete;
70
- requestImpl.patch = m.req.patch;
71
- requestImpl.head = m.req.head;
68
+ const req = m.req as unknown as Record<keyof typeof requestImpl, () => never>;
69
+ for (const k of Object.keys(requestImpl) as Array<keyof typeof requestImpl>)
70
+ requestImpl[k] = req[k];
72
71
  }
73
72
 
74
73
  describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
@@ -101,6 +100,10 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
101
100
  useMock(mock);
102
101
  const r = await httpMarketClient.index();
103
102
  expect(r).toEqual([]);
103
+ // 503 是配置态而非故障:silentError 抑制全局 toast + retry:0 不重试(原
104
+ // market-client-unconfigured 用例的断言并入此处——工厂形态退役后唯一活口)
105
+ expect(mock.calls).toHaveLength(1);
106
+ expect(mock.calls[0].opts).toMatchObject({ silentError: true, retry: 0 });
104
107
  });
105
108
 
106
109
  it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
package/src/index.ts CHANGED
@@ -161,6 +161,12 @@ async function main() {
161
161
  publishCommand(positionals[0] ?? "", { id, key, check });
162
162
  break;
163
163
  }
164
+ case "market": {
165
+ // 本地模拟 CDN 与市场链路(setup / publish / build-index)
166
+ const { runMarket } = await import("./market/command");
167
+ await runMarket(process.argv.slice(3));
168
+ break;
169
+ }
164
170
  default: {
165
171
  if (command && OJ_PASSTHROUGH.has(command)) {
166
172
  const { runOjSubcommand } = await import("./oj-cmd");
package/src/init.ts CHANGED
@@ -98,6 +98,13 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
98
98
  fs.mkdirSync(skillDir, { recursive: true });
99
99
  for (const name of ["SKILL.md", "api-manual.md"])
100
100
  copyIfAbsent(path.join(devkit, name), path.join(skillDir, name), report);
101
+ // v0.1.82 起 devkit 新增两份专题指南(api-manual/SKILL 均引用它们):
102
+ // SQL 构造器能力矩阵/方言差异、oj test L1 测试指南。它们是**增量文档**、
103
+ // 不影响 ojm 运行,故「有则拷、无则记跳过」——bin/devkit 来自用户本地
104
+ // oj 版本(MIN_OJ_VERSION 只钉到 0.1.73),旧版 devkit 没有这两个文件,
105
+ // 为两份文档把整个 init 打挂不值当(用户升 oj 后重跑 init 即补上)。
106
+ for (const name of ["sql-builder-guide.md", "oj-test-guide.md"])
107
+ copyIfPresent(path.join(devkit, name), path.join(skillDir, name), report);
101
108
  copyIfAbsent(path.join(devkit, "global.d.ts"), path.join(destDir, "global.d.ts"), report);
102
109
  }
103
110
  else {
@@ -367,6 +374,21 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
367
374
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
368
375
  }
369
376
 
377
+ /**
378
+ * 同 copyIfAbsent,但**源缺失**时记一条跳过而非抛错。
379
+ *
380
+ * 用于从 bin/devkit 拷第三方版本化内容(两份专题指南):devkit 随用户本地 oj
381
+ * 版本而变,旧版没有新文件属正常版本 skew,不是安装包损坏——缺了只少份文档,
382
+ * 不该让 init 失败。源在而目标已存在仍走 copyIfAbsent 的幂等跳过。
383
+ */
384
+ function copyIfPresent(src: string, dest: string, report: InitReport): void {
385
+ if (!fs.existsSync(src)) {
386
+ report.skipped.push(`${path.basename(src)}(bin/devkit 无此文件,跳过)`);
387
+ return;
388
+ }
389
+ copyIfAbsent(src, dest, report);
390
+ }
391
+
370
392
  /**
371
393
  * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
372
394
  *
@@ -33,7 +33,7 @@ afterEach(() => {
33
33
 
34
34
  /** 预置「已构建」产物:oj build(api/dist/<name>-<version>/)与站点构建(web/dist/modules/) */
35
35
  function makePrebuiltArtifacts(): void {
36
- // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version/desc 与 bundle 级逐一相等,C2-31)
36
+ // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version 与 bundle 级逐一相等,C2-31)
37
37
  const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
38
  mkdirSync(join(apiDist, "ping"), { recursive: true });
39
39
  mkdirSync(join(apiDist, "migrations"), { recursive: true });
@@ -101,9 +101,9 @@ describe("buildBundle · 校验先于打包(fail-fast)", () => {
101
101
  await expect(pack()).rejects.toThrow(/manifest\.yaml/);
102
102
  });
103
103
 
104
- it("c2-31 分层核对:模块级 desc ≠ bundle summary → 拒绝", async () => {
104
+ it("c2-31 分层核对:模块级 desc ≠ bundle summary → 放行(等值要求已移除)", async () => {
105
105
  writeFileSync(join(project, "api/dist/demo-solution-0.1.0/manifest.yaml"), "name: demo-solution\ndesc: 不一致描述\nversion: 0.1.0\n");
106
- await expect(pack()).rejects.toThrow(/C2-31/);
106
+ await expect(pack()).resolves.toMatchObject({ name: "demo-solution" });
107
107
  });
108
108
 
109
109
  it("c2-31 分层核对:api/dist 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {
@@ -2,7 +2,8 @@
2
2
  "menu": {
3
3
  "main": "Lifecycle Sample",
4
4
  "admin": "Sample Admin",
5
- "report": "Sample Report"
5
+ "report": "Sample Report",
6
+ "group": "Sample Group"
6
7
  },
7
8
  "perm": {
8
9
  "view": "View sample",
@@ -2,7 +2,8 @@
2
2
  "menu": {
3
3
  "main": "生命周期样例",
4
4
  "admin": "样例管理",
5
- "report": "样例报告"
5
+ "report": "样例报告",
6
+ "group": "样例分组"
6
7
  },
7
8
  "perm": {
8
9
  "view": "查看样例",
@@ -14,7 +14,7 @@ import { parse } from "yaml";
14
14
  // 跨端引用(双端一致钉):被测实现同仓分居两运行时(计划 §1「两运行时取舍」),
15
15
  // 测试直接互调,比「两套件各读同一夹具」更强——漂移即本文件红。
16
16
  import { preflight } from "../../../templates/api/src/_platform/_lifecycle/steps/preflight";
17
- import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, validateContract } from "../../manifest-contract";
17
+ import { checkLocalesComplete, checkTablesAgainstSchema, groupI18nKeys, parseManifest, validateContract } from "../../manifest-contract";
18
18
 
19
19
  const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
20
20
  const readText = (p: string) => readFileSync(join(FIXTURES, p), "utf8");
@@ -214,6 +214,75 @@ describe("checkLocalesComplete(C2-25:构建期缺 key fail-fast)", () => {
214
214
  });
215
215
  });
216
216
 
217
+ // ── C2-43 归组父标题 i18n(solution menu grouping;纯内容入参)──
218
+
219
+ describe("c2-43 归组父标题 i18n(solution menu grouping)", () => {
220
+ const baseManifest = (): any => ({
221
+ name: "demo-solution",
222
+ version: "0.1.0",
223
+ routes: [
224
+ { path: "/demo-solution", category: "main", order: 200, title_key: "demo-solution:menu.main" },
225
+ ],
226
+ });
227
+ const ZH_FULL = JSON.stringify({ menu: { main: "主", group: "分组" } });
228
+ const EN_FULL = JSON.stringify({ menu: { main: "Main", group: "Group" } });
229
+
230
+ it("groupI18nKeys:声明 main/admin 路由才产一条 <name>:menu.group", () => {
231
+ expect(groupI18nKeys(baseManifest())).toEqual(["demo-solution:menu.group"]);
232
+ });
233
+
234
+ it("groupI18nKeys:多条 main 路由去重只产一条(C5)", () => {
235
+ const m = baseManifest();
236
+ m.routes = [
237
+ { path: "/a", category: "main", order: 200, title_key: "demo-solution:menu.a" },
238
+ { path: "/b", category: "main", order: 210, title_key: "demo-solution:menu.b" },
239
+ ];
240
+ expect(groupI18nKeys(m)).toEqual(["demo-solution:menu.group"]);
241
+ });
242
+
243
+ it("groupI18nKeys:仅 hidden 路由不产 group key(C3)", () => {
244
+ const m = baseManifest();
245
+ m.routes = [{ path: "/x", category: "hidden", order: 210, title_key: "demo-solution:menu.x" }];
246
+ expect(groupI18nKeys(m)).toEqual([]);
247
+ });
248
+
249
+ it("c1 正例:menu.group 双语齐备 → 零违规", () => {
250
+ expect(checkLocalesComplete(baseManifest(), ZH_FULL, EN_FULL)).toEqual([]);
251
+ });
252
+
253
+ it("c2 反例:zh-CN 缺 menu.group → C2-43(非 C2-25,去重生效)", () => {
254
+ const zh = JSON.stringify({ menu: { main: "主" } });
255
+ const v = checkLocalesComplete(baseManifest(), zh, EN_FULL);
256
+ const hit = v.find(x => x.includes("C2-43"));
257
+ expect(hit, `期望 C2-43,实际:\n${v.join("\n")}`).toBeTruthy();
258
+ expect(hit).not.toContain("C2-25");
259
+ expect(hit).toContain("menu.group");
260
+ });
261
+
262
+ it("c4 反例:en-US 缺 menu.group → C2-43 指明 en-US", () => {
263
+ const en = JSON.stringify({ menu: { main: "Main" } });
264
+ const v = checkLocalesComplete(baseManifest(), ZH_FULL, en);
265
+ const hit = v.find(x => x.includes("C2-43"));
266
+ expect(hit).toBeTruthy();
267
+ expect(hit).toContain("en-US");
268
+ });
269
+
270
+ it("去重:多条 main 路由只产一条 group key,缺失时按语言各报一次(非按路由重复)", () => {
271
+ const m = baseManifest();
272
+ m.routes = [
273
+ { path: "/a", category: "main", order: 200, title_key: "demo-solution:menu.a" },
274
+ { path: "/b", category: "main", order: 210, title_key: "demo-solution:menu.b" },
275
+ ];
276
+ const v = checkLocalesComplete(m, JSON.stringify({ menu: { main: "主" } }), JSON.stringify({ menu: { main: "Main" } }));
277
+ const c243 = v.filter(x => x.includes("C2-43"));
278
+ // 关键:2 条路由只产 1 个 group key → 缺失时按语言各报 1 次 = 2 条 C2-43,
279
+ // 而非 2 路由 × 2 语言 = 4 条(去重生效的判据是「< 路由数 × 语言数」)
280
+ expect(c243.length).toBe(2);
281
+ expect(c243.some(x => x.includes("zh-CN 缺少归组父标题 key「demo-solution:menu.group」"))).toBe(true);
282
+ expect(c243.some(x => x.includes("en-US 缺少归组父标题 key「demo-solution:menu.group」"))).toBe(true);
283
+ });
284
+ });
285
+
217
286
  // ── isolate 侧 preflight 复用助手(与 templates/api/tests/lifecycle/preflight.spec.ts 同构)──
218
287
 
219
288
  function makeCtx(manifest: unknown): InstallContext {
@@ -1,5 +1,5 @@
1
1
  // bundle 级 manifest.yaml 契约校验(P1-11,`ojm build --bundle` 构建期执行点)。
2
- // 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33 构建列);
2
+ // 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33 构建列;C2-34~42 为归档签名与信任,见 sign.ts;本文件新增 C2-43);
3
3
  // 期望真源:src/lifecycle/__tests__/fixtures/(fixture-valid 零违规 / fixture-invalid 七码齐报)。
4
4
  // 双端一致:本实现与 isolate 侧 preflight(templates/api/.../steps/preflight.ts)共享同一组夹具,
5
5
  // 正则与规则**逐字一致**(§4.1 矩阵构建列 vs 安装列的执行点差异见各规则注释)——
@@ -581,6 +581,15 @@ export function validateContract(m: unknown): string[] {
581
581
  return v0;
582
582
  }
583
583
 
584
+ /** C2-43 引用面:声明 main/admin 路由的方案须提供归组父标题 `<name>:menu.group`(单 bundle 单 name,去重只产一条) */
585
+ export function groupI18nKeys(m: BundleManifest): string[] {
586
+ if (!isObj(m) || typeof m.name !== "string")
587
+ return [];
588
+ const routes = Array.isArray(m.routes) ? m.routes : [];
589
+ const hasGroupable = routes.some(r => isObj(r) && (r.category === "main" || r.category === "admin"));
590
+ return hasGroupable ? [`${m.name}:menu.group`] : [];
591
+ }
592
+
584
593
  /** 收集 manifest 引用的全部 i18n key(C2-25 locales 齐备检查的引用面;bundle.ts 以此判定 locales 文件是否必查) */
585
594
  export function referencedI18nKeys(m: BundleManifest): string[] {
586
595
  const keys: string[] = [];
@@ -611,6 +620,10 @@ export function referencedI18nKeys(m: BundleManifest): string[] {
611
620
  }
612
621
  }
613
622
  }
623
+ // C2-43:归组父标题纳入引用面,交由 checkLocalesComplete 双语齐备(改报 C2-43)
624
+ for (const k of groupI18nKeys(m)) {
625
+ keys.push(k);
626
+ }
614
627
  return keys;
615
628
  }
616
629
 
@@ -633,9 +646,12 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
633
646
  if (!isObj(m))
634
647
  return [v("C2-25", "manifest 不是键值对象,无法核对 locales 引用")];
635
648
  const locales: [string, Record<string, unknown> | null][] = [["zh-CN", parseJson(zhText)], ["en-US", parseJson(enText)]];
649
+ // group key 识别(C2-43):归组父标题缺失须归因 C2-43 而非笼统 C2-25,避免重复报错
650
+ const groupKeys = new Set(groupI18nKeys(m));
636
651
  const out: string[] = [];
637
652
  for (const key of referencedI18nKeys(m)) {
638
653
  const nsKey = key.includes(":") ? key.slice(key.indexOf(":") + 1) : key;
654
+ const isGroup = groupKeys.has(key);
639
655
  for (const [lang, tree] of locales) {
640
656
  if (tree === null) {
641
657
  out.push(v("C2-25", `${lang} locale 不是合法 JSON 对象,无法核对引用 key`));
@@ -643,7 +659,14 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
643
659
  }
644
660
  const val = lookupKey(tree, nsKey);
645
661
  if (typeof val !== "string" || val === "") {
646
- out.push(v("C2-25", `${lang} 缺少 manifest 引用的 key「${key}」(双语齐备,D4;构建期 fail-fast)`));
662
+ out.push(
663
+ v(
664
+ isGroup ? "C2-43" : "C2-25",
665
+ isGroup
666
+ ? `${lang} 缺少归组父标题 key「${key}」(须为嵌套 menu.group,C2-43;声明 main/admin 路由即须提供)`
667
+ : `${lang} 缺少 manifest 引用的 key「${key}」(双语齐备,D4;构建期 fail-fast)`,
668
+ ),
669
+ );
647
670
  }
648
671
  }
649
672
  }