@oj-module/cli 0.2.1 → 0.2.5
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.
- package/devkit/README.md +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +147 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +46 -7
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +56 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +162 -0
- package/templates/web/src/system/__client/api.ts +382 -0
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// 安装预检(P1-5,R7/R8/R9/R24):十阶段安装状态机的 stage 1——manifest.yaml 契约安全预检。
|
|
2
|
+
// 规则真源:docs/prd/solution/202610171254-lifecycle-manifest-contract.md §2/§4(C2-01~33 安装列);
|
|
3
|
+
// 期望真源:packages/cli/src/lifecycle/__tests__/fixtures/(fixture-valid 零违规 / fixture-invalid 七码齐报)。
|
|
4
|
+
// 实现纪律(契约 §0):收集全部违规后统一报出,不首条即停——否则夹具断言集被截断。
|
|
5
|
+
// 纯校验段:只读 ctx(state 仅 read),不写盘不写库;占用检测真源 = state.solutions 键(已装方案名)。
|
|
6
|
+
// 双端 DRY 于夹具而非运行时:本实现与 Node 侧 validateContract(P1-11)由同一组夹具钉住一致性。
|
|
7
|
+
|
|
8
|
+
import type { InstallContext } from "../types";
|
|
9
|
+
import { compareVersions } from "../semver";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 安装上下文(P1-10 收敛进 types.ts 单源;本模块 re-export 保持既有 `from "./preflight"` 引用面不变)。
|
|
13
|
+
*/
|
|
14
|
+
export type { InstallContext };
|
|
15
|
+
|
|
16
|
+
/** 阶段结果(各安装段通用形状,本段 stage 恒为 "preflight";P1-10 收敛时提升为可判别联合) */
|
|
17
|
+
export interface StageResult {
|
|
18
|
+
stage: "preflight"
|
|
19
|
+
ok: boolean
|
|
20
|
+
message: string
|
|
21
|
+
retryable?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Violation {
|
|
25
|
+
clause: string
|
|
26
|
+
detail: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── 契约 §2/§4 正则与清单(与契约现行文本逐字对应,改动须先改契约再同步夹具与双端实现)──
|
|
30
|
+
|
|
31
|
+
/** C2-01:name 白名单 */
|
|
32
|
+
const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
33
|
+
|
|
34
|
+
/** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留) */
|
|
35
|
+
const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
|
|
36
|
+
|
|
37
|
+
/** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
|
|
38
|
+
const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
|
|
39
|
+
|
|
40
|
+
/** §2.2 范围算子语法(peerRuntime/api.deps 值共用);P1 语义实现集 = 精确(裸/=)、>=、^ */
|
|
41
|
+
const RANGE_RE = /^(>=|<=|[><^=])?([0-9A-Z.-]+)$/i;
|
|
42
|
+
|
|
43
|
+
/** C2-26①:anonymous 相对路径语法(无前导斜杠、无通配符;首字符 [a-z0-9] 使 _system/_platform 天然禁入) */
|
|
44
|
+
const ANONYMOUS_RE = /^[a-z0-9][a-z0-9\-_.]*(?:\/[a-z0-9][a-z0-9\-_.]*)*$/;
|
|
45
|
+
|
|
46
|
+
/** C2-17:路由 path 语法(首段首字符为字母;无参数段/通配符/尾斜杠) */
|
|
47
|
+
const ROUTE_PATH_RE = /^\/[a-z][a-z0-9\-_]*(?:\/[a-z0-9\-_]+)*$/;
|
|
48
|
+
|
|
49
|
+
/** C2-26③:登录语义端点禁令(R8/S1 纵深防御;契约 (?i) 正则逐字) */
|
|
50
|
+
const LOGIN_ENDPOINT_RE = /(?:^|\/)(?:[\w.-]*-)?(?:login|logout|token|session|signin|signup|auth)(?:\/|$)/i;
|
|
51
|
+
|
|
52
|
+
/** C2-17:D4 三值(PRD §5.2 settings/personal 两值废弃) */
|
|
53
|
+
const ROUTE_CATEGORIES = new Set(["main", "admin", "hidden"]);
|
|
54
|
+
|
|
55
|
+
/** C2-24:设置项类型枚举 */
|
|
56
|
+
const SETTING_TYPES = new Set(["string", "bool", "int", "float", "enum", "text"]);
|
|
57
|
+
|
|
58
|
+
function escapeRe(s: string): string {
|
|
59
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
63
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** i18n key 命名空间正则(C2-25 语法层):`<name>:<ns>.<rest>` */
|
|
67
|
+
function nameKeyRe(name: string, ns: string): RegExp {
|
|
68
|
+
return new RegExp(`^${escapeRe(name)}:${ns}\\.[a-zA-Z0-9_.-]+$`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** C2-02 语义检查:字符集 + 无连续点 + 首尾非点 + 主版本段为数字(字母仅限 - 后的 prerelease)。返回违规描述,合法返回 null */
|
|
72
|
+
function versionProblem(s: string): string | null {
|
|
73
|
+
if (!VERSION_CHARS_RE.test(s)) {
|
|
74
|
+
return "含非法字符(允许 [0-9A-Za-z.-];+ 被 oj validate_version 拒装(附录 B-1)、_ 为契约收紧排除)";
|
|
75
|
+
}
|
|
76
|
+
if (s.startsWith(".") || s.endsWith(".")) {
|
|
77
|
+
return "首尾不得为点";
|
|
78
|
+
}
|
|
79
|
+
if (s.includes("..")) {
|
|
80
|
+
return "不得含连续点";
|
|
81
|
+
}
|
|
82
|
+
const hyphen = s.indexOf("-");
|
|
83
|
+
const core = hyphen === -1 ? s : s.slice(0, hyphen);
|
|
84
|
+
if (!/^\d+(?:\.\d+)*$/.test(core)) {
|
|
85
|
+
return `主版本段「${core}」必须为数字段(如 1.0.0);字母仅可用于 - 之后的 prerelease 标识`;
|
|
86
|
+
}
|
|
87
|
+
if (hyphen !== -1) {
|
|
88
|
+
const pre = s.slice(hyphen + 1);
|
|
89
|
+
if (pre === "" || pre.startsWith(".") || pre.endsWith(".") || pre.includes("..")) {
|
|
90
|
+
return "prerelease 标识不得为空或含空段";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** §2.2 范围算子语法检查(仅语法,不判满足性)。返回违规描述,合法返回 null */
|
|
97
|
+
function rangeProblem(range: string): string | null {
|
|
98
|
+
const m = RANGE_RE.exec(range);
|
|
99
|
+
if (m === null) {
|
|
100
|
+
return "不符合范围算子语法 ^(>=|<=|>|<|^|=)?<version>(version 遵守 C2-02)";
|
|
101
|
+
}
|
|
102
|
+
return versionProblem(m[2]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** §2.2 P1 语义集求值:精确(裸/=)、>=、^(标准 semver caret,0.x 下 minor 必须相等) */
|
|
106
|
+
function rangeSatisfies(host: string, op: string | undefined, base: string): boolean {
|
|
107
|
+
const c = compareVersions(host, base);
|
|
108
|
+
if (op === undefined || op === "=") {
|
|
109
|
+
return c === 0;
|
|
110
|
+
}
|
|
111
|
+
if (op === ">=") {
|
|
112
|
+
return c >= 0;
|
|
113
|
+
}
|
|
114
|
+
// ^:标准 semver caret(见 caretSatisfies)——0.x 口径:^0.1.0 仅匹配 0.1.*,^0.2.0 不兼容 0.1.x
|
|
115
|
+
return caretSatisfies(host, base);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 取版本核心段 [major, minor, patch](缺段补 0;prerelease 截断不参与比对)。非法返回 null。 */
|
|
119
|
+
function coreParts(v: string): [number, number, number] | null {
|
|
120
|
+
const core = v.split("-")[0];
|
|
121
|
+
if (!/^\d+(?:\.\d+)*$/.test(core)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
const parts = core.split(".").map(Number);
|
|
125
|
+
while (parts.length < 3) {
|
|
126
|
+
parts.push(0);
|
|
127
|
+
}
|
|
128
|
+
return [parts[0], parts[1], parts[2]];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* semver caret 求值(P2-9 钉 0.x 口径):
|
|
133
|
+
* - ^x.y.z(x>0):x 同主版本且 ≥ 基线(即 x.*.*)
|
|
134
|
+
* - ^0.y.z(y>0):主版本 0 且 minor == y 且 ≥ 基线(即 0.y.*)
|
|
135
|
+
* - ^0.0.z:仅 0.0.z 本身(prerelease 截断,不参与比对)
|
|
136
|
+
*/
|
|
137
|
+
function caretSatisfies(host: string, base: string): boolean {
|
|
138
|
+
const h = coreParts(host);
|
|
139
|
+
const b = coreParts(base);
|
|
140
|
+
if (h === null || b === null) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const [hm, hi, hp] = h;
|
|
144
|
+
const [bm, bi, bp] = b;
|
|
145
|
+
if (bm > 0) {
|
|
146
|
+
return hm === bm && (hi > bi || (hi === bi && hp >= bp));
|
|
147
|
+
}
|
|
148
|
+
if (bi > 0) {
|
|
149
|
+
return hm === 0 && hi === bi && hp >= bp;
|
|
150
|
+
}
|
|
151
|
+
// 0.0.z
|
|
152
|
+
return hm === 0 && hi === 0 && hp === bp;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function preflight(ctx: InstallContext): Promise<StageResult> {
|
|
156
|
+
const v: Violation[] = [];
|
|
157
|
+
const m = ctx.manifest;
|
|
158
|
+
if (!isObj(m)) {
|
|
159
|
+
return report([{ clause: "C2", detail: `manifest 不是键值对象(实为 ${m === null ? "null" : Array.isArray(m) ? "数组" : typeof m}),无法预检` }]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 身份兜底:manifest 字段缺失/非字符串时退回 ctx 身份(占用/命名空间规则仍可继续,违规照报)
|
|
163
|
+
const name = typeof m.name === "string" ? m.name : ctx.name;
|
|
164
|
+
const version = typeof m.version === "string" ? m.version : ctx.version;
|
|
165
|
+
const installed = ctx.state.read().solutions;
|
|
166
|
+
const occupier = (key: string): string | undefined => {
|
|
167
|
+
const e = installed[key];
|
|
168
|
+
return e === undefined ? undefined : `${key}@${e.version}`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// ── C2-01 身份 name:白名单 + 保留名(§4.2,market 经 R9 恢复保留)──
|
|
172
|
+
if (typeof m.name !== "string" || !NAME_RE.test(m.name)) {
|
|
173
|
+
v.push({ clause: "C2-01", detail: `name「${String(m.name)}」不匹配 ^[a-z][a-z0-9-]{0,63}$(小写字母开头,仅小写字母/数字/连字符)` });
|
|
174
|
+
}
|
|
175
|
+
if (RESERVED_NAMES.has(name)) {
|
|
176
|
+
v.push({ clause: "C2-01", detail: `name「${name}」是保留名(§4.2:_platform/system/auth/market/framework/health/plugins/_system),禁用于方案(R9)` });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── C2-02 version:字符集 + oj 相容排除 ──
|
|
180
|
+
if (typeof m.version !== "string") {
|
|
181
|
+
v.push({ clause: "C2-02", detail: "version 必须是字符串" });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const p = versionProblem(m.version);
|
|
185
|
+
if (p !== null) {
|
|
186
|
+
v.push({ clause: "C2-02", detail: `version「${m.version}」${p}` });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── C2-04 summary 非空(安装列仅此一项;安装器将其写入模块 manifest 的 desc)──
|
|
191
|
+
if (typeof m.summary !== "string" || m.summary.trim() === "") {
|
|
192
|
+
v.push({ clause: "C2-04", detail: "summary 必须是非空字符串(安装器取其写入模块 manifest 的 desc)" });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 占用检测(C2-13/14 安装列):方案名已被已装方案占用 → 拒装并报占用者 ──
|
|
196
|
+
// (同名命中且 ctx.reinstall = 安装器升级/重装路径放行,AC-P1-3;缺省拒装保持 P1-5 钉面)
|
|
197
|
+
const nameOcc = occupier(name);
|
|
198
|
+
if (nameOcc !== undefined && ctx.reinstall !== true) {
|
|
199
|
+
v.push({ clause: "C2-13/14", detail: `方案名已被已装方案「${nameOcc}」占用(同站方案名唯一;升级/重装经安装器 runInstall 路径,AC-P1-3)` });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── C2-09 peerRuntime:对宿主 versions.json 的 runtime 版本强制比对(安装预检强制)──
|
|
203
|
+
if (m.peerRuntime !== undefined) {
|
|
204
|
+
const range = typeof m.peerRuntime === "string" ? m.peerRuntime : "";
|
|
205
|
+
const parsed = RANGE_RE.exec(range);
|
|
206
|
+
if (parsed === null || versionProblem(parsed[2]) !== null) {
|
|
207
|
+
v.push({ clause: "C2-09", detail: `peerRuntime「${String(m.peerRuntime)}」不符合范围算子语法 ^(>=|<=|>|<|^|=)?<version>(version 遵守 C2-02)` });
|
|
208
|
+
}
|
|
209
|
+
else if (parsed[1] === "<" || parsed[1] === "<=" || parsed[1] === ">") {
|
|
210
|
+
v.push({ clause: "C2-09", detail: `peerRuntime「${range}」使用了暂不支持的范围算子(P1 语义集 = 精确/=、>=、^)` });
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const host = ctx.hostVersions?.["@oj-module/runtime"];
|
|
214
|
+
if (host === undefined) {
|
|
215
|
+
v.push({ clause: "C2-09", detail: `无法读取宿主 runtime 版本(versions.json 缺失或无 @oj-module/runtime 键),peerRuntime「${range}」强制比对失败(fail-closed)` });
|
|
216
|
+
}
|
|
217
|
+
else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
|
|
218
|
+
v.push({ clause: "C2-09", detail: `宿主 runtime 版本 ${host} 不满足 peerRuntime 声明「${range}」(需升级宿主)` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── C2-11 peerPlatform:对宿主平台编排面 API_VERSION 强制比对(P2-9 起安装预检强制,R23;与 peerRuntime 同算子集)──
|
|
224
|
+
if (m.peerPlatform !== undefined) {
|
|
225
|
+
const range = typeof m.peerPlatform === "string" ? m.peerPlatform : "";
|
|
226
|
+
const parsed = RANGE_RE.exec(range);
|
|
227
|
+
if (parsed === null || versionProblem(parsed[2]) !== null) {
|
|
228
|
+
v.push({ clause: "C2-11", detail: `peerPlatform「${String(m.peerPlatform)}」不符合范围算子语法 ^(>=|<=|>|<|^|=)?<version>(version 遵守 C2-02)` });
|
|
229
|
+
}
|
|
230
|
+
else if (parsed[1] === "<" || parsed[1] === "<=" || parsed[1] === ">") {
|
|
231
|
+
v.push({ clause: "C2-11", detail: `peerPlatform「${range}」使用了暂不支持的范围算子(P1 语义集 = 精确/=、>=、^)` });
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const host = ctx.hostApiVersion;
|
|
235
|
+
if (host === undefined) {
|
|
236
|
+
v.push({ clause: "C2-11", detail: `无法读取宿主平台 API 版本(GET /api/_platform/market/meta 不可用或本仓 API_VERSION 未注入),peerPlatform「${range}」强制比对失败(fail-closed)` });
|
|
237
|
+
}
|
|
238
|
+
else if (!rangeSatisfies(host, parsed[1], parsed[2])) {
|
|
239
|
+
v.push({ clause: "C2-11", detail: `宿主平台 API 版本 ${host} 不满足 peerPlatform 声明「${range}」(需升级宿主平台模块)` });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── api 段(C2-12/12a/12b/13/26)──
|
|
245
|
+
// C2-26② 段内收敛的「本方案段」:api.prefix 缺省 = name
|
|
246
|
+
let apiSeg = name;
|
|
247
|
+
const api = m.api;
|
|
248
|
+
if (api !== undefined) {
|
|
249
|
+
if (!isObj(api)) {
|
|
250
|
+
v.push({ clause: "C2-12", detail: "api 段必须是键值对象" });
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// C2-12:api.module 存在时必须 === name
|
|
254
|
+
if (api.module !== undefined && api.module !== name) {
|
|
255
|
+
v.push({ clause: "C2-12", detail: `api.module「${String(api.module)}」必须等于 name「${name}」` });
|
|
256
|
+
}
|
|
257
|
+
// C2-13:prefix 语法 + 保留名 + 占用检测(报占用者)
|
|
258
|
+
if (api.prefix !== undefined) {
|
|
259
|
+
const p = api.prefix;
|
|
260
|
+
if (typeof p !== "string" || !/^\/api\/[a-z][a-z0-9-]{0,63}$/.test(p)) {
|
|
261
|
+
v.push({ clause: "C2-13", detail: `api.prefix「${String(p)}」必须匹配 ^/api/[a-z][a-z0-9-]{0,63}$` });
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
apiSeg = p.slice("/api/".length);
|
|
265
|
+
if (RESERVED_NAMES.has(apiSeg)) {
|
|
266
|
+
v.push({ clause: "C2-13", detail: `api.prefix 模块段「${apiSeg}」是保留名(§4.2),禁用` });
|
|
267
|
+
}
|
|
268
|
+
if (apiSeg !== name) {
|
|
269
|
+
const occ = occupier(apiSeg);
|
|
270
|
+
if (occ !== undefined) {
|
|
271
|
+
v.push({ clause: "C2-13", detail: `api.prefix 模块段「${apiSeg}」已被已装方案「${occ}」占用` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// C2-12a:tables 语法(与 schema.yaml 的表集对照属构建期/oj 冗余校验,非本段)
|
|
277
|
+
if (api.tables !== undefined) {
|
|
278
|
+
const t = api.tables;
|
|
279
|
+
if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,63}$/.test(x))) {
|
|
280
|
+
v.push({ clause: "C2-12a", detail: "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,63}$ 的字符串" });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// C2-12b:deps 键 = C2-01 语法或 _platform;值 = 范围算子语法(存在性由 oj serve S004 兜底)
|
|
284
|
+
if (api.deps !== undefined) {
|
|
285
|
+
if (!isObj(api.deps)) {
|
|
286
|
+
v.push({ clause: "C2-12b", detail: "api.deps 必须是键值对象" });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
for (const [k, val] of Object.entries(api.deps)) {
|
|
290
|
+
if (k !== "_platform" && !NAME_RE.test(k)) {
|
|
291
|
+
v.push({ clause: "C2-12b", detail: `api.deps 键「${k}」必须匹配 C2-01 name 语法或为 _platform` });
|
|
292
|
+
}
|
|
293
|
+
if (typeof val !== "string" || rangeProblem(val) !== null) {
|
|
294
|
+
v.push({ clause: "C2-12b", detail: `api.deps["${k}"] 值「${String(val)}」不符合范围算子语法(§2.2)` });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// C2-26:anonymous 语法①/段内收敛②(R8,_system/_platform 等保留段天然禁入)/登录端点禁令③/唯一④
|
|
300
|
+
if (api.anonymous !== undefined) {
|
|
301
|
+
const list = api.anonymous;
|
|
302
|
+
if (!Array.isArray(list) || !list.every(x => typeof x === "string")) {
|
|
303
|
+
v.push({ clause: "C2-26", detail: "api.anonymous 必须是字符串数组" });
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const seen = new Set<string>();
|
|
307
|
+
for (const a of list) {
|
|
308
|
+
if (!ANONYMOUS_RE.test(a)) {
|
|
309
|
+
v.push({ clause: "C2-26", detail: `anonymous「${a}」不匹配相对路径语法 ^[a-z0-9][a-z0-9\\-_.]*(/[a-z0-9][a-z0-9\\-_.]*)*$(无前导斜杠/通配符;_system/_platform 段禁入)` });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (a.split("/")[0] !== apiSeg) {
|
|
313
|
+
v.push({ clause: "C2-26", detail: `anonymous「${a}」越段:首段必须 = 本方案段「${apiSeg}」(R8 段内收敛;_system/_platform 等保留段因此禁入)` });
|
|
314
|
+
}
|
|
315
|
+
if (LOGIN_ENDPOINT_RE.test(a)) {
|
|
316
|
+
v.push({ clause: "C2-26", detail: `anonymous「${a}」命中登录语义端点禁令(R8/S1 纵深防御:login/logout/token/session/signin/signup/auth)` });
|
|
317
|
+
}
|
|
318
|
+
if (seen.has(a)) {
|
|
319
|
+
v.push({ clause: "C2-26", detail: `anonymous「${a}」在本 manifest 内重复` });
|
|
320
|
+
}
|
|
321
|
+
seen.add(a);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── web 段(C2-27 语法;integrity 重算比对属 deployWeb→smoke 段,不在本段)──
|
|
329
|
+
const web = m.web;
|
|
330
|
+
if (web !== undefined) {
|
|
331
|
+
if (!isObj(web)) {
|
|
332
|
+
v.push({ clause: "C2-27", detail: "web 段必须是键值对象" });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
// C2-14:route_prefix 语法 + 首段保留名 + 占用检测(缺省 = /<name>,合法免检)
|
|
336
|
+
if (web.route_prefix !== undefined) {
|
|
337
|
+
const rp = web.route_prefix;
|
|
338
|
+
if (typeof rp !== "string" || !/^\/[a-z][a-z0-9-]{0,63}$/.test(rp)) {
|
|
339
|
+
v.push({ clause: "C2-14", detail: `web.route_prefix「${String(rp)}」必须匹配 ^/[a-z][a-z0-9-]{0,63}$` });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const seg = rp.slice(1);
|
|
343
|
+
if (RESERVED_NAMES.has(seg)) {
|
|
344
|
+
v.push({ clause: "C2-14", detail: `web.route_prefix 首段「${seg}」是保留名(§4.2),禁用` });
|
|
345
|
+
}
|
|
346
|
+
if (seg !== name) {
|
|
347
|
+
const occ = occupier(seg);
|
|
348
|
+
if (occ !== undefined) {
|
|
349
|
+
v.push({ clause: "C2-14", detail: `web.route_prefix 首段「${seg}」已被已装方案「${occ}」占用` });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const base = `modules/${escapeRe(name)}/${escapeRe(version)}/[a-z0-9._/-]+`;
|
|
355
|
+
if (typeof web.entry !== "string" || !new RegExp(`^${base}\\.js$`).test(web.entry)) {
|
|
356
|
+
v.push({ clause: "C2-27", detail: `web.entry「${String(web.entry)}」必须匹配 ^modules/${name}/${version}/…\\.js$(web 段存在时必填;版本段必须 === version;POSIX 分隔符)` });
|
|
357
|
+
}
|
|
358
|
+
if (web.css !== undefined) {
|
|
359
|
+
const css = web.css;
|
|
360
|
+
if (!Array.isArray(css) || !css.every(x => typeof x === "string" && new RegExp(`^${base}\\.css$`).test(x))) {
|
|
361
|
+
v.push({ clause: "C2-27", detail: `web.css 每项必须匹配 ^modules/${name}/${version}/…\\.css$(版本段 === version)` });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── C2-15 permissions:code 强制 <name>. 前缀(framework./system./market. 保留空间不可达)+ title_key 必填 + 唯一 ──
|
|
368
|
+
const permCodes = new Set<string>();
|
|
369
|
+
if (m.permissions !== undefined) {
|
|
370
|
+
const list = m.permissions;
|
|
371
|
+
if (!Array.isArray(list)) {
|
|
372
|
+
v.push({ clause: "C2-15", detail: "permissions 必须是数组" });
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
const seen = new Set<string>();
|
|
376
|
+
const codeRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9_]+(\\.[a-z0-9_]+)*$`);
|
|
377
|
+
for (const p of list) {
|
|
378
|
+
if (!isObj(p)) {
|
|
379
|
+
v.push({ clause: "C2-15", detail: "permissions 每项必须是键值对象" });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const code = p.code;
|
|
383
|
+
if (typeof code !== "string" || !codeRe.test(code)) {
|
|
384
|
+
v.push({ clause: "C2-15", detail: `permission code「${String(code)}」必须匹配 ^${name}\\.[a-z0-9_]+(\\.[a-z0-9_]+)*$(强制 <name>. 前缀)` });
|
|
385
|
+
}
|
|
386
|
+
const tk = p.title_key;
|
|
387
|
+
if (typeof tk !== "string" || !nameKeyRe(name, "perm").test(tk)) {
|
|
388
|
+
v.push({ clause: "C2-15", detail: `permission「${String(code)}」title_key 必填且须匹配 ${name}:perm.*(i18n 命名空间)` });
|
|
389
|
+
}
|
|
390
|
+
if (typeof code === "string") {
|
|
391
|
+
if (seen.has(code)) {
|
|
392
|
+
v.push({ clause: "C2-15", detail: `permission code「${code}」在本 manifest 内重复` });
|
|
393
|
+
}
|
|
394
|
+
seen.add(code);
|
|
395
|
+
permCodes.add(code);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── C2-16 roles:name <name>.<role> + title_key 必填 + permissions ⊆ permissions[].code(悬空双端拒绝)+ 唯一 ──
|
|
402
|
+
if (m.roles !== undefined) {
|
|
403
|
+
const list = m.roles;
|
|
404
|
+
if (!Array.isArray(list)) {
|
|
405
|
+
v.push({ clause: "C2-16", detail: "roles 必须是数组" });
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
const seen = new Set<string>();
|
|
409
|
+
const nameRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9-]{1,64}$`);
|
|
410
|
+
for (const r of list) {
|
|
411
|
+
if (!isObj(r)) {
|
|
412
|
+
v.push({ clause: "C2-16", detail: "roles 每项必须是键值对象" });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const rn = r.name;
|
|
416
|
+
if (typeof rn !== "string" || !nameRe.test(rn)) {
|
|
417
|
+
v.push({ clause: "C2-16", detail: `role name「${String(rn)}」必须匹配 ${name}.<role>(写入 sys_role.module=${name})` });
|
|
418
|
+
}
|
|
419
|
+
const tk = r.title_key;
|
|
420
|
+
if (typeof tk !== "string" || !nameKeyRe(name, "role").test(tk)) {
|
|
421
|
+
v.push({ clause: "C2-16", detail: `role「${String(rn)}」title_key 必填且须匹配 ${name}:role.*(i18n 命名空间)` });
|
|
422
|
+
}
|
|
423
|
+
const perms = r.permissions;
|
|
424
|
+
if (!Array.isArray(perms) || !perms.every(x => typeof x === "string")) {
|
|
425
|
+
v.push({ clause: "C2-16", detail: `role「${String(rn)}」permissions 必须是字符串数组` });
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
for (const code of perms) {
|
|
429
|
+
if (!permCodes.has(code)) {
|
|
430
|
+
v.push({ clause: "C2-16", detail: `role「${String(rn)}」引用了未声明的权限「${code}」(悬空引用:须先在 permissions[].code 声明)` });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (typeof rn === "string") {
|
|
435
|
+
if (seen.has(rn)) {
|
|
436
|
+
v.push({ clause: "C2-16", detail: `role name「${rn}」在本 manifest 内重复` });
|
|
437
|
+
}
|
|
438
|
+
seen.add(rn);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── C2-17~21 routes:category 三值 + path 语法/唯一 + order 必填 + title_key + required_permission ∈ permissions ──
|
|
445
|
+
const routePaths: string[] = [];
|
|
446
|
+
if (m.routes !== undefined) {
|
|
447
|
+
const list = m.routes;
|
|
448
|
+
if (!Array.isArray(list)) {
|
|
449
|
+
v.push({ clause: "C2-17", detail: "routes 必须是数组" });
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
const seen = new Set<string>();
|
|
453
|
+
for (const r of list) {
|
|
454
|
+
if (!isObj(r)) {
|
|
455
|
+
v.push({ clause: "C2-17", detail: "routes 每项必须是键值对象" });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const cat = r.category;
|
|
459
|
+
if (typeof cat !== "string" || !ROUTE_CATEGORIES.has(cat)) {
|
|
460
|
+
v.push({ clause: "C2-17", detail: `route「${String(r.path)}」category「${String(cat)}」必须是 main|admin|hidden(D4 三值;settings/personal 已废弃)` });
|
|
461
|
+
}
|
|
462
|
+
const path = r.path;
|
|
463
|
+
if (typeof path !== "string" || !ROUTE_PATH_RE.test(path)) {
|
|
464
|
+
v.push({ clause: "C2-17", detail: `route path「${String(path)}」必须匹配 ^/[a-z][a-z0-9\\-_]*(/[a-z0-9\\-_]+)*$(首段首字符字母;无参数段/通配符/尾斜杠)` });
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
if (seen.has(path)) {
|
|
468
|
+
v.push({ clause: "C2-17", detail: `route path「${path}」在本 manifest 内重复` });
|
|
469
|
+
}
|
|
470
|
+
seen.add(path);
|
|
471
|
+
routePaths.push(path);
|
|
472
|
+
}
|
|
473
|
+
if (!Number.isInteger(r.order)) {
|
|
474
|
+
v.push({ clause: "C2-17", detail: `route「${String(path)}」order 必填整数(建议 ≥200,框架保留 <200 间隔带)` });
|
|
475
|
+
}
|
|
476
|
+
const tk = r.title_key;
|
|
477
|
+
if (cat === "main" || cat === "admin") {
|
|
478
|
+
if (typeof tk !== "string" || !nameKeyRe(name, "menu").test(tk)) {
|
|
479
|
+
v.push({ clause: "C2-17", detail: `route「${String(path)}」(${String(cat)})title_key 必填且须匹配 ${name}:menu.*` });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
else if (tk !== undefined && (typeof tk !== "string" || !nameKeyRe(name, "menu").test(tk))) {
|
|
483
|
+
v.push({ clause: "C2-25", detail: `route「${String(path)}」(hidden)title_key 可省略,给出则须匹配 ${name}:menu.*` });
|
|
484
|
+
}
|
|
485
|
+
// C2-21:required_permission ∈ permissions[].code(R22:仅供后端深检比对,前端不下发)
|
|
486
|
+
const rp = r.required_permission;
|
|
487
|
+
if (rp !== undefined && (typeof rp !== "string" || !permCodes.has(rp))) {
|
|
488
|
+
v.push({ clause: "C2-21", detail: `route「${String(path)}」required_permission「${String(rp)}」未在 permissions[].code 声明(悬空引用)` });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── C2-22~24 settings:组标识/label_key/items 非空 + 键所有权(R24)+ 类型枚举/enum options ──
|
|
495
|
+
if (m.settings !== undefined) {
|
|
496
|
+
const s = m.settings;
|
|
497
|
+
if (!isObj(s) || !Array.isArray(s.groups)) {
|
|
498
|
+
v.push({ clause: "C2-22", detail: "settings.groups 必须是数组" });
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const seenGroups = new Set<string>();
|
|
502
|
+
const seenKeys = new Set<string>();
|
|
503
|
+
const groupRe = /^[a-z][a-z0-9-]{0,63}$/;
|
|
504
|
+
const keyRe = new RegExp(`^${escapeRe(name)}\\.[a-z0-9_-]+(\\.[a-z0-9_-]+)+$`);
|
|
505
|
+
for (const g of s.groups) {
|
|
506
|
+
if (!isObj(g)) {
|
|
507
|
+
v.push({ clause: "C2-22", detail: "settings.groups 每项必须是键值对象" });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const gn = g.group;
|
|
511
|
+
if (typeof gn !== "string" || !groupRe.test(gn)) {
|
|
512
|
+
v.push({ clause: "C2-22", detail: `设置组标识「${String(gn)}」必须匹配 ^[a-z][a-z0-9-]{0,63}$` });
|
|
513
|
+
}
|
|
514
|
+
else if (seenGroups.has(gn)) {
|
|
515
|
+
v.push({ clause: "C2-22", detail: `设置组「${gn}」在本 manifest 内重复(组标识 = sys_setting.group 值)` });
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
seenGroups.add(gn);
|
|
519
|
+
}
|
|
520
|
+
// C2-22:组 label_key 必填,指向 <name>:settings.group.*
|
|
521
|
+
const glk = g.label_key;
|
|
522
|
+
if (typeof glk !== "string" || !nameKeyRe(name, "settings.group").test(glk)) {
|
|
523
|
+
v.push({ clause: "C2-22", detail: `设置组「${String(gn)}」label_key 必填且须匹配 ${name}:settings.group.*(系统设置页组标题)` });
|
|
524
|
+
}
|
|
525
|
+
if (!Array.isArray(g.items) || g.items.length === 0) {
|
|
526
|
+
v.push({ clause: "C2-22", detail: `设置组「${String(gn)}」items 必须是非空数组` });
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
for (const item of g.items) {
|
|
530
|
+
if (!isObj(item)) {
|
|
531
|
+
v.push({ clause: "C2-23", detail: `设置组「${String(gn)}」items 每项必须是键值对象` });
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// C2-23(R24 键所有权):moduleOf(key) === name——正则强制 <name>. 前缀即首段所有权,
|
|
535
|
+
// framework.*/system.*/market.* 等越空间污染由此拒绝;键写入 sys_setting.key
|
|
536
|
+
const key = item.key;
|
|
537
|
+
if (typeof key !== "string" || !keyRe.test(key)) {
|
|
538
|
+
v.push({ clause: "C2-23", detail: `设置键「${String(key)}」必须匹配 ^${name}\\.[a-z0-9_-]+(\\.[a-z0-9_-]+)+$(键所有权:moduleOf(key) === ${name},R24)` });
|
|
539
|
+
}
|
|
540
|
+
else if (seenKeys.has(key)) {
|
|
541
|
+
v.push({ clause: "C2-23", detail: `设置键「${key}」在本 manifest 内重复` });
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
seenKeys.add(key);
|
|
545
|
+
}
|
|
546
|
+
const type = item.type;
|
|
547
|
+
if (typeof type !== "string" || !SETTING_TYPES.has(type)) {
|
|
548
|
+
v.push({ clause: "C2-24", detail: `设置项「${String(key)}」type「${String(type)}」必须是 string|bool|int|float|enum|text` });
|
|
549
|
+
}
|
|
550
|
+
else if (type === "enum") {
|
|
551
|
+
const opts = item.options;
|
|
552
|
+
if (!Array.isArray(opts) || opts.length < 1 || !opts.every(x => typeof x === "string")) {
|
|
553
|
+
v.push({ clause: "C2-24", detail: `设置项「${String(key)}」type=enum 时 options 必填(string[] ≥1 项)` });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const ilk = item.label_key;
|
|
557
|
+
if (ilk !== undefined && (typeof ilk !== "string" || !nameKeyRe(name, "settings").test(ilk))) {
|
|
558
|
+
v.push({ clause: "C2-24", detail: `设置项「${String(key)}」label_key 须匹配 ${name}:settings.*` });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── C2-33 health:值 = C2-17 path 语法 + 存在 r ∈ routes 使值 === r.path 或以 r.path + "/" 开头 ──
|
|
566
|
+
if (m.health !== undefined) {
|
|
567
|
+
const h = m.health;
|
|
568
|
+
if (typeof h !== "string" || !ROUTE_PATH_RE.test(h)) {
|
|
569
|
+
v.push({ clause: "C2-33", detail: `health「${String(h)}」必须匹配路由 path 语法 ^/[a-z][a-z0-9\\-_]*(/[a-z0-9\\-_]+)*$` });
|
|
570
|
+
}
|
|
571
|
+
else if (!routePaths.some(p => h === p || h.startsWith(`${p}/`))) {
|
|
572
|
+
v.push({ clause: "C2-33", detail: `health「${h}」必须是本方案路由或其子路径(routes[] 中存在 r 使 health === r.path 或以 r.path + "/" 开头)` });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return report(v);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** 收集全部违规后统一报出(契约 §0 实现纪律);失败一律 retryable=false(附录 A preflight 行) */
|
|
580
|
+
function report(v: Violation[]): StageResult {
|
|
581
|
+
if (v.length === 0) {
|
|
582
|
+
return { stage: "preflight", ok: true, message: "预检通过:manifest 满足 C2 契约(身份/兼容/占用/匿名/RBAC/路由/设置/health 安全预检全部通过)" };
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
stage: "preflight",
|
|
586
|
+
ok: false,
|
|
587
|
+
retryable: false,
|
|
588
|
+
message: `预检未通过(${v.length} 项违规,全量列出):\n${v.map(x => `- 【${x.clause}】${x.detail}`).join("\n")}`,
|
|
589
|
+
};
|
|
590
|
+
}
|