@oj-module/cli 0.1.17 → 0.1.21

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 (69) hide show
  1. package/devkit/README.md +11 -10
  2. package/devkit/SKILL.md +32 -18
  3. package/devkit/manual.md +332 -137
  4. package/package.json +2 -2
  5. package/shell-dist/assets/contract.js +0 -55
  6. package/shell-dist/assets/runtime.js +229 -213
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +5 -5
  9. package/src/build.ts +3 -0
  10. package/src/contract/__tests__/check-api.test.ts +74 -0
  11. package/src/contract/__tests__/emit-client.test.ts +132 -0
  12. package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
  13. package/src/contract/__tests__/mock.test.ts +56 -0
  14. package/src/contract/__tests__/openapi-ir.test.ts +137 -0
  15. package/src/contract/__tests__/run-api.test.ts +67 -0
  16. package/src/contract/check.ts +67 -294
  17. package/src/contract/emit-client.ts +23 -15
  18. package/src/contract/emit-jsonschema-zod.ts +209 -0
  19. package/src/contract/emit-oj-schema.ts +108 -161
  20. package/src/contract/emit-stub.ts +17 -57
  21. package/src/contract/handler-scan.ts +263 -0
  22. package/src/contract/ir.ts +11 -179
  23. package/src/contract/json-schema-sample.ts +72 -0
  24. package/src/contract/mock.ts +28 -100
  25. package/src/contract/openapi-ir.ts +145 -0
  26. package/src/contract/run.ts +71 -120
  27. package/src/dev.ts +20 -18
  28. package/src/init.ts +18 -3
  29. package/src/oj.ts +6 -2
  30. package/src/vendor.ts +8 -4
  31. package/templates/api/config.yaml +1 -0
  32. package/templates/api/src/auth/api.ts +66 -0
  33. package/templates/api/src/notification/_shared/notify.ts +199 -0
  34. package/templates/api/src/notification/api.ts +231 -0
  35. package/templates/api/src/notification/del/api.ts +47 -0
  36. package/templates/api/src/notification/manifest.yaml +3 -1
  37. package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
  38. package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
  39. package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
  40. package/templates/api/src/notification/schema.yaml +23 -1
  41. package/templates/api/src/notification/seed.sql +6 -4
  42. package/templates/api/src/personal-center/upload/api.ts +12 -1
  43. package/templates/env.d.ts +1 -1
  44. package/templates/openapi.json +471 -0
  45. package/templates/web/src/auth/__client/api.schemas.ts +8 -0
  46. package/templates/web/src/auth/__client/api.ts +110 -0
  47. package/templates/web/src/notification/__client/api.schemas.ts +50 -0
  48. package/templates/web/src/notification/{client → __client}/api.ts +29 -29
  49. package/templates/web/src/notification/entry.ts +11 -9
  50. package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
  51. package/templates/web/src/notifications/__client/api.ts +80 -0
  52. package/templates/web/src/personal-center/__client/api.schemas.ts +10 -0
  53. package/templates/web/src/personal-center/__client/api.ts +88 -0
  54. package/templates/web/src/web/__client/api.schemas.ts +8 -0
  55. package/templates/web/src/web/__client/api.ts +110 -0
  56. package/vendor/host-versions.json +5 -5
  57. package/src/contract/emit-meta.ts +0 -175
  58. package/src/contract/emit-schema.ts +0 -133
  59. package/src/contract/evaluate.ts +0 -88
  60. package/templates/api/src/auth/login/api.ts +0 -27
  61. package/templates/api/src/auth/logout/api.ts +0 -10
  62. package/templates/api/src/auth/refresh/api.ts +0 -26
  63. package/templates/api/src/notification/contract.ts +0 -98
  64. package/templates/api/src/notification/notifications/api.ts +0 -24
  65. package/templates/api/src/notification/notifications/clear/api.ts +0 -13
  66. package/templates/api/src/notification/notifications/read/api.ts +0 -19
  67. package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
  68. package/templates/api/src/personal-center/contract.ts +0 -16
  69. package/templates/web/src/notification/client/api.schemas.ts +0 -50
@@ -0,0 +1,263 @@
1
+ import type { IrEndpoint } from "./ir";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import ts from "typescript";
5
+
6
+ /**
7
+ * oj handler 文件的 AST 扫描(scan 职责独立成模块,供 check 对账与 emit-stub
8
+ * 已实现判定共用——check → run/emit-stub 存在反向依赖,扫描器放哪边都会循环)。
9
+ *
10
+ * 扫描口径与 oj v0.1.64 内省同规则:default 导出对象的动词键 = 端点;非动词键 +
11
+ * 对象值 = 嵌套组(组名 = 路径段,字符串字面量键与标识符键同等);语句起始的
12
+ * `.route = "..."` 为路由覆写(绝对 / 开头挂 base 根下,否则相对本层前缀)。
13
+ */
14
+
15
+ /**
16
+ * oj 方法名 → HTTP 动词(emit-stub 的反向映射)。
17
+ * 注意**不含 options**:契约运行时故意不表达 OPTIONS(预检请求由 fetch/CORS 层处理,
18
+ * 见 runtime/contract 的方法白名单)——oj 树里的 options handler 因此永不进对账表
19
+ * (不会被误报「未登记」);routes.js 的 options 行经 parseRoutesJs 的
20
+ * 大写回落归一,同样不误报 drift。
21
+ */
22
+ export const VERB_OF: Record<string, IrEndpoint["method"]> = {
23
+ get: "GET",
24
+ post: "POST",
25
+ put: "PUT",
26
+ del: "DELETE",
27
+ patch: "PATCH",
28
+ head: "HEAD",
29
+ };
30
+
31
+ /** oj v0.1.64 嵌套上限(与 oj 内省一致);超出即 oj 启动报错,checker 不深追 */
32
+ const MAX_GROUP_DEPTH = 4;
33
+
34
+ export interface HandlerRow {
35
+ /** HTTP 动词 */
36
+ method: IrEndpoint["method"]
37
+ /** 目录镜像相对 api/src 的路径段(含模块段),如 "order/item" */
38
+ dir: string
39
+ /** 语句起始 `.route = "..."` 字面量(无则目录镜像直达) */
40
+ tail?: string
41
+ /** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
42
+ chain: string
43
+ /** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;绝对 .route 即其自身) */
44
+ full: string
45
+ filePath: string
46
+ }
47
+
48
+ /** `a` / `a.b` 形式的属性访问 → 点分名;其余(调用/元素访问)→ undefined */
49
+ function dottedNameOf(node: ts.Expression): string | undefined {
50
+ if (ts.isIdentifier(node))
51
+ return node.text;
52
+ if (ts.isPropertyAccessExpression(node)) {
53
+ const base = dottedNameOf(node.expression);
54
+ return base ? `${base}.${node.name.text}` : undefined;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * 语句起始的 `<路径>.route = "字面量"`(路径可为点分:`cccc.post.route`,运行时即
61
+ * `cccc.post` 函数对象的 `.route`——与 oj 内省读 `fn.route` 同源)。空串视同未挂
62
+ * (oj 口径)。oj build 只认顶层标准赋值,这里同口径且放宽到点分左值。
63
+ */
64
+ function collectRouteDecls(sf: ts.SourceFile): Map<string, string> {
65
+ const routes = new Map<string, string>();
66
+ for (const stmt of sf.statements) {
67
+ if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression))
68
+ continue;
69
+ const expr = stmt.expression;
70
+ if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
71
+ continue;
72
+ if (!ts.isPropertyAccessExpression(expr.left) || expr.left.name.text !== "route")
73
+ continue;
74
+ if (!ts.isStringLiteral(expr.right) || expr.right.text === "")
75
+ continue;
76
+ const path = dottedNameOf(expr.left.expression);
77
+ if (path)
78
+ routes.set(path, expr.right.text);
79
+ }
80
+ return routes;
81
+ }
82
+
83
+ /** 顶层 `const x = { ... }` 对象字面量表:嵌套组经标识符引用时的解析依据 */
84
+ function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpression> {
85
+ const decls = new Map<string, ts.ObjectLiteralExpression>();
86
+ for (const stmt of sf.statements) {
87
+ if (!ts.isVariableStatement(stmt))
88
+ continue;
89
+ for (const decl of stmt.declarationList.declarations) {
90
+ if (ts.isIdentifier(decl.name) && decl.initializer && ts.isObjectLiteralExpression(decl.initializer))
91
+ decls.set(decl.name.text, decl.initializer);
92
+ }
93
+ }
94
+ return decls;
95
+ }
96
+
97
+ /** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
98
+ function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
99
+ if (ts.isShorthandPropertyAssignment(prop))
100
+ return prop.name.text;
101
+ if (ts.isMethodDeclaration(prop) && prop.name)
102
+ return prop.name.getText();
103
+ if (ts.isPropertyAssignment(prop)) {
104
+ if (ts.isIdentifier(prop.name) && ts.isIdentifier(prop.initializer))
105
+ return prop.initializer.text;
106
+ if (prop.name)
107
+ return prop.name.getText();
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /** 非 verb 键 → 子组对象字面量(内联或经标识符解析);函数/数组/基元 → undefined */
113
+ function groupValueOf(prop: ts.ObjectLiteralElementLike, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
114
+ if (ts.isPropertyAssignment(prop)) {
115
+ if (ts.isObjectLiteralExpression(prop.initializer))
116
+ return prop.initializer;
117
+ if (ts.isIdentifier(prop.initializer))
118
+ return decls.get(prop.initializer.text);
119
+ return undefined;
120
+ }
121
+ if (ts.isShorthandPropertyAssignment(prop))
122
+ return decls.get(prop.name.text);
123
+ return undefined;
124
+ }
125
+
126
+ /** default 导出对象:内联字面量或标识符(`export default api`) */
127
+ function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
128
+ for (const stmt of sf.statements) {
129
+ if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
130
+ continue;
131
+ if (ts.isObjectLiteralExpression(stmt.expression))
132
+ return stmt.expression;
133
+ if (ts.isIdentifier(stmt.expression))
134
+ return decls.get(stmt.expression.text);
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ /** 相对路由按层前缀解析(绝对 `/` 开头即根级);与 oj 内省 resolve 同规则 */
140
+ function resolveRoute(own: string, prefix: string): string {
141
+ return own.startsWith("/") ? own : (prefix ? `${prefix}/${own}` : own);
142
+ }
143
+
144
+ /** 有效路由全路径(base 相对、无前导 /):绝对 .route 取其自身,否则目录镜像 + 相对尾 */
145
+ function fullPathOf(dir: string, tail: string | undefined): string {
146
+ if (tail?.startsWith("/"))
147
+ return tail.replace(/^\//, "");
148
+ return [dir, tail].filter(Boolean).join("/");
149
+ }
150
+
151
+ /** 对账键(方法 + 全路径) */
152
+ export function pathKey(method: string, path: string): string {
153
+ return `${method} ${path}`;
154
+ }
155
+
156
+ /**
157
+ * handler 的有效 wire 全路径(带前导 /):绝对 `.route` 即其自身;相对尾巴 = `/` + full。
158
+ * 用于与契约 contractAbs(wirePrefix + route)对齐比对。
159
+ */
160
+ export function handlerAbs(h: HandlerRow): string {
161
+ return h.tail?.startsWith("/") ? h.tail : `/${h.full}`;
162
+ }
163
+
164
+ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow[] {
165
+ const text = readFileSync(filePath, "utf8");
166
+ const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
167
+ const routes = collectRouteDecls(sf);
168
+ const decls = collectObjectDecls(sf);
169
+ const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
170
+ const rows: HandlerRow[] = [];
171
+ const seen = new Set<ts.Node>();
172
+
173
+ const walk = (obj: ts.ObjectLiteralExpression, chainPrefix: string, routePrefix: string, depth: number): void => {
174
+ if (depth > MAX_GROUP_DEPTH || seen.has(obj))
175
+ return;
176
+ seen.add(obj);
177
+ for (const prop of obj.properties) {
178
+ // oj v0.1.64:组名可以是连字符等非标识符("audit-log"),字符串字面量键与
179
+ // 标识符键同等解析。保留限制:元素访问式元数据(api["x-y"].route = ...)
180
+ // 仍不支持——reshape 授权形态为字符串字面量键 + 组内函数挂 .route。
181
+ const name = (prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)))
182
+ ? prop.name.text
183
+ : undefined;
184
+ if (!name)
185
+ continue;
186
+ const chain = chainPrefix ? `${chainPrefix}.${name}` : name;
187
+ if (VERB_OF[name]) {
188
+ const fnName = implNameOf(prop) ?? name;
189
+ const own = routes.get(chain) ?? routes.get(fnName);
190
+ // 无自身 .route → 继承本层前缀(根层即目录镜像);与 oj 内省同规则
191
+ const tail = own === undefined
192
+ ? (routePrefix || undefined)
193
+ : resolveRoute(own, routePrefix);
194
+ rows.push({
195
+ method: VERB_OF[name],
196
+ dir: relDir,
197
+ tail,
198
+ chain,
199
+ full: fullPathOf(relDir, tail),
200
+ filePath,
201
+ });
202
+ }
203
+ else {
204
+ const child = groupValueOf(prop, decls);
205
+ if (!child)
206
+ continue; // 函数/数组/基元:oj 红线,checker 不当路由
207
+ const own = routes.get(chain);
208
+ const childPrefix = own === undefined
209
+ ? (routePrefix ? `${routePrefix}/${name}` : name)
210
+ : resolveRoute(own, routePrefix);
211
+ walk(child, chain, childPrefix, depth + 1);
212
+ }
213
+ }
214
+ seen.delete(obj);
215
+ };
216
+
217
+ const root = defaultExportObject(sf, decls);
218
+ if (root) {
219
+ // 文件层 .route:default 导出为标识符时,该标识符的 .route 语句即整文件默认前缀
220
+ let filePrefix = "";
221
+ for (const stmt of sf.statements) {
222
+ if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
223
+ const own = routes.get(stmt.expression.text);
224
+ if (own !== undefined)
225
+ filePrefix = resolveRoute(own, "");
226
+ }
227
+ }
228
+ walk(root, "", filePrefix, 1);
229
+ }
230
+ return rows;
231
+ }
232
+
233
+ /** 递归收全部 api.ts / api.js(目录形态与模块根单文件同扫) */
234
+ export function walkApiFiles(dir: string, out: string[] = []): string[] {
235
+ if (!existsSync(dir))
236
+ return out;
237
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
238
+ const p = join(dir, entry.name);
239
+ if (entry.isDirectory())
240
+ walkApiFiles(p, out);
241
+ else if (entry.name === "api.ts" || entry.name === "api.js")
242
+ out.push(p);
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * 已实现路由键集合(`"METHOD /abs/path"`,含绝对 .route 形态)。
249
+ * emit-stub 据此跳过「已在别处(模块根嵌套组 / 其他文件)实现」的端点——否则 DDD
250
+ * reshape 后镜像 stub 与真实 handler 双写同路由,oj 路由表出现重复注册。
251
+ */
252
+ export function implementedRouteKeys(apiSrcDir: string): Set<string> {
253
+ const keys = new Set<string>();
254
+ for (const f of walkApiFiles(apiSrcDir)) {
255
+ const text = readFileSync(f, "utf8");
256
+ // stub 镜像文件不算实现——它们覆盖的路由需照常报「stub 缺失/待更新」
257
+ if (/^\/\/ (?:ojm|ram)-api:stub /.test(text))
258
+ continue;
259
+ for (const row of scanHandlerFile(f, apiSrcDir))
260
+ keys.add(pathKey(row.method, handlerAbs(row)));
261
+ }
262
+ return keys;
263
+ }
@@ -1,48 +1,12 @@
1
- import type { ApiDefinitionInput } from "@oj-module/runtime/contract";
2
- import { API_DEF } from "@oj-module/runtime/contract";
3
-
4
- /**
5
- * 旧端点品牌标记(`ram.api.def`):存量工程若仍装着旧 runtime 产物,其端点打旧符号。
6
- * 只读兼容识别,故意不进 runtime 公共出口(兼容专用、且下个 major 会随之移除)。
7
- */
8
- const API_DEF_LEGACY = Symbol.for("ram.api.def");
9
-
10
1
  /**
11
- * AC-D12:契约 IR 构建 + schema 白名单。
2
+ * 统一中间表示(IR)。
12
3
  *
13
- * IR 是 codegen 的统一中间表示:evaluate 出的运行时对象在这里归一
14
- * (method 缺省、fullPath 拼接、参数段提取、目录切分),各发射器只消费 IR,
15
- * 不再触碰 zod 内部结构(除 emit-schema 的白名单内递归发射)。
4
+ * 翻译层退役后,IR 由 `openapi.json`(oj v0.1.73 `oj openapi` 产物)经
5
+ * `openapi-ir.ts` 构造——本模块只承载 IR 的**类型定义**与少量与路由拼接/切分
6
+ * 相关的纯函数(`wirePrefixOf` / `splitRoute`),供 emit-client、emit-stub、check
7
+ * 复用,不再包含任何 zod 求值/白名单逻辑。
16
8
  */
17
9
 
18
- /** schema 白名单 def.type(zod v4 `_zod.def.type`) */
19
- const ALLOWED_TYPES = new Set([
20
- "string",
21
- "number",
22
- "boolean",
23
- "date",
24
- "object",
25
- "array",
26
- "enum",
27
- "literal",
28
- "union",
29
- "optional",
30
- "nullable",
31
- "default",
32
- ]);
33
-
34
- /** 白名单内 string/number 约束 check 名(zod v4 check 实例 `_zod.def.check`) */
35
- const ALLOWED_CHECKS = new Set([
36
- "min_length",
37
- "max_length",
38
- "length_equals",
39
- "greater_than",
40
- "less_than",
41
- "multiple_of",
42
- "number_format",
43
- "string_format",
44
- ]);
45
-
46
10
  /** multipart 文件部件(DSL 声明归一:required/multiple 显式化,发射器不再看 undefined) */
47
11
  export interface IrFormFile {
48
12
  name: string
@@ -51,7 +15,7 @@ export interface IrFormFile {
51
15
  }
52
16
 
53
17
  /**
54
- * multipart 请求体:文本部件保留 schema(转 JSON Schema / 发射 api.schemas 用),
18
+ * multipart 请求体:文本部件保留 schema(JSON Schema,转 api.schemas 用),
55
19
  * 文件部件只有名字与形态——binary 刻意不进 schema(FormData 由生成 client 组装)。
56
20
  */
57
21
  export interface IrForm {
@@ -62,7 +26,7 @@ export interface IrForm {
62
26
  }
63
27
 
64
28
  export interface IrEndpoint {
65
- /** 契约文件里的导出名,如 "getOrderDetail" */
29
+ /** 端点方法名(来自 operationId 的 camelCase 推导) */
66
30
  name: string
67
31
  apiPrefix: string
68
32
  /** 线上前缀(D-M8):缺省 = apiPrefix */
@@ -77,7 +41,7 @@ export interface IrEndpoint {
77
41
  querySchema?: unknown
78
42
  paramsSchema?: unknown
79
43
  bodySchema?: unknown
80
- /** multipart/form-data 请求体(与 bodySchema 互斥,定义期已拦截) */
44
+ /** multipart/form-data 请求体(与 bodySchema 互斥) */
81
45
  form?: IrForm
82
46
  dataSchema?: unknown
83
47
  raw: boolean
@@ -85,60 +49,15 @@ export interface IrEndpoint {
85
49
  description?: string
86
50
  }
87
51
 
88
- interface ZodLike { _zod?: { def?: Record<string, unknown> } }
89
-
90
- function defOf(schema: unknown, where: string): Record<string, unknown> {
91
- const def = (schema as ZodLike)?._zod?.def;
92
- if (!def || typeof def.type !== "string") {
93
- throw new Error(`[ojm-api] 契约 schema 白名单校验失败(${where}):不是 zod schema——契约只接受 @oj-module/runtime/contract 导出的 z 构建的 schema。`);
94
- }
95
- return def;
96
- }
97
-
98
- /**
99
- * 递归白名单校验:拒绝 pipe/transform(def.type "pipe")、refine(custom check)、
100
- * coerce(def.coerce)、lazy 等——它们无法保真反向发射为源码 / 无法转 OpenAPI。
101
- */
102
- export function assertWhitelisted(schema: unknown, where: string): void {
103
- const def = defOf(schema, where);
104
- const type = def.type as string;
105
- if (!ALLOWED_TYPES.has(type)) {
106
- throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):类型 "${type}" 不支持。白名单:${[...ALLOWED_TYPES].join("/")};transform/refine/coerce/pipe/lazy 无法进入契约(无法转 OpenAPI 与保真发射),请改用纯数据结构约束。`);
107
- }
108
- if (def.coerce === true) {
109
- throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):z.coerce.* 不支持——params/query 请直接声明语义类型(如 z.number()),URL 序列化由生成物负责。`);
110
- }
111
- for (const check of (def.checks as unknown[]) ?? []) {
112
- const checkName = (check as ZodLike)?._zod?.def?.check as string | undefined;
113
- if (checkName && !ALLOWED_CHECKS.has(checkName)) {
114
- throw new Error(`[ojm-api] 契约 schema 超出白名单(${where}):约束 "${checkName}" 不支持(refine/superRefine 属 custom 校验,无法转 OpenAPI)。`);
115
- }
116
- }
117
- if (type === "object") {
118
- for (const [key, child] of Object.entries(def.shape as Record<string, unknown>)) {
119
- assertWhitelisted(child, `${where}.${key}`);
120
- }
121
- }
122
- else if (type === "array") {
123
- assertWhitelisted(def.element, `${where}[]`);
124
- }
125
- else if (type === "union") {
126
- (def.options as unknown[]).forEach((opt, i) => assertWhitelisted(opt, `${where}<${i}>`));
127
- }
128
- else if (type === "optional" || type === "nullable" || type === "default") {
129
- assertWhitelisted(def.innerType, where);
130
- }
52
+ /** 线上前缀(D-M8):urlPrefix 声明时取之(URL 命名空间 ≠ 目录名),否则回落 apiPrefix */
53
+ export function wirePrefixOf(api: { apiPrefix: string, urlPrefix?: string }): string {
54
+ return api.urlPrefix ?? api.apiPrefix;
131
55
  }
132
56
 
133
57
  /**
134
58
  * route →(目录镜像 + `.route` 尾巴)切分(AC-D10 §4 算法,stub 生成与
135
59
  * --check 对账共用同一实现):静态前缀段 → 目录;自首个参数段起的尾巴 → `.route`。
136
60
  */
137
- /** 线上前缀(D-M8):urlPrefix 声明时取之(URL 命名空间 ≠ 目录名),否则回落 apiPrefix */
138
- export function wirePrefixOf(api: { apiPrefix: string, urlPrefix?: string }): string {
139
- return api.urlPrefix ?? api.apiPrefix;
140
- }
141
-
142
61
  export function splitRoute(route: string): { dir: string, tail?: string } {
143
62
  const segs = route.split("/").filter(Boolean);
144
63
  const firstParam = segs.findIndex(seg => seg.startsWith("{"));
@@ -149,90 +68,3 @@ export function splitRoute(route: string): { dir: string, tail?: string } {
149
68
  tail: segs.slice(firstParam).join("/"),
150
69
  };
151
70
  }
152
-
153
- /** 提取 route 参数段名(catch-all 去 * 前缀) */
154
- function paramNamesOf(route: string): string[] {
155
- return route.split("/")
156
- .filter(seg => seg.startsWith("{"))
157
- .map(seg => seg.replace(/^\{\*?/, "").replace(/\}$/, ""));
158
- }
159
-
160
- /** evaluate 出的模块导出 → IR 端点数组 */
161
- export function buildIr(exports: Record<string, unknown>): IrEndpoint[] {
162
- const endpoints: IrEndpoint[] = [];
163
- for (const [name, value] of Object.entries(exports)) {
164
- // 双符号识别(R3):新契约打 ojm.api.def,存量(旧 runtime 产物)打 ram.api.def
165
- const brand = value as Record<PropertyKey, unknown> | undefined;
166
- if (brand?.[API_DEF] !== true && brand?.[API_DEF_LEGACY] !== true)
167
- continue;
168
- const def = value as ApiDefinitionInput;
169
- // schema 白名单(报错文案含端点名 + 字段路径)
170
- for (const slot of ["query", "params", "body", "data"] as const) {
171
- const schema = def[slot];
172
- if (schema)
173
- assertWhitelisted(schema, `${name}.${slot}`);
174
- }
175
- // multipart/form-data(与 body 互斥,定义期已拦截):文本部件走同一套 schema 白名单;
176
- // 文件部件只有名字/形态,二进制刻意不进 schema(JSON Schema 无 binary、FormData 由 client 组装)
177
- let form: IrForm | undefined;
178
- if (def.form) {
179
- let fieldNames: string[] = [];
180
- if (def.form.fields) {
181
- assertWhitelisted(def.form.fields, `${name}.form.fields`);
182
- const fieldsDef = defOf(def.form.fields, `${name}.form.fields`);
183
- if (fieldsDef.type !== "object") {
184
- throw new Error(`[ojm-api] 契约端点 "${name}":form.fields 必须是 z.object(收到类型 "${String(fieldsDef.type)}")——文本部件按字段名进 multipart 表单(OpenAPI 同形);单个非对象值请改用 body(JSON)。`);
185
- }
186
- fieldNames = Object.keys(fieldsDef.shape as Record<string, unknown>);
187
- }
188
- form = {
189
- fieldsSchema: def.form.fields,
190
- fieldNames,
191
- files: (def.form.files ?? []).map(f => ({ name: f.name, required: f.required === true, multiple: f.multiple === true })),
192
- };
193
- }
194
- const paramNames = paramNamesOf(def.route);
195
- // 评审 F8:params schema 键必须覆盖 route 参数段——否则生成物 URL 插值出 undefined
196
- if (def.params) {
197
- const shape = (def.params as unknown as ZodLike)?._zod?.def?.shape as Record<string, unknown> | undefined;
198
- if (!shape) {
199
- throw new Error(`[ojm-api] 契约端点 "${name}":params 必须是 z.object(收到非 object schema)——params 与 route 参数段一一对应。`);
200
- }
201
- const missing = paramNames.filter(p => !(p in shape));
202
- if (missing.length) {
203
- throw new Error(`[ojm-api] 契约端点 "${name}":params schema 缺 route 参数段 ${missing.map(p => `{${p}}`).join("、")} 的键——两者须一一对应(缺省 params 则参数段全按 string 声明,二选一)。`);
204
- }
205
- }
206
- endpoints.push({
207
- name,
208
- apiPrefix: def.apiPrefix,
209
- urlPrefix: def.urlPrefix,
210
- route: def.route,
211
- method: def.method ?? "GET",
212
- // apiPrefix "/" 为框架内部根级端点(如 runtime 自带 role-list)——拼出 "//" 即错
213
- fullPath: wirePrefixOf(def) === "/" ? def.route : `${wirePrefixOf(def)}${def.route}`,
214
- paramNames,
215
- querySchema: def.query,
216
- paramsSchema: def.params,
217
- bodySchema: def.body,
218
- form,
219
- dataSchema: def.data,
220
- raw: def.response === "raw",
221
- ignoreLoading: def.ignoreLoading,
222
- description: def.description,
223
- });
224
- }
225
-
226
- // 同(目录, method)冲突:oj 中同一 api.ts 文件同一方法名只能有一个 handler
227
- const seen = new Map<string, string>();
228
- for (const ep of endpoints) {
229
- const { dir } = splitRoute(ep.route);
230
- const key = `${ep.apiPrefix}|${dir}|${ep.method}`;
231
- const existing = seen.get(key);
232
- if (existing) {
233
- throw new Error(`[ojm-api] 契约路由冲突:"${existing}" 与 "${ep.name}" 都映射到 ${ep.apiPrefix}/${dir}/api.ts 的 ${ep.method}——同一目录同一方法只能有一个端点(oj 一文件一方法一 handler),请调整 route 拆目录。`);
234
- }
235
- seen.set(key, ep.name);
236
- }
237
- return endpoints;
238
- }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * JSON Schema(oj `openapi.json` 白名单子集)→ 示例值。
3
+ *
4
+ * 零依赖(不引入 faker,保证 emit-stub 复用时不与 mock 形成循环依赖):
5
+ * 示例值**确定性**生成,便于 stub / mock 字节稳定。仅覆盖 oj `.schema` 表达力
6
+ * (type / properties / items / enum / min / max / minLength / maxLength);
7
+ * `pattern` 是 Rust regex,无法表达,忽略(示例值不强制满足 pattern)。
8
+ *
9
+ * 语义与 emit-stub 的示例源码生成共用同一套(emit-stub 用 JSON.stringify 把返回值
10
+ * 包成 TS 字面量)。
11
+ */
12
+
13
+ function numOf(value: unknown): number | undefined {
14
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
15
+ }
16
+
17
+ /** 类型数组 ["string","null"] → 取非 null 实类型;其余返回 undefined(无法取样本) */
18
+ function singleTypeOf(type: unknown): string | undefined {
19
+ if (typeof type === "string")
20
+ return type;
21
+ if (Array.isArray(type)) {
22
+ const nonNull = (type as unknown[]).filter(t => t !== "null");
23
+ return nonNull.length === 1 ? (nonNull[0] as string) : undefined;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ export function jsonSchemaToSample(schema: unknown): unknown {
29
+ if (!schema || typeof schema !== "object")
30
+ return null;
31
+ const s = schema as Record<string, unknown>;
32
+ // enum 优先:oj 把 zod enum 翻成 {type, enum},示例值应取枚举首值(而非「示例」占位),
33
+ // 否则 mock/client 示例值永远偏离真实取值。
34
+ if (Array.isArray(s.enum) && s.enum.length > 0)
35
+ return s.enum[0];
36
+ const type = singleTypeOf(s.type);
37
+
38
+ switch (type) {
39
+ case "string": {
40
+ const min = numOf(s.minLength) ?? 0;
41
+ const max = numOf(s.maxLength) ?? Number.POSITIVE_INFINITY;
42
+ // 默认「示例」恰好 2 字:低于 min 则补足,高于 max 则截断(保约束成立)
43
+ let word = "示例";
44
+ if (word.length < min)
45
+ word = "示".repeat(Math.max(min, 1));
46
+ if (Number.isFinite(max) && word.length > max)
47
+ word = word.slice(0, max);
48
+ return word;
49
+ }
50
+ case "integer":
51
+ case "number": {
52
+ const min = numOf(s.minimum) ?? 1;
53
+ const lo = Number.isFinite(min) ? Math.ceil(min) : 1;
54
+ return lo;
55
+ }
56
+ case "boolean":
57
+ return true;
58
+ case "array":
59
+ return [jsonSchemaToSample(s.items)];
60
+ case "object": {
61
+ const out: Record<string, unknown> = {};
62
+ const props = (s.properties as Record<string, unknown>) ?? {};
63
+ for (const [k, v] of Object.entries(props))
64
+ out[k] = jsonSchemaToSample(v);
65
+ return out;
66
+ }
67
+ default:
68
+ if (Array.isArray(s.enum) && s.enum.length > 0)
69
+ return s.enum[0];
70
+ return null;
71
+ }
72
+ }