@oj-module/cli 0.1.15 → 0.1.17
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/SKILL.md +3 -0
- package/devkit/framework-dev.md +2 -2
- package/devkit/manual.md +60 -5
- package/package.json +3 -2
- package/shell-dist/assets/runtime.js +34 -3
- package/shell-dist/assets/vditor-index-css.js +7 -0
- package/shell-dist/assets/vditor.js +15223 -0
- package/shell-dist/index.html +4 -2
- package/shell-dist/versions.json +7 -5
- package/src/contract/check.ts +254 -73
- package/src/contract/emit-stub.ts +1 -1
- package/src/index.ts +7 -0
- package/src/shared-deps.ts +18 -0
- package/src/usage.ts +2 -0
- package/src/versions.ts +10 -0
- package/templates/api/src/notification/contract.ts +51 -5
- package/templates/web/src/notification/client/api.schemas.ts +30 -2
- package/templates/web/src/notification/client/api.ts +52 -1
- package/templates/web/src/notification/entry.ts +13 -4
- package/vendor/host-versions.json +7 -5
package/shell-dist/index.html
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" href="/favicon.ico" />
|
|
7
7
|
<title>React Antd Admin · Shell</title>
|
|
8
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-cEEui3MttOAqCTHlBg5Xog=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
|
|
9
9
|
|
|
10
|
-
<script type="importmap" nonce="
|
|
10
|
+
<script type="importmap" nonce="cEEui3MttOAqCTHlBg5Xog==">{
|
|
11
11
|
"imports": {
|
|
12
12
|
"react": "/assets/react.js",
|
|
13
13
|
"react/jsx-runtime": "/assets/jsx-runtime.js",
|
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
"simplebar-react": "/assets/simplebar-react.js",
|
|
74
74
|
"spin-delay": "/assets/spin-delay.js",
|
|
75
75
|
"tailwind-merge": "/assets/tailwind-merge.js",
|
|
76
|
+
"vditor": "/assets/vditor.js",
|
|
77
|
+
"vditor/dist/index.css": "/assets/vditor-index-css.js",
|
|
76
78
|
"antd/es/modal": "/assets/ojm-antd-es-modal.js",
|
|
77
79
|
"antd/es/upload": "/assets/ojm-antd-es-upload.js",
|
|
78
80
|
"antd/es/button": "/assets/ojm-antd-es-button.js",
|
package/shell-dist/versions.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"react-router": "7.18.2",
|
|
8
8
|
"react-router/dom": "7.18.2",
|
|
9
9
|
"@tanstack/react-query": "5.102.8",
|
|
10
|
-
"@oj-module/runtime": "0.1.
|
|
11
|
-
"@oj-module/runtime/contract": "0.1.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.1.
|
|
13
|
-
"@plane/kit": "0.1.
|
|
14
|
-
"@plane/editor": "0.1.
|
|
10
|
+
"@oj-module/runtime": "0.1.17",
|
|
11
|
+
"@oj-module/runtime/contract": "0.1.17",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.1.17",
|
|
13
|
+
"@plane/kit": "0.1.17",
|
|
14
|
+
"@plane/editor": "0.1.17",
|
|
15
15
|
"antd": "6.6.1",
|
|
16
16
|
"antd/locale/en_US": "6.6.1",
|
|
17
17
|
"antd/locale/zh_CN": "6.6.1",
|
|
@@ -63,6 +63,8 @@
|
|
|
63
63
|
"simplebar-react": "3.3.2",
|
|
64
64
|
"spin-delay": "2.0.1",
|
|
65
65
|
"tailwind-merge": "3.6.0",
|
|
66
|
+
"vditor": "4.0.0",
|
|
67
|
+
"vditor/dist/index.css": "4.0.0",
|
|
66
68
|
"typescript": "6.0.3",
|
|
67
69
|
"@types/react": "19.2.18"
|
|
68
70
|
}
|
package/src/contract/check.ts
CHANGED
|
@@ -104,7 +104,13 @@ function matchExemptPath(routePath: string, p: string): boolean {
|
|
|
104
104
|
return routePath === p || routePath.startsWith(`${p}/`);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
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
|
+
*/
|
|
108
114
|
const VERB_OF: Record<string, IrEndpoint["method"]> = {
|
|
109
115
|
get: "GET",
|
|
110
116
|
post: "POST",
|
|
@@ -114,6 +120,9 @@ const VERB_OF: Record<string, IrEndpoint["method"]> = {
|
|
|
114
120
|
head: "HEAD",
|
|
115
121
|
};
|
|
116
122
|
|
|
123
|
+
/** oj v0.1.64 嵌套上限(与 oj 内省一致);超出即 oj 启动报错,checker 不深追 */
|
|
124
|
+
const MAX_GROUP_DEPTH = 4;
|
|
125
|
+
|
|
117
126
|
interface HandlerRow {
|
|
118
127
|
/** HTTP 动词 */
|
|
119
128
|
method: IrEndpoint["method"]
|
|
@@ -121,15 +130,30 @@ interface HandlerRow {
|
|
|
121
130
|
dir: string
|
|
122
131
|
/** 语句起始 `.route = "..."` 字面量(无则目录镜像直达) */
|
|
123
132
|
tail?: string
|
|
133
|
+
/** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
|
|
134
|
+
chain: string
|
|
135
|
+
/** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;绝对 .route 即其自身) */
|
|
136
|
+
full: string
|
|
124
137
|
filePath: string
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
131
150
|
|
|
132
|
-
|
|
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> {
|
|
133
157
|
const routes = new Map<string, string>();
|
|
134
158
|
for (const stmt of sf.statements) {
|
|
135
159
|
if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression))
|
|
@@ -139,38 +163,150 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
|
|
|
139
163
|
continue;
|
|
140
164
|
if (!ts.isPropertyAccessExpression(expr.left) || expr.left.name.text !== "route")
|
|
141
165
|
continue;
|
|
142
|
-
if (!ts.
|
|
166
|
+
if (!ts.isStringLiteral(expr.right) || expr.right.text === "")
|
|
143
167
|
continue;
|
|
144
|
-
|
|
168
|
+
const path = dottedNameOf(expr.left.expression);
|
|
169
|
+
if (path)
|
|
170
|
+
routes.set(path, expr.right.text);
|
|
145
171
|
}
|
|
172
|
+
return routes;
|
|
173
|
+
}
|
|
146
174
|
|
|
147
|
-
|
|
148
|
-
|
|
175
|
+
/** 顶层 `const x = { ... }` 对象字面量表:嵌套组经标识符引用时的解析依据 */
|
|
176
|
+
function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpression> {
|
|
177
|
+
const decls = new Map<string, ts.ObjectLiteralExpression>();
|
|
149
178
|
for (const stmt of sf.statements) {
|
|
150
|
-
if (!ts.
|
|
179
|
+
if (!ts.isVariableStatement(stmt))
|
|
151
180
|
continue;
|
|
152
|
-
for (const
|
|
153
|
-
if (ts.
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
else if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
157
|
-
methods.set(prop.name.text, ts.isIdentifier(prop.initializer) ? prop.initializer.text : prop.name.text);
|
|
158
|
-
}
|
|
159
|
-
else if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name)) {
|
|
160
|
-
methods.set(prop.name.text, prop.name.text);
|
|
161
|
-
}
|
|
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);
|
|
162
184
|
}
|
|
163
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
|
+
}
|
|
164
230
|
|
|
165
|
-
|
|
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);
|
|
166
255
|
const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
|
|
167
256
|
const rows: HandlerRow[] = [];
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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);
|
|
174
310
|
}
|
|
175
311
|
return rows;
|
|
176
312
|
}
|
|
@@ -230,74 +366,119 @@ function checkWsAnonymous(wsFiles: string[], apiSrcDir: string, configPath: stri
|
|
|
230
366
|
}));
|
|
231
367
|
}
|
|
232
368
|
|
|
233
|
-
|
|
234
|
-
|
|
369
|
+
/** 对账键(方法 + 全路径) */
|
|
370
|
+
const pathKey = (method: string, path: string) => `${method} ${path}`;
|
|
371
|
+
|
|
372
|
+
/** 路径段(去空串,忽略前导 /) */
|
|
373
|
+
const pathSegs = (p: string) => p.split("/").filter(Boolean);
|
|
374
|
+
/** 静态前缀:首个参数段({...})之前的路径段 */
|
|
375
|
+
function staticPrefixOf(p: string): string {
|
|
376
|
+
const segs = pathSegs(p);
|
|
377
|
+
const i = segs.findIndex(s => s.startsWith("{"));
|
|
378
|
+
return segs.slice(0, i === -1 ? segs.length : i).join("/");
|
|
235
379
|
}
|
|
236
380
|
|
|
237
|
-
/**
|
|
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
|
+
/**
|
|
390
|
+
* ② route 双向对账:契约路由表 vs AST 扫描的 handler 表(D12/F19:豁免降级)。
|
|
391
|
+
*
|
|
392
|
+
* 定位分两层,先精确后模糊:
|
|
393
|
+
* ① **精确全路径匹配**(方法 + ojFull,或方法 + wire 全路径 contractAbs)——覆盖
|
|
394
|
+
* v0.1.64 嵌套组(组名 = 虚拟路径段,全路径唯一)与绝对 `.route` 精确命中(D-M8
|
|
395
|
+
* urlPrefix 映射)。
|
|
396
|
+
* ② **同位置参数段比对**(模糊兜底)——未精确命中时,在全 handler 里找「同方法、
|
|
397
|
+
* 静态前缀相同、段数相同、但参数名不同」的唯一一条:即「同一端点、参数段写错」,
|
|
398
|
+
* 报 route-params-mismatch(旧行为)。静态前缀/段数不同而非同位置 → 视为不同端点,
|
|
399
|
+
* 报未实现 + 未登记(避免误判 mismatch)。嵌套组(虚拟段)与绝对形态都经此口径。
|
|
400
|
+
*/
|
|
238
401
|
function reconcileRoutes(contractIr: IrEndpoint[], handlers: HandlerRow[], exemption: ApiExemption): CheckViolation[] {
|
|
239
402
|
const violations: CheckViolation[] = [];
|
|
240
|
-
const key = (method: string, dir: string) => `${method} ${dir}`;
|
|
241
403
|
|
|
242
|
-
|
|
404
|
+
// ① 精确索引:handler 全路径(绝对 .route 另按 wire 全路径索引)。
|
|
405
|
+
const byPath = new Map<string, HandlerRow>();
|
|
406
|
+
for (const h of handlers) {
|
|
407
|
+
byPath.set(pathKey(h.method, h.full), h);
|
|
408
|
+
if (h.tail?.startsWith("/"))
|
|
409
|
+
byPath.set(pathKey(h.method, h.tail), h);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const claimed = new Set<HandlerRow>();
|
|
243
413
|
for (const ep of contractIr) {
|
|
244
|
-
const { dir
|
|
414
|
+
const { dir } = splitRoute(ep.route);
|
|
245
415
|
const dirFull = `${ep.apiPrefix.replace(/^\//, "")}${dir ? `/${dir}` : ""}`;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const handlerMap = new Map<string, HandlerRow>();
|
|
249
|
-
for (const h of handlers)
|
|
250
|
-
handlerMap.set(key(h.method, h.dir), h);
|
|
416
|
+
const ojFull = `${ep.apiPrefix.replace(/^\//, "")}${ep.route}`.replace(/\/+/g, "/");
|
|
417
|
+
const contractAbs = `${wirePrefixOf(ep)}${ep.route}`;
|
|
251
418
|
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
level: "warn",
|
|
257
|
-
kind: "route-not-implemented",
|
|
258
|
-
message: `[ojm-api] 契约端点 "${ep.name}"(${ep.method} ${routeLabel(ep.dir, ep.tail)})未实现——oj 树缺少 handler;重跑 ojm api 生成 stub 或手动实现。`,
|
|
259
|
-
});
|
|
419
|
+
// ① 精确全路径:嵌套组 / 绝对精确命中
|
|
420
|
+
const exact = byPath.get(pathKey(ep.method, ojFull)) ?? byPath.get(pathKey(ep.method, contractAbs));
|
|
421
|
+
if (exact) {
|
|
422
|
+
claimed.add(exact);
|
|
260
423
|
continue;
|
|
261
424
|
}
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
425
|
+
|
|
426
|
+
// ② 模糊兜底:同位置(静态前缀 + 段数相同)但参数名不同的唯一 handler → mismatch
|
|
427
|
+
const candidates = handlers.filter(h =>
|
|
428
|
+
!claimed.has(h)
|
|
429
|
+
&& h.method === ep.method
|
|
430
|
+
&& handlerAbs(h) !== contractAbs
|
|
431
|
+
&& staticPrefixOf(handlerAbs(h)) === staticPrefixOf(contractAbs)
|
|
432
|
+
&& pathSegs(handlerAbs(h)).length === pathSegs(contractAbs).length,
|
|
433
|
+
);
|
|
434
|
+
if (candidates.length === 1) {
|
|
435
|
+
const h = candidates[0];
|
|
436
|
+
claimed.add(h);
|
|
270
437
|
violations.push({
|
|
271
438
|
level: "error",
|
|
272
439
|
kind: "route-params-mismatch",
|
|
273
|
-
message: `[ojm-api] 参数段不一致(${ep.method} ${
|
|
274
|
-
filePath: handler.filePath,
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
for (const [k, h] of handlerMap) {
|
|
279
|
-
if (!contractMap.has(k)) {
|
|
280
|
-
// D12/F19:整模块豁免,或路径命中豁免前缀 → 视为已覆盖,跳过未登记报错
|
|
281
|
-
const mod = moduleOf(h.dir);
|
|
282
|
-
if (exemption.modules.includes(mod))
|
|
283
|
-
continue;
|
|
284
|
-
if (exemption.paths.some(p => matchExemptPath(`/${h.dir}`, p)))
|
|
285
|
-
continue;
|
|
286
|
-
violations.push({
|
|
287
|
-
level: "error",
|
|
288
|
-
kind: "route-unregistered",
|
|
289
|
-
message: `[ojm-api] handler 未登记(${h.method} ${routeLabel(h.dir, h.tail)})——oj 路由表存在但契约没有对应端点,请补契约或删除该 handler。`,
|
|
440
|
+
message: `[ojm-api] 参数段不一致(${ep.method} ${dirFull}):契约 route "${ep.route}" vs handler .route "${h.tail ?? h.full}"——契约 route 是唯一手写事实源(AC-D10),请改 handler 的 .route 字面量对齐。`,
|
|
290
441
|
filePath: h.filePath,
|
|
291
442
|
});
|
|
443
|
+
continue;
|
|
292
444
|
}
|
|
445
|
+
violations.push({
|
|
446
|
+
level: "warn",
|
|
447
|
+
kind: "route-not-implemented",
|
|
448
|
+
message: `[ojm-api] 契约端点 "${ep.name}"(${ep.method} ${ojFull})未实现——oj 树缺少 handler;重跑 ojm api 生成 stub 或手动实现(嵌套组形态:组名即路径段,见 oj v0.1.64)。`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
for (const h of handlers) {
|
|
452
|
+
if (claimed.has(h))
|
|
453
|
+
continue;
|
|
454
|
+
// D12/F19:整模块豁免,或路径命中豁免前缀 → 视为已覆盖,跳过未登记报错。
|
|
455
|
+
// 豁免路径同时按文件目录(旧口径)与有效路由全路径(v0.1.64 嵌套口径)判定。
|
|
456
|
+
const mod = moduleOf(h.dir);
|
|
457
|
+
if (exemption.modules.includes(mod))
|
|
458
|
+
continue;
|
|
459
|
+
if (exemption.paths.some(p => matchExemptPath(`/${h.dir}`, p) || matchExemptPath(`/${h.full}`, p)))
|
|
460
|
+
continue;
|
|
461
|
+
const nested = h.chain.includes(".") ? `,handler 链 ${h.chain}` : "";
|
|
462
|
+
violations.push({
|
|
463
|
+
level: "error",
|
|
464
|
+
kind: "route-unregistered",
|
|
465
|
+
message: `[ojm-api] handler 未登记(${h.method} ${h.full}${nested})——oj 路由表存在但契约没有对应端点,请补契约或删除该 handler。`,
|
|
466
|
+
filePath: h.filePath,
|
|
467
|
+
});
|
|
293
468
|
}
|
|
294
469
|
return violations;
|
|
295
470
|
}
|
|
296
471
|
|
|
297
|
-
/**
|
|
472
|
+
/**
|
|
473
|
+
* routes.js(oj build 产物)行解析:`{ method: "get", pattern: "order/list", ... }`。
|
|
474
|
+
* `options` 行直接跳过——契约不表达 OPTIONS(runtime 白名单拒绝),oj 树里合法的
|
|
475
|
+
* options handler 进了 routes.js 也不该判「dist 多」(跨仓同步:oj v0.1.64)。
|
|
476
|
+
*/
|
|
298
477
|
function parseRoutesJs(text: string): { method: string, pattern: string }[] {
|
|
299
478
|
const rows: { method: string, pattern: string }[] = [];
|
|
300
479
|
for (const m of text.matchAll(/\{\s*method:\s*"(\w+)",\s*pattern:\s*"([^"]+)"/g)) {
|
|
480
|
+
if (m[1].toLowerCase() === "options")
|
|
481
|
+
continue;
|
|
301
482
|
// oj 的路由表用短方法名(DELETE → "del");与契约 IR 的 HTTP 名对齐(同 VERB_OF 口径),
|
|
302
483
|
// 否则 uni-dev 工程里任何 DELETE 端点都会被误报 routes-js-drift。
|
|
303
484
|
const method = VERB_OF[m[1].toLowerCase()] ?? m[1].toUpperCase() as string;
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { initProject } from "./init";
|
|
|
9
9
|
import { previewServer } from "./preview";
|
|
10
10
|
import { usageText } from "./usage";
|
|
11
11
|
import { vendorCommand } from "./vendor";
|
|
12
|
+
import { readCliVersion } from "./versions";
|
|
12
13
|
|
|
13
14
|
/** ojm → oj 透传子命令(其余参数原样转发,见 src/oj-cmd.ts) */
|
|
14
15
|
const OJ_PASSTHROUGH = new Set(["test", "exec", "openapi", "migrate", "schema"]);
|
|
@@ -23,6 +24,12 @@ function usage(): never {
|
|
|
23
24
|
async function main() {
|
|
24
25
|
const projectRoot = process.cwd();
|
|
25
26
|
|
|
27
|
+
// 顶层 --version / -V:打印 cli 包版本(与 `ojm info` 的 cli 版本同源)
|
|
28
|
+
if (command === "--version" || command === "-V") {
|
|
29
|
+
console.log(readCliVersion());
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
switch (command) {
|
|
27
34
|
case "build":
|
|
28
35
|
// 设计 §5:后端 oj build(零 DB 副作用)→ 前端全站合并(仅 build 清场合并)
|
package/src/shared-deps.ts
CHANGED
|
@@ -131,6 +131,24 @@ export const SHARED_DEPS: SharedDepEntry[] = [
|
|
|
131
131
|
{ specifier: "simplebar-react", asset: "simplebar-react", hard: false },
|
|
132
132
|
{ specifier: "spin-delay", asset: "spin-delay", hard: false },
|
|
133
133
|
{ specifier: "tailwind-merge", asset: "tailwind-merge", hard: false },
|
|
134
|
+
/**
|
|
135
|
+
* Markdown 编辑器(Vditor v4,M5 回升自下游 oj-wiki):npm 包是 webpack **UMD**
|
|
136
|
+
* 单产物(main=dist/index.js,无 ESM 出口、仅 default 导出),走 starShim + esbuild
|
|
137
|
+
* 的既有 CJS/UMD interop 链路打包成 ESM 资产(与 react 家族同路,无需新机制)。
|
|
138
|
+
*
|
|
139
|
+
* 软共享:无单例语义(多副本只是重复体积,不拆 context),且不是每个模块都用。
|
|
140
|
+
* 体积大(gzip ≈700 KiB),故**惰性生效**:importmap 只做映射,宿主 shell 自身
|
|
141
|
+
* 不 import vditor ⇒ /assets/vditor.js 仅在模块产物真正 import 它时才被拉取,
|
|
142
|
+
* 不进宿主首屏。
|
|
143
|
+
*
|
|
144
|
+
* 深路径只登记 CSS 主样式(nprogress 同款样式垫片)。vditor 其余 dist 深路径
|
|
145
|
+
* (lute / i18n / icons / 主题 CSS…)是**运行期经 cdn 选项按需拉取的资源**而非
|
|
146
|
+
* import 说明符,由模块侧自行处理(自注入或指向宿主可服务的路径);若模块代码
|
|
147
|
+
* 直接 import 它们,会命中前缀 external 但无 importmap 键 → 构建期深路径报错,
|
|
148
|
+
* 属预期拦截(与 dayjs/plugin/* 同规则)。
|
|
149
|
+
*/
|
|
150
|
+
{ specifier: "vditor", asset: "vditor", hard: false },
|
|
151
|
+
{ specifier: "vditor/dist/index.css", asset: "vditor-index-css", hard: false },
|
|
134
152
|
];
|
|
135
153
|
|
|
136
154
|
/** 判断一个裸说明符是否命中共享表(命中主包前缀的深路径也按共享处理) */
|
package/src/usage.ts
CHANGED
package/src/versions.ts
CHANGED
|
@@ -17,6 +17,16 @@ import { SHARED_DEPS } from "./shared-deps";
|
|
|
17
17
|
/** cli 包根(本文件位于 <cliRoot>/src/) */
|
|
18
18
|
const CLI_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
19
19
|
|
|
20
|
+
/** 读取 cli 包自身版本(package.json 的 version 字段);不可读回退 unknown */
|
|
21
|
+
export function readCliVersion(): string {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(path.join(CLI_ROOT, "package.json"), "utf-8")).version ?? "unknown";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "unknown";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/** 读取宿主 shell 产物中的共享依赖实际版本表 */
|
|
21
31
|
export function readHostVersions(shellDist: string): Record<string, string> {
|
|
22
32
|
return JSON.parse(fs.readFileSync(path.join(shellDist, "versions.json"), "utf-8"));
|
|
@@ -3,17 +3,20 @@ import { defineApi, z } from "@oj-module/runtime/contract";
|
|
|
3
3
|
/**
|
|
4
4
|
* notification 模块契约(uni-dev 形态,apiPrefix 字面等于 "/notification",AC-D9)。
|
|
5
5
|
* 通知读写端点——前端经 ctx.register.notificationsApi 全量接管内置通知 API(D9 + G4):
|
|
6
|
-
* 读 + 单条已读 + 全部已读 +
|
|
6
|
+
* 读 + 单条已读 + 全部已读 + 清空,四端点全必填;另含发射原语端点 push/push-broadcast/del。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/** 通知项(id 为命名空间串:"u:<id>" 按人 / "b:<id>" 广播) */
|
|
10
10
|
const notificationItem = z.object({
|
|
11
|
-
id: z.
|
|
11
|
+
id: z.string(),
|
|
12
|
+
scope: z.enum(["user", "broadcast"]),
|
|
12
13
|
avatar: z.string(),
|
|
13
14
|
date: z.string(),
|
|
14
15
|
isRead: z.boolean().optional(),
|
|
15
16
|
message: z.string(),
|
|
16
17
|
title: z.string(),
|
|
18
|
+
link: z.string().nullable().optional(),
|
|
19
|
+
category: z.enum(["notice", "pm", "mention"]).optional(),
|
|
17
20
|
});
|
|
18
21
|
|
|
19
22
|
/* 通知列表 */
|
|
@@ -21,7 +24,7 @@ export const fetchNotifications = defineApi({
|
|
|
21
24
|
apiPrefix: "/notification",
|
|
22
25
|
route: "/notifications",
|
|
23
26
|
data: z.array(notificationItem),
|
|
24
|
-
description: "
|
|
27
|
+
description: "通知列表(按人 + 广播合并)",
|
|
25
28
|
});
|
|
26
29
|
|
|
27
30
|
/* 单条已读 */
|
|
@@ -30,7 +33,7 @@ export const markRead = defineApi({
|
|
|
30
33
|
route: "/notifications/read",
|
|
31
34
|
method: "POST",
|
|
32
35
|
body: z.object({
|
|
33
|
-
id: z.
|
|
36
|
+
id: z.string(),
|
|
34
37
|
}),
|
|
35
38
|
description: "单条通知已读",
|
|
36
39
|
});
|
|
@@ -50,3 +53,46 @@ export const clearAll = defineApi({
|
|
|
50
53
|
method: "POST",
|
|
51
54
|
description: "清空通知",
|
|
52
55
|
});
|
|
56
|
+
|
|
57
|
+
/* 推按人通知(发射原语 HTTP 入口) */
|
|
58
|
+
export const push = defineApi({
|
|
59
|
+
apiPrefix: "/notification",
|
|
60
|
+
route: "/push",
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: z.object({
|
|
63
|
+
userId: z.union([z.string(), z.number()]).optional(),
|
|
64
|
+
title: z.string(),
|
|
65
|
+
message: z.string(),
|
|
66
|
+
link: z.string().optional(),
|
|
67
|
+
avatar: z.string().optional(),
|
|
68
|
+
category: z.enum(["notice", "pm", "mention"]).optional(),
|
|
69
|
+
}),
|
|
70
|
+
description: "推按人通知",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/* 广播通知(全员可见,一行存储) */
|
|
74
|
+
export const pushBroadcast = defineApi({
|
|
75
|
+
apiPrefix: "/notification",
|
|
76
|
+
route: "/push-broadcast",
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: z.object({
|
|
79
|
+
title: z.string(),
|
|
80
|
+
message: z.string(),
|
|
81
|
+
link: z.string().optional(),
|
|
82
|
+
avatar: z.string().optional(),
|
|
83
|
+
category: z.enum(["notice", "pm", "mention"]).optional(),
|
|
84
|
+
}),
|
|
85
|
+
description: "广播通知",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/* 删除通知(按人项校验归属,非本人 403;广播项仅删本人回执) */
|
|
89
|
+
export const del = defineApi({
|
|
90
|
+
apiPrefix: "/notification",
|
|
91
|
+
route: "/del",
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: z.object({
|
|
94
|
+
id: z.string(),
|
|
95
|
+
userId: z.union([z.string(), z.number()]).optional(),
|
|
96
|
+
}),
|
|
97
|
+
description: "删除通知",
|
|
98
|
+
});
|