bk-schema-checker 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  请求参数不看 mock 读了什么,而看 services 调用的参数类型:GET 的第二个参数是查询参数,POST、PUT、PATCH、DELETE 的第二个参数是请求体。路径参数来自 Django 的 `<str:system_id>` 这类转换器。
16
16
 
17
- 后台用 Python 标准库 `ast` 静态读取,包括字段继承、自定义字段子类、`SerializerMethodField` 的返回注解,以及能静态求值的 `choices`。前端 `tsconfig.json` 的 `paths` 用来展开 `@common/const` 这类别名;`export enum`、`export interface` 和 `` `${Enum}` `` 会展开成具体类型再比较。
17
+ 后台用 Python 标准库 `ast` 静态读取,包括字段继承、自定义字段子类、`SerializerMethodField` 的返回注解,以及能静态求值的 `choices`。前端 `tsconfig.json` 的 `paths` 用来展开 `@common/const` 这类别名;`export enum`、`export interface`(含 `extends`)、`A & { extra }` 交叉类型和 `` `${Enum}` `` 会展开成具体类型再比较。
18
18
 
19
19
  ## 环境要求
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bk-schema-checker",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "对照前端 services、Django REST 序列化器,以及可选的 bk-local-mock 响应,做接口协议检查。不启动 Django。",
5
5
  "type": "module",
6
6
  "author": "austinqli",
package/src/mock-shim.js CHANGED
@@ -23,7 +23,7 @@ export const listResult = (results) => ({
23
23
  });
24
24
 
25
25
  export const getRequestParam = (config, key) => {
26
- const value = config?.params?.[key];
26
+ const value = config?.params?.[key] ?? new URL(config?.url || "", "https://mock.local").searchParams.get(key);
27
27
  return value === undefined || value === null ? "" : String(value);
28
28
  };
29
29
 
@@ -38,3 +38,5 @@ export const paginatedListResult = (results, config) => {
38
38
  };
39
39
 
40
40
  export const getMethod = (config) => (config?.method || "get").toLowerCase();
41
+
42
+ export const getPathname = (url) => new URL(url || "", "https://mock.local").pathname;
package/src/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs";
2
+ import { createRequire } from "node:module";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
4
5
 
@@ -9,12 +10,12 @@ const METHOD_ORDER = ["get", "post", "put", "patch", "delete"];
9
10
  * 同一个运行时里模块缓存是共享的,后执行的接口能看到前面写入的内存数据。
10
11
  */
11
12
  export async function createMockRuntime({ root, mockDir, servicesDir }) {
12
- const createJiti = await loadCreateJiti(root);
13
+ const createJiti = await loadCreateJiti(root, mockDir);
13
14
  const jiti = createJiti(import.meta.url, {
14
15
  alias: {
15
16
  "@mock": mockDir,
16
17
  "@services": servicesDir,
17
- "bk-local-mock": findMockPackage(root),
18
+ "bk-local-mock": findMockPackage(root, mockDir),
18
19
  },
19
20
  interopDefault: true,
20
21
  fsCache: false,
@@ -200,7 +201,7 @@ function snapshot(value) {
200
201
  }
201
202
  }
202
203
 
203
- async function loadCreateJiti(root) {
204
+ async function loadCreateJiti(root, mockDir) {
204
205
  try {
205
206
  const mod = await import("jiti");
206
207
  if (typeof mod.createJiti === "function") {
@@ -209,6 +210,17 @@ async function loadCreateJiti(root) {
209
210
  } catch {
210
211
  // 检查器自己没装 jiti 时,用前端工程里的那一份。
211
212
  }
213
+ for (const start of moduleResolveStarts(root, mockDir)) {
214
+ try {
215
+ const resolved = createRequire(start).resolve("jiti");
216
+ const mod = await import(pathToFileURL(resolved).href);
217
+ if (typeof mod.createJiti === "function") {
218
+ return mod.createJiti;
219
+ }
220
+ } catch {
221
+ // 这一层没有安装,继续往上看。
222
+ }
223
+ }
212
224
  for (const candidate of jitiCandidates(root)) {
213
225
  if (fs.existsSync(candidate)) {
214
226
  const mod = await import(pathToFileURL(candidate).href);
@@ -220,13 +232,25 @@ async function loadCreateJiti(root) {
220
232
  throw new Error("执行 mock 需要 jiti。请在 frontend 安装依赖,或在 bk-schema-checker 中安装 jiti。");
221
233
  }
222
234
 
223
- function findMockPackage(root) {
224
- for (const candidate of [
225
- path.join(root, "frontend/node_modules/bk-local-mock/index.js"),
226
- path.resolve(fileDir(), "../../frontend/node_modules/bk-local-mock/index.js"),
227
- ]) {
228
- if (fs.existsSync(candidate)) {
229
- return candidate;
235
+ function moduleResolveStarts(root, mockDir) {
236
+ return [
237
+ mockDir && path.join(mockDir, "handler.js"),
238
+ root && path.join(root, "package.json"),
239
+ root && path.join(root, "frontend/package.json"),
240
+ path.join(fileDir(), "runtime.js"),
241
+ ].filter(Boolean);
242
+ }
243
+
244
+ /**
245
+ * 按 Node 解析规则找已安装的 bk-local-mock。
246
+ * 配置文件常放在 frontend/,旧逻辑只认 root/frontend/node_modules,会漏掉当前工程自己的依赖。
247
+ */
248
+ export function findMockPackage(root, mockDir) {
249
+ for (const start of moduleResolveStarts(root, mockDir)) {
250
+ try {
251
+ return createRequire(start).resolve("bk-local-mock");
252
+ } catch {
253
+ // 这一层没有安装,继续往上看。
230
254
  }
231
255
  }
232
256
  return path.join(fileDir(), "mock-shim.js");
package/src/ts.js CHANGED
@@ -6,6 +6,7 @@ import { maskComments, readIdentifier, skipSpace, sliceBalanced, splitTop } from
6
6
  const HELPERS = new Set([
7
7
  "clone",
8
8
  "getMethod",
9
+ "getPathname",
9
10
  "getRequestParam",
10
11
  "listResult",
11
12
  "paginatedListResult",
@@ -480,7 +481,10 @@ function parseTypeExport(source, cursor, mod) {
480
481
  if (interfaceLike || source[bodyAt] === "{") {
481
482
  const open = source.indexOf("{", bodyAt);
482
483
  const block = sliceBalanced(source, open);
483
- mod.types.set(ident.name, { kind: "source", source: source.slice(open, block.end + 1) });
484
+ const body = source.slice(open, block.end + 1);
485
+ const parents = interfaceLike ? parseInterfaceExtends(source, bodyAt, open) : [];
486
+ const composed = parents.length ? `${parents.join(" & ")} & ${body}` : body;
487
+ mod.types.set(ident.name, { kind: "source", source: composed });
484
488
  return block.end + 1;
485
489
  }
486
490
  if (source[bodyAt] === "=") {
@@ -492,6 +496,31 @@ function parseTypeExport(source, cursor, mod) {
492
496
  return skipStatement(source, cursor).end;
493
497
  }
494
498
 
499
+ function parseInterfaceExtends(source, start, open) {
500
+ const text = source.slice(start, open).trim();
501
+ if (!/^extends\b/.test(text)) {
502
+ return [];
503
+ }
504
+ const names = [];
505
+ let cursor = skipSpace(text, "extends".length);
506
+ while (cursor < text.length) {
507
+ const ident = readIdentifier(text, cursor);
508
+ if (!ident) {
509
+ break;
510
+ }
511
+ names.push(ident.name);
512
+ cursor = skipSpace(text, ident.end);
513
+ if (text[cursor] === "<") {
514
+ const generic = sliceAngles(text, cursor);
515
+ cursor = skipSpace(text, generic ? generic.end + 1 : cursor + 1);
516
+ }
517
+ if (text[cursor] === ",") {
518
+ cursor = skipSpace(text, cursor + 1);
519
+ }
520
+ }
521
+ return names;
522
+ }
523
+
495
524
  function parseEnumExport(source, cursor, mod) {
496
525
  const head = source.slice(cursor, cursor + 24);
497
526
  const keyword = /^export\s+const\s+enum\b/.test(head) ? "export const enum" : "export enum";
@@ -1504,7 +1533,7 @@ function evalCall(node, env) {
1504
1533
  }
1505
1534
  return new Partial("string");
1506
1535
  }
1507
- if (callee?.helper === "getMethod") {
1536
+ if (callee?.helper === "getMethod" || callee?.helper === "getPathname") {
1508
1537
  return new Partial("string");
1509
1538
  }
1510
1539
  if (callee && callee.kind === "function") {
@@ -1747,6 +1776,17 @@ function skeleton(type) {
1747
1776
  value.fields = Object.fromEntries(type.props.map((prop) => [prop.name, skeleton(prop.type)]));
1748
1777
  return value;
1749
1778
  }
1779
+ if (type.kind === "intersection") {
1780
+ const value = new Partial("object");
1781
+ value.fields = {};
1782
+ for (const item of type.types) {
1783
+ const next = skeleton(item);
1784
+ if (next instanceof Partial && next.fields) {
1785
+ Object.assign(value.fields, next.fields);
1786
+ }
1787
+ }
1788
+ return value;
1789
+ }
1750
1790
  return new Partial();
1751
1791
  }
1752
1792
 
@@ -1764,7 +1804,7 @@ function parseTypeAt(source, cursor) {
1764
1804
  }
1765
1805
 
1766
1806
  function parseUnionType(source, cursor) {
1767
- let current = parsePrimaryType(source, cursor);
1807
+ let current = parseIntersectionType(source, cursor);
1768
1808
  const types = [current.type];
1769
1809
  let end = current.end;
1770
1810
  while (true) {
@@ -1772,13 +1812,29 @@ function parseUnionType(source, cursor) {
1772
1812
  if (source[next] !== "|") {
1773
1813
  break;
1774
1814
  }
1775
- current = parsePrimaryType(source, next + 1);
1815
+ current = parseIntersectionType(source, next + 1);
1776
1816
  types.push(current.type);
1777
1817
  end = current.end;
1778
1818
  }
1779
1819
  return { type: types.length === 1 ? types[0] : { kind: "union", types }, end };
1780
1820
  }
1781
1821
 
1822
+ function parseIntersectionType(source, cursor) {
1823
+ let current = parsePrimaryType(source, cursor);
1824
+ const types = [current.type];
1825
+ let end = current.end;
1826
+ while (true) {
1827
+ const next = skipSpace(source, end);
1828
+ if (source[next] !== "&") {
1829
+ break;
1830
+ }
1831
+ current = parsePrimaryType(source, next + 1);
1832
+ types.push(current.type);
1833
+ end = current.end;
1834
+ }
1835
+ return { type: types.length === 1 ? types[0] : { kind: "intersection", types }, end };
1836
+ }
1837
+
1782
1838
  function parsePrimaryType(source, cursor) {
1783
1839
  const start = skipSpace(source, cursor);
1784
1840
  if (source[start] === "{") {
@@ -2004,16 +2060,19 @@ function splitTypeProps(source) {
2004
2060
  export function resolveTsType(type, mod, index, seen = new Set()) {
2005
2061
  if (!type || type.kind !== "ref" && type.kind !== "index" && type.kind !== "parameters") {
2006
2062
  if (type?.kind === "array") {
2007
- return { kind: "array", item: resolveTsType(type.item, mod, index, seen) };
2063
+ return { kind: "array", item: resolveTsType(type.item, mod, index, new Set(seen)) };
2008
2064
  }
2009
2065
  if (type?.kind === "union") {
2010
- return { kind: "union", types: type.types.map((item) => resolveTsType(item, mod, index, seen)) };
2066
+ return { kind: "union", types: type.types.map((item) => resolveTsType(item, mod, index, new Set(seen))) };
2067
+ }
2068
+ if (type?.kind === "intersection") {
2069
+ return mergeResolvedTypes(type.types.map((item) => resolveTsType(item, mod, index, new Set(seen))));
2011
2070
  }
2012
2071
  if (type?.kind === "object") {
2013
2072
  return {
2014
2073
  kind: "object",
2015
2074
  open: Boolean(type.open),
2016
- props: type.props.map((prop) => ({ ...prop, type: resolveTsType(prop.type, mod, index, seen) })),
2075
+ props: type.props.map((prop) => ({ ...prop, type: resolveTsType(prop.type, mod, index, new Set(seen)) })),
2017
2076
  };
2018
2077
  }
2019
2078
  return type || { kind: "any" };
@@ -2044,6 +2103,59 @@ export function resolveTsType(type, mod, index, seen = new Set()) {
2044
2103
  return resolved;
2045
2104
  }
2046
2105
 
2106
+ function mergeResolvedTypes(types) {
2107
+ const parts = types.flatMap((type) => (type?.kind === "intersection" ? type.types : [type])).filter(Boolean);
2108
+ const unions = parts.filter((type) => type.kind === "union");
2109
+ if (unions.length) {
2110
+ const rest = parts.filter((type) => type.kind !== "union");
2111
+ return {
2112
+ kind: "union",
2113
+ types: unions[0].types.map((item) => mergeResolvedTypes([item, ...unions.slice(1), ...rest])),
2114
+ };
2115
+ }
2116
+ const objects = parts.filter((type) => type.kind === "object");
2117
+ if (objects.length) {
2118
+ return mergeObjectTypes(objects);
2119
+ }
2120
+ const concrete = parts.filter((type) => type.kind !== "any" && type.kind !== "null");
2121
+ return concrete[0] || parts[0] || { kind: "any" };
2122
+ }
2123
+
2124
+ function mergeObjectTypes(objects) {
2125
+ const props = [];
2126
+ let open = false;
2127
+ for (const object of objects) {
2128
+ open = open || Boolean(object.open);
2129
+ for (const prop of object.props || []) {
2130
+ const index = props.findIndex((item) => item.name === prop.name);
2131
+ if (index === -1) {
2132
+ props.push({ ...prop });
2133
+ continue;
2134
+ }
2135
+ const prev = props[index];
2136
+ props[index] = {
2137
+ name: prop.name,
2138
+ optional: Boolean(prev.optional && prop.optional),
2139
+ type: mergePropertyType(prev.type, prop.type),
2140
+ };
2141
+ }
2142
+ }
2143
+ return { kind: "object", props, open };
2144
+ }
2145
+
2146
+ function mergePropertyType(left, right) {
2147
+ if (!left || left.kind === "any") {
2148
+ return right;
2149
+ }
2150
+ if (!right || right.kind === "any") {
2151
+ return left;
2152
+ }
2153
+ if (left.kind === "object" && right.kind === "object") {
2154
+ return mergeObjectTypes([left, right]);
2155
+ }
2156
+ return right;
2157
+ }
2158
+
2047
2159
  function lookupType(mod, name, index, stack = new Set()) {
2048
2160
  if (!mod || stack.has(mod.file)) {
2049
2161
  return null;