@supacloud/compiler 0.10.0 → 0.12.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/README.md +38 -0
- package/dist/cli.js +253 -28
- package/dist/compile.d.ts +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +250 -24
- package/dist/inspect.d.ts +14 -0
- package/dist/route-contracts.d.ts +20 -0
- package/dist/types.d.ts +27 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @supacloud/compiler
|
|
2
2
|
|
|
3
|
+
FA-derived direct-command RPC ownership, contract inspection and POST command
|
|
4
|
+
protocol migration are documented in `docs/fa-consumer-governance.md` in the
|
|
5
|
+
repository. `context <module> --json` reports `routeContracts` and standalone
|
|
6
|
+
command execution plans; these are declarations and obligations, not runtime proof.
|
|
7
|
+
|
|
3
8
|
SupaCloud 应用静态编译器:读取 `@supacloud/app` 装饰器元数据的原生 TypeScript AST,构建 ApplicationGraph,做静态校验,并生成**无反射、无容器**的工厂代码与 manifest。
|
|
4
9
|
|
|
5
10
|
本包不依赖 `@supacloud/app`:AST 只按装饰器名匹配(`Module`/`Injectable`/`Inject`/`Command`/`Query`/`Controller`/`Get`/`Post`/`Put`/`Patch`/`Delete`/`defineModule`/`InjectionToken`),不校验 import 来源。
|
|
@@ -185,6 +190,7 @@ IDE 和 AI agent 做状态机漂移检查。
|
|
|
185
190
|
| `unsupported-provider-helper` | warn(strict 时 error) | functional provider 的动态参数无法安全展开为静态 factory |
|
|
186
191
|
| `dynamic-aspect-reference` | error | aspects 不是显式数组字面量,或包含 spread/表达式/字符串 pointcut |
|
|
187
192
|
| `invalid-aspect-reference` | error | aspect 不是可静态解析的函数声明、箭头函数或函数表达式 |
|
|
193
|
+
| `invalid-command-mode` | error | transaction/idempotency 必须显式为 `"required"` 或 `"none"`,不允许拼写错误或动态值悄悄关闭治理(SC4012) |
|
|
188
194
|
| `missing-deps` | warn(strict 时 error) | 构造/工厂依赖无法静态解析 |
|
|
189
195
|
| `generated-any` | warn(strict 时 error) | 生成的 TypeScript 产物包含 `any` |
|
|
190
196
|
| `source-any` | warn(strict 时 error) | 未被排除的生产源码包含显式 `any` |
|
|
@@ -238,6 +244,20 @@ supacloud-compiler context case --root ./app --json
|
|
|
238
244
|
`supacloud-compiler fix ./fix.json --dry-run` 调用;CLI 默认预览,需显式
|
|
239
245
|
使用 `--write` 才写盘。写盘前会重新解析 AST,
|
|
240
246
|
前置条件不满足时拒绝修改,并通过临时文件原子替换。
|
|
247
|
+
CLI 修复的 `targetFile` 相对于配置的源码根目录解析,也可以用 `--root` 显式指定;
|
|
248
|
+
JSON 修复文件本身仍相对于当前工作目录读取。
|
|
249
|
+
|
|
250
|
+
上下游分别沿单一方向遍历,不会经过共享基础模块再扩散到无关兄弟业务。
|
|
251
|
+
上下文包还包含准确的切面源文件、校验诊断和 `executionPlans`;`explain <module>`
|
|
252
|
+
也展示静态执行计划。计划描述标准命令治理;自定义 executor 的内部实现和短路行为
|
|
253
|
+
仍需运行时追踪验证。成功审计在 handler 返回后执行,而非 handler 之前。
|
|
254
|
+
|
|
255
|
+
例如 `@Command({ transaction: "requried" })` 会报告 `invalid-command-mode`,
|
|
256
|
+
并输出 `set_command_mode` 修复建议。必须显式给 fix 的 `value` 选择 `"required"`
|
|
257
|
+
或 `"none"` 才能预览或写入;不会推断较弱权限。若诊断后的源表达式变化,修复拒绝写盘。
|
|
258
|
+
|
|
259
|
+
`compileProject()` 现在默认在存在 error 时保留已有产物。仅诊断/迁移工具可以显式
|
|
260
|
+
设置 `writeOnError: true` 导出错误版本;这些产物不应被部署或视为可执行成功产物。
|
|
241
261
|
|
|
242
262
|
## 编译基准
|
|
243
263
|
|
|
@@ -257,6 +277,24 @@ bun test
|
|
|
257
277
|
bun run build
|
|
258
278
|
```
|
|
259
279
|
|
|
280
|
+
## Route Contract Policy
|
|
281
|
+
|
|
282
|
+
Enable `requireRouteContracts: true` in `defineSupacloudConfig(...)` or
|
|
283
|
+
`CompileOptions` to report `route-contract-required` errors in both compile and
|
|
284
|
+
check (including JSON diagnostics). Changing this option invalidates incremental
|
|
285
|
+
results. Combine it with `writeOnError: false` when programmatic compilation must
|
|
286
|
+
not emit files on errors.
|
|
287
|
+
|
|
288
|
+
`inspectRouteContracts(graph)` lists each route and its missing body, params,
|
|
289
|
+
query, and response declarations. Required inputs are detected from handler
|
|
290
|
+
bindings and controller/route path parameters. Responses always require an
|
|
291
|
+
explicit declaration, including intentional void contracts.
|
|
292
|
+
|
|
293
|
+
This checks declaration coverage only, not schema quality, handler/schema type
|
|
294
|
+
equivalence, or database authorization. It deliberately does not auto-fix missing
|
|
295
|
+
schemas with `unknown` placeholders. Consumers must define the actual contracts
|
|
296
|
+
and test decoding separately. The policy defaults to false for existing projects.
|
|
297
|
+
|
|
260
298
|
## License
|
|
261
299
|
|
|
262
300
|
MIT
|
package/dist/cli.js
CHANGED
|
@@ -668,9 +668,10 @@ async function analyzeProject(rootDir, include, cache, changedPaths) {
|
|
|
668
668
|
className: classInfo.decl.name?.text ?? name,
|
|
669
669
|
name: stringLiteralProp(meta, "name") ?? classInfo.decl.name?.text ?? name,
|
|
670
670
|
permission: stringLiteralProp(meta, "permission"),
|
|
671
|
-
|
|
671
|
+
rpc: checkedRpc(meta, ctx),
|
|
672
|
+
transaction: checkedCommandMode(meta, "transaction", ctx, classInfo.decl.name?.text ?? name) ?? "none",
|
|
672
673
|
audit: stringLiteralProp(meta, "audit"),
|
|
673
|
-
idempotency:
|
|
674
|
+
idempotency: checkedCommandMode(meta, "idempotency", ctx, classInfo.decl.name?.text ?? name) ?? "none",
|
|
674
675
|
standalone: true,
|
|
675
676
|
aspects: parseAspectRefs(getProp(meta, "aspects"), ctx, `command ${classInfo.decl.name?.text ?? name}`)
|
|
676
677
|
});
|
|
@@ -960,9 +961,10 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
960
961
|
className: cls.name?.text ?? "<anonymous>",
|
|
961
962
|
name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
|
|
962
963
|
permission: stringLiteralProp(meta, "permission"),
|
|
963
|
-
|
|
964
|
+
rpc: checkedRpc(meta, ctx),
|
|
965
|
+
transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
|
|
964
966
|
audit: stringLiteralProp(meta, "audit"),
|
|
965
|
-
idempotency:
|
|
967
|
+
idempotency: checkedCommandMode(meta, "idempotency", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
|
|
966
968
|
...booleanProp(meta, "standalone") ? { standalone: true } : {},
|
|
967
969
|
...aspects2.length > 0 ? { aspects: aspects2 } : {}
|
|
968
970
|
});
|
|
@@ -1092,6 +1094,31 @@ function commandModeProp(object, name) {
|
|
|
1092
1094
|
const value = stringLiteralProp(object, name);
|
|
1093
1095
|
return value === "required" || value === "none" ? value : undefined;
|
|
1094
1096
|
}
|
|
1097
|
+
function checkedCommandMode(object, property, ctx, command) {
|
|
1098
|
+
const expression = getProp(object, property);
|
|
1099
|
+
const value = commandModeProp(object, property);
|
|
1100
|
+
if (expression && value === undefined) {
|
|
1101
|
+
const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
|
|
1102
|
+
ctx.diagnostics.push({
|
|
1103
|
+
severity: "error",
|
|
1104
|
+
code: "invalid-command-mode",
|
|
1105
|
+
errorCode: "SC4012",
|
|
1106
|
+
docsUrl: "https://supacloud.dev/errors/SC4012",
|
|
1107
|
+
message: `${command}.${property} must be the explicit literal "required" or "none"; invalid governance cannot be disabled silently.`,
|
|
1108
|
+
file,
|
|
1109
|
+
line: lineOf(expression),
|
|
1110
|
+
suggestion: `Choose the intended ${property} policy explicitly; use set_command_mode with value "required" or "none".`,
|
|
1111
|
+
fix: {
|
|
1112
|
+
type: "set_command_mode",
|
|
1113
|
+
targetFile: file,
|
|
1114
|
+
command,
|
|
1115
|
+
property,
|
|
1116
|
+
expectedExpression: nodeText(expression)
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
1095
1122
|
function parseProvider(el, exportsSet, ctx) {
|
|
1096
1123
|
const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
|
|
1097
1124
|
const line = lineOf(el);
|
|
@@ -1552,6 +1579,10 @@ function parseController(input, ctx) {
|
|
|
1552
1579
|
path: routePath,
|
|
1553
1580
|
handler: propertyName(method.name)
|
|
1554
1581
|
};
|
|
1582
|
+
const signature = ctx.checker.getSignatureFromDeclaration(method);
|
|
1583
|
+
const resultType = signature && ctx.checker.typeToString(signature.getReturnType());
|
|
1584
|
+
if (resultType && /\bResponse\b/.test(resultType))
|
|
1585
|
+
route.nativeResponse = true;
|
|
1555
1586
|
const pathParams = [];
|
|
1556
1587
|
const paramRegex = /:([a-zA-Z0-9_]+)/g;
|
|
1557
1588
|
let match;
|
|
@@ -1700,6 +1731,23 @@ function parseController(input, ctx) {
|
|
|
1700
1731
|
}
|
|
1701
1732
|
const optionsArg = args[1];
|
|
1702
1733
|
if (optionsArg && ts3.isObjectLiteralExpression(optionsArg)) {
|
|
1734
|
+
const contract = getProp(optionsArg, "contract");
|
|
1735
|
+
if (contract && ts3.isObjectLiteralExpression(contract)) {
|
|
1736
|
+
route.contract = {};
|
|
1737
|
+
for (const field of ["body", "response", "evidence"]) {
|
|
1738
|
+
const value = stringLiteralProp(contract, field);
|
|
1739
|
+
const allowed = field === "body" ? ["framework", "domain"] : field === "response" ? ["framework", "native-json", "binary", "stream"] : undefined;
|
|
1740
|
+
if (getProp(contract, field) && (!value || allowed && !allowed.includes(value))) {
|
|
1741
|
+
ctx.diagnostics.push({
|
|
1742
|
+
severity: "error",
|
|
1743
|
+
code: "invalid-route-contract",
|
|
1744
|
+
file,
|
|
1745
|
+
message: `Invalid contract.${field} on ${route.handler}; use an explicit supported string literal.`
|
|
1746
|
+
});
|
|
1747
|
+
} else if (value)
|
|
1748
|
+
Object.assign(route.contract, { [field]: value });
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1703
1751
|
for (const field of ["body", "params", "query", "response"]) {
|
|
1704
1752
|
const schemaExpr = getProp(optionsArg, field);
|
|
1705
1753
|
if (schemaExpr && ts3.isIdentifier(schemaExpr)) {
|
|
@@ -1707,6 +1755,11 @@ function parseController(input, ctx) {
|
|
|
1707
1755
|
const importPath = importPathOf(schemaExpr, ctx);
|
|
1708
1756
|
if (importPath)
|
|
1709
1757
|
schemaImports[schemaExpr.text] = importPath;
|
|
1758
|
+
const declaration = resolveDeclaration(schemaExpr, ctx)[0];
|
|
1759
|
+
const typeName = ctx.checker.typeToString(ctx.checker.getTypeAtLocation(schemaExpr));
|
|
1760
|
+
const initializer = declaration && ts3.isVariableDeclaration(declaration) ? declaration.initializer : undefined;
|
|
1761
|
+
const opaque = /\bT(?:Unknown|Any)\b/.test(typeName) || initializer && ts3.isCallExpression(initializer) && ts3.isPropertyAccessExpression(initializer.expression) && ["Unknown", "Any"].includes(initializer.expression.name.text);
|
|
1762
|
+
(route.schemaKinds ??= {})[field] = opaque ? "opaque" : "declared";
|
|
1710
1763
|
}
|
|
1711
1764
|
}
|
|
1712
1765
|
const commandExpr = getProp(optionsArg, "command");
|
|
@@ -1801,6 +1854,23 @@ function parseController(input, ctx) {
|
|
|
1801
1854
|
schemaImports: Object.keys(schemaImports).length > 0 ? schemaImports : undefined
|
|
1802
1855
|
};
|
|
1803
1856
|
}
|
|
1857
|
+
function checkedRpc(meta, ctx) {
|
|
1858
|
+
const expression = getProp(meta, "rpc");
|
|
1859
|
+
if (!expression)
|
|
1860
|
+
return;
|
|
1861
|
+
const value = stringLiteralProp(meta, "rpc");
|
|
1862
|
+
if (value?.trim())
|
|
1863
|
+
return value;
|
|
1864
|
+
ctx.diagnostics.push({
|
|
1865
|
+
severity: "error",
|
|
1866
|
+
code: "invalid-command-rpc",
|
|
1867
|
+
file: sourcePath(ctx.rootDir, meta.getSourceFile().fileName),
|
|
1868
|
+
line: lineOf(meta),
|
|
1869
|
+
message: "Command rpc must be a non-empty string literal identifying a configured adapter.",
|
|
1870
|
+
suggestion: "Declare a named RPC adapter and its tested persistence capabilities."
|
|
1871
|
+
});
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1804
1874
|
function classDeps(cls, ctx) {
|
|
1805
1875
|
const injectable = parseInjectableOptions(cls, ctx);
|
|
1806
1876
|
const ctor = cls.members.find(ts3.isConstructorDeclaration);
|
|
@@ -2156,6 +2226,7 @@ function parseAspectRefs(expression, ctx, owner) {
|
|
|
2156
2226
|
const declaredFile = declaration.getSourceFile().fileName;
|
|
2157
2227
|
const projectLocal = isProjectSourcePath(declaredFile, ctx.rootDir);
|
|
2158
2228
|
refs.push({
|
|
2229
|
+
...projectLocal ? { file: sourcePath(ctx.rootDir, declaredFile) } : {},
|
|
2159
2230
|
name,
|
|
2160
2231
|
expression: element.text,
|
|
2161
2232
|
importPath: projectLocal ? modulePath(ctx.rootDir, declaredFile) : undefined,
|
|
@@ -2298,6 +2369,7 @@ var INTERFACES = `export interface CompiledRoute {
|
|
|
2298
2369
|
}
|
|
2299
2370
|
|
|
2300
2371
|
export interface CompiledCommand {
|
|
2372
|
+
rpc?: string;
|
|
2301
2373
|
className: string;
|
|
2302
2374
|
name: string;
|
|
2303
2375
|
permission: string;
|
|
@@ -2823,6 +2895,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
2823
2895
|
`transaction: ${JSON.stringify(command.transaction)}`,
|
|
2824
2896
|
...command.audit ? [`audit: ${JSON.stringify(command.audit)}`] : [],
|
|
2825
2897
|
`idempotency: ${JSON.stringify(command.idempotency)}`,
|
|
2898
|
+
...command.rpc ? [`rpc: ${JSON.stringify(command.rpc)}`] : [],
|
|
2826
2899
|
...command.standalone ? ["standalone: true"] : [],
|
|
2827
2900
|
...command.aspects && command.aspects.length > 0 ? [`aspects: ${this.renderAspects(command.aspects)}`] : []
|
|
2828
2901
|
];
|
|
@@ -3690,6 +3763,10 @@ var COMPILER_DIAGNOSTIC_CODES = {
|
|
|
3690
3763
|
"invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
|
|
3691
3764
|
"dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
|
|
3692
3765
|
"invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
|
|
3766
|
+
"invalid-command-mode": { code: "SC4012", docsUrl: "https://supacloud.dev/errors/SC4012" },
|
|
3767
|
+
"invalid-command-rpc": { code: "SC4013", docsUrl: "https://supacloud.dev/errors/SC4013" },
|
|
3768
|
+
"command-rpc-unavailable": { code: "SC4014", docsUrl: "https://supacloud.dev/errors/SC4014" },
|
|
3769
|
+
"invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
|
|
3693
3770
|
"unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" },
|
|
3694
3771
|
"invalid-feature-states": { code: "SC6001", docsUrl: "https://supacloud.dev/errors/SC6001" },
|
|
3695
3772
|
"duplicate-feature-transition": { code: "SC6002", docsUrl: "https://supacloud.dev/errors/SC6002" },
|
|
@@ -4119,8 +4196,18 @@ function validateGraph(graph, options = false) {
|
|
|
4119
4196
|
permission: `${module.name}.${command.name}`
|
|
4120
4197
|
});
|
|
4121
4198
|
}
|
|
4199
|
+
if (command.rpc && (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, command.rpc))) {
|
|
4200
|
+
error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${command.rpc}'.`, module.file, module.line);
|
|
4201
|
+
}
|
|
4122
4202
|
if (typeof options === "object" && options.commandCapabilities) {
|
|
4123
|
-
const
|
|
4203
|
+
const hostCaps = options.commandCapabilities;
|
|
4204
|
+
const rpcCaps = command.rpc && Object.hasOwn(hostCaps.rpc ?? {}, command.rpc) ? hostCaps.rpc?.[command.rpc] : undefined;
|
|
4205
|
+
const caps = command.rpc ? {
|
|
4206
|
+
permission: hostCaps.permission,
|
|
4207
|
+
audit: rpcCaps?.audit === true,
|
|
4208
|
+
transaction: rpcCaps?.transaction === true,
|
|
4209
|
+
idempotency: rpcCaps?.idempotency === true
|
|
4210
|
+
} : hostCaps;
|
|
4124
4211
|
const location = `${command.className} (${module.file})`;
|
|
4125
4212
|
if (command.permission && caps.permission === false) {
|
|
4126
4213
|
error("command-permission-unsupported", `Command ${command.name} declares permission, but runtime permission checks are unavailable (${location}).`, module.file, module.line);
|
|
@@ -4682,6 +4769,55 @@ function isAnyKeyword(node) {
|
|
|
4682
4769
|
return node.kind === ts4.SyntaxKind.AnyKeyword;
|
|
4683
4770
|
}
|
|
4684
4771
|
|
|
4772
|
+
// src/route-contracts.ts
|
|
4773
|
+
function inspectRouteContracts(graph) {
|
|
4774
|
+
return graph.modules.flatMap((module) => module.controllers.flatMap((controller) => controller.routes.map((route) => {
|
|
4775
|
+
const missing = [];
|
|
4776
|
+
if ((route.hasBodyBinding || route.handlerParams?.some((param) => param.kind === "body")) && !route.body)
|
|
4777
|
+
missing.push("body");
|
|
4778
|
+
if ((route.pathParams?.length || route.paramBindings?.length || route.handlerParams?.some((param) => param.kind === "param") || /:[^/]+/.test(`${controller.path}/${route.path}`)) && !route.params)
|
|
4779
|
+
missing.push("params");
|
|
4780
|
+
if ((route.queryBindings?.length || route.handlerParams?.some((param) => param.kind === "query")) && !route.query)
|
|
4781
|
+
missing.push("query");
|
|
4782
|
+
if (!route.response && !["binary", "stream"].includes(route.contract?.response ?? ""))
|
|
4783
|
+
missing.push("response");
|
|
4784
|
+
const body = !route.body ? "missing" : route.contract?.body === "domain" ? "domain" : route.schemaKinds?.body === "opaque" ? "opaque" : "framework-declared";
|
|
4785
|
+
const response = route.contract?.response ?? (route.nativeResponse ? "native-response-unclassified" : route.schemaKinds?.response === "opaque" ? "opaque" : !route.response ? "missing" : "framework-declared");
|
|
4786
|
+
return {
|
|
4787
|
+
module: module.name,
|
|
4788
|
+
controller: controller.className,
|
|
4789
|
+
handler: route.handler,
|
|
4790
|
+
method: route.method,
|
|
4791
|
+
path: `${controller.path}${route.path}`,
|
|
4792
|
+
file: controller.file,
|
|
4793
|
+
missing,
|
|
4794
|
+
validation: {
|
|
4795
|
+
body,
|
|
4796
|
+
response,
|
|
4797
|
+
schemas: route.schemaKinds ?? {},
|
|
4798
|
+
evidence: route.contract?.evidence ?? null,
|
|
4799
|
+
verified: false,
|
|
4800
|
+
obligations: [
|
|
4801
|
+
"Exercise actual HTTP request and response boundaries.",
|
|
4802
|
+
...response === "opaque" ? ["Decode the response's business fields; an opaque schema accepts unvalidated output."] : [],
|
|
4803
|
+
...body === "domain" || body === "opaque" ? ["Prove invalid input is rejected by the domain before writes."] : [],
|
|
4804
|
+
...response === "native-json" || response === "native-response-unclassified" ? ["Validate serialized JSON explicitly; native Response bypasses framework response schemas."] : [],
|
|
4805
|
+
...["binary", "stream"].includes(response) ? ["Test transport headers, access and bytes without JSON decoding."] : []
|
|
4806
|
+
]
|
|
4807
|
+
}
|
|
4808
|
+
};
|
|
4809
|
+
})));
|
|
4810
|
+
}
|
|
4811
|
+
function validateRouteContracts(graph) {
|
|
4812
|
+
return inspectRouteContracts(graph).filter((route) => route.missing.length).map((route) => ({
|
|
4813
|
+
severity: "error",
|
|
4814
|
+
code: "route-contract-required",
|
|
4815
|
+
file: route.file,
|
|
4816
|
+
message: `${route.controller}.${route.handler} (${route.method} ${route.path}) is missing contract declarations: ${route.missing.join(", ")}.`,
|
|
4817
|
+
suggestion: "Declare the missing schemas and test actual request/response decoding. An opaque schema is not proof of validation."
|
|
4818
|
+
}));
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4685
4821
|
// src/compile.ts
|
|
4686
4822
|
async function compileProject(options) {
|
|
4687
4823
|
const graph = await analyzeProject(options.rootDir, options.include, options.cache, options.changedPaths);
|
|
@@ -4704,6 +4840,8 @@ async function compileProject(options) {
|
|
|
4704
4840
|
}
|
|
4705
4841
|
}
|
|
4706
4842
|
const typeSafety = resolveTypeSafety(options);
|
|
4843
|
+
if (options.requireRouteContracts)
|
|
4844
|
+
diagnostics.push(...validateRouteContracts(graph));
|
|
4707
4845
|
const rendered = renderApplication(graph, {
|
|
4708
4846
|
rootDir: options.rootDir,
|
|
4709
4847
|
outDir: options.outDir,
|
|
@@ -4736,7 +4874,7 @@ async function compileProject(options) {
|
|
|
4736
4874
|
treeShakeUnusedProviders: options.treeShakeUnusedProviders,
|
|
4737
4875
|
artifactHashes: options.cache?.generatedHashes
|
|
4738
4876
|
};
|
|
4739
|
-
const written = !hasErrors || options.writeOnError
|
|
4877
|
+
const written = !hasErrors || options.writeOnError === true ? await generateApplication(graph, generatedOptions) : [];
|
|
4740
4878
|
const stats = graph.cacheStats ? {
|
|
4741
4879
|
cacheHit: graph.cacheStats.reanalyzedModules.length === 0,
|
|
4742
4880
|
changedFiles: [],
|
|
@@ -4767,6 +4905,8 @@ async function checkProject(options) {
|
|
|
4767
4905
|
}
|
|
4768
4906
|
}
|
|
4769
4907
|
const typeSafety = resolveTypeSafety(options);
|
|
4908
|
+
if (options.requireRouteContracts)
|
|
4909
|
+
diagnostics.push(...validateRouteContracts(graph));
|
|
4770
4910
|
const rendered = renderApplication(graph, {
|
|
4771
4911
|
rootDir: options.rootDir,
|
|
4772
4912
|
outDir: options.outDir,
|
|
@@ -4873,22 +5013,23 @@ function createContextPack(graph, subject) {
|
|
|
4873
5013
|
}
|
|
4874
5014
|
const byName = new Map(graph.modules.map((module) => [module.name, module]));
|
|
4875
5015
|
const selected = new Set([subjectModule.name]);
|
|
4876
|
-
const
|
|
4877
|
-
|
|
4878
|
-
const
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
5016
|
+
for (const direction of ["imports", "dependents"]) {
|
|
5017
|
+
const visited = new Set([subjectModule.name]);
|
|
5018
|
+
const queue = [subjectModule.name];
|
|
5019
|
+
while (queue.length > 0) {
|
|
5020
|
+
const current = queue.shift();
|
|
5021
|
+
if (!current)
|
|
5022
|
+
continue;
|
|
5023
|
+
const module = byName.get(current);
|
|
5024
|
+
if (!module)
|
|
5025
|
+
continue;
|
|
5026
|
+
const neighbors = direction === "imports" ? module.imports : graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
|
|
5027
|
+
for (const neighbor of neighbors) {
|
|
5028
|
+
if (!visited.has(neighbor) && byName.has(neighbor)) {
|
|
5029
|
+
visited.add(neighbor);
|
|
5030
|
+
selected.add(neighbor);
|
|
5031
|
+
queue.push(neighbor);
|
|
5032
|
+
}
|
|
4892
5033
|
}
|
|
4893
5034
|
}
|
|
4894
5035
|
}
|
|
@@ -4896,7 +5037,8 @@ function createContextPack(graph, subject) {
|
|
|
4896
5037
|
const files = [...new Set(modules.flatMap((module) => [
|
|
4897
5038
|
module.file,
|
|
4898
5039
|
...module.providers.map((provider) => provider.file),
|
|
4899
|
-
...module.controllers.map((controller) => controller.file)
|
|
5040
|
+
...module.controllers.map((controller) => controller.file),
|
|
5041
|
+
...allAspects(module).flatMap((aspect) => aspect.file ? [aspect.file] : [])
|
|
4900
5042
|
]))].sort();
|
|
4901
5043
|
const referencedTokens = new Set;
|
|
4902
5044
|
for (const module of modules) {
|
|
@@ -4915,12 +5057,76 @@ function createContextPack(graph, subject) {
|
|
|
4915
5057
|
modules,
|
|
4916
5058
|
files,
|
|
4917
5059
|
externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
|
|
5060
|
+
executionPlans: createExecutionPlans({ ...graph, modules }),
|
|
5061
|
+
routeContracts: inspectRouteContracts({ ...graph, modules }),
|
|
5062
|
+
diagnostics: (graph.diagnostics ?? []).filter((diagnostic) => diagnostic.file === undefined || files.includes(diagnostic.file)),
|
|
4918
5063
|
relatedModules: {
|
|
4919
5064
|
imports: subjectModule.imports.filter((name) => selected.has(name)),
|
|
4920
5065
|
importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
|
|
4921
5066
|
}
|
|
4922
5067
|
};
|
|
4923
5068
|
}
|
|
5069
|
+
function allAspects(module) {
|
|
5070
|
+
return [
|
|
5071
|
+
...module.aspects ?? [],
|
|
5072
|
+
...module.controllers.flatMap((controller) => controller.routes.flatMap((route) => route.aspects ?? [])),
|
|
5073
|
+
...module.commands.flatMap((command) => command.aspects ?? []),
|
|
5074
|
+
...(module.jobs ?? []).flatMap((job) => job.aspects ?? [])
|
|
5075
|
+
];
|
|
5076
|
+
}
|
|
5077
|
+
function createExecutionPlans(graph) {
|
|
5078
|
+
const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
|
|
5079
|
+
return graph.modules.flatMap((module) => [
|
|
5080
|
+
...module.controllers.flatMap((controller) => controller.routes.map((route) => {
|
|
5081
|
+
const command = module.commands.find((item) => item.className === route.command);
|
|
5082
|
+
const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
|
|
5083
|
+
return {
|
|
5084
|
+
module: module.name,
|
|
5085
|
+
kind: "route",
|
|
5086
|
+
name: `${route.method} ${path.length > 1 ? path.replace(/\/+$/, "") : path}`,
|
|
5087
|
+
...command ? { command: command.name } : {},
|
|
5088
|
+
stages: [
|
|
5089
|
+
...aspects(`module:${module.name}`, module.aspects),
|
|
5090
|
+
...aspects("route", route.aspects),
|
|
5091
|
+
...aspects("command", command?.aspects),
|
|
5092
|
+
...command ? [
|
|
5093
|
+
"commandExecutor",
|
|
5094
|
+
"authorize",
|
|
5095
|
+
...command.rpc ? [`rpc:${command.rpc}`] : [
|
|
5096
|
+
...command.idempotency === "required" ? ["idempotency"] : [],
|
|
5097
|
+
...command.transaction === "required" ? ["transaction"] : []
|
|
5098
|
+
]
|
|
5099
|
+
] : [],
|
|
5100
|
+
"handler",
|
|
5101
|
+
...command?.audit && !command.rpc ? ["audit"] : []
|
|
5102
|
+
]
|
|
5103
|
+
};
|
|
5104
|
+
})),
|
|
5105
|
+
...module.commands.map((command) => ({
|
|
5106
|
+
module: module.name,
|
|
5107
|
+
kind: "command",
|
|
5108
|
+
name: command.name,
|
|
5109
|
+
command: command.name,
|
|
5110
|
+
stages: [
|
|
5111
|
+
"authorize",
|
|
5112
|
+
...command.rpc ? [`rpc:${command.rpc}`] : [
|
|
5113
|
+
...command.idempotency === "required" ? ["idempotency"] : [],
|
|
5114
|
+
...command.transaction === "required" ? ["transaction"] : []
|
|
5115
|
+
],
|
|
5116
|
+
...aspects(`module:${module.name}`, module.aspects),
|
|
5117
|
+
...aspects("command", command.aspects),
|
|
5118
|
+
"handler",
|
|
5119
|
+
...command.audit && !command.rpc ? ["audit"] : []
|
|
5120
|
+
]
|
|
5121
|
+
})),
|
|
5122
|
+
...(module.jobs ?? []).map((job) => ({
|
|
5123
|
+
module: module.name,
|
|
5124
|
+
kind: "job",
|
|
5125
|
+
name: job.name,
|
|
5126
|
+
stages: [...aspects(`module:${module.name}`, module.aspects), ...aspects("job", job.aspects), "jobExecutor", "handler"]
|
|
5127
|
+
}))
|
|
5128
|
+
]);
|
|
5129
|
+
}
|
|
4924
5130
|
function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
|
|
4925
5131
|
const checks = [
|
|
4926
5132
|
{
|
|
@@ -4960,7 +5166,8 @@ function explainModule(graph, module) {
|
|
|
4960
5166
|
` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
|
|
4961
5167
|
` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
|
|
4962
5168
|
` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
|
|
4963
|
-
` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}
|
|
5169
|
+
` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`,
|
|
5170
|
+
...createExecutionPlans({ ...graph, modules: [module] }).map((plan) => ` execution ${plan.name}: ${plan.stages.join(" -> ")}`)
|
|
4964
5171
|
].join(`
|
|
4965
5172
|
`);
|
|
4966
5173
|
}
|
|
@@ -5144,6 +5351,7 @@ function optionsKeyOf(options) {
|
|
|
5144
5351
|
allowRouteCommandBindings: options.allowRouteCommandBindings,
|
|
5145
5352
|
commandCapabilities: options.commandCapabilities,
|
|
5146
5353
|
disallowControllerDirectDb: options.disallowControllerDirectDb,
|
|
5354
|
+
requireRouteContracts: options.requireRouteContracts,
|
|
5147
5355
|
detectOrphanModules: options.detectOrphanModules,
|
|
5148
5356
|
generateClient: options.generateClient,
|
|
5149
5357
|
generatePermissions: options.generatePermissions,
|
|
@@ -5399,6 +5607,7 @@ var DEFAULT_SUPACLOUD_CONFIG = {
|
|
|
5399
5607
|
outDir: "generated",
|
|
5400
5608
|
include: ["**/*.module.ts", "**/*.ts"],
|
|
5401
5609
|
strict: true,
|
|
5610
|
+
requireRouteContracts: false,
|
|
5402
5611
|
generateClient: true,
|
|
5403
5612
|
generatePermissions: true,
|
|
5404
5613
|
treeShakeUnusedProviders: true,
|
|
@@ -5418,6 +5627,7 @@ function resolveSupacloudConfig(config = {}, cwd = process.cwd()) {
|
|
|
5418
5627
|
outDir: resolve5(cwd, resolved.outDir ?? DEFAULT_SUPACLOUD_CONFIG.outDir),
|
|
5419
5628
|
include: resolved.include ?? [...DEFAULT_SUPACLOUD_CONFIG.include],
|
|
5420
5629
|
strict: resolved.strict ?? DEFAULT_SUPACLOUD_CONFIG.strict,
|
|
5630
|
+
requireRouteContracts: resolved.requireRouteContracts ?? DEFAULT_SUPACLOUD_CONFIG.requireRouteContracts,
|
|
5421
5631
|
generateClient: resolved.generateClient ?? DEFAULT_SUPACLOUD_CONFIG.generateClient,
|
|
5422
5632
|
generatePermissions: resolved.generatePermissions ?? DEFAULT_SUPACLOUD_CONFIG.generatePermissions,
|
|
5423
5633
|
moduleBoundaryPreset: resolved.moduleBoundaryPreset ?? DEFAULT_SUPACLOUD_CONFIG.moduleBoundaryPreset,
|
|
@@ -5445,6 +5655,7 @@ function compileOptionsFromConfig(config, cwd = process.cwd()) {
|
|
|
5445
5655
|
outDir: resolved.outDir,
|
|
5446
5656
|
include: resolved.include,
|
|
5447
5657
|
strict: resolved.strict,
|
|
5658
|
+
requireRouteContracts: resolved.requireRouteContracts,
|
|
5448
5659
|
generateClient: resolved.generateClient,
|
|
5449
5660
|
generatePermissions: resolved.generatePermissions,
|
|
5450
5661
|
moduleBoundaryPreset: resolved.moduleBoundaryPreset,
|
|
@@ -5473,6 +5684,18 @@ async function applyDiagnosticFix(fix, options = {}) {
|
|
|
5473
5684
|
let source = parse(file, original);
|
|
5474
5685
|
let content;
|
|
5475
5686
|
switch (fix.type) {
|
|
5687
|
+
case "set_command_mode": {
|
|
5688
|
+
if (fix.property !== "transaction" && fix.property !== "idempotency" || fix.value !== "required" && fix.value !== "none") {
|
|
5689
|
+
throw new Error("Command mode fix requires an explicit property and policy value");
|
|
5690
|
+
}
|
|
5691
|
+
const object = decoratorObject(findClass(source, fix.command), "Command");
|
|
5692
|
+
const current = property(object, fix.property);
|
|
5693
|
+
if (!current || current.initializer.getText(source) !== fix.expectedExpression) {
|
|
5694
|
+
throw new Error("Command mode changed since diagnosis; analyze the project again");
|
|
5695
|
+
}
|
|
5696
|
+
content = replaceProperty(source, object, fix.property, ts5.factory.createStringLiteral(fix.value));
|
|
5697
|
+
break;
|
|
5698
|
+
}
|
|
5476
5699
|
case "add_module_import": {
|
|
5477
5700
|
if (!fix.importPath || !fix.symbol)
|
|
5478
5701
|
throw new Error("Module fix requires importPath and symbol");
|
|
@@ -5775,7 +5998,7 @@ async function run() {
|
|
|
5775
5998
|
if (!query)
|
|
5776
5999
|
throw new Error("fix requires a JSON file containing one DiagnosticFix");
|
|
5777
6000
|
const fix = JSON.parse(await readFile3(resolve7(process.cwd(), query), "utf8"));
|
|
5778
|
-
const result = await applyDiagnosticFix(fix, { rootDir:
|
|
6001
|
+
const result = await applyDiagnosticFix(fix, { rootDir: resolvedRoot, dryRun });
|
|
5779
6002
|
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
5780
6003
|
} else if (command === "compile") {
|
|
5781
6004
|
const result = await compileProject(compileDefaults);
|
|
@@ -5893,8 +6116,8 @@ Source change detected; compiling...`);
|
|
|
5893
6116
|
process.exit(1);
|
|
5894
6117
|
}
|
|
5895
6118
|
try {
|
|
5896
|
-
const
|
|
5897
|
-
const pack = createContextPack(graph, query);
|
|
6119
|
+
const result = await checkProject(compileDefaults);
|
|
6120
|
+
const pack = createContextPack({ ...result.graph, diagnostics: result.diagnostics }, query);
|
|
5898
6121
|
if (json) {
|
|
5899
6122
|
console.log(JSON.stringify(pack, null, 2));
|
|
5900
6123
|
} else {
|
|
@@ -5904,7 +6127,9 @@ Source change detected; compiling...`);
|
|
|
5904
6127
|
` files: ${pack.files.join(", ") || "-"}`,
|
|
5905
6128
|
` external tokens: ${pack.externalTokens.join(", ") || "-"}`,
|
|
5906
6129
|
` imports: ${pack.relatedModules.imports.join(", ") || "-"}`,
|
|
5907
|
-
` imported by: ${pack.relatedModules.importedBy.join(", ") || "-"}
|
|
6130
|
+
` imported by: ${pack.relatedModules.importedBy.join(", ") || "-"}`,
|
|
6131
|
+
...pack.executionPlans.map((plan) => ` execution ${plan.name}: ${plan.stages.join(" -> ")}`),
|
|
6132
|
+
...pack.diagnostics.map((diagnostic) => ` ${diagnostic.code}: ${diagnostic.message}`)
|
|
5908
6133
|
].join(`
|
|
5909
6134
|
`));
|
|
5910
6135
|
}
|
package/dist/compile.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CheckProjectResult, CompileOptions, CompileResult } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* Complete compilation pipeline: AST analysis -> validation -> generate static factory code and manifest.
|
|
4
|
-
*
|
|
4
|
+
* Errors preserve the last working artifacts unless writeOnError is explicitly enabled.
|
|
5
5
|
*/
|
|
6
6
|
export declare function compileProject(options: CompileOptions): Promise<CompileResult>;
|
|
7
7
|
/**
|
package/dist/config.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface SupaCloudConfig {
|
|
|
4
4
|
outDir?: string;
|
|
5
5
|
include?: string[];
|
|
6
6
|
strict?: boolean;
|
|
7
|
+
requireRouteContracts?: boolean;
|
|
7
8
|
generateClient?: boolean;
|
|
8
9
|
generatePermissions?: boolean;
|
|
9
10
|
moduleBoundaryPreset?: ModuleBoundaryPresetName;
|
|
@@ -20,6 +21,7 @@ export declare function resolveSupacloudConfig(config?: SupaCloudConfig, cwd?: s
|
|
|
20
21
|
outDir: string;
|
|
21
22
|
include: string[];
|
|
22
23
|
strict: boolean;
|
|
24
|
+
requireRouteContracts: boolean;
|
|
23
25
|
generateClient: boolean;
|
|
24
26
|
generatePermissions: boolean;
|
|
25
27
|
moduleBoundaryPreset: ModuleBoundaryPresetName;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { applyDiagnosticFix } from "./fixes";
|
|
|
4
4
|
export type { AppliedDiagnosticFix, ApplyDiagnosticFixOptions } from "./fixes";
|
|
5
5
|
export { checkProject, compileProject } from "./compile";
|
|
6
6
|
export { watchProject } from "./watch";
|
|
7
|
-
export { createContextPack, doctorProject, explainGraph, formatGraph, exportGraphDot, exportGraphMermaid, } from "./inspect";
|
|
7
|
+
export { createContextPack, createExecutionPlans, doctorProject, explainGraph, formatGraph, exportGraphDot, exportGraphMermaid, } from "./inspect";
|
|
8
8
|
export { createIncrementalCompiler } from "./incremental";
|
|
9
9
|
export { createDependencyGraphCache } from "./incremental";
|
|
10
10
|
export { ModuleDependencyGraph } from "./incremental";
|
|
@@ -15,7 +15,7 @@ export { TraitCompiler } from "./traits";
|
|
|
15
15
|
export type { TraitCompilation, TraitHandler, TraitKind, TraitRecord } from "./traits";
|
|
16
16
|
export { generateApplication, renderApplication } from "./generate";
|
|
17
17
|
export type { GenerateOptions, RenderedArtifacts } from "./generate";
|
|
18
|
-
export type { ContextPack, DoctorResult } from "./inspect";
|
|
18
|
+
export type { ContextPack, DoctorResult, ExecutionPlan } from "./inspect";
|
|
19
19
|
export { validateGraph, COMPILER_DIAGNOSTIC_CODES } from "./validate";
|
|
20
20
|
export { scanGeneratedArtifacts, scanProductionSource } from "./type-safety";
|
|
21
21
|
export type { TypeSafetyScanOptions } from "./type-safety";
|
|
@@ -24,3 +24,4 @@ export type { SupaCloudConfig } from "./config";
|
|
|
24
24
|
export { camelName } from "./util";
|
|
25
25
|
export { ANGULAR_ENTERPRISE_RULES, CLEAN_ARCHITECTURE_RULES, MODULAR_MONOLITH_RULES, MODULE_BOUNDARY_PROFILES, getModuleBoundaryPreset, getModuleBoundaryProfile, resolveModuleBoundaries, } from "./profiles";
|
|
26
26
|
export type { ApplicationGraph, AspectRefNode, CachedModuleEntry, CheckProjectResult, CommandExecutionCapabilities, CommandNode, CompileOptions, CompileResult, CompileStats, ControllerNode, DependencyGraphCache, DependencyGraphIndex, Diagnostic, DiagnosticFix, ModuleBoundaryPresetName, ModuleBoundaryProfile, ModuleBoundaryRule, ModuleNode, JobNode, ProviderKind, ProviderNode, QueryNode, RouteNode, Scope, TokenKind, TypeSafetyOptions, ValidateOptions, WatchEvent, WatchHandle, WatchOptions, FeatureSpecNode, FeatureTransitionNode, } from "./types";
|
|
27
|
+
export { inspectRouteContracts, validateRouteContracts } from "./route-contracts";
|
package/dist/index.js
CHANGED
|
@@ -662,9 +662,10 @@ async function analyzeProject(rootDir, include, cache, changedPaths) {
|
|
|
662
662
|
className: classInfo.decl.name?.text ?? name,
|
|
663
663
|
name: stringLiteralProp(meta, "name") ?? classInfo.decl.name?.text ?? name,
|
|
664
664
|
permission: stringLiteralProp(meta, "permission"),
|
|
665
|
-
|
|
665
|
+
rpc: checkedRpc(meta, ctx),
|
|
666
|
+
transaction: checkedCommandMode(meta, "transaction", ctx, classInfo.decl.name?.text ?? name) ?? "none",
|
|
666
667
|
audit: stringLiteralProp(meta, "audit"),
|
|
667
|
-
idempotency:
|
|
668
|
+
idempotency: checkedCommandMode(meta, "idempotency", ctx, classInfo.decl.name?.text ?? name) ?? "none",
|
|
668
669
|
standalone: true,
|
|
669
670
|
aspects: parseAspectRefs(getProp(meta, "aspects"), ctx, `command ${classInfo.decl.name?.text ?? name}`)
|
|
670
671
|
});
|
|
@@ -954,9 +955,10 @@ function parseModule(candidate, nameByNode, ctx) {
|
|
|
954
955
|
className: cls.name?.text ?? "<anonymous>",
|
|
955
956
|
name: stringLiteralProp(meta, "name") ?? cls.name?.text ?? "<anonymous>",
|
|
956
957
|
permission: stringLiteralProp(meta, "permission"),
|
|
957
|
-
|
|
958
|
+
rpc: checkedRpc(meta, ctx),
|
|
959
|
+
transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
|
|
958
960
|
audit: stringLiteralProp(meta, "audit"),
|
|
959
|
-
idempotency:
|
|
961
|
+
idempotency: checkedCommandMode(meta, "idempotency", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
|
|
960
962
|
...booleanProp(meta, "standalone") ? { standalone: true } : {},
|
|
961
963
|
...aspects2.length > 0 ? { aspects: aspects2 } : {}
|
|
962
964
|
});
|
|
@@ -1086,6 +1088,31 @@ function commandModeProp(object, name) {
|
|
|
1086
1088
|
const value = stringLiteralProp(object, name);
|
|
1087
1089
|
return value === "required" || value === "none" ? value : undefined;
|
|
1088
1090
|
}
|
|
1091
|
+
function checkedCommandMode(object, property, ctx, command) {
|
|
1092
|
+
const expression = getProp(object, property);
|
|
1093
|
+
const value = commandModeProp(object, property);
|
|
1094
|
+
if (expression && value === undefined) {
|
|
1095
|
+
const file = sourcePath(ctx.rootDir, expression.getSourceFile().fileName);
|
|
1096
|
+
ctx.diagnostics.push({
|
|
1097
|
+
severity: "error",
|
|
1098
|
+
code: "invalid-command-mode",
|
|
1099
|
+
errorCode: "SC4012",
|
|
1100
|
+
docsUrl: "https://supacloud.dev/errors/SC4012",
|
|
1101
|
+
message: `${command}.${property} must be the explicit literal "required" or "none"; invalid governance cannot be disabled silently.`,
|
|
1102
|
+
file,
|
|
1103
|
+
line: lineOf(expression),
|
|
1104
|
+
suggestion: `Choose the intended ${property} policy explicitly; use set_command_mode with value "required" or "none".`,
|
|
1105
|
+
fix: {
|
|
1106
|
+
type: "set_command_mode",
|
|
1107
|
+
targetFile: file,
|
|
1108
|
+
command,
|
|
1109
|
+
property,
|
|
1110
|
+
expectedExpression: nodeText(expression)
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
return value;
|
|
1115
|
+
}
|
|
1089
1116
|
function parseProvider(el, exportsSet, ctx) {
|
|
1090
1117
|
const file = sourcePath(ctx.rootDir, el.getSourceFile().fileName);
|
|
1091
1118
|
const line = lineOf(el);
|
|
@@ -1546,6 +1573,10 @@ function parseController(input, ctx) {
|
|
|
1546
1573
|
path: routePath,
|
|
1547
1574
|
handler: propertyName(method.name)
|
|
1548
1575
|
};
|
|
1576
|
+
const signature = ctx.checker.getSignatureFromDeclaration(method);
|
|
1577
|
+
const resultType = signature && ctx.checker.typeToString(signature.getReturnType());
|
|
1578
|
+
if (resultType && /\bResponse\b/.test(resultType))
|
|
1579
|
+
route.nativeResponse = true;
|
|
1549
1580
|
const pathParams = [];
|
|
1550
1581
|
const paramRegex = /:([a-zA-Z0-9_]+)/g;
|
|
1551
1582
|
let match;
|
|
@@ -1694,6 +1725,23 @@ function parseController(input, ctx) {
|
|
|
1694
1725
|
}
|
|
1695
1726
|
const optionsArg = args[1];
|
|
1696
1727
|
if (optionsArg && ts3.isObjectLiteralExpression(optionsArg)) {
|
|
1728
|
+
const contract = getProp(optionsArg, "contract");
|
|
1729
|
+
if (contract && ts3.isObjectLiteralExpression(contract)) {
|
|
1730
|
+
route.contract = {};
|
|
1731
|
+
for (const field of ["body", "response", "evidence"]) {
|
|
1732
|
+
const value = stringLiteralProp(contract, field);
|
|
1733
|
+
const allowed = field === "body" ? ["framework", "domain"] : field === "response" ? ["framework", "native-json", "binary", "stream"] : undefined;
|
|
1734
|
+
if (getProp(contract, field) && (!value || allowed && !allowed.includes(value))) {
|
|
1735
|
+
ctx.diagnostics.push({
|
|
1736
|
+
severity: "error",
|
|
1737
|
+
code: "invalid-route-contract",
|
|
1738
|
+
file,
|
|
1739
|
+
message: `Invalid contract.${field} on ${route.handler}; use an explicit supported string literal.`
|
|
1740
|
+
});
|
|
1741
|
+
} else if (value)
|
|
1742
|
+
Object.assign(route.contract, { [field]: value });
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1697
1745
|
for (const field of ["body", "params", "query", "response"]) {
|
|
1698
1746
|
const schemaExpr = getProp(optionsArg, field);
|
|
1699
1747
|
if (schemaExpr && ts3.isIdentifier(schemaExpr)) {
|
|
@@ -1701,6 +1749,11 @@ function parseController(input, ctx) {
|
|
|
1701
1749
|
const importPath = importPathOf(schemaExpr, ctx);
|
|
1702
1750
|
if (importPath)
|
|
1703
1751
|
schemaImports[schemaExpr.text] = importPath;
|
|
1752
|
+
const declaration = resolveDeclaration(schemaExpr, ctx)[0];
|
|
1753
|
+
const typeName = ctx.checker.typeToString(ctx.checker.getTypeAtLocation(schemaExpr));
|
|
1754
|
+
const initializer = declaration && ts3.isVariableDeclaration(declaration) ? declaration.initializer : undefined;
|
|
1755
|
+
const opaque = /\bT(?:Unknown|Any)\b/.test(typeName) || initializer && ts3.isCallExpression(initializer) && ts3.isPropertyAccessExpression(initializer.expression) && ["Unknown", "Any"].includes(initializer.expression.name.text);
|
|
1756
|
+
(route.schemaKinds ??= {})[field] = opaque ? "opaque" : "declared";
|
|
1704
1757
|
}
|
|
1705
1758
|
}
|
|
1706
1759
|
const commandExpr = getProp(optionsArg, "command");
|
|
@@ -1795,6 +1848,23 @@ function parseController(input, ctx) {
|
|
|
1795
1848
|
schemaImports: Object.keys(schemaImports).length > 0 ? schemaImports : undefined
|
|
1796
1849
|
};
|
|
1797
1850
|
}
|
|
1851
|
+
function checkedRpc(meta, ctx) {
|
|
1852
|
+
const expression = getProp(meta, "rpc");
|
|
1853
|
+
if (!expression)
|
|
1854
|
+
return;
|
|
1855
|
+
const value = stringLiteralProp(meta, "rpc");
|
|
1856
|
+
if (value?.trim())
|
|
1857
|
+
return value;
|
|
1858
|
+
ctx.diagnostics.push({
|
|
1859
|
+
severity: "error",
|
|
1860
|
+
code: "invalid-command-rpc",
|
|
1861
|
+
file: sourcePath(ctx.rootDir, meta.getSourceFile().fileName),
|
|
1862
|
+
line: lineOf(meta),
|
|
1863
|
+
message: "Command rpc must be a non-empty string literal identifying a configured adapter.",
|
|
1864
|
+
suggestion: "Declare a named RPC adapter and its tested persistence capabilities."
|
|
1865
|
+
});
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1798
1868
|
function classDeps(cls, ctx) {
|
|
1799
1869
|
const injectable = parseInjectableOptions(cls, ctx);
|
|
1800
1870
|
const ctor = cls.members.find(ts3.isConstructorDeclaration);
|
|
@@ -2150,6 +2220,7 @@ function parseAspectRefs(expression, ctx, owner) {
|
|
|
2150
2220
|
const declaredFile = declaration.getSourceFile().fileName;
|
|
2151
2221
|
const projectLocal = isProjectSourcePath(declaredFile, ctx.rootDir);
|
|
2152
2222
|
refs.push({
|
|
2223
|
+
...projectLocal ? { file: sourcePath(ctx.rootDir, declaredFile) } : {},
|
|
2153
2224
|
name,
|
|
2154
2225
|
expression: element.text,
|
|
2155
2226
|
importPath: projectLocal ? modulePath(ctx.rootDir, declaredFile) : undefined,
|
|
@@ -2370,6 +2441,18 @@ async function applyDiagnosticFix(fix, options = {}) {
|
|
|
2370
2441
|
let source = parse(file, original);
|
|
2371
2442
|
let content;
|
|
2372
2443
|
switch (fix.type) {
|
|
2444
|
+
case "set_command_mode": {
|
|
2445
|
+
if (fix.property !== "transaction" && fix.property !== "idempotency" || fix.value !== "required" && fix.value !== "none") {
|
|
2446
|
+
throw new Error("Command mode fix requires an explicit property and policy value");
|
|
2447
|
+
}
|
|
2448
|
+
const object = decoratorObject(findClass(source, fix.command), "Command");
|
|
2449
|
+
const current = property(object, fix.property);
|
|
2450
|
+
if (!current || current.initializer.getText(source) !== fix.expectedExpression) {
|
|
2451
|
+
throw new Error("Command mode changed since diagnosis; analyze the project again");
|
|
2452
|
+
}
|
|
2453
|
+
content = replaceProperty(source, object, fix.property, ts4.factory.createStringLiteral(fix.value));
|
|
2454
|
+
break;
|
|
2455
|
+
}
|
|
2373
2456
|
case "add_module_import": {
|
|
2374
2457
|
if (!fix.importPath || !fix.symbol)
|
|
2375
2458
|
throw new Error("Module fix requires importPath and symbol");
|
|
@@ -2579,6 +2662,7 @@ var INTERFACES = `export interface CompiledRoute {
|
|
|
2579
2662
|
}
|
|
2580
2663
|
|
|
2581
2664
|
export interface CompiledCommand {
|
|
2665
|
+
rpc?: string;
|
|
2582
2666
|
className: string;
|
|
2583
2667
|
name: string;
|
|
2584
2668
|
permission: string;
|
|
@@ -3104,6 +3188,7 @@ ${indent(item, 2)}`).join(",")}
|
|
|
3104
3188
|
`transaction: ${JSON.stringify(command.transaction)}`,
|
|
3105
3189
|
...command.audit ? [`audit: ${JSON.stringify(command.audit)}`] : [],
|
|
3106
3190
|
`idempotency: ${JSON.stringify(command.idempotency)}`,
|
|
3191
|
+
...command.rpc ? [`rpc: ${JSON.stringify(command.rpc)}`] : [],
|
|
3107
3192
|
...command.standalone ? ["standalone: true"] : [],
|
|
3108
3193
|
...command.aspects && command.aspects.length > 0 ? [`aspects: ${this.renderAspects(command.aspects)}`] : []
|
|
3109
3194
|
];
|
|
@@ -3871,6 +3956,10 @@ var COMPILER_DIAGNOSTIC_CODES = {
|
|
|
3871
3956
|
"invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
|
|
3872
3957
|
"dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
|
|
3873
3958
|
"invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
|
|
3959
|
+
"invalid-command-mode": { code: "SC4012", docsUrl: "https://supacloud.dev/errors/SC4012" },
|
|
3960
|
+
"invalid-command-rpc": { code: "SC4013", docsUrl: "https://supacloud.dev/errors/SC4013" },
|
|
3961
|
+
"command-rpc-unavailable": { code: "SC4014", docsUrl: "https://supacloud.dev/errors/SC4014" },
|
|
3962
|
+
"invalid-route-contract": { code: "SC3020", docsUrl: "https://supacloud.dev/errors/SC3020" },
|
|
3874
3963
|
"unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" },
|
|
3875
3964
|
"invalid-feature-states": { code: "SC6001", docsUrl: "https://supacloud.dev/errors/SC6001" },
|
|
3876
3965
|
"duplicate-feature-transition": { code: "SC6002", docsUrl: "https://supacloud.dev/errors/SC6002" },
|
|
@@ -4300,8 +4389,18 @@ function validateGraph(graph, options = false) {
|
|
|
4300
4389
|
permission: `${module.name}.${command.name}`
|
|
4301
4390
|
});
|
|
4302
4391
|
}
|
|
4392
|
+
if (command.rpc && (typeof options !== "object" || !Object.hasOwn(options.commandCapabilities?.rpc ?? {}, command.rpc))) {
|
|
4393
|
+
error("command-rpc-unavailable", `Command ${command.name} requires configured RPC adapter '${command.rpc}'.`, module.file, module.line);
|
|
4394
|
+
}
|
|
4303
4395
|
if (typeof options === "object" && options.commandCapabilities) {
|
|
4304
|
-
const
|
|
4396
|
+
const hostCaps = options.commandCapabilities;
|
|
4397
|
+
const rpcCaps = command.rpc && Object.hasOwn(hostCaps.rpc ?? {}, command.rpc) ? hostCaps.rpc?.[command.rpc] : undefined;
|
|
4398
|
+
const caps = command.rpc ? {
|
|
4399
|
+
permission: hostCaps.permission,
|
|
4400
|
+
audit: rpcCaps?.audit === true,
|
|
4401
|
+
transaction: rpcCaps?.transaction === true,
|
|
4402
|
+
idempotency: rpcCaps?.idempotency === true
|
|
4403
|
+
} : hostCaps;
|
|
4305
4404
|
const location = `${command.className} (${module.file})`;
|
|
4306
4405
|
if (command.permission && caps.permission === false) {
|
|
4307
4406
|
error("command-permission-unsupported", `Command ${command.name} declares permission, but runtime permission checks are unavailable (${location}).`, module.file, module.line);
|
|
@@ -4863,6 +4962,55 @@ function isAnyKeyword(node) {
|
|
|
4863
4962
|
return node.kind === ts5.SyntaxKind.AnyKeyword;
|
|
4864
4963
|
}
|
|
4865
4964
|
|
|
4965
|
+
// src/route-contracts.ts
|
|
4966
|
+
function inspectRouteContracts(graph) {
|
|
4967
|
+
return graph.modules.flatMap((module) => module.controllers.flatMap((controller) => controller.routes.map((route) => {
|
|
4968
|
+
const missing = [];
|
|
4969
|
+
if ((route.hasBodyBinding || route.handlerParams?.some((param) => param.kind === "body")) && !route.body)
|
|
4970
|
+
missing.push("body");
|
|
4971
|
+
if ((route.pathParams?.length || route.paramBindings?.length || route.handlerParams?.some((param) => param.kind === "param") || /:[^/]+/.test(`${controller.path}/${route.path}`)) && !route.params)
|
|
4972
|
+
missing.push("params");
|
|
4973
|
+
if ((route.queryBindings?.length || route.handlerParams?.some((param) => param.kind === "query")) && !route.query)
|
|
4974
|
+
missing.push("query");
|
|
4975
|
+
if (!route.response && !["binary", "stream"].includes(route.contract?.response ?? ""))
|
|
4976
|
+
missing.push("response");
|
|
4977
|
+
const body = !route.body ? "missing" : route.contract?.body === "domain" ? "domain" : route.schemaKinds?.body === "opaque" ? "opaque" : "framework-declared";
|
|
4978
|
+
const response = route.contract?.response ?? (route.nativeResponse ? "native-response-unclassified" : route.schemaKinds?.response === "opaque" ? "opaque" : !route.response ? "missing" : "framework-declared");
|
|
4979
|
+
return {
|
|
4980
|
+
module: module.name,
|
|
4981
|
+
controller: controller.className,
|
|
4982
|
+
handler: route.handler,
|
|
4983
|
+
method: route.method,
|
|
4984
|
+
path: `${controller.path}${route.path}`,
|
|
4985
|
+
file: controller.file,
|
|
4986
|
+
missing,
|
|
4987
|
+
validation: {
|
|
4988
|
+
body,
|
|
4989
|
+
response,
|
|
4990
|
+
schemas: route.schemaKinds ?? {},
|
|
4991
|
+
evidence: route.contract?.evidence ?? null,
|
|
4992
|
+
verified: false,
|
|
4993
|
+
obligations: [
|
|
4994
|
+
"Exercise actual HTTP request and response boundaries.",
|
|
4995
|
+
...response === "opaque" ? ["Decode the response's business fields; an opaque schema accepts unvalidated output."] : [],
|
|
4996
|
+
...body === "domain" || body === "opaque" ? ["Prove invalid input is rejected by the domain before writes."] : [],
|
|
4997
|
+
...response === "native-json" || response === "native-response-unclassified" ? ["Validate serialized JSON explicitly; native Response bypasses framework response schemas."] : [],
|
|
4998
|
+
...["binary", "stream"].includes(response) ? ["Test transport headers, access and bytes without JSON decoding."] : []
|
|
4999
|
+
]
|
|
5000
|
+
}
|
|
5001
|
+
};
|
|
5002
|
+
})));
|
|
5003
|
+
}
|
|
5004
|
+
function validateRouteContracts(graph) {
|
|
5005
|
+
return inspectRouteContracts(graph).filter((route) => route.missing.length).map((route) => ({
|
|
5006
|
+
severity: "error",
|
|
5007
|
+
code: "route-contract-required",
|
|
5008
|
+
file: route.file,
|
|
5009
|
+
message: `${route.controller}.${route.handler} (${route.method} ${route.path}) is missing contract declarations: ${route.missing.join(", ")}.`,
|
|
5010
|
+
suggestion: "Declare the missing schemas and test actual request/response decoding. An opaque schema is not proof of validation."
|
|
5011
|
+
}));
|
|
5012
|
+
}
|
|
5013
|
+
|
|
4866
5014
|
// src/compile.ts
|
|
4867
5015
|
async function compileProject(options) {
|
|
4868
5016
|
const graph = await analyzeProject(options.rootDir, options.include, options.cache, options.changedPaths);
|
|
@@ -4885,6 +5033,8 @@ async function compileProject(options) {
|
|
|
4885
5033
|
}
|
|
4886
5034
|
}
|
|
4887
5035
|
const typeSafety = resolveTypeSafety(options);
|
|
5036
|
+
if (options.requireRouteContracts)
|
|
5037
|
+
diagnostics.push(...validateRouteContracts(graph));
|
|
4888
5038
|
const rendered = renderApplication(graph, {
|
|
4889
5039
|
rootDir: options.rootDir,
|
|
4890
5040
|
outDir: options.outDir,
|
|
@@ -4917,7 +5067,7 @@ async function compileProject(options) {
|
|
|
4917
5067
|
treeShakeUnusedProviders: options.treeShakeUnusedProviders,
|
|
4918
5068
|
artifactHashes: options.cache?.generatedHashes
|
|
4919
5069
|
};
|
|
4920
|
-
const written = !hasErrors || options.writeOnError
|
|
5070
|
+
const written = !hasErrors || options.writeOnError === true ? await generateApplication(graph, generatedOptions) : [];
|
|
4921
5071
|
const stats = graph.cacheStats ? {
|
|
4922
5072
|
cacheHit: graph.cacheStats.reanalyzedModules.length === 0,
|
|
4923
5073
|
changedFiles: [],
|
|
@@ -4948,6 +5098,8 @@ async function checkProject(options) {
|
|
|
4948
5098
|
}
|
|
4949
5099
|
}
|
|
4950
5100
|
const typeSafety = resolveTypeSafety(options);
|
|
5101
|
+
if (options.requireRouteContracts)
|
|
5102
|
+
diagnostics.push(...validateRouteContracts(graph));
|
|
4951
5103
|
const rendered = renderApplication(graph, {
|
|
4952
5104
|
rootDir: options.rootDir,
|
|
4953
5105
|
outDir: options.outDir,
|
|
@@ -5132,6 +5284,7 @@ function optionsKeyOf(options) {
|
|
|
5132
5284
|
allowRouteCommandBindings: options.allowRouteCommandBindings,
|
|
5133
5285
|
commandCapabilities: options.commandCapabilities,
|
|
5134
5286
|
disallowControllerDirectDb: options.disallowControllerDirectDb,
|
|
5287
|
+
requireRouteContracts: options.requireRouteContracts,
|
|
5135
5288
|
detectOrphanModules: options.detectOrphanModules,
|
|
5136
5289
|
generateClient: options.generateClient,
|
|
5137
5290
|
generatePermissions: options.generatePermissions,
|
|
@@ -5423,22 +5576,23 @@ function createContextPack(graph, subject) {
|
|
|
5423
5576
|
}
|
|
5424
5577
|
const byName = new Map(graph.modules.map((module) => [module.name, module]));
|
|
5425
5578
|
const selected = new Set([subjectModule.name]);
|
|
5426
|
-
const
|
|
5427
|
-
|
|
5428
|
-
const
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5579
|
+
for (const direction of ["imports", "dependents"]) {
|
|
5580
|
+
const visited = new Set([subjectModule.name]);
|
|
5581
|
+
const queue = [subjectModule.name];
|
|
5582
|
+
while (queue.length > 0) {
|
|
5583
|
+
const current = queue.shift();
|
|
5584
|
+
if (!current)
|
|
5585
|
+
continue;
|
|
5586
|
+
const module = byName.get(current);
|
|
5587
|
+
if (!module)
|
|
5588
|
+
continue;
|
|
5589
|
+
const neighbors = direction === "imports" ? module.imports : graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name);
|
|
5590
|
+
for (const neighbor of neighbors) {
|
|
5591
|
+
if (!visited.has(neighbor) && byName.has(neighbor)) {
|
|
5592
|
+
visited.add(neighbor);
|
|
5593
|
+
selected.add(neighbor);
|
|
5594
|
+
queue.push(neighbor);
|
|
5595
|
+
}
|
|
5442
5596
|
}
|
|
5443
5597
|
}
|
|
5444
5598
|
}
|
|
@@ -5446,7 +5600,8 @@ function createContextPack(graph, subject) {
|
|
|
5446
5600
|
const files = [...new Set(modules.flatMap((module) => [
|
|
5447
5601
|
module.file,
|
|
5448
5602
|
...module.providers.map((provider) => provider.file),
|
|
5449
|
-
...module.controllers.map((controller) => controller.file)
|
|
5603
|
+
...module.controllers.map((controller) => controller.file),
|
|
5604
|
+
...allAspects(module).flatMap((aspect) => aspect.file ? [aspect.file] : [])
|
|
5450
5605
|
]))].sort();
|
|
5451
5606
|
const referencedTokens = new Set;
|
|
5452
5607
|
for (const module of modules) {
|
|
@@ -5465,12 +5620,76 @@ function createContextPack(graph, subject) {
|
|
|
5465
5620
|
modules,
|
|
5466
5621
|
files,
|
|
5467
5622
|
externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
|
|
5623
|
+
executionPlans: createExecutionPlans({ ...graph, modules }),
|
|
5624
|
+
routeContracts: inspectRouteContracts({ ...graph, modules }),
|
|
5625
|
+
diagnostics: (graph.diagnostics ?? []).filter((diagnostic) => diagnostic.file === undefined || files.includes(diagnostic.file)),
|
|
5468
5626
|
relatedModules: {
|
|
5469
5627
|
imports: subjectModule.imports.filter((name) => selected.has(name)),
|
|
5470
5628
|
importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
|
|
5471
5629
|
}
|
|
5472
5630
|
};
|
|
5473
5631
|
}
|
|
5632
|
+
function allAspects(module) {
|
|
5633
|
+
return [
|
|
5634
|
+
...module.aspects ?? [],
|
|
5635
|
+
...module.controllers.flatMap((controller) => controller.routes.flatMap((route) => route.aspects ?? [])),
|
|
5636
|
+
...module.commands.flatMap((command) => command.aspects ?? []),
|
|
5637
|
+
...(module.jobs ?? []).flatMap((job) => job.aspects ?? [])
|
|
5638
|
+
];
|
|
5639
|
+
}
|
|
5640
|
+
function createExecutionPlans(graph) {
|
|
5641
|
+
const aspects = (boundary, refs = []) => refs.map((ref, index) => `${boundary}.aspect[${index}]:${ref.name}`);
|
|
5642
|
+
return graph.modules.flatMap((module) => [
|
|
5643
|
+
...module.controllers.flatMap((controller) => controller.routes.map((route) => {
|
|
5644
|
+
const command = module.commands.find((item) => item.className === route.command);
|
|
5645
|
+
const path = `${controller.path}/${route.path}`.replace(/\/+/g, "/");
|
|
5646
|
+
return {
|
|
5647
|
+
module: module.name,
|
|
5648
|
+
kind: "route",
|
|
5649
|
+
name: `${route.method} ${path.length > 1 ? path.replace(/\/+$/, "") : path}`,
|
|
5650
|
+
...command ? { command: command.name } : {},
|
|
5651
|
+
stages: [
|
|
5652
|
+
...aspects(`module:${module.name}`, module.aspects),
|
|
5653
|
+
...aspects("route", route.aspects),
|
|
5654
|
+
...aspects("command", command?.aspects),
|
|
5655
|
+
...command ? [
|
|
5656
|
+
"commandExecutor",
|
|
5657
|
+
"authorize",
|
|
5658
|
+
...command.rpc ? [`rpc:${command.rpc}`] : [
|
|
5659
|
+
...command.idempotency === "required" ? ["idempotency"] : [],
|
|
5660
|
+
...command.transaction === "required" ? ["transaction"] : []
|
|
5661
|
+
]
|
|
5662
|
+
] : [],
|
|
5663
|
+
"handler",
|
|
5664
|
+
...command?.audit && !command.rpc ? ["audit"] : []
|
|
5665
|
+
]
|
|
5666
|
+
};
|
|
5667
|
+
})),
|
|
5668
|
+
...module.commands.map((command) => ({
|
|
5669
|
+
module: module.name,
|
|
5670
|
+
kind: "command",
|
|
5671
|
+
name: command.name,
|
|
5672
|
+
command: command.name,
|
|
5673
|
+
stages: [
|
|
5674
|
+
"authorize",
|
|
5675
|
+
...command.rpc ? [`rpc:${command.rpc}`] : [
|
|
5676
|
+
...command.idempotency === "required" ? ["idempotency"] : [],
|
|
5677
|
+
...command.transaction === "required" ? ["transaction"] : []
|
|
5678
|
+
],
|
|
5679
|
+
...aspects(`module:${module.name}`, module.aspects),
|
|
5680
|
+
...aspects("command", command.aspects),
|
|
5681
|
+
"handler",
|
|
5682
|
+
...command.audit && !command.rpc ? ["audit"] : []
|
|
5683
|
+
]
|
|
5684
|
+
})),
|
|
5685
|
+
...(module.jobs ?? []).map((job) => ({
|
|
5686
|
+
module: module.name,
|
|
5687
|
+
kind: "job",
|
|
5688
|
+
name: job.name,
|
|
5689
|
+
stages: [...aspects(`module:${module.name}`, module.aspects), ...aspects("job", job.aspects), "jobExecutor", "handler"]
|
|
5690
|
+
}))
|
|
5691
|
+
]);
|
|
5692
|
+
}
|
|
5474
5693
|
function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
|
|
5475
5694
|
const checks = [
|
|
5476
5695
|
{
|
|
@@ -5510,7 +5729,8 @@ function explainModule(graph, module) {
|
|
|
5510
5729
|
` imported by: ${dependents.length > 0 ? dependents.join(", ") : "-"}`,
|
|
5511
5730
|
` providers: ${module.providers.length > 0 ? module.providers.map((provider) => provider.token).join(", ") : "-"}`,
|
|
5512
5731
|
` controllers: ${module.controllers.length > 0 ? module.controllers.map((controller) => controller.className).join(", ") : "-"}`,
|
|
5513
|
-
` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}
|
|
5732
|
+
` commands: ${module.commands.length > 0 ? module.commands.map((command) => command.name).join(", ") : "-"}`,
|
|
5733
|
+
...createExecutionPlans({ ...graph, modules: [module] }).map((plan) => ` execution ${plan.name}: ${plan.stages.join(" -> ")}`)
|
|
5514
5734
|
].join(`
|
|
5515
5735
|
`);
|
|
5516
5736
|
}
|
|
@@ -5577,6 +5797,7 @@ var DEFAULT_SUPACLOUD_CONFIG = {
|
|
|
5577
5797
|
outDir: "generated",
|
|
5578
5798
|
include: ["**/*.module.ts", "**/*.ts"],
|
|
5579
5799
|
strict: true,
|
|
5800
|
+
requireRouteContracts: false,
|
|
5580
5801
|
generateClient: true,
|
|
5581
5802
|
generatePermissions: true,
|
|
5582
5803
|
treeShakeUnusedProviders: true,
|
|
@@ -5596,6 +5817,7 @@ function resolveSupacloudConfig(config = {}, cwd = process.cwd()) {
|
|
|
5596
5817
|
outDir: resolve6(cwd, resolved.outDir ?? DEFAULT_SUPACLOUD_CONFIG.outDir),
|
|
5597
5818
|
include: resolved.include ?? [...DEFAULT_SUPACLOUD_CONFIG.include],
|
|
5598
5819
|
strict: resolved.strict ?? DEFAULT_SUPACLOUD_CONFIG.strict,
|
|
5820
|
+
requireRouteContracts: resolved.requireRouteContracts ?? DEFAULT_SUPACLOUD_CONFIG.requireRouteContracts,
|
|
5599
5821
|
generateClient: resolved.generateClient ?? DEFAULT_SUPACLOUD_CONFIG.generateClient,
|
|
5600
5822
|
generatePermissions: resolved.generatePermissions ?? DEFAULT_SUPACLOUD_CONFIG.generatePermissions,
|
|
5601
5823
|
moduleBoundaryPreset: resolved.moduleBoundaryPreset ?? DEFAULT_SUPACLOUD_CONFIG.moduleBoundaryPreset,
|
|
@@ -5623,6 +5845,7 @@ function compileOptionsFromConfig(config, cwd = process.cwd()) {
|
|
|
5623
5845
|
outDir: resolved.outDir,
|
|
5624
5846
|
include: resolved.include,
|
|
5625
5847
|
strict: resolved.strict,
|
|
5848
|
+
requireRouteContracts: resolved.requireRouteContracts,
|
|
5626
5849
|
generateClient: resolved.generateClient,
|
|
5627
5850
|
generatePermissions: resolved.generatePermissions,
|
|
5628
5851
|
moduleBoundaryPreset: resolved.moduleBoundaryPreset,
|
|
@@ -5648,6 +5871,7 @@ export {
|
|
|
5648
5871
|
compileTraits,
|
|
5649
5872
|
createContextPack,
|
|
5650
5873
|
createDependencyGraphCache,
|
|
5874
|
+
createExecutionPlans,
|
|
5651
5875
|
createIncrementalCompiler,
|
|
5652
5876
|
createIncrementalProgramSession,
|
|
5653
5877
|
defineSupacloudConfig,
|
|
@@ -5660,6 +5884,7 @@ export {
|
|
|
5660
5884
|
generateFeatureSource,
|
|
5661
5885
|
getModuleBoundaryPreset,
|
|
5662
5886
|
getModuleBoundaryProfile,
|
|
5887
|
+
inspectRouteContracts,
|
|
5663
5888
|
loadSupacloudConfig,
|
|
5664
5889
|
renderApplication,
|
|
5665
5890
|
resolveModuleBoundaries,
|
|
@@ -5668,5 +5893,6 @@ export {
|
|
|
5668
5893
|
scanProductionSource,
|
|
5669
5894
|
validateFeatureSpec,
|
|
5670
5895
|
validateGraph,
|
|
5896
|
+
validateRouteContracts,
|
|
5671
5897
|
watchProject
|
|
5672
5898
|
};
|
package/dist/inspect.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import type { ApplicationGraph, Diagnostic, ModuleNode } from "./types";
|
|
2
|
+
import { inspectRouteContracts } from "./route-contracts";
|
|
3
|
+
export interface ExecutionPlan {
|
|
4
|
+
module: string;
|
|
5
|
+
kind: "route" | "command" | "job";
|
|
6
|
+
name: string;
|
|
7
|
+
command?: string;
|
|
8
|
+
/** Standard governance contract. Audit success follows the handler; custom executors may short-circuit. */
|
|
9
|
+
stages: string[];
|
|
10
|
+
}
|
|
2
11
|
export interface ContextPack {
|
|
3
12
|
version: 1;
|
|
4
13
|
subject: string;
|
|
5
14
|
modules: ModuleNode[];
|
|
6
15
|
files: string[];
|
|
7
16
|
externalTokens: string[];
|
|
17
|
+
executionPlans: ExecutionPlan[];
|
|
18
|
+
routeContracts: ReturnType<typeof inspectRouteContracts>;
|
|
19
|
+
diagnostics: Diagnostic[];
|
|
8
20
|
relatedModules: {
|
|
9
21
|
importedBy: string[];
|
|
10
22
|
imports: string[];
|
|
@@ -26,6 +38,8 @@ export declare function explainGraph(graph: ApplicationGraph, subject: string):
|
|
|
26
38
|
* editing one feature: the subject module, its imports, and its dependents.
|
|
27
39
|
*/
|
|
28
40
|
export declare function createContextPack(graph: ApplicationGraph, subject: string): ContextPack;
|
|
41
|
+
/** Static plan, not runtime discovery; custom executors remain explicit boundaries. */
|
|
42
|
+
export declare function createExecutionPlans(graph: ApplicationGraph): ExecutionPlan[];
|
|
29
43
|
export declare function doctorProject(rootDir: string, outDir: string, graph: ApplicationGraph, upToDate: boolean, diagnostics?: Diagnostic[]): DoctorResult;
|
|
30
44
|
/**
|
|
31
45
|
* Exports the application module architecture as a Mermaid graph diagram.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ApplicationGraph, Diagnostic } from "./types";
|
|
2
|
+
/** Declaration coverage only; runtime decoding and database behavior require separate tests. */
|
|
3
|
+
export declare function inspectRouteContracts(graph: ApplicationGraph): {
|
|
4
|
+
module: string;
|
|
5
|
+
controller: string;
|
|
6
|
+
handler: string;
|
|
7
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
8
|
+
path: string;
|
|
9
|
+
file: string;
|
|
10
|
+
missing: ("query" | "body" | "params" | "response")[];
|
|
11
|
+
validation: {
|
|
12
|
+
body: string;
|
|
13
|
+
response: string;
|
|
14
|
+
schemas: Partial<Record<"query" | "body" | "params" | "response", "opaque" | "declared">>;
|
|
15
|
+
evidence: string | null;
|
|
16
|
+
verified: false;
|
|
17
|
+
obligations: string[];
|
|
18
|
+
};
|
|
19
|
+
}[];
|
|
20
|
+
export declare function validateRouteContracts(graph: ApplicationGraph): Diagnostic[];
|
package/dist/types.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface FunctionalInjectNode {
|
|
|
21
21
|
host?: boolean;
|
|
22
22
|
}
|
|
23
23
|
export interface AspectRefNode {
|
|
24
|
+
/** Exact project-local source path for context packs. */
|
|
25
|
+
file?: string;
|
|
24
26
|
/** Exported symbol name used in the generated static import. */
|
|
25
27
|
name: string;
|
|
26
28
|
/** Source expression retained for diagnostics and manifest inspection. */
|
|
@@ -50,6 +52,14 @@ export interface Diagnostic {
|
|
|
50
52
|
* than raw text offsets so fixes remain valid after unrelated edits.
|
|
51
53
|
*/
|
|
52
54
|
export type DiagnosticFix = {
|
|
55
|
+
type: "set_command_mode";
|
|
56
|
+
targetFile: string;
|
|
57
|
+
command: string;
|
|
58
|
+
property: "transaction" | "idempotency";
|
|
59
|
+
expectedExpression: string;
|
|
60
|
+
/** Must be selected by the caller; never infer weaker governance. */
|
|
61
|
+
value?: "required" | "none";
|
|
62
|
+
} | {
|
|
53
63
|
type: "add_module_import";
|
|
54
64
|
targetFile: string;
|
|
55
65
|
module: string;
|
|
@@ -136,6 +146,13 @@ export interface HandlerParamNode {
|
|
|
136
146
|
default?: unknown;
|
|
137
147
|
}
|
|
138
148
|
export interface RouteNode {
|
|
149
|
+
contract?: {
|
|
150
|
+
body?: "framework" | "domain";
|
|
151
|
+
response?: "framework" | "native-json" | "binary" | "stream";
|
|
152
|
+
evidence?: string;
|
|
153
|
+
};
|
|
154
|
+
schemaKinds?: Partial<Record<"body" | "params" | "query" | "response", "opaque" | "declared">>;
|
|
155
|
+
nativeResponse?: boolean;
|
|
139
156
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
140
157
|
path: string;
|
|
141
158
|
handler: string;
|
|
@@ -206,6 +223,7 @@ export interface ControllerNode {
|
|
|
206
223
|
schemaImports?: Record<string, string>;
|
|
207
224
|
}
|
|
208
225
|
export interface CommandNode {
|
|
226
|
+
rpc?: string;
|
|
209
227
|
className: string;
|
|
210
228
|
name: string;
|
|
211
229
|
permission?: string;
|
|
@@ -290,6 +308,8 @@ export interface ApplicationGraph {
|
|
|
290
308
|
};
|
|
291
309
|
}
|
|
292
310
|
export interface CompileOptions {
|
|
311
|
+
/** Require schemas for bound route inputs and responses. Does not prove runtime validation. */
|
|
312
|
+
requireRouteContracts?: boolean;
|
|
293
313
|
/** Project root directory (containing tsconfig). */
|
|
294
314
|
rootDir: string;
|
|
295
315
|
/** Glob patterns, defaults to ['**\/*.module.ts', '**\/*.ts']. */
|
|
@@ -310,7 +330,7 @@ export interface CompileOptions {
|
|
|
310
330
|
disallowControllerDirectDb?: boolean;
|
|
311
331
|
/** Detect modules declared in the project that are unreachable from any root module. */
|
|
312
332
|
detectOrphanModules?: boolean;
|
|
313
|
-
/**
|
|
333
|
+
/** Explicit unsafe opt-in to emit artifacts with errors (default: false). */
|
|
314
334
|
writeOnError?: boolean;
|
|
315
335
|
/** Generate typed API client in client.ts (default: false). */
|
|
316
336
|
generateClient?: boolean;
|
|
@@ -366,6 +386,12 @@ export interface ValidateOptions {
|
|
|
366
386
|
}
|
|
367
387
|
/** Runtime capabilities declared by the Command executor. */
|
|
368
388
|
export interface CommandExecutionCapabilities {
|
|
389
|
+
/** Explicit named adapters; declarations must also be tested against the database. */
|
|
390
|
+
rpc?: Record<string, {
|
|
391
|
+
audit?: boolean;
|
|
392
|
+
idempotency?: boolean;
|
|
393
|
+
transaction?: boolean;
|
|
394
|
+
}>;
|
|
369
395
|
/** Whether runtime permission checks are supported. */
|
|
370
396
|
permission?: boolean;
|
|
371
397
|
/** Whether runtime audit persistence is supported. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/compiler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Static compiler for @supacloud/app metadata: builds the application graph from AST, validates it, and generates reflection-free factory code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|