@oj-module/cli 0.2.14 → 0.2.17

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.
@@ -0,0 +1,102 @@
1
+ import type { DerivedRoute, ModuleDefinitionLike } from "../manifest-routes-derive";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { deriveManifestRoutes, reconcileManifestRoutes } from "../manifest-routes-derive";
5
+
6
+ /**
7
+ * BDD:R5 manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源,避免手工双写漂移)。
8
+ * - d-1:三类(main/admin/hidden)路由均派生,字段口径正确;
9
+ * - d-2:无 handle.category 的路由(框架/纯页面/动态段子页)跳过,不进 manifest;
10
+ * - d-3:既有 manifest routes 与派生结果漂移时,reconcile 以 entry.ts 为准并发警告(不阻断)。
11
+ */
12
+
13
+ function def(routes: ModuleDefinitionLike["routes"]): ModuleDefinitionLike {
14
+ return { name: "wiki", routes };
15
+ }
16
+
17
+ describe("deriveManifestRoutes(entry.ts → manifest routes)", () => {
18
+ it("d-1 三类 category 均派生,字段口径正确(path/category/order/title_key)", () => {
19
+ const d = def([
20
+ { path: "/space", handle: { category: "main", order: 200, title: "wiki:menu.space" } },
21
+ { path: "/admin", handle: { category: "admin", order: 220, title: "wiki:menu.admin" } },
22
+ { path: "/wiki/status", handle: { category: "hidden", order: 201, title: "wiki:menu.status" } },
23
+ ]);
24
+ const out = deriveManifestRoutes(d);
25
+ expect(out).toEqual([
26
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
27
+ { path: "/admin", category: "admin", order: 220, title_key: "wiki:menu.admin" },
28
+ { path: "/wiki/status", category: "hidden", order: 201, title_key: "wiki:menu.status" },
29
+ ]);
30
+ });
31
+
32
+ it("d-2 无 category 的路由跳过:框架页/动态段子页/纯组件页不进 manifest", () => {
33
+ const d = def([
34
+ { path: "/space", handle: { category: "main", order: 200, title: "wiki:menu.space" } },
35
+ // 动态段子页(空间成员页):不进菜单,无 category → 跳过
36
+ { path: ":id/members", handle: { title: "wiki:menu.members", hideInMenu: true } },
37
+ // 框架/纯页面路由:无 category → 跳过
38
+ { path: "/dashboard", handle: { title: "framework:menu.dashboard" } },
39
+ ]);
40
+ const out = deriveManifestRoutes(d);
41
+ expect(out).toEqual([
42
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
43
+ ]);
44
+ });
45
+
46
+ it("d-2b 缺 order 的 category 路由仍派生(order 省略,交 C2-17 契约校验报必填)", () => {
47
+ const d = def([
48
+ { path: "/space", handle: { category: "main", title: "wiki:menu.space" } },
49
+ ]);
50
+ const out = deriveManifestRoutes(d);
51
+ expect(out).toEqual([{ path: "/space", category: "main", title_key: "wiki:menu.space" }]);
52
+ });
53
+
54
+ it("d-2c routes 缺失/空 → 派生为空数组(不抛)", () => {
55
+ expect(deriveManifestRoutes(def(undefined))).toEqual([]);
56
+ expect(deriveManifestRoutes(def([]))).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("reconcileManifestRoutes(entry.ts 为准 + 漂移警告)", () => {
61
+ it("d-3a 既有 routes 与派生一致:不警告,采用派生", () => {
62
+ const existing: DerivedRoute[] = [
63
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
64
+ ];
65
+ const derived: DerivedRoute[] = [
66
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
67
+ ];
68
+ const { routes, warnings } = reconcileManifestRoutes(existing, derived);
69
+ expect(routes).toEqual(derived);
70
+ expect(warnings).toEqual([]);
71
+ });
72
+
73
+ it("d-3b 既有 routes 与派生漂移:以派生为准并发警告(不阻断)", () => {
74
+ const existing: DerivedRoute[] = [
75
+ // 旧 manifest 手写的 order 与 category 与 entry.ts 不一致
76
+ { path: "/space", category: "admin", order: 999, title_key: "wiki:menu.space" },
77
+ ];
78
+ const derived: DerivedRoute[] = [
79
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
80
+ ];
81
+ const { routes, warnings } = reconcileManifestRoutes(existing, derived);
82
+ expect(routes).toEqual(derived); // 以 entry.ts 为准
83
+ expect(warnings).toHaveLength(1);
84
+ expect(warnings[0]).toContain("/space");
85
+ expect(warnings[0]).toContain("entry.ts 为准");
86
+ });
87
+
88
+ it("d-3c 派生为空(未迁移方案未声明 category):保留既有 routes,不覆盖", () => {
89
+ const existing: DerivedRoute[] = [
90
+ { path: "/space", category: "main", order: 200, title_key: "wiki:menu.space" },
91
+ ];
92
+ const { routes, warnings } = reconcileManifestRoutes(existing, []);
93
+ expect(routes).toEqual(existing); // 沿用既有,向后兼容
94
+ expect(warnings).toEqual([]);
95
+ });
96
+
97
+ it("d-3d 既有为空且派生为空:结果为空,无警告", () => {
98
+ const { routes, warnings } = reconcileManifestRoutes([], []);
99
+ expect(routes).toEqual([]);
100
+ expect(warnings).toEqual([]);
101
+ });
102
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getCommandHelp, resolveHelpRequest } from "../usage";
3
+
4
+ describe("getCommandHelp", () => {
5
+ it("返回命令级帮助(init)", () => {
6
+ const t = getCommandHelp("init");
7
+ expect(t).toBeDefined();
8
+ expect(t).toContain("ojm init");
9
+ });
10
+
11
+ it("子命令优先:market setup 命中专属条目", () => {
12
+ expect(getCommandHelp("market", "setup")).toContain("ojm market setup");
13
+ });
14
+
15
+ it("子命令无专属条目时回退到命令级(market 无 sub)", () => {
16
+ const t = getCommandHelp("market");
17
+ expect(t).toContain("ojm market");
18
+ });
19
+
20
+ it("trust 子命令(add/revoke/confirm/ls)各自有专属条目", () => {
21
+ for (const sub of ["ls", "add", "revoke", "confirm"]) {
22
+ const t = getCommandHelp("trust", sub);
23
+ expect(t, `trust ${sub}`).toBeDefined();
24
+ expect(t).toContain(`ojm trust ${sub}`);
25
+ }
26
+ });
27
+
28
+ it("未知命令返回 undefined", () => {
29
+ expect(getCommandHelp("nope")).toBeUndefined();
30
+ });
31
+ });
32
+
33
+ describe("resolveHelpRequest", () => {
34
+ it("ojm --help → 顶层(无 cmd)", () => {
35
+ expect(resolveHelpRequest(["--help"])).toEqual({ cmd: undefined, sub: undefined });
36
+ });
37
+
38
+ it("ojm -h → 顶层(无 cmd)", () => {
39
+ expect(resolveHelpRequest(["-h"])).toEqual({ cmd: undefined, sub: undefined });
40
+ });
41
+
42
+ it("ojm init --help → cmd=init", () => {
43
+ expect(resolveHelpRequest(["init", "--help"])).toEqual({ cmd: "init", sub: undefined });
44
+ });
45
+
46
+ it("ojm trust add --help → cmd=trust sub=add", () => {
47
+ expect(resolveHelpRequest(["trust", "add", "--help"])).toEqual({ cmd: "trust", sub: "add" });
48
+ });
49
+
50
+ it("ojm market setup --help → cmd=market sub=setup", () => {
51
+ expect(resolveHelpRequest(["market", "setup", "--help"])).toEqual({ cmd: "market", sub: "setup" });
52
+ });
53
+
54
+ it("ojm help init → cmd=init", () => {
55
+ expect(resolveHelpRequest(["help", "init"])).toEqual({ cmd: "init", sub: undefined });
56
+ });
57
+
58
+ it("ojm help trust add → cmd=trust sub=add", () => {
59
+ expect(resolveHelpRequest(["help", "trust", "add"])).toEqual({ cmd: "trust", sub: "add" });
60
+ });
61
+
62
+ it("ojm help → 顶层(无 cmd)", () => {
63
+ expect(resolveHelpRequest(["help"])).toEqual({ cmd: undefined, sub: undefined });
64
+ });
65
+
66
+ it("非帮助调用返回 null(ojm build)", () => {
67
+ expect(resolveHelpRequest(["build", "--bundle"])).toBeNull();
68
+ });
69
+ });
package/src/build.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Plugin as EsbuildPlugin } from "esbuild";
2
2
  import type { Plugin } from "vite";
3
+ import type { RouteDefinitionLike } from "./manifest-routes-derive";
3
4
  import { execFileSync } from "node:child_process";
4
5
  import crypto from "node:crypto";
5
6
  import fs from "node:fs";
@@ -12,6 +13,7 @@ import { build } from "vite";
12
13
  import { loadModulesConfig, resolveModuleEntry } from "./config";
13
14
  import { resolveConfigPath, resolveLayout } from "./layout";
14
15
  import { isSharedDep, SHARED_DEPS } from "./shared-deps";
16
+ import { normalizeSiteBase, rewriteIndexHtml } from "./site-base";
15
17
  import { resolveOjBin } from "./vendor";
16
18
  import { checkSharedVersions, resolveShellDist } from "./versions";
17
19
 
@@ -138,6 +140,9 @@ export const mapTree = _fn;
138
140
  export const getAllExpandedKeys = _fn;
139
141
  export const getAppInfo = _fn;
140
142
  export const getRedirectPath = _fn;
143
+ // 站点部署 base(子路径部署):stub 只需占位,元数据读取期不拼 URL
144
+ export const resolveSiteBase = _fn;
145
+ export const withSiteBase = _fn;
141
146
  export const getYesNoOptions = _fn;
142
147
  export const getBooleanOptions = _fn;
143
148
  // 模块契约
@@ -387,6 +392,9 @@ export async function readModuleDefinition(entryFile: string, projectRoot: strin
387
392
  version: string
388
393
  peerRuntime?: string
389
394
  config?: { dependencies?: string[] }
395
+ // R5:entry.ts 声明的路由树(含 handle.category/order/title),
396
+ // buildBundle 据此派生 manifest.routes(单一真源,避免手工双写漂移)
397
+ routes?: RouteDefinitionLike[]
390
398
  };
391
399
  }
392
400
  finally {
@@ -528,12 +536,22 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
528
536
  * 再由模块构建写入 modules.json 与 modules/。清场防旧哈希资产无限累积
529
537
  * (shell 每次重构建生成新哈希文件名);devServer 热重建路径不合并——
530
538
  * dev 的 / 与 /assets/* 直接服务 shell dist,合并产物无人消费。
539
+ *
540
+ * siteBase 非空时(站点挂载在 `/oj` 这类子路径)改写 index.html 的站内绝对
541
+ * URL 并注入 `window.__OJ_BASE__`(见 site-base.ts);为空则产物与既有行为
542
+ * 逐字节一致。
531
543
  */
532
- function mergeShellSite(distDir: string): void {
544
+ function mergeShellSite(distDir: string, siteBase: string): void {
533
545
  const shellDist = resolveShellDist();
534
546
  fs.rmSync(distDir, { recursive: true, force: true });
535
547
  fs.mkdirSync(path.dirname(distDir), { recursive: true });
536
548
  fs.cpSync(shellDist, distDir, { recursive: true });
549
+
550
+ const indexPath = path.join(distDir, "index.html");
551
+ if (normalizeSiteBase(siteBase) !== "/") {
552
+ fs.writeFileSync(indexPath, rewriteIndexHtml(fs.readFileSync(indexPath, "utf-8"), siteBase));
553
+ console.log(`[ojm] 站点 index.html 已按 siteBase=${normalizeSiteBase(siteBase)} 重写(子路径部署)`);
554
+ }
537
555
  console.log(`[ojm] 已合并宿主站点 → ${distDir}`);
538
556
  }
539
557
 
@@ -557,7 +575,7 @@ export async function buildModules(
557
575
  const baseUrl = config.baseUrl ?? "";
558
576
 
559
577
  if (opts.mergeSite)
560
- mergeShellSite(outDir);
578
+ mergeShellSite(outDir, config.siteBase ?? "");
561
579
 
562
580
  // P4.5 / C4 / D12:构建前先过版本矩阵门禁,版本漂移直接拒绝
563
581
  checkSharedVersions(projectRoot, resolveShellDist());
package/src/bundle.ts CHANGED
@@ -10,8 +10,11 @@ import fs from "node:fs";
10
10
  import path from "node:path";
11
11
 
12
12
  import { parse } from "yaml";
13
+ import { readModuleDefinition } from "./build";
13
14
  import { createDeterministicTgz } from "./deterministic-tar";
15
+ import { resolveLayout } from "./layout";
14
16
  import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
17
+ import { deriveManifestRoutes, reconcileManifestRoutes } from "./manifest-routes-derive";
15
18
 
16
19
  export interface BundleBuildResult {
17
20
  tgz: string
@@ -37,11 +40,34 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
37
40
  throw new Error(`[ojm] 方案包构建失败:${parsed.error}`);
38
41
  }
39
42
  const m = parsed.manifest;
43
+ const name = m.name as string;
44
+
45
+ // R5:manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源),覆盖手工书写。
46
+ // 仅当 entry.ts 声明了 category 路由才覆盖;否则保留 manifest 既有 routes(未迁移方案兼容)。
47
+ // entry 读不出来(如第三依赖未装)时软降级:沿用既有 routes,不阻断打包。
48
+ const layout = resolveLayout(projectRoot);
49
+ const entryFile = path.join(layout.modulesSrc, name, "entry.ts");
50
+ if (fs.existsSync(entryFile)) {
51
+ try {
52
+ const def = await readModuleDefinition(entryFile, projectRoot);
53
+ const derived = deriveManifestRoutes(def);
54
+ const { routes, warnings } = reconcileManifestRoutes(
55
+ Array.isArray(m.routes) ? m.routes : [],
56
+ derived,
57
+ );
58
+ for (const w of warnings)
59
+ console.warn(w);
60
+ m.routes = routes;
61
+ }
62
+ catch (e) {
63
+ console.warn(`[ojm] ⚠️ 无法从 entry.ts 派生 routes(${e instanceof Error ? e.message : String(e)}),沿用 manifest.yaml 既有 routes`);
64
+ }
65
+ }
66
+
40
67
  const violations = validateContract(m);
41
68
  if (violations.length > 0) {
42
69
  throw new Error(`[ojm] 方案包构建失败:manifest.yaml 契约校验未通过(${violations.length} 项违规,全量列出):\n${violations.map(x => `- ${x}`).join("\n")}`);
43
70
  }
44
- const name = m.name as string;
45
71
  const version = m.version as string;
46
72
 
47
73
  // 2) 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
package/src/config.ts CHANGED
@@ -12,6 +12,19 @@ export interface ModuleConfigEntry {
12
12
  export interface ModulesConfig {
13
13
  /** 产物 URL 前缀,留空表示同源相对路径 */
14
14
  baseUrl?: string
15
+ /**
16
+ * 站点部署子路径(如 `"/oj"`),留空 = 挂在域名根。
17
+ *
18
+ * 站点被 oj 挂到 `mounts: [{ prefix: /oj, web: dist, spa: true }]` 这类非根
19
+ * 前缀时**必须**配它:`ojm build` 会把全站 index.html 的站内绝对 URL
20
+ * (importmap / `<script src>` / `<link href>`)改写为 `/oj/...`,并注入
21
+ * `window.__OJ_BASE__` 供路由 basename 与模块资源解析取用。
22
+ * 不配则浏览器去域名根取 `/assets/*` → 404 白屏,深链刷新也匹配不到路由。
23
+ *
24
+ * 与 `baseUrl` 的区别:`baseUrl` 是**模块产物**的 URL 前缀(可指向 CDN
25
+ * 绝对域),`siteBase` 是**站点自身**的部署位置;两者正交,可同时使用。
26
+ */
27
+ siteBase?: string
15
28
  modules: ModuleConfigEntry[]
16
29
  /**
17
30
  * 站点 HTML 变换模块(可选):相对工程根的路径(`./html-transform.ts`)
@@ -24,8 +37,9 @@ export interface ModulesConfig {
24
37
  htmlTransform?: string
25
38
  }
26
39
 
27
- const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl">> = {
40
+ const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl" | "siteBase">> = {
28
41
  baseUrl: "",
42
+ siteBase: "",
29
43
  };
30
44
 
31
45
  /**
@@ -34,7 +48,7 @@ const DEFAULT_CONFIG: Required<Pick<ModulesConfig, "baseUrl">> = {
34
48
  * 用 tsx 真实 import,而不是正则解析——这样配置文件里可以写注释、
35
49
  * 用变量、做条件判断,且类型错误会直接暴露(B10 的正则方案做不到)。
36
50
  */
37
- export async function loadModulesConfig(projectRoot: string): Promise<ModulesConfig & { baseUrl: string }> {
51
+ export async function loadModulesConfig(projectRoot: string): Promise<ModulesConfig & { baseUrl: string, siteBase: string }> {
38
52
  const configPath = path.join(projectRoot, "web.config.ts");
39
53
 
40
54
  let mod: { default?: ModulesConfig };
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { devServer } from "./dev";
8
8
  import { mergeManifests, printInfo } from "./info";
9
9
  import { initProject } from "./init";
10
10
  import { runPreview } from "./preview";
11
- import { usageText } from "./usage";
11
+ import { getCommandHelp, resolveHelpRequest, usageText } from "./usage";
12
12
  import { vendorCommand } from "./vendor";
13
13
  import { readCliVersion } from "./versions";
14
14
 
@@ -25,6 +25,23 @@ function usage(): never {
25
25
  async function main() {
26
26
  const projectRoot = process.cwd();
27
27
 
28
+ // 帮助请求:`ojm <cmd> --help` / `ojm help <cmd>` / 顶层 `ojm --help`
29
+ // 必须在 switch 之前拦截,否则会真正执行子命令(如 `ojm market setup --help` 会去建 CDN)。
30
+ const helpReq = resolveHelpRequest();
31
+ if (helpReq !== null) {
32
+ if (!helpReq.cmd) {
33
+ console.log(usageText());
34
+ return;
35
+ }
36
+ const text = getCommandHelp(helpReq.cmd, helpReq.sub);
37
+ if (text) {
38
+ console.log(text);
39
+ return;
40
+ }
41
+ console.error(`[ojm] 未知命令:${helpReq.cmd}${helpReq.sub ? ` ${helpReq.sub}` : ""}`);
42
+ process.exit(1);
43
+ }
44
+
28
45
  // 顶层 --version / -V:打印 cli 包版本(与 `ojm info` 的 cli 版本同源)
29
46
  if (command === "--version" || command === "-V") {
30
47
  console.log(readCliVersion());
@@ -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
  }
@@ -0,0 +1,130 @@
1
+ // R5:manifest.yaml `routes` 由 entry.ts 路由 handle 自动派生(单一真源)。
2
+ //
3
+ // 背景:0.2.15 时期 manifest.routes 由作者手工书写,且 runtime 分组还依赖
4
+ // `sys_solution_route` 表行(dev 下该表空 → 不分组,见上游 wiki parity 方案 R1–R6)。
5
+ // 0.2.16 起:category/order/title 的真源是 entry.ts 路由 handle,manifest.routes
6
+ // 改为 build 期从 handle 派生,杜绝「entry.ts 改了 handle 但 manifest.yaml 没同步」的漂移。
7
+ //
8
+ // 派生口径(仅取顶层一级菜单路由;children 是动态段/子页,不进 manifest routes):
9
+ // - handle.category ∈ {main,admin,hidden} 才纳入(无 category 的路由不进菜单,跳过)
10
+ // - path ← route.path
11
+ // - category ← handle.category
12
+ // - order ← handle.order(缺省不写,交 C2-17 契约校验报「必填整数」)
13
+ // - title_key ← handle.title(main/admin 必填且须匹配 <name>:menu.*,契约校验)
14
+ //
15
+ // 兼容:entry.ts 未声明任何 category 路由时(迁移前的旧方案),调用方保留 manifest
16
+ // 既有 routes,不强制覆盖——避免未迁移方案构建即坏。
17
+
18
+ /** entry.ts 路由树节点(派生只读字段,宽松结构,避免 import runtime 类型形成环) */
19
+ export interface RouteDefinitionLike {
20
+ path?: string
21
+ handle?: {
22
+ category?: "main" | "admin" | "hidden"
23
+ order?: number
24
+ title?: string
25
+ [key: string]: unknown
26
+ }
27
+ children?: RouteDefinitionLike[]
28
+ [key: string]: unknown
29
+ }
30
+
31
+ /** 派生出的 manifest route 契约项(字段口径见 C2-17) */
32
+ export interface DerivedRoute {
33
+ path: string
34
+ category: "main" | "admin" | "hidden"
35
+ order?: number
36
+ title_key?: string
37
+ }
38
+
39
+ /** 模块定义(只需 name + routes 子集,宽松结构) */
40
+ export interface ModuleDefinitionLike {
41
+ name: string
42
+ routes?: RouteDefinitionLike[]
43
+ [key: string]: unknown
44
+ }
45
+
46
+ /**
47
+ * 从 entry.ts 模块定义派生 manifest routes(仅顶层一级菜单路由)。
48
+ * 不抛异常:handle 缺字段时照常产出,交由 C2-17 契约校验报出具体违规项。
49
+ */
50
+ export function deriveManifestRoutes(definition: ModuleDefinitionLike): DerivedRoute[] {
51
+ const routes = Array.isArray(definition.routes) ? definition.routes : [];
52
+ const out: DerivedRoute[] = [];
53
+ for (const r of routes) {
54
+ if (r === null || typeof r !== "object")
55
+ continue;
56
+ const handle = (typeof r.handle === "object" && r.handle !== null) ? r.handle : undefined;
57
+ const category = handle?.category;
58
+ if (category !== "main" && category !== "admin" && category !== "hidden")
59
+ continue;
60
+ if (typeof r.path !== "string" || r.path.length === 0)
61
+ continue;
62
+ const route: DerivedRoute = { path: r.path, category };
63
+ if (typeof handle?.order === "number")
64
+ route.order = handle.order;
65
+ if (typeof handle?.title === "string")
66
+ route.title_key = handle.title;
67
+ out.push(route);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * 归并既有 manifest routes 与 entry.ts 派生 routes,返回最终采用项 + 警告。
74
+ *
75
+ * 规则(单一真源 = entry.ts):
76
+ * - derived 为空(entry.ts 未声明任何 category 路由)→ 沿用 existing,不覆盖(向后兼容)
77
+ * - derived 非空 → 以 derived 为准;若 existing 非空且与 derived 在某 path 上漂移
78
+ * (category/order/title_key 任一不一致)→ 追加警告(不阻断,R5 选择项「自动生成」而非「双写+校验」)
79
+ */
80
+ export function reconcileManifestRoutes(
81
+ existing: unknown[],
82
+ derived: DerivedRoute[],
83
+ ): { routes: DerivedRoute[], warnings: string[] } {
84
+ const warnings: string[] = [];
85
+ if (derived.length === 0) {
86
+ // 未迁移方案:保留 manifest 既有 routes,等作者后续在 entry.ts 登记 category
87
+ return { routes: normalizeExisting(existing), warnings };
88
+ }
89
+ const existingByPath = new Map<string, DerivedRoute>();
90
+ for (const r of normalizeExisting(existing)) {
91
+ existingByPath.set(r.path, r);
92
+ }
93
+ const drift: DerivedRoute[] = [];
94
+ for (const d of derived) {
95
+ const e = existingByPath.get(d.path);
96
+ if (e === undefined)
97
+ continue;
98
+ if (e.category !== d.category || e.order !== d.order || e.title_key !== d.title_key) {
99
+ drift.push(d);
100
+ }
101
+ }
102
+ if (existing.length > 0 && drift.length > 0) {
103
+ warnings.push(
104
+ `[ojm] manifest.yaml 既有 routes 与 entry.ts 派生结果不一致,已采用 entry.ts 为准(R5 单一真源,避免手工双写漂移):\n${
105
+ drift.map(d => `- ${d.path}(派生 category=${d.category} order=${d.order ?? "?"} title_key=${d.title_key ?? "?"})`).join("\n")}`,
106
+ );
107
+ }
108
+ return { routes: derived, warnings };
109
+ }
110
+
111
+ function normalizeExisting(existing: unknown[]): DerivedRoute[] {
112
+ const out: DerivedRoute[] = [];
113
+ for (const r of existing) {
114
+ if (r === null || typeof r !== "object")
115
+ continue;
116
+ const o = r as Record<string, unknown>;
117
+ if (typeof o.path !== "string")
118
+ continue;
119
+ const cat = o.category;
120
+ if (cat !== "main" && cat !== "admin" && cat !== "hidden")
121
+ continue;
122
+ const route: DerivedRoute = { path: o.path, category: cat };
123
+ if (typeof o.order === "number")
124
+ route.order = o.order;
125
+ if (typeof o.title_key === "string")
126
+ route.title_key = o.title_key;
127
+ out.push(route);
128
+ }
129
+ return out;
130
+ }