@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 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
- transaction: commandModeProp(meta, "transaction") ?? "none",
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: commandModeProp(meta, "idempotency") ?? "none",
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
- transaction: commandModeProp(meta, "transaction") ?? "none",
964
+ rpc: checkedRpc(meta, ctx),
965
+ transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
964
966
  audit: stringLiteralProp(meta, "audit"),
965
- idempotency: commandModeProp(meta, "idempotency") ?? "none",
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 caps = options.commandCapabilities;
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 !== false ? await generateApplication(graph, generatedOptions) : [];
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 queue = [subjectModule.name];
4877
- while (queue.length > 0) {
4878
- const current = queue.shift();
4879
- if (!current)
4880
- continue;
4881
- const module = byName.get(current);
4882
- if (!module)
4883
- continue;
4884
- const neighbors = [
4885
- ...module.imports,
4886
- ...graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name)
4887
- ];
4888
- for (const neighbor of neighbors) {
4889
- if (!selected.has(neighbor) && byName.has(neighbor)) {
4890
- selected.add(neighbor);
4891
- queue.push(neighbor);
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: process.cwd(), dryRun });
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 graph = await analyzeProject(resolvedRoot);
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
- * Files are emitted even when error-level diagnostics exist; caller decides adoption based on diagnostics.
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
- transaction: commandModeProp(meta, "transaction") ?? "none",
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: commandModeProp(meta, "idempotency") ?? "none",
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
- transaction: commandModeProp(meta, "transaction") ?? "none",
958
+ rpc: checkedRpc(meta, ctx),
959
+ transaction: checkedCommandMode(meta, "transaction", ctx, cls.name?.text ?? "<anonymous>") ?? "none",
958
960
  audit: stringLiteralProp(meta, "audit"),
959
- idempotency: commandModeProp(meta, "idempotency") ?? "none",
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 caps = options.commandCapabilities;
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 !== false ? await generateApplication(graph, generatedOptions) : [];
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 queue = [subjectModule.name];
5427
- while (queue.length > 0) {
5428
- const current = queue.shift();
5429
- if (!current)
5430
- continue;
5431
- const module = byName.get(current);
5432
- if (!module)
5433
- continue;
5434
- const neighbors = [
5435
- ...module.imports,
5436
- ...graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name)
5437
- ];
5438
- for (const neighbor of neighbors) {
5439
- if (!selected.has(neighbor) && byName.has(neighbor)) {
5440
- selected.add(neighbor);
5441
- queue.push(neighbor);
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
- /** Write generated artifacts even when error-level diagnostics exist (default: true). */
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.10.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",