Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/dev/npm-publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
| npm 包 | 目录 | 说明 |
|--------|------|------|
| `@sfmc-bds/sdk` | `modules/sdk/@sfmc-sdk/` | 模块作者 SDK(SAPI + Node) |
| `@sfmc-bds/eslint-plugin` | `modules/sdk/@sfmc-eslint-plugin/` | SFMC 约定 ESLint 规则 |
| `@sfmc-bds/cli` | `sfmc/` | 管理 CLI(`sfmc` 命令) |
| `@sfmc-bds/db-server` | `db-server/` | SQLite HTTP REST 后端 |
| `@sfmc-bds/qq-bridge` | `qq-bridge/` | QQ ↔ MC 桥接 |
Expand Down
1 change: 1 addition & 0 deletions modules/sdk/@sfmc-eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,6 @@ export default [

```bash
npm run build -w @sfmc-bds/eslint-plugin
# 发布 build 不含 *.test.ts / rule-tester;测试走独立 tsconfig
npm run test -w @sfmc-bds/eslint-plugin
```
3 changes: 2 additions & 1 deletion modules/sdk/@sfmc-eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"scripts": {
"build": "tsc7 -p tsconfig.json",
"typecheck": "tsc7 --noEmit -p tsconfig.json",
"test": "npm run build && node --test dist/**/*.test.js",
"build:test": "tsc7 -p tsconfig.test.json",
"test": "npm run build:test && node --test \"dist/**/*.test.js\"",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
Expand Down
8 changes: 6 additions & 2 deletions modules/sdk/@sfmc-eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { ESLint } from "eslint";
import { createRequire } from "node:module";
import {
allRules,
createFlatConfig,
recommendedRules,
rules,
} from "./configs/recommended.js";

const require = createRequire(import.meta.url);
const pkg = require("../package.json") as { name: string; version: string };

/**
* ESLint.Plugin.rules 期望核心 RuleDefinition,而 @typescript-eslint 的 RuleModule
* 在 create/context 形状上更窄;运行时兼容,此处用断言对齐发布面契约(LSP 边界)。
*/
const plugin = {
meta: {
name: "@sfmc-bds/eslint-plugin",
version: "0.1.0",
name: pkg.name,
version: pkg.version,
},
rules,
} as unknown as ESLint.Plugin;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from "node:path";
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../utils/create-rule.js";
import { visitModuleSourceLiterals } from "../utils/module-source-visitor.js";

function toPosix(p: string): string {
return p.replace(/\\/g, "/");
Expand Down Expand Up @@ -46,12 +47,12 @@ export const noCrossModuleSourceImport = createRule({
const filename = toPosix(context.filename);
const own = packageRootOf(filename);

function check(source: string, node: TSESTree.Node): void {
return visitModuleSourceLiterals((source, sourceNode) => {
const norm = toPosix(source);

if (/^@sfmc-bds\/module-[a-z0-9-]+(?:\/|$)/.test(norm)) {
if (/\/sapi\/src(?:\/|$)/.test(norm)) {
context.report({ node, messageId: "crossSource", data: { source } });
context.report({ node: sourceNode, messageId: "crossSource", data: { source } });
}
return;
}
Expand All @@ -65,31 +66,17 @@ export const noCrossModuleSourceImport = createRule({
}
const other = packageRootOf(resolved);
if (other && other !== own) {
context.report({ node, messageId: "crossSource", data: { source } });
context.report({ node: sourceNode, messageId: "crossSource", data: { source } });
}
return;
}

if (/\/(?:packages|modules\/packages)\/[^/]+\/sapi\/src(?:\/|$)/.test(norm)) {
const hit = packageRootOf(norm);
if (!own || (hit && hit !== own)) {
context.report({ node, messageId: "crossSource", data: { source } });
context.report({ node: sourceNode, messageId: "crossSource", data: { source } });
}
}
}

function onSource(
node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration
) {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
check(src.value, src);
}

return {
ImportDeclaration: onSource,
ExportNamedDeclaration: onSource,
ExportAllDeclaration: onSource,
};
});
},
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../utils/create-rule.js";
import { visitModuleSourceLiterals } from "../utils/module-source-visitor.js";

const DEFAULT_PACKAGES = ["db-server", "qq-bridge", "bds-tools", "@sfmc-bds/db-server", "@sfmc-bds/qq-bridge", "@sfmc-bds/bds-tools", "@sfmc-bds/cli"];
const DEFAULT_PACKAGES = [
"db-server",
"qq-bridge",
"bds-tools",
"@sfmc-bds/db-server",
"@sfmc-bds/qq-bridge",
"@sfmc-bds/bds-tools",
"@sfmc-bds/cli",
];
const DEFAULT_FRAGMENTS = [
"/db-server/",
"/qq-bridge/",
Expand Down Expand Up @@ -73,18 +82,8 @@ export const noPlatformInternalImport = createRule<Options, "blocked">({
}
}

function onSource(
node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration
) {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
check(src.value, src);
}

return {
ImportDeclaration: onSource,
ExportNamedDeclaration: onSource,
ExportAllDeclaration: onSource,
};
return visitModuleSourceLiterals((source, sourceNode) => {
check(source, sourceNode);
});
},
});
19 changes: 5 additions & 14 deletions modules/sdk/@sfmc-eslint-plugin/src/rules/no-sdk-deep-import.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../utils/create-rule.js";
import { visitModuleSourceLiterals } from "../utils/module-source-visitor.js";

/**
* 禁止相对路径深挖 SDK 源码
Expand All @@ -19,25 +19,16 @@ export const noSdkDeepImport = createRule({
},
defaultOptions: [],
create(context) {
function checkSource(
node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration
) {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
const v = src.value.replace(/\\/g, "/");
return visitModuleSourceLiterals((source, sourceNode) => {
const v = source.replace(/\\/g, "/");
if (
v.includes("@sfmc-sdk/src") ||
v.includes("modules/sdk/") ||
/(?:^|\/)@sfmc-sdk(?:\/|$)/.test(v)
) {
if (v.startsWith("@sfmc-bds/sdk")) return;
context.report({ node: src, messageId: "usePublic" });
context.report({ node: sourceNode, messageId: "usePublic" });
}
}
return {
ImportDeclaration: checkSource,
ExportNamedDeclaration: checkSource,
ExportAllDeclaration: checkSource,
};
});
},
});
21 changes: 6 additions & 15 deletions modules/sdk/@sfmc-eslint-plugin/src/rules/no-sdk-private-export.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../utils/create-rule.js";
import { checkSdkImportPath } from "../utils/sdk-public-exports.js";
import { visitModuleSourceLiterals } from "../utils/module-source-visitor.js";

type Options = [{ extraAllowed?: string[] }];

Expand Down Expand Up @@ -32,21 +32,12 @@ export const noSdkPrivateExport = createRule<Options, "bare" | "private">({
},
defaultOptions: [{}],
create(context, [options]) {
function check(
node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration
) {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
const kind = checkSdkImportPath(src.value, options.extraAllowed ?? []);
if (kind === "bare") context.report({ node: src, messageId: "bare" });
return visitModuleSourceLiterals((source, sourceNode) => {
const kind = checkSdkImportPath(source, options.extraAllowed ?? []);
if (kind === "bare") context.report({ node: sourceNode, messageId: "bare" });
else if (kind === "private") {
context.report({ node: src, messageId: "private", data: { source: src.value } });
context.report({ node: sourceNode, messageId: "private", data: { source } });
}
}
return {
ImportDeclaration: check,
ExportNamedDeclaration: check,
ExportAllDeclaration: check,
};
});
},
});
19 changes: 5 additions & 14 deletions modules/sdk/@sfmc-eslint-plugin/src/rules/no-sfmc-sdk-alias.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TSESTree } from "@typescript-eslint/utils";
import { createRule } from "../utils/create-rule.js";
import { visitModuleSourceLiterals } from "../utils/module-source-visitor.js";

/**
* 禁止 @sfmc/sdk 别名,统一 @sfmc-bds/sdk
Expand All @@ -19,19 +19,10 @@ export const noSfmcSdkAlias = createRule({
},
defaultOptions: [],
create(context) {
function checkSource(
node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration
) {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
if (src.value === "@sfmc/sdk" || src.value.startsWith("@sfmc/sdk/")) {
context.report({ node: src, messageId: "useOfficial" });
return visitModuleSourceLiterals((source, sourceNode) => {
if (source === "@sfmc/sdk" || source.startsWith("@sfmc/sdk/")) {
context.report({ node: sourceNode, messageId: "useOfficial" });
}
}
return {
ImportDeclaration: checkSource,
ExportNamedDeclaration: checkSource,
ExportAllDeclaration: checkSource,
};
});
},
});
30 changes: 30 additions & 0 deletions modules/sdk/@sfmc-eslint-plugin/src/utils/module-source-visitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* import/export 源字面量访问器 — 规则侧统一入口,避免重复样板(DRY)。
*/
import type { TSESLint, TSESTree } from "@typescript-eslint/utils";

export type ModuleSourceDeclaration =
| TSESTree.ImportDeclaration
| TSESTree.ExportNamedDeclaration
| TSESTree.ExportAllDeclaration;

export type ModuleSourceLiteral = TSESTree.StringLiteral;

/**
* 对 Import/Export* 的字符串 source 逐条回调。
* @param check (source, sourceNode, declaration)
*/
export function visitModuleSourceLiterals(
check: (source: string, sourceNode: ModuleSourceLiteral, node: ModuleSourceDeclaration) => void
): TSESLint.RuleListener {
function onSource(node: ModuleSourceDeclaration): void {
const src = node.source;
if (!src || src.type !== "Literal" || typeof src.value !== "string") return;
check(src.value, src as ModuleSourceLiteral, node);
}
return {
ImportDeclaration: onSource,
ExportNamedDeclaration: onSource,
ExportAllDeclaration: onSource,
};
}
15 changes: 9 additions & 6 deletions modules/sdk/@sfmc-eslint-plugin/src/utils/rule-tester.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { RuleTester } from "@typescript-eslint/rule-tester";
import test from "node:test";
import { after, describe, it } from "node:test";

/** 统一 RuleTester 与 node:test 桥接,避免各规则测试重复样板(DRY) */
RuleTester.afterAll = () => {};
RuleTester.describe = test;
RuleTester.it = test;
RuleTester.itOnly = test.only;
/**
* 统一 RuleTester 与 node:test 桥接(DRY)。
* 须使用 describe/it(而非顶层 test),否则嵌套用例会被 parent 提前取消。
*/
RuleTester.afterAll = after;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;

export function createRuleTester(): RuleTester {
return new RuleTester({
Expand Down
72 changes: 68 additions & 4 deletions modules/sdk/@sfmc-eslint-plugin/src/utils/sdk-public-exports.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
/**
* 与 @sfmc-bds/sdk package.json#exports 同步的公开子路径白名单。
* 更新 SDK exports 时请同步本列表。
* @sfmc-bds/sdk 公开 exports 白名单。
* 权威来源:sibling / 已安装 `@sfmc-bds/sdk` 的 package.json#exports(DIP/DRY);
* 读不到时回落到与当前 SDK 对齐的静态表。
*/
export const SDK_PUBLIC_EXPORTS = [
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

/** 与当前 SDK exports 对齐的回落表(无 SDK 包时使用) */
export const SDK_PUBLIC_EXPORTS_FALLBACK = [
"contracts",
"logs",
"sapi/sdk",
Expand All @@ -20,6 +27,63 @@ export const SDK_PUBLIC_EXPORTS = [
] as const;

const SDK_PREFIX = "@sfmc-bds/sdk";
const require = createRequire(import.meta.url);

let cachedExports: string[] | null = null;

function parseExportsKeys(pkgPath: string): string[] | null {
try {
if (!fs.existsSync(pkgPath)) return null;
const raw = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
exports?: Record<string, unknown>;
};
const keys = Object.keys(raw.exports ?? {})
.map((k) => (k === "." ? null : k.replace(/^\.\//, "")))
.filter((k): k is string => !!k && !k.includes("*"));
return keys.length > 0 ? keys : null;
} catch {
return null;
}
}

/**
* 从 monorepo sibling 或已安装的 @sfmc-bds/sdk 解析 exports 子路径。
* `schemas/*` 由 checkSdkImportPath 前缀放行,不进本列表。
*/
export function loadSdkPublicExports(): readonly string[] {
if (cachedExports) return cachedExports;

const here = path.dirname(fileURLToPath(import.meta.url));
const candidates: string[] = [
// dist/utils 或 src/utils → modules/sdk/@sfmc-sdk/package.json
path.resolve(here, "..", "..", "..", "@sfmc-sdk", "package.json"),
];

try {
candidates.unshift(require.resolve("@sfmc-bds/sdk/package.json"));
} catch {
// 未安装 SDK 时忽略
}

for (const pkgPath of candidates) {
const keys = parseExportsKeys(pkgPath);
if (keys) {
cachedExports = keys;
return cachedExports;
}
}

cachedExports = [...SDK_PUBLIC_EXPORTS_FALLBACK];
return cachedExports;
}

/** @deprecated 兼容旧名;请优先 loadSdkPublicExports() */
export const SDK_PUBLIC_EXPORTS = SDK_PUBLIC_EXPORTS_FALLBACK;

/** 测试用:清空 exports 缓存 */
export function clearSdkPublicExportsCache(): void {
cachedExports = null;
}

/** 检查 import 源是否为合法公开入口;非法返回错误原因,合法返回 null */
export function checkSdkImportPath(
Expand All @@ -32,7 +96,7 @@ export function checkSdkImportPath(
const sub = source.slice(SDK_PREFIX.length + 1);
if (sub.startsWith("schemas/")) return null; // schemas/* 公开

const allowed = new Set<string>([...SDK_PUBLIC_EXPORTS, ...extraAllowed]);
const allowed = new Set<string>([...loadSdkPublicExports(), ...extraAllowed]);
if (allowed.has(sub)) return null;
return "private";
}
Loading