@supacloud/compiler 0.2.0 → 0.4.0
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/dist/cli.js +1698 -0
- package/dist/compile.d.ts +6 -1
- package/dist/generate.d.ts +8 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +413 -7
- package/dist/profiles.d.ts +36 -0
- package/dist/types.d.ts +67 -1
- package/dist/validate.d.ts +2 -2
- package/package.json +6 -3
package/dist/compile.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import type { CompileOptions, CompileResult } from "./types";
|
|
1
|
+
import type { CheckProjectResult, CompileOptions, CompileResult } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* 完整编译流程:AST 分析 → 校验 → 生成静态工厂代码与 manifest。
|
|
4
4
|
* 即使存在 error 级诊断也会照常写出文件,由调用方根据 diagnostics 决定是否采用。
|
|
5
5
|
*/
|
|
6
6
|
export declare function compileProject(options: CompileOptions): Promise<CompileResult>;
|
|
7
|
+
/**
|
|
8
|
+
* Check generated artifacts without writing files to disk.
|
|
9
|
+
* Analyze the AST, run governance checks, and compare application.ts and app.manifest.json.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkProject(options: CompileOptions): Promise<CheckProjectResult>;
|
package/dist/generate.d.ts
CHANGED
|
@@ -3,8 +3,14 @@ export interface GenerateOptions {
|
|
|
3
3
|
rootDir: string;
|
|
4
4
|
outDir: string;
|
|
5
5
|
}
|
|
6
|
+
export interface RenderedArtifacts {
|
|
7
|
+
applicationCode: string;
|
|
8
|
+
manifestJson: string;
|
|
9
|
+
}
|
|
10
|
+
/** Render application.ts and app.manifest.json content without file I/O. */
|
|
11
|
+
export declare function renderApplication(graph: ApplicationGraph, options: GenerateOptions): RenderedArtifacts;
|
|
6
12
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
13
|
+
* Generate static factory code (application.ts) and app.manifest.json.
|
|
14
|
+
* Generated code imports only application classes and schemas, with no runtime reflection or container lookup.
|
|
9
15
|
*/
|
|
10
16
|
export declare function generateApplication(graph: ApplicationGraph, options: GenerateOptions): Promise<string[]>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { analyzeProject } from "./analyze";
|
|
2
|
-
export { compileProject } from "./compile";
|
|
3
|
-
export { generateApplication } from "./generate";
|
|
4
|
-
export type { GenerateOptions } from "./generate";
|
|
2
|
+
export { checkProject, compileProject } from "./compile";
|
|
3
|
+
export { generateApplication, renderApplication } from "./generate";
|
|
4
|
+
export type { GenerateOptions, RenderedArtifacts } from "./generate";
|
|
5
5
|
export { validateGraph } from "./validate";
|
|
6
6
|
export { camelName } from "./util";
|
|
7
|
-
export
|
|
7
|
+
export { ANGULAR_ENTERPRISE_RULES, CLEAN_ARCHITECTURE_RULES, MODULAR_MONOLITH_RULES, MODULE_BOUNDARY_PROFILES, getModuleBoundaryPreset, getModuleBoundaryProfile, resolveModuleBoundaries, } from "./profiles";
|
|
8
|
+
export type { ApplicationGraph, CheckProjectResult, CommandExecutionCapabilities, CommandNode, CompileOptions, CompileResult, ControllerNode, Diagnostic, ModuleBoundaryPresetName, ModuleBoundaryProfile, ModuleBoundaryRule, ModuleNode, ProviderKind, ProviderNode, QueryNode, RouteNode, Scope, TokenKind, ValidateOptions, } from "./types";
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,9 @@ var ROUTE_DECORATORS = {
|
|
|
12
12
|
Post: "POST",
|
|
13
13
|
Put: "PUT",
|
|
14
14
|
Patch: "PATCH",
|
|
15
|
-
Delete: "DELETE"
|
|
15
|
+
Delete: "DELETE",
|
|
16
|
+
Head: "HEAD",
|
|
17
|
+
Options: "OPTIONS"
|
|
16
18
|
};
|
|
17
19
|
var SCOPES = ["application", "request", "job"];
|
|
18
20
|
async function analyzeProject(rootDir, include) {
|
|
@@ -137,6 +139,7 @@ function parseTokenVariable(decl, file) {
|
|
|
137
139
|
function parseModule(candidate, nameByNode, ctx) {
|
|
138
140
|
const { options, className, file, line } = candidate;
|
|
139
141
|
const name = nameByNode.get(candidate.node) ?? className;
|
|
142
|
+
const tags = arrayProp(options, "tags").map((el) => Node.isStringLiteral(el) ? el.getLiteralText() : el.getText().replace(/['"]/g, "")).filter(Boolean);
|
|
140
143
|
const imports = arrayProp(options, "imports").map((el) => {
|
|
141
144
|
const decl = Node.isIdentifier(el) ? resolveDeclaration(el)[0] : undefined;
|
|
142
145
|
if (decl) {
|
|
@@ -221,6 +224,7 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
221
224
|
return {
|
|
222
225
|
name,
|
|
223
226
|
className,
|
|
227
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
224
228
|
file: sourcePath(ctx.rootDir, file),
|
|
225
229
|
line,
|
|
226
230
|
imports,
|
|
@@ -672,7 +676,7 @@ export interface CompiledModule {
|
|
|
672
676
|
controllers: CompiledController[];
|
|
673
677
|
commands: CompiledCommand[];
|
|
674
678
|
}`;
|
|
675
|
-
|
|
679
|
+
function renderApplication(graph, options) {
|
|
676
680
|
const modules = topoSortModules(graph.modules);
|
|
677
681
|
const imports = new ImportManager;
|
|
678
682
|
const factorySections = [];
|
|
@@ -704,12 +708,19 @@ async function generateApplication(graph, options) {
|
|
|
704
708
|
modules: graph.modules,
|
|
705
709
|
externalTokens: graph.externalTokens
|
|
706
710
|
};
|
|
711
|
+
return {
|
|
712
|
+
applicationCode: code,
|
|
713
|
+
manifestJson: JSON.stringify(manifest, null, 2) + `
|
|
714
|
+
`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async function generateApplication(graph, options) {
|
|
718
|
+
const rendered = renderApplication(graph, options);
|
|
707
719
|
await mkdir(options.outDir, { recursive: true });
|
|
708
720
|
const applicationPath = join2(options.outDir, "application.ts");
|
|
709
721
|
const manifestPath = join2(options.outDir, "app.manifest.json");
|
|
710
|
-
await writeFile(applicationPath,
|
|
711
|
-
await writeFile(manifestPath,
|
|
712
|
-
`, "utf8");
|
|
722
|
+
await writeFile(applicationPath, rendered.applicationCode, "utf8");
|
|
723
|
+
await writeFile(manifestPath, rendered.manifestJson, "utf8");
|
|
713
724
|
return [applicationPath, manifestPath];
|
|
714
725
|
}
|
|
715
726
|
function factoryOfScope(scope) {
|
|
@@ -1006,14 +1017,199 @@ function orderProviders(providers) {
|
|
|
1006
1017
|
return result;
|
|
1007
1018
|
}
|
|
1008
1019
|
|
|
1020
|
+
// src/profiles.ts
|
|
1021
|
+
var MODULAR_MONOLITH_RULES = [
|
|
1022
|
+
{
|
|
1023
|
+
sourceTag: "type:feature",
|
|
1024
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
sourceTag: "type:root",
|
|
1028
|
+
onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
sourceTag: "type:app",
|
|
1032
|
+
onlyDependOnLibsWithTags: ["type:feature", "type:core", "type:shared", "type:domain"]
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
sourceTag: "type:core",
|
|
1036
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
sourceTag: "type:shared",
|
|
1040
|
+
bannedDependenciesWithTags: ["type:feature", "type:core", "type:root", "type:app"]
|
|
1041
|
+
}
|
|
1042
|
+
];
|
|
1043
|
+
var ANGULAR_ENTERPRISE_RULES = [
|
|
1044
|
+
{
|
|
1045
|
+
sourceTag: "type:feature",
|
|
1046
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
sourceTag: "type:ui",
|
|
1050
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
sourceTag: "type:data-access",
|
|
1054
|
+
bannedDependenciesWithTags: ["type:feature", "type:ui", "type:root", "type:app"]
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
sourceTag: "type:util",
|
|
1058
|
+
bannedDependenciesWithTags: ["type:feature", "type:ui", "type:data-access", "type:root", "type:app"]
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
sourceTag: "type:shared",
|
|
1062
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
sourceTag: "type:core",
|
|
1066
|
+
bannedDependenciesWithTags: ["type:feature", "type:root", "type:app"]
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
sourceTag: "type:root",
|
|
1070
|
+
onlyDependOnLibsWithTags: ["type:feature", "type:ui", "type:data-access", "type:core", "type:shared", "type:util"]
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
sourceTag: "type:app",
|
|
1074
|
+
onlyDependOnLibsWithTags: ["type:feature", "type:ui", "type:data-access", "type:core", "type:shared", "type:util"]
|
|
1075
|
+
}
|
|
1076
|
+
];
|
|
1077
|
+
var CLEAN_ARCHITECTURE_RULES = [
|
|
1078
|
+
{
|
|
1079
|
+
sourceTag: "type:api",
|
|
1080
|
+
onlyDependOnLibsWithTags: ["type:application", "type:domain", "type:shared", "type:common"]
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
sourceTag: "type:controller",
|
|
1084
|
+
onlyDependOnLibsWithTags: ["type:application", "type:domain", "type:shared", "type:common"]
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
sourceTag: "type:presentation",
|
|
1088
|
+
onlyDependOnLibsWithTags: ["type:application", "type:domain", "type:shared", "type:common"]
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
sourceTag: "type:application",
|
|
1092
|
+
onlyDependOnLibsWithTags: ["type:domain", "type:shared", "type:common"]
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
sourceTag: "type:service",
|
|
1096
|
+
onlyDependOnLibsWithTags: ["type:domain", "type:shared", "type:common"]
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
sourceTag: "type:domain",
|
|
1100
|
+
bannedDependenciesWithTags: [
|
|
1101
|
+
"type:api",
|
|
1102
|
+
"type:controller",
|
|
1103
|
+
"type:presentation",
|
|
1104
|
+
"type:application",
|
|
1105
|
+
"type:service",
|
|
1106
|
+
"type:infrastructure",
|
|
1107
|
+
"type:infra",
|
|
1108
|
+
"type:root",
|
|
1109
|
+
"type:app"
|
|
1110
|
+
]
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
sourceTag: "type:infrastructure",
|
|
1114
|
+
onlyDependOnLibsWithTags: ["type:domain", "type:shared", "type:common"]
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
sourceTag: "type:infra",
|
|
1118
|
+
onlyDependOnLibsWithTags: ["type:domain", "type:shared", "type:common"]
|
|
1119
|
+
}
|
|
1120
|
+
];
|
|
1121
|
+
var MODULE_BOUNDARY_PROFILES = {
|
|
1122
|
+
"modular-monolith": {
|
|
1123
|
+
name: "modular-monolith",
|
|
1124
|
+
description: "Modular monolith / vertical slice preset (blocks cross-feature dependencies and limits root aggregation to feature/core/shared/domain)",
|
|
1125
|
+
rules: MODULAR_MONOLITH_RULES
|
|
1126
|
+
},
|
|
1127
|
+
"feature-slices": {
|
|
1128
|
+
name: "feature-slices",
|
|
1129
|
+
description: "Vertical slice preset (alias for modular-monolith)",
|
|
1130
|
+
rules: MODULAR_MONOLITH_RULES
|
|
1131
|
+
},
|
|
1132
|
+
"vertical-slices": {
|
|
1133
|
+
name: "vertical-slices",
|
|
1134
|
+
description: "Vertical slice architecture preset (alias for modular-monolith)",
|
|
1135
|
+
rules: MODULAR_MONOLITH_RULES
|
|
1136
|
+
},
|
|
1137
|
+
"angular-enterprise": {
|
|
1138
|
+
name: "angular-enterprise",
|
|
1139
|
+
description: "Angular / Nx enterprise monorepo preset (enforces one-way layering across feature, UI, data-access, util, shared, and root modules)",
|
|
1140
|
+
rules: ANGULAR_ENTERPRISE_RULES
|
|
1141
|
+
},
|
|
1142
|
+
angular: {
|
|
1143
|
+
name: "angular",
|
|
1144
|
+
description: "Angular enterprise layering preset (alias for angular-enterprise)",
|
|
1145
|
+
rules: ANGULAR_ENTERPRISE_RULES
|
|
1146
|
+
},
|
|
1147
|
+
"clean-architecture": {
|
|
1148
|
+
name: "clean-architecture",
|
|
1149
|
+
description: "Clean Architecture / DDD layering preset (API/presentation -> application -> domain <- infrastructure)",
|
|
1150
|
+
rules: CLEAN_ARCHITECTURE_RULES
|
|
1151
|
+
},
|
|
1152
|
+
"domain-driven": {
|
|
1153
|
+
name: "domain-driven",
|
|
1154
|
+
description: "DDD layering preset (alias for clean-architecture)",
|
|
1155
|
+
rules: CLEAN_ARCHITECTURE_RULES
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
function getModuleBoundaryProfile(name) {
|
|
1159
|
+
const profile = MODULE_BOUNDARY_PROFILES[name];
|
|
1160
|
+
if (!profile) {
|
|
1161
|
+
throw new Error(`Unknown module boundary preset: '${name}'. Supported presets: ${Object.keys(MODULE_BOUNDARY_PROFILES).join(", ")}`);
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
...profile,
|
|
1165
|
+
rules: profile.rules.map((rule) => ({
|
|
1166
|
+
...rule,
|
|
1167
|
+
onlyDependOnLibsWithTags: rule.onlyDependOnLibsWithTags ? [...rule.onlyDependOnLibsWithTags] : undefined,
|
|
1168
|
+
bannedDependenciesWithTags: rule.bannedDependenciesWithTags ? [...rule.bannedDependenciesWithTags] : undefined
|
|
1169
|
+
}))
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function getModuleBoundaryPreset(name) {
|
|
1173
|
+
return getModuleBoundaryProfile(name).rules;
|
|
1174
|
+
}
|
|
1175
|
+
function resolveModuleBoundaries(options) {
|
|
1176
|
+
if (!options)
|
|
1177
|
+
return;
|
|
1178
|
+
const { preset, rules } = options;
|
|
1179
|
+
if (!preset && !rules)
|
|
1180
|
+
return;
|
|
1181
|
+
const presetRules = preset ? getModuleBoundaryPreset(preset) : [];
|
|
1182
|
+
const customRules = rules ?? [];
|
|
1183
|
+
const merged = [...presetRules, ...customRules];
|
|
1184
|
+
return merged.length > 0 ? merged : undefined;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1009
1187
|
// src/validate.ts
|
|
1010
1188
|
var SCOPE_LIFETIME_RANK = {
|
|
1011
1189
|
application: 0,
|
|
1012
1190
|
request: 1,
|
|
1013
1191
|
job: 1
|
|
1014
1192
|
};
|
|
1015
|
-
function validateGraph(graph,
|
|
1193
|
+
function validateGraph(graph, options = false) {
|
|
1194
|
+
const strict = typeof options === "boolean" ? options : options.strict ?? false;
|
|
1016
1195
|
const diagnostics = [];
|
|
1196
|
+
let moduleBoundaries;
|
|
1197
|
+
if (typeof options === "object") {
|
|
1198
|
+
try {
|
|
1199
|
+
moduleBoundaries = resolveModuleBoundaries({
|
|
1200
|
+
preset: options.moduleBoundaryPreset,
|
|
1201
|
+
rules: options.moduleBoundaries
|
|
1202
|
+
});
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
diagnostics.push({
|
|
1205
|
+
severity: "error",
|
|
1206
|
+
code: "invalid-boundary-preset",
|
|
1207
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1208
|
+
file: graph.modules[0]?.file,
|
|
1209
|
+
line: graph.modules[0]?.line
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1017
1213
|
const globalProviders = new Map;
|
|
1018
1214
|
for (const module of graph.modules) {
|
|
1019
1215
|
for (const provider of module.providers) {
|
|
@@ -1075,6 +1271,17 @@ function validateGraph(graph, strict = false) {
|
|
|
1075
1271
|
if (route.command && !module.commands.some((command) => command.className === route.command)) {
|
|
1076
1272
|
error("route-command-unresolved", `路由 ${key} 绑定的 command 类 ${route.command} 未在模块 ${module.name} 声明`, controller.file);
|
|
1077
1273
|
}
|
|
1274
|
+
if (typeof options === "object" && options.allowRouteCommandBindings === false && route.command) {
|
|
1275
|
+
error("route-command-binding-disallowed", `Route ${key} binds command ${route.command}, but route-level command bindings are disabled by policy. Use an application service (${controller.className}.${route.handler}, ${controller.file}).`, controller.file);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (typeof options === "object" && options.disallowControllerDirectDb) {
|
|
1279
|
+
for (const dep of controller.deps) {
|
|
1280
|
+
const isDbClient = dep === "DB_CLIENT" || dep === "DatabaseClient" || graph.tokenNames?.[dep] === "supacloud.db-client";
|
|
1281
|
+
if (isDbClient) {
|
|
1282
|
+
error("controller-direct-db-access", `Controller ${controller.className} directly injects database client '${dep}', violating presentation layer separation (${controller.file})`, controller.file);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1078
1285
|
}
|
|
1079
1286
|
}
|
|
1080
1287
|
}
|
|
@@ -1111,9 +1318,62 @@ function validateGraph(graph, strict = false) {
|
|
|
1111
1318
|
if (!command.permission) {
|
|
1112
1319
|
error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line);
|
|
1113
1320
|
}
|
|
1321
|
+
if (typeof options === "object" && options.commandCapabilities) {
|
|
1322
|
+
const caps = options.commandCapabilities;
|
|
1323
|
+
const location = `${command.className} (${module.file})`;
|
|
1324
|
+
if (command.permission && caps.permission === false) {
|
|
1325
|
+
error("command-permission-unsupported", `Command ${command.name} declares permission, but runtime permission checks are unavailable (${location}).`, module.file, module.line);
|
|
1326
|
+
}
|
|
1327
|
+
if (command.audit && caps.audit === false) {
|
|
1328
|
+
error("command-audit-unsupported", `Command ${command.name} declares audit, but audit persistence is unavailable (${location}).`, module.file, module.line);
|
|
1329
|
+
}
|
|
1330
|
+
if (command.idempotency === "required" && caps.idempotency === false) {
|
|
1331
|
+
error("command-idempotency-unsupported", `Command ${command.name} declares idempotency, but idempotency receipt persistence is unavailable (${location}).`, module.file, module.line);
|
|
1332
|
+
}
|
|
1333
|
+
if (command.transaction === "required") {
|
|
1334
|
+
if (caps.transaction === "rpc-only") {
|
|
1335
|
+
warn2("command-transaction-rpc-only", `Command ${command.name} declares transaction: 'required', but only DB RPC transactions are available; multi-table writes must use one DB RPC (${location}).`, module.file, module.line);
|
|
1336
|
+
} else if (caps.transaction === false) {
|
|
1337
|
+
error("command-transaction-unsupported", `Command ${command.name} declares transaction: 'required', but transaction support is unavailable (${location}).`, module.file, module.line);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
if (moduleBoundaries && moduleBoundaries.length > 0) {
|
|
1344
|
+
for (const module of graph.modules) {
|
|
1345
|
+
const sourceTags = module.tags ?? [];
|
|
1346
|
+
for (const importName of module.imports) {
|
|
1347
|
+
const targetModule = graph.modules.find((m) => m.name === importName);
|
|
1348
|
+
if (!targetModule)
|
|
1349
|
+
continue;
|
|
1350
|
+
const targetTags = targetModule.tags ?? [];
|
|
1351
|
+
for (const rule of moduleBoundaries) {
|
|
1352
|
+
const matchesSource = rule.sourceTag === "*" || sourceTags.includes(rule.sourceTag);
|
|
1353
|
+
if (!matchesSource)
|
|
1354
|
+
continue;
|
|
1355
|
+
if (rule.bannedDependenciesWithTags) {
|
|
1356
|
+
for (const bannedTag of rule.bannedDependenciesWithTags) {
|
|
1357
|
+
if (targetTags.includes(bannedTag)) {
|
|
1358
|
+
error("module-boundary-violation", `模块 ${module.name} (tags: [${sourceTags.join(", ")}]) 禁止依赖带有标签 '${bannedTag}' 的模块 ${targetModule.name} (tags: [${targetTags.join(", ")}])`, module.file, module.line);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (rule.onlyDependOnLibsWithTags && rule.onlyDependOnLibsWithTags.length > 0) {
|
|
1363
|
+
const hasAllowed = targetTags.some((t) => rule.onlyDependOnLibsWithTags.includes(t));
|
|
1364
|
+
if (!hasAllowed && targetTags.length > 0) {
|
|
1365
|
+
error("module-boundary-violation", `模块 ${module.name} (tags: [${sourceTags.join(", ")}]) 仅允许依赖带有 [${rule.onlyDependOnLibsWithTags.join(", ")}] 标签的模块,但模块 ${targetModule.name} 的标签为 [${targetTags.join(", ")}]`, module.file, module.line);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1114
1370
|
}
|
|
1115
1371
|
}
|
|
1116
1372
|
diagnostics.push(...detectCycles(graph, resolveDep));
|
|
1373
|
+
diagnostics.push(...detectModuleCycles(graph));
|
|
1374
|
+
if (typeof options === "object" && options.detectOrphanModules) {
|
|
1375
|
+
diagnostics.push(...detectOrphanModules(graph));
|
|
1376
|
+
}
|
|
1117
1377
|
return diagnostics;
|
|
1118
1378
|
}
|
|
1119
1379
|
function joinRoutePaths(prefix, path) {
|
|
@@ -1163,13 +1423,103 @@ function detectCycles(graph, resolveDep) {
|
|
|
1163
1423
|
visit(ref);
|
|
1164
1424
|
return diagnostics;
|
|
1165
1425
|
}
|
|
1426
|
+
function detectModuleCycles(graph) {
|
|
1427
|
+
const diagnostics = [];
|
|
1428
|
+
const moduleMap = new Map(graph.modules.map((m) => [m.name, m]));
|
|
1429
|
+
const state = new Map;
|
|
1430
|
+
const stack = [];
|
|
1431
|
+
const reported = new Set;
|
|
1432
|
+
const visit = (name) => {
|
|
1433
|
+
if (state.get(name) === "done")
|
|
1434
|
+
return;
|
|
1435
|
+
if (state.get(name) === "visiting") {
|
|
1436
|
+
const cycleStart = stack.indexOf(name);
|
|
1437
|
+
const cycle = [...stack.slice(cycleStart), name];
|
|
1438
|
+
const cycleKey = [...cycle].sort().join("|");
|
|
1439
|
+
if (!reported.has(cycleKey)) {
|
|
1440
|
+
reported.add(cycleKey);
|
|
1441
|
+
const mod2 = moduleMap.get(name);
|
|
1442
|
+
diagnostics.push({
|
|
1443
|
+
severity: "error",
|
|
1444
|
+
code: "circular-module-import",
|
|
1445
|
+
message: `Module circular import detected: ${cycle.join(" -> ")}`,
|
|
1446
|
+
file: mod2?.file,
|
|
1447
|
+
line: mod2?.line
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
state.set(name, "visiting");
|
|
1453
|
+
stack.push(name);
|
|
1454
|
+
const mod = moduleMap.get(name);
|
|
1455
|
+
if (mod) {
|
|
1456
|
+
for (const importName of mod.imports) {
|
|
1457
|
+
if (moduleMap.has(importName)) {
|
|
1458
|
+
visit(importName);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
stack.pop();
|
|
1463
|
+
state.set(name, "done");
|
|
1464
|
+
};
|
|
1465
|
+
for (const mod of graph.modules) {
|
|
1466
|
+
visit(mod.name);
|
|
1467
|
+
}
|
|
1468
|
+
return diagnostics;
|
|
1469
|
+
}
|
|
1470
|
+
function detectOrphanModules(graph) {
|
|
1471
|
+
const diagnostics = [];
|
|
1472
|
+
const rootModules = graph.modules.filter((m) => m.tags && (m.tags.includes("type:root") || m.tags.includes("type:app")) || m.name === "app" || m.name === "root");
|
|
1473
|
+
if (rootModules.length === 0)
|
|
1474
|
+
return diagnostics;
|
|
1475
|
+
const reachable = new Set;
|
|
1476
|
+
const moduleMap = new Map(graph.modules.map((m) => [m.name, m]));
|
|
1477
|
+
const queue = rootModules.map((m) => m.name);
|
|
1478
|
+
for (const root of rootModules) {
|
|
1479
|
+
reachable.add(root.name);
|
|
1480
|
+
}
|
|
1481
|
+
while (queue.length > 0) {
|
|
1482
|
+
const current = queue.shift();
|
|
1483
|
+
const mod = moduleMap.get(current);
|
|
1484
|
+
if (!mod)
|
|
1485
|
+
continue;
|
|
1486
|
+
for (const imp of mod.imports) {
|
|
1487
|
+
if (!reachable.has(imp)) {
|
|
1488
|
+
reachable.add(imp);
|
|
1489
|
+
queue.push(imp);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
for (const mod of graph.modules) {
|
|
1494
|
+
if (!reachable.has(mod.name)) {
|
|
1495
|
+
diagnostics.push({
|
|
1496
|
+
severity: "warn",
|
|
1497
|
+
code: "orphan-module",
|
|
1498
|
+
message: `Module '${mod.name}' is declared but not reachable from any root module (${rootModules.map((r) => r.name).join(", ")})`,
|
|
1499
|
+
file: mod.file,
|
|
1500
|
+
line: mod.line
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return diagnostics;
|
|
1505
|
+
}
|
|
1166
1506
|
|
|
1167
1507
|
// src/compile.ts
|
|
1508
|
+
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
1509
|
+
import { join as join3 } from "node:path";
|
|
1168
1510
|
async function compileProject(options) {
|
|
1169
1511
|
const graph = await analyzeProject(options.rootDir, options.include);
|
|
1170
1512
|
const diagnostics = [
|
|
1171
1513
|
...graph.diagnostics ?? [],
|
|
1172
|
-
...validateGraph(graph,
|
|
1514
|
+
...validateGraph(graph, {
|
|
1515
|
+
strict: options.strict,
|
|
1516
|
+
moduleBoundaryPreset: options.moduleBoundaryPreset,
|
|
1517
|
+
moduleBoundaries: options.moduleBoundaries,
|
|
1518
|
+
allowRouteCommandBindings: options.allowRouteCommandBindings,
|
|
1519
|
+
commandCapabilities: options.commandCapabilities,
|
|
1520
|
+
disallowControllerDirectDb: options.disallowControllerDirectDb,
|
|
1521
|
+
detectOrphanModules: options.detectOrphanModules
|
|
1522
|
+
})
|
|
1173
1523
|
];
|
|
1174
1524
|
if (options.strict) {
|
|
1175
1525
|
for (const diagnostic of diagnostics) {
|
|
@@ -1183,10 +1533,66 @@ async function compileProject(options) {
|
|
|
1183
1533
|
});
|
|
1184
1534
|
return { diagnostics, graph, written };
|
|
1185
1535
|
}
|
|
1536
|
+
async function checkProject(options) {
|
|
1537
|
+
const graph = await analyzeProject(options.rootDir, options.include);
|
|
1538
|
+
const diagnostics = [
|
|
1539
|
+
...graph.diagnostics ?? [],
|
|
1540
|
+
...validateGraph(graph, {
|
|
1541
|
+
strict: options.strict,
|
|
1542
|
+
moduleBoundaryPreset: options.moduleBoundaryPreset,
|
|
1543
|
+
moduleBoundaries: options.moduleBoundaries,
|
|
1544
|
+
allowRouteCommandBindings: options.allowRouteCommandBindings,
|
|
1545
|
+
commandCapabilities: options.commandCapabilities,
|
|
1546
|
+
disallowControllerDirectDb: options.disallowControllerDirectDb,
|
|
1547
|
+
detectOrphanModules: options.detectOrphanModules
|
|
1548
|
+
})
|
|
1549
|
+
];
|
|
1550
|
+
if (options.strict) {
|
|
1551
|
+
for (const diagnostic of diagnostics) {
|
|
1552
|
+
if (diagnostic.severity === "warn")
|
|
1553
|
+
diagnostic.severity = "error";
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const rendered = renderApplication(graph, {
|
|
1557
|
+
rootDir: options.rootDir,
|
|
1558
|
+
outDir: options.outDir
|
|
1559
|
+
});
|
|
1560
|
+
const expectedFiles = {
|
|
1561
|
+
"application.ts": rendered.applicationCode,
|
|
1562
|
+
"app.manifest.json": rendered.manifestJson
|
|
1563
|
+
};
|
|
1564
|
+
const mismatches = [];
|
|
1565
|
+
for (const [filename, expectedContent] of Object.entries(expectedFiles)) {
|
|
1566
|
+
const diskPath = join3(options.outDir, filename);
|
|
1567
|
+
if (!existsSync2(diskPath)) {
|
|
1568
|
+
mismatches.push(`${filename}: generated artifact is missing from disk`);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
const diskContent = readFileSync(diskPath, "utf8");
|
|
1572
|
+
if (diskContent !== expectedContent) {
|
|
1573
|
+
mismatches.push(`${filename}: disk artifact differs from current compiler output`);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return {
|
|
1577
|
+
upToDate: mismatches.length === 0,
|
|
1578
|
+
mismatches,
|
|
1579
|
+
diagnostics,
|
|
1580
|
+
graph
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1186
1583
|
export {
|
|
1584
|
+
ANGULAR_ENTERPRISE_RULES,
|
|
1585
|
+
CLEAN_ARCHITECTURE_RULES,
|
|
1586
|
+
MODULAR_MONOLITH_RULES,
|
|
1587
|
+
MODULE_BOUNDARY_PROFILES,
|
|
1187
1588
|
analyzeProject,
|
|
1188
1589
|
camelName,
|
|
1590
|
+
checkProject,
|
|
1189
1591
|
compileProject,
|
|
1190
1592
|
generateApplication,
|
|
1593
|
+
getModuleBoundaryPreset,
|
|
1594
|
+
getModuleBoundaryProfile,
|
|
1595
|
+
renderApplication,
|
|
1596
|
+
resolveModuleBoundaries,
|
|
1191
1597
|
validateGraph
|
|
1192
1598
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ModuleBoundaryPresetName, ModuleBoundaryProfile, ModuleBoundaryRule } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Modular monolith / vertical slice architecture rules:
|
|
4
|
+
* - type:feature slices cannot depend on one another or on root/app modules;
|
|
5
|
+
* - type:root / type:app entry modules may aggregate feature, core, shared, and domain modules;
|
|
6
|
+
* - type:core foundational modules cannot depend upward on feature or root/app modules;
|
|
7
|
+
* - type:shared common utilities/components cannot depend on feature or root/app modules.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MODULAR_MONOLITH_RULES: ModuleBoundaryRule[];
|
|
10
|
+
/**
|
|
11
|
+
* Angular / Nx enterprise monorepo layering rules:
|
|
12
|
+
* follows the recommended Nx / Angular enterprise workspace conventions:
|
|
13
|
+
* - feature slices cannot directly depend on one another;
|
|
14
|
+
* - ui / data-access / util / shared libraries cannot depend upward on feature or root modules;
|
|
15
|
+
* - root / app modules serve only as composition entry points.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ANGULAR_ENTERPRISE_RULES: ModuleBoundaryRule[];
|
|
18
|
+
/**
|
|
19
|
+
* Clean Architecture / DDD layering rules (similar to Spring Boot DDD conventions):
|
|
20
|
+
* - presentation layers (api/controller/presentation) may depend only on application, domain, and shared layers;
|
|
21
|
+
* - application layers (application/service) orchestrate use cases and depend only on domain and shared layers;
|
|
22
|
+
* - the domain layer remains pure and cannot depend on presentation, application, infrastructure, or root modules;
|
|
23
|
+
* - infrastructure layers (infrastructure/infra) implement domain ports and may depend only on domain and shared layers.
|
|
24
|
+
*/
|
|
25
|
+
export declare const CLEAN_ARCHITECTURE_RULES: ModuleBoundaryRule[];
|
|
26
|
+
/** Registry of built-in module boundary profiles. */
|
|
27
|
+
export declare const MODULE_BOUNDARY_PROFILES: Record<ModuleBoundaryPresetName, ModuleBoundaryProfile>;
|
|
28
|
+
/** Return the architecture governance rules for a preset name. */
|
|
29
|
+
export declare function getModuleBoundaryProfile(name: ModuleBoundaryPresetName): ModuleBoundaryProfile;
|
|
30
|
+
/** Return a copy of the rules included in a preset. */
|
|
31
|
+
export declare function getModuleBoundaryPreset(name: ModuleBoundaryPresetName): ModuleBoundaryRule[];
|
|
32
|
+
/** Resolve and merge preset rules with optional user-defined rules. */
|
|
33
|
+
export declare function resolveModuleBoundaries(options?: {
|
|
34
|
+
preset?: ModuleBoundaryPresetName;
|
|
35
|
+
rules?: ModuleBoundaryRule[];
|
|
36
|
+
}): ModuleBoundaryRule[] | undefined;
|
package/dist/types.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ProviderNode {
|
|
|
31
31
|
importPath?: string;
|
|
32
32
|
}
|
|
33
33
|
export interface RouteNode {
|
|
34
|
-
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
34
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
35
35
|
path: string;
|
|
36
36
|
handler: string;
|
|
37
37
|
body?: string;
|
|
@@ -68,6 +68,8 @@ export interface ModuleNode {
|
|
|
68
68
|
/** @Module({ name }) 或 defineModule 的 name。 */
|
|
69
69
|
name: string;
|
|
70
70
|
className: string;
|
|
71
|
+
/** 模块标签(如 ['scope:case', 'type:feature']),用于架构边界治理。 */
|
|
72
|
+
tags?: string[];
|
|
71
73
|
file: string;
|
|
72
74
|
line: number;
|
|
73
75
|
/** 被 import 模块的 name。 */
|
|
@@ -104,9 +106,73 @@ export interface CompileOptions {
|
|
|
104
106
|
outDir: string;
|
|
105
107
|
/** warn 级诊断升级为 error。 */
|
|
106
108
|
strict?: boolean;
|
|
109
|
+
/** Built-in architecture boundary preset (for example, 'modular-monolith'). */
|
|
110
|
+
moduleBoundaryPreset?: ModuleBoundaryPresetName;
|
|
111
|
+
/** Module boundary and architecture governance rules inspired by Nx enforce-module-boundaries. */
|
|
112
|
+
moduleBoundaries?: ModuleBoundaryRule[];
|
|
113
|
+
/** Allow routes to bind directly to @Command (defaults to true). */
|
|
114
|
+
allowRouteCommandBindings?: boolean;
|
|
115
|
+
/** Runtime Command executor capabilities used to validate declared governance metadata. */
|
|
116
|
+
commandCapabilities?: CommandExecutionCapabilities;
|
|
117
|
+
/** Disallow controllers from directly injecting DB clients (enforces presentation layer separation). */
|
|
118
|
+
disallowControllerDirectDb?: boolean;
|
|
119
|
+
/** Detect modules declared in the project that are unreachable from any root module. */
|
|
120
|
+
detectOrphanModules?: boolean;
|
|
121
|
+
}
|
|
122
|
+
export interface ModuleBoundaryRule {
|
|
123
|
+
/** Source module tag pattern or tag (for example, 'type:ui', 'scope:case', or '*'). */
|
|
124
|
+
sourceTag: string;
|
|
125
|
+
/** Tags allowed for modules imported by the source module. */
|
|
126
|
+
onlyDependOnLibsWithTags?: string[];
|
|
127
|
+
/** Tags forbidden for modules imported by the source module. */
|
|
128
|
+
bannedDependenciesWithTags?: string[];
|
|
129
|
+
}
|
|
130
|
+
/** Names of built-in module boundary presets. */
|
|
131
|
+
export type ModuleBoundaryPresetName = "modular-monolith" | "feature-slices" | "vertical-slices" | "angular-enterprise" | "angular" | "clean-architecture" | "domain-driven";
|
|
132
|
+
export interface ModuleBoundaryProfile {
|
|
133
|
+
name: ModuleBoundaryPresetName;
|
|
134
|
+
description: string;
|
|
135
|
+
rules: ModuleBoundaryRule[];
|
|
136
|
+
}
|
|
137
|
+
export interface ValidateOptions {
|
|
138
|
+
strict?: boolean;
|
|
139
|
+
moduleBoundaryPreset?: ModuleBoundaryPresetName;
|
|
140
|
+
moduleBoundaries?: ModuleBoundaryRule[];
|
|
141
|
+
/** Allow routes to bind directly to @Command (defaults to true). */
|
|
142
|
+
allowRouteCommandBindings?: boolean;
|
|
143
|
+
/** Runtime Command executor capabilities used to validate declared governance metadata. */
|
|
144
|
+
commandCapabilities?: CommandExecutionCapabilities;
|
|
145
|
+
/** Disallow controllers from directly injecting DB clients. */
|
|
146
|
+
disallowControllerDirectDb?: boolean;
|
|
147
|
+
/** Detect modules declared in the project that are unreachable from any root module. */
|
|
148
|
+
detectOrphanModules?: boolean;
|
|
149
|
+
}
|
|
150
|
+
/** Runtime capabilities declared by the Command executor. */
|
|
151
|
+
export interface CommandExecutionCapabilities {
|
|
152
|
+
/** Whether runtime permission checks are supported. */
|
|
153
|
+
permission?: boolean;
|
|
154
|
+
/** Whether runtime audit persistence is supported. */
|
|
155
|
+
audit?: boolean;
|
|
156
|
+
/** Whether idempotency receipts are supported. */
|
|
157
|
+
idempotency?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Transaction execution capability:
|
|
160
|
+
* - true: full transaction boundaries are supported;
|
|
161
|
+
* - 'rpc-only': application-level transactions are unavailable and multi-table writes must use one DB RPC (warn);
|
|
162
|
+
* - false: transaction support is disabled (error).
|
|
163
|
+
*/
|
|
164
|
+
transaction?: boolean | "rpc-only";
|
|
107
165
|
}
|
|
108
166
|
export interface CompileResult {
|
|
109
167
|
diagnostics: Diagnostic[];
|
|
110
168
|
graph: ApplicationGraph;
|
|
111
169
|
written: string[];
|
|
112
170
|
}
|
|
171
|
+
export interface CheckProjectResult {
|
|
172
|
+
/** Whether generated artifacts exactly match the files on disk. */
|
|
173
|
+
upToDate: boolean;
|
|
174
|
+
/** Relative paths of missing or mismatched artifacts. */
|
|
175
|
+
mismatches: string[];
|
|
176
|
+
diagnostics: Diagnostic[];
|
|
177
|
+
graph: ApplicationGraph;
|
|
178
|
+
}
|