@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.
- package/devkit/README.md +11 -10
- package/devkit/SKILL.md +32 -18
- package/devkit/manual.md +318 -131
- package/package.json +2 -2
- package/shell-dist/assets/contract.js +0 -55
- package/shell-dist/assets/runtime.js +206 -212
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +5 -5
- package/src/build.ts +1 -0
- package/src/contract/__tests__/check-api.test.ts +74 -0
- package/src/contract/__tests__/emit-client.test.ts +132 -0
- package/src/contract/__tests__/emit-jsonschema-zod.test.ts +93 -0
- package/src/contract/__tests__/mock.test.ts +56 -0
- package/src/contract/__tests__/openapi-ir.test.ts +137 -0
- package/src/contract/__tests__/run-api.test.ts +67 -0
- package/src/contract/check.ts +67 -294
- package/src/contract/emit-client.ts +23 -15
- package/src/contract/emit-jsonschema-zod.ts +209 -0
- package/src/contract/emit-oj-schema.ts +108 -161
- package/src/contract/emit-stub.ts +17 -57
- package/src/contract/handler-scan.ts +263 -0
- package/src/contract/ir.ts +11 -179
- package/src/contract/json-schema-sample.ts +72 -0
- package/src/contract/mock.ts +28 -100
- package/src/contract/openapi-ir.ts +145 -0
- package/src/contract/run.ts +71 -120
- package/src/dev.ts +20 -18
- package/src/init.ts +18 -3
- package/src/oj.ts +6 -2
- package/src/vendor.ts +8 -4
- package/templates/api/config.yaml +1 -0
- package/templates/api/src/auth/api.ts +66 -0
- package/templates/api/src/notification/_shared/notify.ts +199 -0
- package/templates/api/src/notification/api.ts +231 -0
- package/templates/api/src/notification/del/api.ts +47 -0
- package/templates/api/src/notification/manifest.yaml +3 -1
- package/templates/api/src/notification/migrations/0001__create_notifications.sql +1 -1
- package/templates/api/src/notification/migrations/0002__notifications_user.sql +9 -0
- package/templates/api/src/notification/migrations/0003__notifications_broadcast.sql +22 -0
- package/templates/api/src/notification/schema.yaml +23 -1
- package/templates/api/src/notification/seed.sql +6 -4
- package/templates/api/src/personal-center/upload/api.ts +12 -1
- package/templates/env.d.ts +1 -1
- package/templates/openapi.json +471 -0
- package/templates/web/src/auth/__client/api.schemas.ts +8 -0
- package/templates/web/src/auth/__client/api.ts +110 -0
- package/templates/web/src/notification/__client/api.schemas.ts +50 -0
- package/templates/web/src/notification/{client → __client}/api.ts +29 -29
- package/templates/web/src/notification/entry.ts +11 -9
- package/templates/web/src/notifications/__client/api.schemas.ts +8 -0
- package/templates/web/src/notifications/__client/api.ts +80 -0
- package/templates/web/src/personal-center/__client/api.schemas.ts +10 -0
- package/templates/web/src/personal-center/__client/api.ts +88 -0
- package/templates/web/src/web/__client/api.schemas.ts +8 -0
- package/templates/web/src/web/__client/api.ts +110 -0
- package/vendor/host-versions.json +5 -5
- package/src/contract/emit-meta.ts +0 -175
- package/src/contract/emit-schema.ts +0 -133
- package/src/contract/evaluate.ts +0 -88
- package/templates/api/src/auth/login/api.ts +0 -27
- package/templates/api/src/auth/logout/api.ts +0 -10
- package/templates/api/src/auth/refresh/api.ts +0 -26
- package/templates/api/src/notification/contract.ts +0 -98
- package/templates/api/src/notification/notifications/api.ts +0 -24
- package/templates/api/src/notification/notifications/clear/api.ts +0 -13
- package/templates/api/src/notification/notifications/read/api.ts +0 -19
- package/templates/api/src/notification/notifications/read-all/api.ts +0 -13
- package/templates/api/src/personal-center/contract.ts +0 -16
- package/templates/web/src/notification/client/api.schemas.ts +0 -50
package/src/contract/check.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
586
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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)[
|
|
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)[
|
|
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)[
|
|
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
|
|
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 =
|
|
237
|
+
export const API_PREFIX = ${JSON.stringify(wirePrefix ?? `/${module}`)};
|
|
230
238
|
|
|
231
239
|
/** 认领身份前缀(契约 apiPrefix,字面等于模块目录名):跨模块认领护栏对比基准 */
|
|
232
|
-
export const MODULE_PREFIX =
|
|
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: ${
|
|
320
|
+
slots.push(`\t\tparams: ${emitZodFromJsonSchema(ep.paramsSchema as JsonSchema, `${ep.name}.params`)},`);
|
|
313
321
|
if (ep.querySchema)
|
|
314
|
-
slots.push(`\t\tquery: ${
|
|
322
|
+
slots.push(`\t\tquery: ${emitZodFromJsonSchema(ep.querySchema as JsonSchema, `${ep.name}.query`)},`);
|
|
315
323
|
if (ep.bodySchema)
|
|
316
|
-
slots.push(`\t\tbody: ${
|
|
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: ${
|
|
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: ${
|
|
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
|
|
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 目标需要模块名——"
|