@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
package/src/layout.ts CHANGED
@@ -52,3 +52,34 @@ export function resolveLayout(projectRoot: string): ProjectLayout {
52
52
  export function resolveWatchTarget(projectRoot: string): string {
53
53
  return resolveLayout(projectRoot).watchTarget;
54
54
  }
55
+
56
+ /**
57
+ * 定位工程 config.yaml。
58
+ *
59
+ * 唯一规范位置:`<project>/api/config.yaml`。
60
+ *
61
+ * 不能挪到与 api/ 同级的 config/ 目录:oj 的模块解析把「项目根」锚定在
62
+ * **config 文件所在目录**,且相对 import 被钳制在该根内(devkit 手册第 5 章)。
63
+ * config 一进 `config/` 子目录,`api/src` 就整体落到项目根之外,凡是带 `../`
64
+ * 相对 import 的 handler(模板的 system/auth/_platform 依赖 `_shared/`)在
65
+ * `ojm dev` 下会被判 `escapes project root` 而整条路由加载失败
66
+ * (实测:26 个 api 文件 → 只剩 8 个,18 条路由被跳过)。
67
+ */
68
+ export function resolveConfigPath(projectRoot: string): string {
69
+ return path.join(projectRoot, "api", "config.yaml");
70
+ }
71
+
72
+ /**
73
+ * 证书与密钥的唯一落点:`<project>/api/config/`(config.yaml 同级的 config/ 子目录)。
74
+ *
75
+ * 三个入口都指向这里,别再各自手拼:
76
+ * ① `server.public_key_path` → ./config/public.pem(oj 验签公钥)
77
+ * ② `server.certificate_path` → ./config/cert.jws(oj 拒启闸门)
78
+ * ③ `secrets.private/public_key_path` → ./config/secrets-{private,public}.pem(jwt_secret 密封密钥对)
79
+ * 另加签发私钥 `private.pem`(config 不引用,仅 mintDevCert 产出)。
80
+ *
81
+ * 由 config 位置派生,config 若挪位这里自动跟随——不需要第二处硬编码。
82
+ */
83
+ export function resolveCertDir(projectRoot: string): string {
84
+ return path.join(path.dirname(resolveConfigPath(projectRoot)), "config");
85
+ }
@@ -0,0 +1,109 @@
1
+ // BDD:`ojm keygen` / `ojm sign`(P2-4 作者工具链,Ed25519 自备版 stopgap)。
2
+ // 上游 PR 合入 `oj secret keygen --alg ed25519` 后,这两个命令将收敛为壳调用(计划台账已记)。
3
+ // 私钥红线 / 命名空间 / 离线自检 / detached .sig 五字段为本次覆盖重点。
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+ import { DEFAULT_KEY_ID, isValidKeyId, keygenCommand, parseKeygenArgs } from "../../keygen";
9
+ import { parseSignArgs, signCommand } from "../../sign";
10
+ import { readPrivateKeyPem } from "../keygen";
11
+ import { fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
12
+
13
+ const tmp = path.join(os.tmpdir(), `ojm-authoring-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+
15
+ beforeEach(() => {
16
+ fs.mkdirSync(tmp, { recursive: true });
17
+ });
18
+ afterEach(() => {
19
+ fs.rmSync(tmp, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("ojm keygen 参数解析 + key_id 命名空间(C2-37)", () => {
23
+ it("parseKeygenArgs:--id/--out 值式消费 + --yes 独立", () => {
24
+ expect(parseKeygenArgs(["--id", "acme.market", "--out", "./k", "--yes"]))
25
+ .toEqual({ id: "acme.market", out: "./k", yes: true });
26
+ });
27
+ it("isValidKeyId:<publisher>.<id> 合法 / 裸名·大写·空非法", () => {
28
+ expect(isValidKeyId("acme.market")).toBe(true);
29
+ expect(isValidKeyId("acme.dev.key-1")).toBe(true);
30
+ expect(isValidKeyId("local")).toBe(false);
31
+ expect(isValidKeyId("Acme.Market")).toBe(false);
32
+ expect(isValidKeyId("")).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("ojm keygen 命令体(Ed25519 落盘 + 私钥红线)", () => {
37
+ it("缺省 key_id=local.dev,五产物齐全且私钥 0600、公钥 64hex、指纹一致", () => {
38
+ const r = keygenCommand({ cwd: tmp });
39
+ expect(r.keyId).toBe(DEFAULT_KEY_ID);
40
+ expect(r.publicKeyHex).toMatch(/^[0-9a-f]{64}$/);
41
+ expect(r.fingerprint).toBe(fingerprintOfPublicKey(r.publicKeyHex));
42
+ expect(fs.statSync(r.privateKeyPath).mode & 0o777).toBe(0o600);
43
+ expect(fs.readFileSync(r.publicKeyPath, "utf8").trim()).toBe(r.publicKeyHex);
44
+ });
45
+
46
+ it("落盘私钥可还原并用于签验(证明是 Ed25519 而非 X25519)", () => {
47
+ const r = keygenCommand({ cwd: tmp });
48
+ const pem = readPrivateKeyPem(r.privateKeyPath);
49
+ const canon = `demo@0.1.0:${"0".repeat(64)}`;
50
+ const sig = signCanonicalLocal(pem, canon);
51
+ expect(verifyCanonical(r.publicKeyHex, canon, sig)).toBe(true);
52
+ });
53
+
54
+ it("工程树内落盘无 --yes → 抛「私钥入仓」拒绝", () => {
55
+ expect(() => keygenCommand({ out: "./keys", cwd: tmp })).toThrow(/私钥入仓/);
56
+ });
57
+ it("工程树内落盘带 --yes → 放行", () => {
58
+ const r = keygenCommand({ out: "./keys", yes: true, cwd: tmp });
59
+ expect(fs.existsSync(r.privateKeyPath)).toBe(true);
60
+ });
61
+ it("非法 key_id 命名空间 → 直接拒(C2-37)", () => {
62
+ expect(() => keygenCommand({ id: "local", cwd: tmp })).toThrow(/key_id 命名空间非法/);
63
+ });
64
+ });
65
+
66
+ describe("ojm sign 参数解析", () => {
67
+ it("parseSignArgs:位置参数 + --id/--key 值式消费 + --check 独立", () => {
68
+ expect(parseSignArgs(["pkg.tgz", "--id", "acme.market", "--key", "k.pem", "--check"]))
69
+ .toEqual({ positionals: ["pkg.tgz"], id: "acme.market", key: "k.pem", check: true });
70
+ });
71
+ });
72
+
73
+ describe("ojm sign 命令体(detached .sig,G4 载体)", () => {
74
+ const TGZ = path.join(tmp, "demo-solution-0.1.0.tgz");
75
+
76
+ beforeEach(() => {
77
+ fs.writeFileSync(TGZ, "demo-archive-bytes\n");
78
+ });
79
+
80
+ it("签名产出 .sig 五字段(alg/key_id/public_key/value/canon),且 verifyCanonical 通过", () => {
81
+ const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
82
+ const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath });
83
+ const sig = JSON.parse(fs.readFileSync(r.sigPath, "utf8"));
84
+ expect(sig.alg).toBe("Ed25519");
85
+ expect(sig.key_id).toBe("acme.market");
86
+ expect(sig.public_key).toBe(kp.publicKeyHex);
87
+ expect(sig.canon).toBe("v1");
88
+ expect(verifyCanonical(sig.public_key, r.canonical, sig.value)).toBe(true);
89
+ });
90
+
91
+ it("--check 离线自检:真签名通过(checkPassed=true);--key+--id 自定义位置", () => {
92
+ const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
93
+ const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, check: true });
94
+ expect(r.checkPassed).toBe(true);
95
+ });
96
+
97
+ it("缺 --id 且未给 --key → 拒绝并指引先 keygen", () => {
98
+ expect(() => signCommand(TGZ, {})).toThrow(/缺少签名身份/);
99
+ });
100
+ it("非法 key_id 命名空间 → 拒绝(C2-37)", () => {
101
+ const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
102
+ expect(() => signCommand(TGZ, { id: "local", key: kp.privateKeyPath })).toThrow(/key_id 命名空间非法/);
103
+ });
104
+ it("非 .tgz 目标 → 拒绝", () => {
105
+ const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
106
+ expect(() => signCommand(path.join(tmp, "x.zip"), { id: "acme.market", key: kp.privateKeyPath }))
107
+ .toThrow(/\.tgz/);
108
+ });
109
+ });
@@ -0,0 +1,150 @@
1
+ // BDD:`ojm build --bundle` 方案包组装(P1-11)。
2
+ // 组装契约(对齐 tgz 内布局,契约 §2.9 + P1-9 staging copy 源定版):
3
+ // <root>/manifest.yaml ← 必填,契约校验 fail-fast 先于打包
4
+ // api/dist/<name>-<version>/ ← oj build 产物原样 → tgz 内 api/<name>-<version>/(C2-31 分层核对)
5
+ // web/dist/modules/ ← 站点构建模块产物 → tgz 内 web/modules/(web.entry 存在性复核)
6
+ // 禁携 modules.json(C2-31 红线);产物确定性(C2-32)由 deterministic-tar 承担,此处断言端到端复现。
7
+ import { execFileSync } from "node:child_process";
8
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
+ import { parse, stringify } from "yaml";
15
+ import { buildBundle } from "../../bundle";
16
+
17
+ const FIXTURE_VALID = join(dirname(fileURLToPath(import.meta.url)), "fixtures/fixture-valid");
18
+
19
+ let project: string;
20
+ let out: string;
21
+
22
+ beforeEach(() => {
23
+ project = mkdtempSync(join(tmpdir(), "ojm-bundle-proj-"));
24
+ out = mkdtempSync(join(tmpdir(), "ojm-bundle-out-"));
25
+ cpSync(FIXTURE_VALID, project, { recursive: true });
26
+ makePrebuiltArtifacts();
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(project, { recursive: true, force: true });
31
+ rmSync(out, { recursive: true, force: true });
32
+ });
33
+
34
+ /** 预置「已构建」产物:oj build(api/dist/<name>-<version>/)与站点构建(web/dist/modules/) */
35
+ function makePrebuiltArtifacts(): void {
36
+ // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version/desc 与 bundle 级逐一相等,C2-31)
37
+ const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
+ mkdirSync(join(apiDist, "ping"), { recursive: true });
39
+ mkdirSync(join(apiDist, "migrations"), { recursive: true });
40
+ writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - note\n");
41
+ writeFileSync(join(apiDist, "schema.yaml"), "tables:\n note: {}\n");
42
+ writeFileSync(join(apiDist, "routes.js"), "export {};\n");
43
+ writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
44
+ // 站点构建产物(web/dist/modules/<name>/<version>/entry.js + css,对应 web.entry/css 声明)
45
+ const webDist = join(project, "web/dist/modules/demo-solution/0.1.0");
46
+ mkdirSync(webDist, { recursive: true });
47
+ writeFileSync(join(webDist, "entry.js"), "export default {};\n");
48
+ writeFileSync(join(webDist, "demo-solution.css"), ".demo{}\n");
49
+ }
50
+
51
+ const pack = () => buildBundle(project, { out });
52
+
53
+ describe("buildBundle · 正路径", () => {
54
+ it("产出 out/<name>-<version>.tgz + 64 位 sha256,布局单根三分(manifest/api/web)", async () => {
55
+ const r = await pack();
56
+ expect(r.tgz).toBe(join(out, "demo-solution-0.1.0.tgz"));
57
+ expect(existsSync(r.tgz)).toBe(true);
58
+ expect(r.sha256).toMatch(/^[a-f0-9]{64}$/);
59
+ const names = tarList(r.tgz);
60
+ // 顶层目录成员(仅一个斜杠且在末尾)恰为单根,且居首(字典序)
61
+ expect(names.filter(n => n.endsWith("/") && n.indexOf("/") === n.length - 1)).toEqual(["demo-solution-0.1.0/"]);
62
+ expect(names[0]).toBe("demo-solution-0.1.0/");
63
+ for (const member of ["manifest.yaml", "api/demo-solution-0.1.0/manifest.yaml", "web/modules/demo-solution/0.1.0/entry.js", "web/modules/demo-solution/0.1.0/demo-solution.css"])
64
+ expect(names, `缺成员 ${member}`).toContain(`demo-solution-0.1.0/${member}`);
65
+ });
66
+
67
+ it("端到端确定性:同一工程两次打包 sha256 相等(C2-32)", async () => {
68
+ const r1 = await pack();
69
+ const r2 = await pack();
70
+ expect(r1.sha256).toBe(r2.sha256);
71
+ });
72
+
73
+ it("tgz 内任何位置无 modules.json(C2-31:清单由安装器在目标站点 upsert)", async () => {
74
+ const r = await pack();
75
+ expect(tarList(r.tgz).some(n => n.endsWith("/modules.json"))).toBe(false);
76
+ });
77
+
78
+ it("产物树混入 modules.json → 打包自检拒绝(C2-31 红线)", async () => {
79
+ writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/modules.json"), "[]");
80
+ await expect(pack()).rejects.toThrow(/modules\.json/);
81
+ });
82
+
83
+ it("私钥/凭证混入包树 → 打包自检拒绝(P2-4 入包红线:*.pem/*.key/.env/id_ed25519/id_rsa)", async () => {
84
+ writeFileSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"), "-----BEGIN PRIVATE KEY-----\n");
85
+ await expect(pack()).rejects.toThrow(/私钥\/凭证/);
86
+ rmSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"));
87
+ writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/.env"), "SECRET=1\n");
88
+ await expect(pack()).rejects.toThrow(/私钥\/凭证/);
89
+ });
90
+ });
91
+
92
+ describe("buildBundle · 校验先于打包(fail-fast)", () => {
93
+ it("契约违规:抛可读清单(编号逐条),且不产出 tgz", async () => {
94
+ writeFileSync(join(project, "manifest.yaml"), "name: Demo\nversion: 1.0.0+build.5\nsummary: x\nchangelog: x\n");
95
+ await expect(pack()).rejects.toThrow(/C2-01[\s\S]*C2-02/);
96
+ expect(existsSync(join(out, "Demo-1.0.0+build.5.tgz"))).toBe(false);
97
+ });
98
+
99
+ it("manifest.yaml 缺失:可读报错指明期望路径", async () => {
100
+ rmSync(join(project, "manifest.yaml"));
101
+ await expect(pack()).rejects.toThrow(/manifest\.yaml/);
102
+ });
103
+
104
+ it("c2-31 分层核对:模块级 desc ≠ bundle summary → 拒绝", async () => {
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/);
107
+ });
108
+
109
+ it("c2-31 分层核对:api/dist 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {
110
+ rmSync(join(project, "api/dist"), { recursive: true, force: true });
111
+ await expect(pack()).rejects.toThrow(/api\/dist\/demo-solution-0\.1\.0/);
112
+ });
113
+
114
+ it("web 段存在但站点产物缺失 → 可读报错指引先构建", async () => {
115
+ rmSync(join(project, "web/dist"), { recursive: true, force: true });
116
+ await expect(pack()).rejects.toThrow(/web\/dist\/modules/);
117
+ });
118
+
119
+ it("c2-12a:api.tables 与 schema.yaml 表集不一致 → 构建期拒绝", async () => {
120
+ const m = readManifest();
121
+ m.api.tables = ["note", "ghost"];
122
+ writeManifest(m);
123
+ await expect(pack()).rejects.toThrow(/C2-12a/);
124
+ });
125
+
126
+ it("c2-25:locales 缺引用 key → 构建期 fail-fast", async () => {
127
+ const m = readManifest();
128
+ m.roles[0].title_key = "demo-solution:role.absent";
129
+ writeManifest(m);
130
+ await expect(pack()).rejects.toThrow(/C2-25/);
131
+ });
132
+ });
133
+
134
+ // ── 助手 ──
135
+
136
+ function readManifest(): any {
137
+ return parse(readFileSync(join(project, "manifest.yaml"), "utf8"));
138
+ }
139
+
140
+ function writeManifest(m: unknown): void {
141
+ writeFileSync(join(project, "manifest.yaml"), stringify(m));
142
+ }
143
+
144
+ function tarList(tgz: string): string[] {
145
+ return execFileSync("tar", ["-tvzf", tgz], { encoding: "utf8" })
146
+ // 铁律②(P1-6):tar 输出恒带尾随换行,trim 后再 split
147
+ .trim()
148
+ .split("\n")
149
+ .map(line => line.split(" ").pop()!);
150
+ }
@@ -0,0 +1,145 @@
1
+ // BDD:确定性 tgz(P1-11,契约 C2-32/C2-30)。
2
+ // 硬验收(简报逐字):同一目录两次打包 sha256 必须相等——mtime/umask/遍历序不得泄漏进产物。
3
+ // 打包自检(C2-30 构建列):symlink/硬链接成员拒绝;产物可被系统 tar -tvzf/-xzf 消费
4
+ // (安装侧 extract 段即系统 tar,P1-6 铁律①——本侧产物与彼侧输入契约互为镜像)。
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
+ import { createDeterministicTgz } from "../../deterministic-tar";
12
+
13
+ let dir: string;
14
+ let outDir: string;
15
+
16
+ beforeEach(() => {
17
+ dir = mkdtempSync(join(tmpdir(), "ojm-dtar-src-"));
18
+ outDir = mkdtempSync(join(tmpdir(), "ojm-dtar-out-"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ rmSync(outDir, { recursive: true, force: true });
24
+ });
25
+
26
+ /** 组装样例树(单根 pkg/ = bundle staging 形态):文件(多目录嵌套)/空目录/utf-8 内容;b-dir 先创建(创建序 ≠ 字典序) */
27
+ function makeTree(root: string): void {
28
+ const p = join(root, "pkg");
29
+ mkdirSync(join(p, "b-dir/sub"), { recursive: true });
30
+ mkdirSync(join(p, "a-dir"), { recursive: true });
31
+ writeFileSync(join(p, "b-dir/sub/entry.js"), "export default 1;\n");
32
+ writeFileSync(join(p, "b-dir/zh.txt"), "中文内容\n");
33
+ writeFileSync(join(p, "root.txt"), "root\n");
34
+ writeFileSync(join(p, "a-dir/first.css"), ".a{}\n");
35
+ mkdirSync(join(p, "b-dir/empty-dir"), { recursive: true }); // 空目录(fixture migrations/ 同形态)
36
+ }
37
+
38
+ /** 全树设置指定 mtime(确定性负对照:mtime 不得泄漏进包) */
39
+ function setTreeMtime(root: string, mtime: Date): void {
40
+ const walk = (p: string) => {
41
+ utimesSync(p, mtime, mtime);
42
+ if (!statSync(p).isDirectory())
43
+ return;
44
+ for (const e of readdirSync(p))
45
+ walk(join(p, e));
46
+ };
47
+ walk(root);
48
+ }
49
+
50
+ /** 打包 + 以系统 `tar -tvzf` 读回成员名列表(安装侧 extract 段同款消费面) */
51
+ async function packAndList(tgz: string): Promise<{ names: string[], listing: string[], sha256: string }> {
52
+ const sha256 = await createDeterministicTgz(dir, join(outDir, tgz));
53
+ const listing = tarList(join(outDir, tgz));
54
+ return { names: memberNames(listing), listing, sha256 };
55
+ }
56
+
57
+ describe("createDeterministicTgz · 确定性(C2-32 硬验收)", () => {
58
+ it("同目录两次打包 sha256 相等(mtime/遍历序不泄漏)", async () => {
59
+ makeTree(dir);
60
+ setTreeMtime(dir, new Date(1_700_000_000_000));
61
+ const sha1 = await createDeterministicTgz(dir, join(outDir, "a.tgz"));
62
+ setTreeMtime(dir, new Date(1_800_000_000_000));
63
+ const sha2 = await createDeterministicTgz(dir, join(outDir, "b.tgz"));
64
+ expect(sha1).toMatch(/^[a-f0-9]{64}$/);
65
+ expect(sha1).toBe(sha2);
66
+ });
67
+
68
+ it("内容变化 → sha256 变化(负对照:不是恒等哈希)", async () => {
69
+ makeTree(dir);
70
+ const sha1 = await createDeterministicTgz(dir, join(outDir, "a.tgz"));
71
+ writeFileSync(join(dir, "root.txt"), "changed\n");
72
+ const sha2 = await createDeterministicTgz(dir, join(outDir, "b.tgz"));
73
+ expect(sha1).not.toBe(sha2);
74
+ });
75
+
76
+ it("成员按路径字典序(创建顺序无关)、单根居首", async () => {
77
+ makeTree(dir);
78
+ const { names } = await packAndList("a.tgz");
79
+ const sorted = [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
80
+ expect(names).toEqual(sorted);
81
+ expect(names[0]).toMatch(/\/$/); // 唯一顶层目录成员居首
82
+ });
83
+ });
84
+
85
+ describe("createDeterministicTgz · 产物可被系统 tar 消费(extract 段输入契约)", () => {
86
+ it("tar -tvzf:单根、含空目录成员、mtime 显示 1970(=0)", async () => {
87
+ makeTree(dir);
88
+ const { names, listing } = await packAndList("a.tgz");
89
+ const topDirs = names.filter(n => n.endsWith("/") && !n.slice(0, -1).includes("/"));
90
+ expect(topDirs).toHaveLength(1);
91
+ expect(names).toContain(`${topDirs[0]}b-dir/empty-dir/`);
92
+ expect(listing.join("\n")).toContain("1970");
93
+ });
94
+
95
+ it("tar -xzf 解压还原:内容一致、权限 0644/0755(umask 无关)、空目录还原", async () => {
96
+ makeTree(dir);
97
+ const { names } = await packAndList("a.tgz");
98
+ const extractDir = join(outDir, "unpacked");
99
+ mkdirSync(extractDir);
100
+ execFileSync("tar", ["-xzf", join(outDir, "a.tgz"), "-C", extractDir]);
101
+ const root = join(extractDir, names[0]!.slice(0, -1));
102
+ expect(readFileSync(join(root, "b-dir/zh.txt"), "utf8")).toBe("中文内容\n");
103
+ expect(existsSync(join(root, "b-dir/empty-dir"))).toBe(true);
104
+ expect(statSync(join(root, "b-dir/sub/entry.js")).mode & 0o777).toBe(0o644);
105
+ expect(statSync(join(root, "b-dir")).mode & 0o777).toBe(0o755);
106
+ });
107
+ });
108
+
109
+ describe("createDeterministicTgz · 打包自检(C2-30 构建列)", () => {
110
+ it("symlink 成员拒绝(tar 忠实还原链接,校验为安全必需)", async () => {
111
+ makeTree(dir);
112
+ symlinkSync(join(dir, "root.txt"), join(dir, "evil-link"));
113
+ await expect(createDeterministicTgz(dir, join(outDir, "a.tgz"))).rejects.toThrow(/symlink|链接/i);
114
+ });
115
+
116
+ it("超长路径(ustar name+prefix 容量外)拒绝且可读", async () => {
117
+ // 单文件名 250 字符(fs 文件名上限内、无 "/" 可拆 prefix)→ ustar 必然容量外
118
+ writeFileSync(join(dir, "x".repeat(250)), "x");
119
+ await expect(createDeterministicTgz(dir, join(outDir, "a.tgz"))).rejects.toThrow(/路径|155|100/);
120
+ });
121
+
122
+ it("输出自动建父目录并落盘", async () => {
123
+ makeTree(dir);
124
+ const out = join(outDir, "deep/nested/pkg.tgz");
125
+ await createDeterministicTgz(dir, out);
126
+ expect(existsSync(out)).toBe(true);
127
+ });
128
+ });
129
+
130
+ // ── 助手 ──
131
+
132
+ function tarList(tgz: string): string[] {
133
+ return execFileSync("tar", ["-tvzf", tgz], { encoding: "utf8" })
134
+ // 铁律②(P1-6):tar 输出恒带尾随换行,trim 后再 split
135
+ .trim()
136
+ .split("\n");
137
+ }
138
+
139
+ /** 从 `tar -tvzf` 行提取成员名(bsdtar:`-rw-r--r-- 0/0 2026-.. name`;成员名不含空格——确定性产物) */
140
+ function memberNames(listing: string[]): string[] {
141
+ return listing.map((line) => {
142
+ const m = /\s(?:\d+|\S[^\s/]*\/\S+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s(.+)$/.exec(line);
143
+ return m ? m[1]! : line.split(" ").pop()!;
144
+ });
145
+ }
@@ -0,0 +1,59 @@
1
+ # lifecycle 测试夹具(C2 交付物 · 双端唯一共同真源)
2
+
3
+ 契约:[`docs/prd/solution/202610171254-lifecycle-manifest-contract.md`](../../../../../docs/prd/solution/202610171254-lifecycle-manifest-contract.md)(规则编号 C2-01~C2-33)。
4
+
5
+ ## 用途
6
+
7
+ manifest.yaml 契约在两个运行时各有一份实现(计划 §1「两运行时取舍」——Node CLI 构建期与 oj isolate 安装预检之间无共享模块机制):
8
+
9
+ | 端 | 实现(P1) | 消费夹具的测试 |
10
+ |---|---|---|
11
+ | Node(构建期,`ojm build --bundle`) | `packages/cli/src/manifest-contract.ts` → `validateContract(m): string[]` | `packages/cli/src/lifecycle/__tests__/manifest-contract.test.ts`(P1-11) |
12
+ | oj isolate(安装期 preflight) | `templates/api/src/_platform/_lifecycle/steps/preflight.ts` | `templates/api/tests/lifecycle/preflight.spec.ts`(P1-5) |
13
+
14
+ **DRY 于夹具而非运行时**:两份实现的一致性由同一组夹具钉住——任何一端正则/规则变更,必须先改契约文档对应条款,再同步另一端与夹具,双端测试全绿后方可提交。
15
+
16
+ ## 夹具清单
17
+
18
+ ```
19
+ fixtures/
20
+ ├── README.md # 本文件
21
+ ├── fixture-valid/ # 合法样例:双端零违规
22
+ │ ├── manifest.yaml # bundle 级契约(全字段合法)
23
+ │ ├── api/src/demo-solution/ # oj 模块源(manifest/schema/ping 路由/0001__init.sql 建表迁移)
24
+ │ └── web/src/demo-solution/ # entry.ts + locales(zh-CN/en-US 全 key 双语)
25
+ └── fixture-invalid/ # 非法样例:双端七码齐报
26
+ ├── manifest.yaml # 故意触发 V1–V7
27
+ ├── EXPECTED-VIOLATIONS.md # 每条违规 ↔ 契约条款对照(P1-5 期望真源)
28
+ ├── api/src/Demo/
29
+ └── web/src/Demo/
30
+ ```
31
+
32
+ ## 双端测试约定
33
+
34
+ 1. `fixture-valid/manifest.yaml`:`validateContract` 与 `preflight` 均断言**零违规**。
35
+ 2. `fixture-invalid/manifest.yaml`:两端均断言违规码集合 **⊇** `{C2-01, C2-02, C2-16, C2-17, C2-22, C2-23, C2-26}`,逐条依据见 `fixture-invalid/EXPECTED-VIOLATIONS.md`;衍生违规(name 非法导致的 `demo:*` 命名空间连锁失配)不计入断言。
36
+ 3. 断言前提:两端实现均**收集全部违规后统一报出**(返回违规列表而非首个即抛),否则截断会使后续码无法报出——此为实现纪律,随 P1-5/P1-11 测试钉住。
37
+
38
+ ## 夹具断言范围之外(勿在此过度断言)
39
+
40
+ - `web.integrity` 的安装期重算比对(C2-28/R10)针对**构建产物 tgz**,源树夹具中该值为格式样例;
41
+ - prefix 占用检测、peerRuntime 对宿主 `versions.json`(C2-09)等需要站点/宿主状态的规则,由 P1-5/P1-11 各自的运行时夹具(内存 state/versions 假数据)覆盖;
42
+ - 恶意 tgz 成员(`..`/symlink)用例属 P1-2 `tgz-guard`,独立构造,不入本目录。
43
+
44
+ ## 签名链夹具(`sig/`,v1.3 / P2-0)
45
+
46
+ ```
47
+ fixtures/sig/
48
+ ├── gen-signed-fixtures.mjs # 确定性生成器(自包含 .mjs,两端可 import/spawn;私钥不入仓)
49
+ ├── gen-signed-fixtures.d.mts # 类型声明(供 TS 测试 import)
50
+ ├── demo-solution-0.2.0.tgz # 共用包体(Form A,确定性 ustar + gzip)
51
+ ├── demo-solution-0.2.0.tgz.sig # 已签(TRUSTED_KEY = ojm.test)
52
+ ├── demo-solution-0.2.0.tgz.bad.sig # 篡改(value 首字符翻转 → tampered)
53
+ └── demo-solution-0.2.0.tgz.unknown.sig # 未知发布方(UNKNOWN_KEY = stranger.unknown)
54
+ ```
55
+
56
+ - 再生:`node gen-signed-fixtures.mjs`(幂等确定,与提交产物字节一致,由双端 spec 钉住);
57
+ - 测试 key 由公开 seed 常量派生(SHA-256 → 32B → PKCS#8 DER → Ed25519),**无保密性、私钥永不入仓**(P2-12「模板/夹具不得含私钥」的前提);
58
+ - 双端消费:CLI 面 `src/lifecycle/__tests__/sig-fixtures.test.ts`;templates 面 `tests/lifecycle/sig-fixtures.spec.ts`(跨 root import 生成器);
59
+ - P2-3 起 isolate 六码/TOFU 全链以同组夹具为锚(`steps/verify.ts` + `CryptoFace.verifyEd25519`)。
@@ -0,0 +1,27 @@
1
+ # fixture-invalid · 期望违规清单(P1-5/P1-11 测试的期望真源)
2
+
3
+ 夹具 `manifest.yaml` 故意触发 7 条**内在违规**——定义为「对任意合法 name/version 取值均违规」,与 name 非法引起的连锁衍生失配(如 `demo:*` 命名空间字段)区分开。
4
+
5
+ ## 测试断言约定
6
+
7
+ 1. 双端(Node `validateContract` / isolate `preflight`)各自解析本夹具,断言违规码集合 **⊇** 下表 7 码;不断言「恰好等于」(衍生违规允许存在,允许 fail-fast 提前截断——但被断言的码必须在其截断前可报出,故双端实现均要求**收集全部违规后统一报出**,见契约 §0 唯一真源纪律)。
8
+ 2. `fixture-valid` 同规则跑双端,断言**零违规**(防误报)。
9
+ 3. 衍生违规(`demo:menu.demo` 等 namespace 失配)不计入断言,不要求报出或不报出。
10
+
11
+ ## 违规对照表
12
+
13
+ | ID | 触发点 | 期望违规码 | 契约条款 | 上游依据 | 内在性说明 |
14
+ |---|---|---|---|---|---|
15
+ | V1 | `name: Demo`(大写) | C2-01 | §2.1 身份 name 白名单 `^[a-z][a-z0-9-]{0,63}$`(契约 §2.1 现行式) | R7 预检白名单 | 大写对任意取值均违规 |
16
+ | V2 | `version: 1.0.0+build.5` | C2-02 | §2.1 version 禁 `+`(oj validate_version 字符集 `[A-Za-z0-9._-]` 无 `+`) | 契约附录 B-1(oj 相容排除) | `+` 恒被 oj 拒装 |
17
+ | V3 | `api.anonymous: [system/health]` | C2-26 | §2.4 匿名路径段内收敛(首段必须 = 本方案段;`system`/`_system` 恒非本方案段) | R8 越段匿名化 + `_system` 禁入 | `system/health` 对任意 name 均越段 |
18
+ | V4 | `routes[0].category: personal` | C2-17 | §2.6 路由 category ∈ main\|admin\|hidden(D4 三值) | D4(PRD §5.2 五值废弃) | `personal` 为已删除值 |
19
+ | V5 | `settings.groups[0]` 缺 `label_key` | C2-22 | §2.7 组 label_key 必填,指向 `<name>:settings.group.*` | D4 设置组标题契约 | 必填字段缺失 |
20
+ | V6 | `settings.groups[0].items[0].key: framework.site_title` | C2-23 | §2.7 键所有权 `moduleOf(key) === name` | R24(防 `framework.*` 污染,跨模块提权同类洞) | `framework.` 前缀对任意方案 name 均越空间 |
21
+ | V7 | `roles[0].permissions: [demo.manage]`(未声明) | C2-16 | §2.5 角色 permissions ⊆ permissions[].code | §2.5 悬空引用双端拒绝 | `demo.manage` 不在 permissions[] 中,对任意 name 均悬空 |
22
+
23
+ ## 关联文件
24
+
25
+ - 契约:`docs/prd/solution/202610171254-lifecycle-manifest-contract.md`(规则编号 §2/§4)
26
+ - 合法对照:`../fixture-valid/manifest.yaml`(全字段合法,双端零违规)
27
+ - 双端测试约定:`../README.md`
@@ -0,0 +1,6 @@
1
+ # oj 模块级 manifest(负例夹具):与 bundle 级同错(name 大写、version 含 `+`)。
2
+ # oj validate_module 允许大写、validate_version 拒绝 `+`——仅后者为 oj 侧真实拒装点;
3
+ # 契约双端均拒(C2-01/C2-02)。
4
+ name: Demo
5
+ desc: 契约非法夹具(预检负例真源)
6
+ version: 1.0.0+build.5
@@ -0,0 +1,40 @@
1
+ # ── C2 契约非法夹具(fixture-invalid)──────────────────────────────────
2
+ # 故意触发 7 条契约违规(V1–V7),逐条对照同目录 EXPECTED-VIOLATIONS.md。
3
+ # 双端测试期望:Node 侧 validateContract 与 isolate 侧 preflight 均报告
4
+ # 违规码集合 ⊇ {C2-01, C2-02, C2-16, C2-17, C2-22, C2-23, C2-26}。
5
+ # 说明:name/version 非法会连锁触发 demo:* 命名空间字段的失配(衍生违规),
6
+ # 衍生项不计入断言集——V1–V7 均为「对任意合法 name/version 均违规」的内在违规。
7
+ name: Demo # V1 违 C2-01(大写,白名单 ^[a-z][a-z0-9-]{0,63}$)
8
+ version: 1.0.0+build.5 # V2 违 C2-02(`+` 为 oj validate_version 拒绝字符)
9
+ summary: 契约非法夹具(预检负例真源)
10
+ changelog: 负例夹具,禁止安装到任何站点
11
+
12
+ api:
13
+ prefix: /api/demo
14
+ anonymous:
15
+ - system/health # V3 违 C2-26(越段:首段 system ≠ 本方案段,R8;_system 同理禁入)
16
+
17
+ routes:
18
+ - path: /demo
19
+ category: personal # V4 违 C2-17(D4 已删除该值,三值 main|admin|hidden)
20
+ order: 30
21
+ title_key: demo:menu.demo
22
+ - path: /demo/admin
23
+ category: admin
24
+ order: 200
25
+ title_key: demo:menu.admin
26
+
27
+ permissions:
28
+ - {code: demo.view, title_key: demo:perm.view}
29
+
30
+ roles:
31
+ - name: demo.admin
32
+ title_key: demo:role.admin
33
+ permissions: [demo.manage] # V7 违 C2-16(悬空引用:demo.manage 未在 permissions[] 声明)
34
+
35
+ settings:
36
+ groups:
37
+ - group: display
38
+ items: # V5 违 C2-22(label_key 必填缺失)
39
+ - key: framework.site_title # V6 违 C2-23(moduleOf(key)=framework ≠ name,R24 越空间污染)
40
+ type: string
@@ -0,0 +1,14 @@
1
+ // 夹具 entry 占位(负例):结构对齐 fixture-valid,仅承载 manifest 违规断言所需最小形态。
2
+ const entry = {
3
+ name: "Demo",
4
+ description: "契约非法夹具(预检负例真源)",
5
+ version: "1.0.0+build.5",
6
+ routes: [
7
+ {
8
+ path: "/demo",
9
+ handle: { layout: "container", order: 30, title: "demo:menu.demo" },
10
+ },
11
+ ],
12
+ };
13
+
14
+ export default entry;
@@ -0,0 +1,12 @@
1
+ {
2
+ "menu": {
3
+ "demo": "Invalid Fixture",
4
+ "admin": "Invalid Fixture Admin"
5
+ },
6
+ "perm": {
7
+ "view": "View invalid fixture"
8
+ },
9
+ "role": {
10
+ "admin": "Invalid Fixture Admin Role"
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "menu": {
3
+ "demo": "非法夹具",
4
+ "admin": "非法夹具管理"
5
+ },
6
+ "perm": {
7
+ "view": "查看非法夹具"
8
+ },
9
+ "role": {
10
+ "admin": "非法夹具管理员"
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ # oj 模块级 manifest(安装后由 oj 真实解析;oj/src/manifest.rs 7-22)。
2
+ # 与 bundle 级 manifest.yaml 分层:仅 oj 原生七键(name/desc/version/config/db/tables/deps),
3
+ # 禁携带 bundle 字段(契约 §3 分层红线 OJ-X)。
4
+ # 构建期核对:name === bundle name、version === bundle version、desc === bundle summary(C2-31)。
5
+ name: demo-solution
6
+ desc: manifest.yaml 契约全字段合法夹具
7
+ version: 0.1.0
8
+ tables:
9
+ - note
@@ -0,0 +1,6 @@
1
+ -- 0001__init:建 note 表(与 schema.yaml 列集一致;oj migrate 按 seq 升序执行)
2
+ CREATE TABLE IF NOT EXISTS note (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ title TEXT NOT NULL,
5
+ create_time INTEGER NOT NULL
6
+ );
@@ -0,0 +1,9 @@
1
+ // 夹具路由占位(对应 manifest api.anonymous 条目 demo-solution/ping)。
2
+ // 形态示例:不参与构建、不引用 oj isolate 全局(json/http/exec)。
3
+ const ping = {
4
+ async get() {
5
+ return { pong: true, module: "demo-solution" };
6
+ },
7
+ };
8
+
9
+ export default ping;