@oj-module/cli 0.1.17 → 0.1.20

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 +318 -131
  4. package/package.json +2 -2
  5. package/shell-dist/assets/contract.js +0 -55
  6. package/shell-dist/assets/runtime.js +206 -212
  7. package/shell-dist/index.html +2 -2
  8. package/shell-dist/versions.json +5 -5
  9. package/src/build.ts +1 -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
@@ -1,20 +1,23 @@
1
+ import type { HandlerRow } from "./handler-scan";
1
2
  import type { IrEndpoint } from "./ir";
3
+ import type { OpenApiDoc } from "./openapi-ir";
2
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
3
5
  import { dirname, join, relative, sep } from "node:path";
4
- import ts from "typescript";
5
6
  import { readOjAnonymousPaths } from "../oj-config";
6
7
  import { emitClient } from "./emit-client";
7
- import { emitOpenapiYaml, emitRoutesJson } from "./emit-meta";
8
8
  import { planStubWrites } from "./emit-stub";
9
+ import { handlerAbs, implementedRouteKeys, pathKey, scanHandlerFile, VERB_OF, walkApiFiles } from "./handler-scan";
9
10
  import { splitRoute, wirePrefixOf } from "./ir";
10
- import { artifactPaths, discoverContracts, irOf } from "./run";
11
+ import { irFromOpenapi } from "./openapi-ir";
12
+ import { findOpenapiJson, groupIrByModule } from "./run";
11
13
 
12
14
  /**
13
15
  * AC-D10:`ojm api --check` 三重校验(纯对账不写盘)——
14
16
  * ① 生成物同步:内存重生成 vs 磁盘逐字节 diff(含 stub 待更新)
15
17
  * ② route 双向对账:AST 扫 oj api.ts(default 导出方法名 + 语句起始 `.route = "..."` 赋值)
16
18
  * vs 契约路由表:未实现 warn / 未登记 error / 参数段不一致 error
17
- * ③ routes.js diff:release 路由表(oj build 产物)vs routes.json;无 dist 给提示不判违规
19
+ * ③ routes.js diff:release 路由表(oj build 产物,api/dist 下各模块的 routes.js)vs openapi.json
20
+ * 契约路由表;无 dist 给提示不判违规
18
21
  *
19
22
  * 原则:check 永不修文件——修复动作永远由人执行(重跑 ojm api / 补 handler / oj build)。
20
23
  */
@@ -104,226 +107,6 @@ function matchExemptPath(routePath: string, p: string): boolean {
104
107
  return routePath === p || routePath.startsWith(`${p}/`);
105
108
  }
106
109
 
107
- /**
108
- * oj 方法名 → HTTP 动词(emit-stub 的反向映射)。
109
- * 注意**不含 options**:契约运行时故意不表达 OPTIONS(预检请求由 fetch/CORS 层处理,
110
- * 见 runtime/contract 的方法白名单)——oj 树里的 options handler 因此永不进对账表
111
- * (不会被误报「未登记」);routes.js 的 options 行经下方 parseRoutesJs 的
112
- * 大写回落归一,同样不误报 drift。
113
- */
114
- const VERB_OF: Record<string, IrEndpoint["method"]> = {
115
- get: "GET",
116
- post: "POST",
117
- put: "PUT",
118
- del: "DELETE",
119
- patch: "PATCH",
120
- head: "HEAD",
121
- };
122
-
123
- /** oj v0.1.64 嵌套上限(与 oj 内省一致);超出即 oj 启动报错,checker 不深追 */
124
- const MAX_GROUP_DEPTH = 4;
125
-
126
- interface HandlerRow {
127
- /** HTTP 动词 */
128
- method: IrEndpoint["method"]
129
- /** 目录镜像相对 api/src 的路径段(含模块段),如 "order/item" */
130
- dir: string
131
- /** 语句起始 `.route = "..."` 字面量(无则目录镜像直达) */
132
- tail?: string
133
- /** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
134
- chain: string
135
- /** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;绝对 .route 即其自身) */
136
- full: string
137
- filePath: string
138
- }
139
-
140
- /** `a` / `a.b` 形式的属性访问 → 点分名;其余(调用/元素访问)→ undefined */
141
- function dottedNameOf(node: ts.Expression): string | undefined {
142
- if (ts.isIdentifier(node))
143
- return node.text;
144
- if (ts.isPropertyAccessExpression(node)) {
145
- const base = dottedNameOf(node.expression);
146
- return base ? `${base}.${node.name.text}` : undefined;
147
- }
148
- return undefined;
149
- }
150
-
151
- /**
152
- * 语句起始的 `<路径>.route = "字面量"`(路径可为点分:`cccc.post.route`,运行时即
153
- * `cccc.post` 函数对象的 `.route`——与 oj 内省读 `fn.route` 同源)。空串视同未挂
154
- * (oj 口径)。oj build 只认顶层标准赋值,这里同口径且放宽到点分左值。
155
- */
156
- function collectRouteDecls(sf: ts.SourceFile): Map<string, string> {
157
- const routes = new Map<string, string>();
158
- for (const stmt of sf.statements) {
159
- if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression))
160
- continue;
161
- const expr = stmt.expression;
162
- if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
163
- continue;
164
- if (!ts.isPropertyAccessExpression(expr.left) || expr.left.name.text !== "route")
165
- continue;
166
- if (!ts.isStringLiteral(expr.right) || expr.right.text === "")
167
- continue;
168
- const path = dottedNameOf(expr.left.expression);
169
- if (path)
170
- routes.set(path, expr.right.text);
171
- }
172
- return routes;
173
- }
174
-
175
- /** 顶层 `const x = { ... }` 对象字面量表:嵌套组经标识符引用时的解析依据 */
176
- function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpression> {
177
- const decls = new Map<string, ts.ObjectLiteralExpression>();
178
- for (const stmt of sf.statements) {
179
- if (!ts.isVariableStatement(stmt))
180
- continue;
181
- for (const decl of stmt.declarationList.declarations) {
182
- if (ts.isIdentifier(decl.name) && decl.initializer && ts.isObjectLiteralExpression(decl.initializer))
183
- decls.set(decl.name.text, decl.initializer);
184
- }
185
- }
186
- return decls;
187
- }
188
-
189
- /** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
190
- function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
191
- if (ts.isShorthandPropertyAssignment(prop))
192
- return prop.name.text;
193
- if (ts.isMethodDeclaration(prop) && prop.name)
194
- return prop.name.getText();
195
- if (ts.isPropertyAssignment(prop)) {
196
- if (ts.isIdentifier(prop.name) && ts.isIdentifier(prop.initializer))
197
- return prop.initializer.text;
198
- if (prop.name)
199
- return prop.name.getText();
200
- }
201
- return undefined;
202
- }
203
-
204
- /** 非 verb 键 → 子组对象字面量(内联或经标识符解析);函数/数组/基元 → undefined */
205
- function groupValueOf(prop: ts.ObjectLiteralElementLike, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
206
- if (ts.isPropertyAssignment(prop)) {
207
- if (ts.isObjectLiteralExpression(prop.initializer))
208
- return prop.initializer;
209
- if (ts.isIdentifier(prop.initializer))
210
- return decls.get(prop.initializer.text);
211
- return undefined;
212
- }
213
- if (ts.isShorthandPropertyAssignment(prop))
214
- return decls.get(prop.name.text);
215
- return undefined;
216
- }
217
-
218
- /** default 导出对象:内联字面量或标识符(`export default api`) */
219
- function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLiteralExpression>): ts.ObjectLiteralExpression | undefined {
220
- for (const stmt of sf.statements) {
221
- if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
222
- continue;
223
- if (ts.isObjectLiteralExpression(stmt.expression))
224
- return stmt.expression;
225
- if (ts.isIdentifier(stmt.expression))
226
- return decls.get(stmt.expression.text);
227
- }
228
- return undefined;
229
- }
230
-
231
- /** 相对路由按层前缀解析(绝对 `/` 开头即根级);与 oj 内省 resolve 同规则 */
232
- function resolveRoute(own: string, prefix: string): string {
233
- return own.startsWith("/") ? own : (prefix ? `${prefix}/${own}` : own);
234
- }
235
-
236
- /** 有效路由全路径(base 相对、无前导 /):绝对 .route 取其自身,否则目录镜像 + 相对尾 */
237
- function fullPathOf(dir: string, tail: string | undefined): string {
238
- if (tail?.startsWith("/"))
239
- return tail.replace(/^\//, "");
240
- return [dir, tail].filter(Boolean).join("/");
241
- }
242
-
243
- /**
244
- * AST 扫单个 api.ts(v0.1.64 起支持嵌套 handler 组):
245
- * default 导出对象的键——verb 键(函数)即端点;非 verb 键(普通对象)即子组,
246
- * 递归展开(上限 4 层,路径环守卫)。组级 `.route` 作整组默认前缀,动词自身
247
- * `.route` 优先覆盖;文件层(`const api = {...}; api.route = ...`)同为默认前缀。
248
- * 非 verb 键挂函数/数组/基元是 oj 判别红线(启动即报错),此处按「不当路由」跳过。
249
- */
250
- export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow[] {
251
- const text = readFileSync(filePath, "utf8");
252
- const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
253
- const routes = collectRouteDecls(sf);
254
- const decls = collectObjectDecls(sf);
255
- const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
256
- const rows: HandlerRow[] = [];
257
- const seen = new Set<ts.Node>();
258
-
259
- const walk = (obj: ts.ObjectLiteralExpression, chainPrefix: string, routePrefix: string, depth: number): void => {
260
- if (depth > MAX_GROUP_DEPTH || seen.has(obj))
261
- return;
262
- seen.add(obj);
263
- for (const prop of obj.properties) {
264
- const name = (prop.name && ts.isIdentifier(prop.name)) ? prop.name.text : undefined;
265
- if (!name)
266
- continue;
267
- const chain = chainPrefix ? `${chainPrefix}.${name}` : name;
268
- if (VERB_OF[name]) {
269
- const fnName = implNameOf(prop) ?? name;
270
- const own = routes.get(chain) ?? routes.get(fnName);
271
- // 无自身 .route → 继承本层前缀(根层即目录镜像);与 oj 内省同规则
272
- const tail = own === undefined
273
- ? (routePrefix || undefined)
274
- : resolveRoute(own, routePrefix);
275
- rows.push({
276
- method: VERB_OF[name],
277
- dir: relDir,
278
- tail,
279
- chain,
280
- full: fullPathOf(relDir, tail),
281
- filePath,
282
- });
283
- }
284
- else {
285
- const child = groupValueOf(prop, decls);
286
- if (!child)
287
- continue; // 函数/数组/基元:oj 红线,checker 不当路由
288
- const own = routes.get(chain);
289
- const childPrefix = own === undefined
290
- ? (routePrefix ? `${routePrefix}/${name}` : name)
291
- : resolveRoute(own, routePrefix);
292
- walk(child, chain, childPrefix, depth + 1);
293
- }
294
- }
295
- seen.delete(obj);
296
- };
297
-
298
- const root = defaultExportObject(sf, decls);
299
- if (root) {
300
- // 文件层 .route:default 导出为标识符时,该标识符的 .route 语句即整文件默认前缀
301
- let filePrefix = "";
302
- for (const stmt of sf.statements) {
303
- if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
304
- const own = routes.get(stmt.expression.text);
305
- if (own !== undefined)
306
- filePrefix = resolveRoute(own, "");
307
- }
308
- }
309
- walk(root, "", filePrefix, 1);
310
- }
311
- return rows;
312
- }
313
-
314
- function walkApiFiles(dir: string, out: string[] = []): string[] {
315
- if (!existsSync(dir))
316
- return out;
317
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
318
- const p = join(dir, entry.name);
319
- if (entry.isDirectory())
320
- walkApiFiles(p, out);
321
- else if (entry.name === "api.ts" || entry.name === "api.js")
322
- out.push(p);
323
- }
324
- return out;
325
- }
326
-
327
110
  /** ws.ts / ws.js(目录内放即产生一条 WS 路由,文件名必须小写——oj 0.1.5 约定) */
328
111
  function walkWsFiles(dir: string, out: string[] = []): string[] {
329
112
  if (!existsSync(dir))
@@ -366,9 +149,6 @@ function checkWsAnonymous(wsFiles: string[], apiSrcDir: string, configPath: stri
366
149
  }));
367
150
  }
368
151
 
369
- /** 对账键(方法 + 全路径) */
370
- const pathKey = (method: string, path: string) => `${method} ${path}`;
371
-
372
152
  /** 路径段(去空串,忽略前导 /) */
373
153
  const pathSegs = (p: string) => p.split("/").filter(Boolean);
374
154
  /** 静态前缀:首个参数段({...})之前的路径段 */
@@ -378,14 +158,6 @@ function staticPrefixOf(p: string): string {
378
158
  return segs.slice(0, i === -1 ? segs.length : i).join("/");
379
159
  }
380
160
 
381
- /**
382
- * handler 的有效 wire 全路径(带前导 /):绝对 `.route` 即其自身;相对尾巴 = `/` + full。
383
- * 用于与契约 contractAbs(wirePrefix + route)对齐比对。
384
- */
385
- function handlerAbs(h: HandlerRow): string {
386
- return h.tail?.startsWith("/") ? h.tail : `/${h.full}`;
387
- }
388
-
389
161
  /**
390
162
  * ② route 双向对账:契约路由表 vs AST 扫描的 handler 表(D12/F19:豁免降级)。
391
163
  *
@@ -411,6 +183,14 @@ function reconcileRoutes(contractIr: IrEndpoint[], handlers: HandlerRow[], exemp
411
183
 
412
184
  const claimed = new Set<HandlerRow>();
413
185
  for (const ep of contractIr) {
186
+ // D12/F19:豁免模块/路径在「契约→handler」侧同样降级——否则 openapi.json 含豁免
187
+ // 模块(oj 照常为其生成 path)时,根级模块路由(route="/" 但 handler .route 为
188
+ // 模块名)会误报 route-params-mismatch。整模块/路径前缀命中即跳过本端点全部核对。
189
+ const epModule = ep.apiPrefix.replace(/^\//, "");
190
+ if (exemption.modules.includes(epModule))
191
+ continue;
192
+ if (exemption.paths.some(p => matchExemptPath(ep.fullPath, p)))
193
+ continue;
414
194
  const { dir } = splitRoute(ep.route);
415
195
  const dirFull = `${ep.apiPrefix.replace(/^\//, "")}${dir ? `/${dir}` : ""}`;
416
196
  const ojFull = `${ep.apiPrefix.replace(/^\//, "")}${ep.route}`.replace(/\/+/g, "/");
@@ -487,70 +267,58 @@ function parseRoutesJs(text: string): { method: string, pattern: string }[] {
487
267
  return rows;
488
268
  }
489
269
 
270
+ /**
271
+ * 新口径(Task #5):以 openapi.json 为事实源做 --check。
272
+ * - ① 生成物同步:仅核对 `web/src/<模块>/__client/{api.ts,api.schemas.ts}`(routes.json/openapi.yaml
273
+ * 已废弃,openapi.json 即权威,ojm 不再自产)。
274
+ * - ② route 双向对账复用 `uniDevIr`(与旧口径同源 `IrEndpoint` 形状,reconcileRoutes 不变)。
275
+ * - stub 计划:openapi IR 同样喂 `planStubWrites`(handler 骨架生成;`.schema` 由开发者手写,不注入)。
276
+ */
277
+ async function checkFromOpenapi(openapiPath: string, cwd: string, _exemption: ApiExemption): Promise<{ violations: CheckViolation[], uniDevIr: IrEndpoint[] }> {
278
+ const doc = JSON.parse(readFileSync(openapiPath, "utf8")) as OpenApiDoc;
279
+ const ir = irFromOpenapi(doc);
280
+ const violations: CheckViolation[] = [];
281
+ for (const [module, eps] of groupIrByModule(ir)) {
282
+ const client = emitClient(eps, { target: "module", module });
283
+ const clientDir = join(cwd, "web/src", module, "__client");
284
+ for (const [file, content] of [["api.ts", client["api.ts"]], ["api.schemas.ts", client["api.schemas.ts"]]] as const) {
285
+ const p = join(clientDir, file);
286
+ if (!existsSync(p) || readFileSync(p, "utf8") !== content)
287
+ violations.push({ level: "error", kind: "artifact-stale", message: `[ojm-api] 生成物过期或缺失:${relative(cwd, p)}——请重跑 ojm api 使其与 openapi.json 同步。`, filePath: p });
288
+ }
289
+ }
290
+ const apiSrcDir = join(cwd, "api/src");
291
+ if (existsSync(apiSrcDir)) {
292
+ const stubWrites = await planStubWrites(ir, { apiSrcDir, implemented: implementedRouteKeys(apiSrcDir) });
293
+ for (const w of stubWrites) {
294
+ if (w.action === "create")
295
+ violations.push({ level: "error", kind: "artifact-stale", message: `[ojm-api] stub 缺失:${relative(cwd, w.filePath)}——请重跑 ojm api 生成。`, filePath: w.filePath });
296
+ else if (w.action === "update" && !w.prefixUpgrade)
297
+ violations.push({ level: "error", kind: "artifact-stale", message: `[ojm-api] stub 待随契约更新:${relative(cwd, w.filePath)}——请重跑 ojm api。`, filePath: w.filePath });
298
+ }
299
+ }
300
+ return { violations, uniDevIr: ir };
301
+ }
302
+
490
303
  /** ojm api --check 主入口。opts.exempt 为可选豁免清单路径(覆盖默认 api/.ojm-api-exempt.json) */
491
304
  export async function checkApi(opts: { cwd: string, exempt?: string }): Promise<CheckResult> {
492
305
  const exemption = loadExemption(opts);
493
- const contracts = discoverContracts(opts.cwd);
494
- if (contracts.length === 0) {
495
- throw new Error(`[ojm-api] ${opts.cwd} 下没有发现契约文件——默认发现:api/src/*/contract.ts(uni-dev)与 web/src/*/client/contract.ts(纯前端)。存量工程请迁移:modules/ → web/、模块内 api/ → client/。`);
496
- }
306
+ const openapiPath = findOpenapiJson(opts.cwd);
497
307
  const violations: CheckViolation[] = [];
498
308
  const hints: string[] = [];
499
309
  const uniDevIr: IrEndpoint[] = [];
500
310
 
501
- for (const found of contracts) {
502
- const ir = await irOf(found, opts.cwd);
503
- if (found.kind === "uni-dev")
504
- uniDevIr.push(...ir);
505
-
506
- // ① 生成物同步:内存重生成 vs 磁盘逐字节
507
- const paths = artifactPaths(found, opts.cwd);
508
- const client = emitClient(ir, { target: "module", module: found.module });
509
- const expected: [string, string][] = [
510
- [paths.client, client["api.ts"]],
511
- [paths.schemas, client["api.schemas.ts"]],
512
- [paths.routes, emitRoutesJson(ir)],
513
- [paths.openapi, emitOpenapiYaml(ir, { title: `${found.module} api`, version: "0.0.0" })],
514
- ];
515
- for (const [p, content] of expected) {
516
- if (!existsSync(p) || readFileSync(p, "utf8") !== content) {
517
- violations.push({
518
- level: "error",
519
- kind: "artifact-stale",
520
- message: `[ojm-api] 生成物过期或缺失:${relative(opts.cwd, p)}——请重跑 ojm api 使其与契约同步。`,
521
- filePath: p,
522
- });
523
- }
524
- }
525
- // stub 生成物:缺失(create)与待更新(update,指纹匹配但契约已变)都属过期。
526
- // 纯指纹头前缀升级(旧 ram-api:stub → 新 ojm-api:stub,正文未变)不算过期:
527
- // 存量工程升级后首次 --check 不应因改名误报(R2 平滑升级)。
528
- if (found.kind === "uni-dev") {
529
- const stubWrites = await planStubWrites(ir, { apiSrcDir: join(opts.cwd, "api/src") });
530
- for (const w of stubWrites) {
531
- if (w.action === "create") {
532
- violations.push({
533
- level: "error",
534
- kind: "artifact-stale",
535
- message: `[ojm-api] stub 缺失:${relative(opts.cwd, w.filePath)}——请重跑 ojm api 生成。`,
536
- filePath: w.filePath,
537
- });
538
- continue;
539
- }
540
- if (w.action === "update" && !w.prefixUpgrade) {
541
- violations.push({
542
- level: "error",
543
- kind: "artifact-stale",
544
- message: `[ojm-api] stub 待随契约更新:${relative(opts.cwd, w.filePath)}——请重跑 ojm api。`,
545
- filePath: w.filePath,
546
- });
547
- }
548
- }
549
- }
311
+ if (openapiPath) {
312
+ const r = await checkFromOpenapi(openapiPath, opts.cwd, exemption);
313
+ violations.push(...r.violations);
314
+ uniDevIr.push(...r.uniDevIr);
315
+ }
316
+ else {
317
+ throw new Error(`[ojm-api] ${opts.cwd} 下未发现 openapi.json——请先运行 oj v0.1.73 的 \`oj openapi -c config -d api/src -b /api -o openapi.json\` 生成(OpenAPI 3.1,含全部模块)。\`ojm api --check\` 的单一事实源即 openapi.json,不再消费旧 contract.ts。`);
550
318
  }
551
319
 
552
320
  // ② route 双向对账(仅 uni-dev——oj 树存在才有意义)
553
- if (contracts.some(c => c.kind === "uni-dev")) {
321
+ if (existsSync(join(opts.cwd, "api/src"))) {
554
322
  const apiSrcDir = join(opts.cwd, "api/src");
555
323
  const handlers = walkApiFiles(apiSrcDir).flatMap(f => scanHandlerFile(f, apiSrcDir));
556
324
  violations.push(...reconcileRoutes(uniDevIr, handlers, exemption));
@@ -579,19 +347,24 @@ export async function checkApi(opts: { cwd: string, exempt?: string }): Promise<
579
347
  hints.push("[ojm-api] 未发现 api/dist/**/routes.js——release 路由表对账跳过;发布前请先 oj build 再跑 ojm api --check。");
580
348
  }
581
349
  else {
582
- const contractRows = uniDevIr.map(ep => ({ method: ep.method, pattern: ep.fullPath.replace(/^\//, "") }));
350
+ // 尾斜杠规范化:契约侧 fullPath 对「模块根路径」带尾斜杠(stripBase 的
351
+ // `rel || "/"`:如 notifications 模块的 GET / → fullPath "/notifications/"),
352
+ // 而 oj 产出的 routes.js pattern 无尾斜杠("notifications")——直接比键必然误报。
353
+ const normPattern = (p: string) => p.replace(/^\/+/, "").replace(/\/+$/, "");
354
+ const contractRows = uniDevIr.map(ep => ({ method: ep.method, pattern: normPattern(ep.fullPath) }));
355
+ const distRowsNorm = distRows.map(r => ({ method: r.method, pattern: normPattern(r.pattern) }));
583
356
  const keyOf = (r: { method: string, pattern: string }) => `${r.method} ${r.pattern}`;
584
357
  const contractSet = new Set(contractRows.map(keyOf));
585
- const distSet = new Set(distRows.map(keyOf));
586
- const missingInDist = [...contractSet].filter(r => !distSet.has(r));
587
- // D12/F19:dist 行命中豁免(整模块或路径前缀)→ 不报 "dist 多"
358
+ const distSet = new Set(distRowsNorm.map(keyOf));
359
+ // D12/F19:行命中豁免(整模块或路径前缀)→ 两个方向都不报(dist 缺 / dist 多)
588
360
  const isExemptRow = (r: { method: string, pattern: string }) => {
589
361
  const mod = moduleOf(r.pattern);
590
362
  const hitModule = exemption.modules.includes(mod);
591
363
  const hitPath = exemption.paths.some(p => matchExemptPath(`/${r.pattern}`, p));
592
364
  return hitModule || hitPath;
593
365
  };
594
- const extraInDist = distRows
366
+ const missingInDist = contractRows.filter(r => !distSet.has(keyOf(r))).filter(r => !isExemptRow(r)).map(keyOf);
367
+ const extraInDist = distRowsNorm
595
368
  .filter(r => !contractSet.has(keyOf(r)))
596
369
  .filter(r => !isExemptRow(r))
597
370
  .map(keyOf);
@@ -1,5 +1,6 @@
1
1
  import type { IrEndpoint } from "./ir";
2
- import { emitSchemaSource, keyOf } from "./emit-schema";
2
+ import type { JsonSchema } from "./openapi-ir";
3
+ import { emitZodFromJsonSchema } from "./emit-jsonschema-zod";
3
4
  import { wirePrefixOf } from "./ir";
4
5
 
5
6
  /**
@@ -40,6 +41,12 @@ function pascalModuleName(module: string): string {
40
41
  return module.split("-").map(pascal).join("");
41
42
  }
42
43
 
44
+ /** 对象键发射:合法标识符原样,其余 JSON 引号化(与 emit-stub 示例值同款) */
45
+ const IDENT = /^[a-z_$][\w$]*$/i;
46
+ function keyOf(name: string): string {
47
+ return IDENT.test(name) ? name : JSON.stringify(name);
48
+ }
49
+
43
50
  function slotsOf(ep: IrEndpoint): Slot[] {
44
51
  const slots: Slot[] = [];
45
52
  if (ep.paramNames.length > 0)
@@ -65,7 +72,8 @@ function urlTemplate(ep: IrEndpoint, prefix: string): string {
65
72
  return seg;
66
73
  const isCatchAll = seg.startsWith("{*");
67
74
  const key = seg.replace(/^\{\*?/, "").replace(/\}$/, "");
68
- const expr = `${prefix}params.${key}`;
75
+ // 参数段名一律 JSON.stringify 成字符串字面量索引(防止段名含特殊字符破坏源码)
76
+ const expr = `${prefix}params[${JSON.stringify(key)}]`;
69
77
  return isCatchAll
70
78
  ? `\${String(${expr}).split("/").map(encodeURIComponent).join("/")}`
71
79
  : `\${encodeURIComponent(String(${expr}))}`;
@@ -87,10 +95,10 @@ function emitTypes(ep: IrEndpoint): string[] {
87
95
  continue;
88
96
  }
89
97
  // 请求槽用 z.input(带 .default() 的字段入参可选);data 用 z.infer(输出型)
90
- lines.push(`export type ${pascal(ep.name)}${SLOT_TYPE_SUFFIX[slot]} = z.input<(typeof schemas)["${ep.name}"]["${slot}"]>;`);
98
+ lines.push(`export type ${pascal(ep.name)}${SLOT_TYPE_SUFFIX[slot]} = z.input<(typeof schemas)[${JSON.stringify(ep.name)}][${JSON.stringify(slot)}]>;`);
91
99
  }
92
100
  if (ep.dataSchema)
93
- lines.push(`export type ${pascal(ep.name)}Data = z.infer<(typeof schemas)["${ep.name}"]["data"]>;`);
101
+ lines.push(`export type ${pascal(ep.name)}Data = z.infer<(typeof schemas)[${JSON.stringify(ep.name)}]["data"]>;`);
94
102
  return lines;
95
103
  }
96
104
 
@@ -102,7 +110,7 @@ function emitFormType(ep: IrEndpoint): string {
102
110
  const form = ep.form!;
103
111
  const members: string[] = [];
104
112
  if (form.fieldsSchema)
105
- members.push(`\tfields: z.input<(typeof schemas)["${ep.name}"]["form"]>`);
113
+ members.push(`\tfields: z.input<(typeof schemas)[${JSON.stringify(ep.name)}]["form"]>`);
106
114
  if (form.files.length) {
107
115
  const files = form.files
108
116
  .map(f => `${keyOf(f.name)}${f.required ? "" : "?"}: ${f.multiple ? "Array<File | Blob>" : "File | Blob"}`)
@@ -199,7 +207,7 @@ function emitEndpoint(ep: IrEndpoint): string {
199
207
  ? `
200
208
  if (import.meta.env.DEV) {
201
209
  const { schemas } = await import("./api.schemas");
202
- const r = schemas.${ep.name}.data.safeParse(data);
210
+ const r = schemas[${JSON.stringify(ep.name)}].data.safeParse(data);
203
211
  if (!r.success)
204
212
  throw new ContractApiError(-1, \`[契约违例] ${ep.name} 响应与契约不符:\${r.error.issues.map(i => \`\${i.path.join(".") || "(root)"}: \${i.message}\`).join("; ")}\`);
205
213
  }`
@@ -226,10 +234,10 @@ function emitPrelude(target: "module" | "internal", module?: string, endpointNam
226
234
  ? `let req: ScopedRequestLike | undefined;
227
235
 
228
236
  /** 线上 API 前缀(ojm api 从契约抽取,唯一真源,勿手改):请求基址 + 前缀登记用 */
229
- export const API_PREFIX = "${wirePrefix ?? `/${module}`}";
237
+ export const API_PREFIX = ${JSON.stringify(wirePrefix ?? `/${module}`)};
230
238
 
231
239
  /** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
232
- export const MODULE_PREFIX = "/${module}";
240
+ export const MODULE_PREFIX = ${JSON.stringify(`/${module}`)};
233
241
 
234
242
  /**
235
243
  * 模块 entry 的 onInit 里一行创建(构造即 install):完成 apiPrefix 登记 +
@@ -309,19 +317,19 @@ function emitSchemas(ir: IrEndpoint[], target: "module" | "internal"): string {
309
317
  // raw 端点也保留请求槽(params/query/body 类型引用此处 schema);
310
318
  // 仅 data 槽与 raw 互斥(定义期已拦截 data+raw)
311
319
  if (ep.paramsSchema)
312
- slots.push(`\t\tparams: ${emitSchemaSource(ep.paramsSchema)},`);
320
+ slots.push(`\t\tparams: ${emitZodFromJsonSchema(ep.paramsSchema as JsonSchema, `${ep.name}.params`)},`);
313
321
  if (ep.querySchema)
314
- slots.push(`\t\tquery: ${emitSchemaSource(ep.querySchema)},`);
322
+ slots.push(`\t\tquery: ${emitZodFromJsonSchema(ep.querySchema as JsonSchema, `${ep.name}.query`)},`);
315
323
  if (ep.bodySchema)
316
- slots.push(`\t\tbody: ${emitSchemaSource(ep.bodySchema)},`);
324
+ slots.push(`\t\tbody: ${emitZodFromJsonSchema(ep.bodySchema as JsonSchema, `${ep.name}.body`)},`);
317
325
  // form 槽只发射文本部件(files 是二进制,无 schema);api.ts 的 Form 类型据此推导
318
326
  if (ep.form?.fieldsSchema)
319
- slots.push(`\t\tform: ${emitSchemaSource(ep.form.fieldsSchema)},`);
327
+ slots.push(`\t\tform: ${emitZodFromJsonSchema(ep.form.fieldsSchema as JsonSchema, `${ep.name}.form`)},`);
320
328
  if (!ep.raw && ep.dataSchema)
321
- slots.push(`\t\tdata: ${emitSchemaSource(ep.dataSchema)},`);
329
+ slots.push(`\t\tdata: ${emitZodFromJsonSchema(ep.dataSchema as JsonSchema, `${ep.name}.data`)},`);
322
330
  if (slots.length === 0)
323
331
  return "";
324
- return `\t${ep.name}: {\n${slots.join("\n")}\n\t},`;
332
+ return `\t${JSON.stringify(ep.name)}: {\n${slots.join("\n")}\n\t},`;
325
333
  })
326
334
  .filter(Boolean);
327
335
 
@@ -338,7 +346,7 @@ ${entries.join("\n")}
338
346
  /** 发射双产物:api.ts(类型 + 请求函数 + 工厂)与 api.schemas.ts(DEV 校验 schema) */
339
347
  export function emitClient(ir: IrEndpoint[], opts: { target: "module" | "internal", module?: string }): { "api.ts": string, "api.schemas.ts": string } {
340
348
  if (ir.length === 0)
341
- throw new Error("[ojm-api] client 发射失败:IR 为空——契约文件里没有 defineApi 端点,无需生成。");
349
+ throw new Error("[ojm-api] client 发射失败:IR 为空——openapi.json 里没有任何端点(operationId + tags),无需生成;请检查 \`oj openapi\` 产物。");
342
350
  if (opts.target === "module" && !opts.module) {
343
351
  throw new Error(
344
352
  "[ojm-api] client 发射失败:module 目标需要模块名——"