@supacloud/compiler 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { resolve as resolve6 } from "node:path";
4
+ import { resolve as resolve7 } from "node:path";
5
+ import { readFile as readFile3 } from "node:fs/promises";
5
6
 
6
7
  // src/analyze.ts
7
8
  import { createHash as createHash3 } from "node:crypto";
@@ -106,6 +107,15 @@ function createDefaultTraitHandlers() {
106
107
  return initializer && ts.isCallExpression(initializer) && expressionName(initializer.expression) === "defineModule" ? node.name.text : undefined;
107
108
  }
108
109
  },
110
+ {
111
+ kind: "defineFeatureSlice",
112
+ detect: (node) => {
113
+ if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name))
114
+ return;
115
+ const initializer = node.initializer;
116
+ return initializer && ts.isCallExpression(initializer) && expressionName(initializer.expression) === "defineFeatureSlice" ? node.name.text : undefined;
117
+ }
118
+ },
109
119
  {
110
120
  kind: "injectionToken",
111
121
  detect: (node) => {
@@ -454,6 +464,7 @@ async function analyzeProject(rootDir, include, cache, changedPaths) {
454
464
  checker,
455
465
  tokensByName: new Map,
456
466
  classesByName: new Map,
467
+ variablesByName: new Map,
457
468
  diagnostics: []
458
469
  };
459
470
  const nativeTraitFiles = new Map;
@@ -488,9 +499,9 @@ async function analyzeProject(rootDir, include, cache, changedPaths) {
488
499
  });
489
500
  }
490
501
  }
491
- if (!cache || traits?.has("defineModule")) {
502
+ if (!cache || traits?.has("defineModule") || traits?.has("defineFeatureSlice")) {
492
503
  for (const call of descendantsOfKind(sf, ts3.isCallExpression)) {
493
- if (nodeText(call.expression) !== "defineModule")
504
+ if (!["defineModule", "defineFeatureSlice"].includes(nodeText(call.expression)))
494
505
  continue;
495
506
  const parent = call.parent;
496
507
  if (!parent || !ts3.isVariableDeclaration(parent))
@@ -796,6 +807,9 @@ function indexFile(sf, ctx) {
796
807
  }
797
808
  for (const statement of sf.statements.filter(ts3.isVariableStatement)) {
798
809
  for (const decl of statement.declarationList.declarations) {
810
+ if (ts3.isIdentifier(decl.name) && !ctx.variablesByName.has(decl.name.text)) {
811
+ ctx.variablesByName.set(decl.name.text, decl);
812
+ }
799
813
  const info = parseTokenVariable(decl, sf.fileName);
800
814
  if (info && !ctx.tokensByName.has(info.name)) {
801
815
  ctx.tokensByName.set(info.name, info);
@@ -833,6 +847,7 @@ function parseTokenVariable(decl, file) {
833
847
  function parseModule(candidate, nameByNode, ctx) {
834
848
  const { options, className, file, line } = candidate;
835
849
  const name = nameByNode.get(candidate.node) ?? className;
850
+ const featureSpec = parseFeatureSpec(getProp(options, "spec"), ctx);
836
851
  const tags = arrayProp(options, "tags").map((el) => ts3.isStringLiteral(el) ? el.text : nodeText(el).replace(/['"]/g, "")).filter(Boolean);
837
852
  const aspects = parseAspectRefs(getProp(options, "aspects"), ctx, `module ${name}`);
838
853
  const imports = arrayProp(options, "imports").map((el) => {
@@ -1007,8 +1022,71 @@ function parseModule(candidate, nameByNode, ctx) {
1007
1022
  jobs,
1008
1023
  queries,
1009
1024
  ...aspects.length > 0 ? { aspects } : {},
1010
- exports
1025
+ exports,
1026
+ ...featureSpec ? { featureSpec } : {}
1027
+ };
1028
+ }
1029
+ function parseFeatureSpec(input, ctx, seen = new Set) {
1030
+ if (!input)
1031
+ return;
1032
+ if (seen.has(input))
1033
+ return;
1034
+ seen.add(input);
1035
+ if (ts3.isIdentifier(input)) {
1036
+ const local = input.getSourceFile().statements.flatMap((statement) => ts3.isVariableStatement(statement) ? [...statement.declarationList.declarations] : []);
1037
+ const resolved = resolveDeclaration(input, ctx)[0];
1038
+ const decl = (resolved && ts3.isVariableDeclaration(resolved) ? resolved : undefined) ?? ctx.variablesByName.get(input.text) ?? local.find((candidate) => ts3.isIdentifier(candidate.name) && candidate.name.text === input.text) ?? descendantsOfKind(input.getSourceFile(), ts3.isVariableDeclaration).find((candidate) => ts3.isIdentifier(candidate.name) && candidate.name.text === input.text);
1039
+ if (decl && ts3.isVariableDeclaration(decl))
1040
+ return parseFeatureSpec(decl.initializer, ctx, seen);
1041
+ }
1042
+ if (ts3.isCallExpression(input) && nodeText(input.expression) === "defineFeatureSpec") {
1043
+ return parseFeatureSpec(input.arguments[0], ctx, seen);
1044
+ }
1045
+ if (ts3.isAsExpression(input) || ts3.isSatisfiesExpression(input) || ts3.isParenthesizedExpression(input)) {
1046
+ return parseFeatureSpec(input.expression, ctx, seen);
1047
+ }
1048
+ const invalid = () => {
1049
+ ctx.diagnostics.push({
1050
+ severity: "error",
1051
+ code: "invalid-feature-spec",
1052
+ message: "Feature spec must use static name, states and transition objects.",
1053
+ file: sourcePath(ctx.rootDir, input.getSourceFile().fileName),
1054
+ line: lineOf(input)
1055
+ });
1056
+ return;
1011
1057
  };
1058
+ if (!ts3.isObjectLiteralExpression(input))
1059
+ return invalid();
1060
+ const name = stringLiteralProp(input, "name");
1061
+ const statesExpr = getProp(input, "states");
1062
+ const transitionObject = getProp(input, "transitions");
1063
+ if (!name || !statesExpr || !ts3.isArrayLiteralExpression(statesExpr) || statesExpr.elements.some((state) => !ts3.isStringLiteral(state)) || !transitionObject || !ts3.isObjectLiteralExpression(transitionObject) || input.properties.some((property) => !ts3.isPropertyAssignment(property))) {
1064
+ return invalid();
1065
+ }
1066
+ const states = statesExpr.elements.map((state) => state.text);
1067
+ const transitions = [];
1068
+ for (const property of transitionObject.properties) {
1069
+ if (!ts3.isPropertyAssignment(property) || ts3.isComputedPropertyName(property.name) || !ts3.isObjectLiteralExpression(property.initializer))
1070
+ return invalid();
1071
+ const options = property.initializer;
1072
+ const from = stringLiteralProp(options, "from");
1073
+ const to = stringLiteralProp(options, "to");
1074
+ if (!from || !to || options.properties.some((prop) => !ts3.isPropertyAssignment(prop)) || ["permission", "command", "route", "audit"].some((key) => getProp(options, key) && !stringLiteralProp(options, key)) || ["transaction", "idempotency"].some((key) => getProp(options, key) && !commandModeProp(options, key))) {
1075
+ return invalid();
1076
+ }
1077
+ transitions.push({
1078
+ name: propertyName(property.name),
1079
+ from,
1080
+ to,
1081
+ permission: stringLiteralProp(options, "permission"),
1082
+ command: stringLiteralProp(options, "command"),
1083
+ route: stringLiteralProp(options, "route"),
1084
+ transaction: commandModeProp(options, "transaction"),
1085
+ idempotency: commandModeProp(options, "idempotency"),
1086
+ audit: stringLiteralProp(options, "audit")
1087
+ });
1088
+ }
1089
+ return { name, states, transitions, file: sourcePath(ctx.rootDir, input.getSourceFile().fileName), line: lineOf(input) };
1012
1090
  }
1013
1091
  function commandModeProp(object, name) {
1014
1092
  const value = stringLiteralProp(object, name);
@@ -2186,7 +2264,7 @@ import { access, mkdir, rename, unlink, writeFile } from "node:fs/promises";
2186
2264
  import { join as join2 } from "node:path";
2187
2265
  var HEADER = "// GENERATED BY @supacloud/compiler — do not edit";
2188
2266
  var INTERFACES = `export interface CompiledRoute {
2189
- method: string;
2267
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
2190
2268
  path: string;
2191
2269
  handler: string;
2192
2270
  body?: unknown;
@@ -2257,7 +2335,7 @@ export type CompiledAspect = (
2257
2335
  export interface CompiledController {
2258
2336
  path: string;
2259
2337
  serviceKey: string;
2260
- scope: string;
2338
+ scope: "application" | "request" | "job";
2261
2339
  routes: CompiledRoute[];
2262
2340
  }
2263
2341
 
@@ -2871,7 +2949,7 @@ ${indent(item, 2)}`).join(",")}
2871
2949
  switch (provider.kind) {
2872
2950
  case "class": {
2873
2951
  const useClass = this.imports.add(provider.useClass ?? provider.token, provider.importPath, provider.importModule);
2874
- const args = provider.deps.map((dep) => this.depExpr(dep, kind, this.depOptions(provider, dep))).join(", ");
2952
+ const args = provider.deps.map((dep, index) => this.typedDepExpr(dep, kind, this.depOptions(provider, dep), `ConstructorParameters<typeof ${useClass}>[${index}]`)).join(", ");
2875
2953
  const local = this.localVar(isMulti ? provider.useClass ?? `${provider.token}Item` : provider.token, kind);
2876
2954
  return {
2877
2955
  constLine: `const ${local} = ${this.instantiate(useClass, args, kind, provider.functionalInjects)};`,
@@ -2892,7 +2970,7 @@ ${indent(item, 2)}`).join(",")}
2892
2970
  return { constLine, key, expr: local2 };
2893
2971
  }
2894
2972
  const factory = this.imports.add(provider.useFactoryName ?? "", provider.importPath, provider.importModule);
2895
- const args = provider.deps.map((dep) => this.depExpr(dep, kind, this.depOptions(provider, dep))).join(", ");
2973
+ const args = provider.deps.map((dep, index) => this.typedDepExpr(dep, kind, this.depOptions(provider, dep), `Parameters<typeof ${factory}>[${index}]`)).join(", ");
2896
2974
  const local = this.localVar(isMulti ? provider.useFactoryName ?? `${provider.token}Item` : provider.token, kind);
2897
2975
  return { constLine: `const ${local} = ${factory}(${args});`, key, expr: local };
2898
2976
  }
@@ -2906,7 +2984,7 @@ ${indent(item, 2)}`).join(",")}
2906
2984
  }
2907
2985
  emitController(controller, kind) {
2908
2986
  const className = this.imports.add(controller.className, controller.importPath);
2909
- const args = controller.deps.map((dep) => this.depExpr(dep, kind, this.depOptions(controller, dep))).join(", ");
2987
+ const args = controller.deps.map((dep, index) => this.typedDepExpr(dep, kind, this.depOptions(controller, dep), `ConstructorParameters<typeof ${className}>[${index}]`)).join(", ");
2910
2988
  const key = camelName(controller.className);
2911
2989
  const local = this.localVar(controller.className, kind);
2912
2990
  return {
@@ -2964,6 +3042,13 @@ ${indent(item, 2)}`).join(",")}
2964
3042
  host: "hostDeps" in node ? node.hostDeps?.includes(token) ?? false : false
2965
3043
  };
2966
3044
  }
3045
+ typedDepExpr(token, kind, options, type) {
3046
+ const expression = this.depExpr(token, kind, options);
3047
+ const localProvider = this.module.providers.find((provider) => this.locals[kind].get(provider.token) === expression);
3048
+ if (expression === "undefined" || localProvider && !(localProvider.kind === "factory" && !localProvider.useFactoryName))
3049
+ return expression;
3050
+ return `${expression} as ${type}`;
3051
+ }
2967
3052
  depExpr(token, kind, options = {}) {
2968
3053
  const isOptional = options.optional ?? false;
2969
3054
  const isSelf = options.self ?? false;
@@ -3277,6 +3362,9 @@ function renderPermissions(graph) {
3277
3362
  `);
3278
3363
  }
3279
3364
 
3365
+ // src/validate.ts
3366
+ import { dirname as dirname2, relative as relative2, sep as sep2 } from "node:path";
3367
+
3280
3368
  // src/profiles.ts
3281
3369
  var MODULAR_MONOLITH_RULES = [
3282
3370
  {
@@ -3444,6 +3532,106 @@ function resolveModuleBoundaries(options) {
3444
3532
  return merged.length > 0 ? merged : undefined;
3445
3533
  }
3446
3534
 
3535
+ // src/feature.ts
3536
+ function validateFeatureSpec(spec, module) {
3537
+ const diagnostics = [];
3538
+ const error = (code, message) => {
3539
+ diagnostics.push({ severity: "error", code, message, file: spec.file, line: spec.line });
3540
+ };
3541
+ if (!spec.name.trim() || spec.states.length === 0 || spec.states.some((state) => !state.trim()) || new Set(spec.states).size !== spec.states.length) {
3542
+ error("invalid-feature-states", "Feature name and states must be non-empty; states must be unique.");
3543
+ }
3544
+ const names = new Set;
3545
+ for (const transition of spec.transitions) {
3546
+ if (!transition.name.trim() || names.has(transition.name)) {
3547
+ error("duplicate-feature-transition", `Feature ${spec.name} has duplicate/empty transition '${transition.name}'.`);
3548
+ }
3549
+ names.add(transition.name);
3550
+ if (!spec.states.includes(transition.from) || !spec.states.includes(transition.to)) {
3551
+ error("invalid-feature-transition", `Transition ${transition.name} references an undeclared state.`);
3552
+ }
3553
+ if (transition.permission !== undefined && !transition.permission.trim()) {
3554
+ error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
3555
+ }
3556
+ if (!module)
3557
+ continue;
3558
+ const commands = module.commands.filter((command2) => command2.className === transition.command || command2.name === transition.command);
3559
+ const command = commands[0];
3560
+ if (transition.command && commands.length !== 1) {
3561
+ error("feature-command-unresolved", `Transition ${transition.name} must reference exactly one command in module ${module.name}.`);
3562
+ }
3563
+ if (!transition.command && [transition.permission, transition.transaction, transition.idempotency, transition.audit].some((value) => value !== undefined)) {
3564
+ error("feature-command-unresolved", `Transition ${transition.name} declares governance without a command binding.`);
3565
+ }
3566
+ if (command) {
3567
+ for (const key of ["permission", "transaction", "idempotency", "audit"]) {
3568
+ if (transition[key] !== undefined && transition[key] !== command[key]) {
3569
+ error("feature-governance-drift", `Transition ${transition.name} ${key} differs from command ${command.className}.`);
3570
+ }
3571
+ }
3572
+ }
3573
+ if (transition.route) {
3574
+ const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
3575
+ if (routes.length !== 1) {
3576
+ error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
3577
+ } else if (command && routes[0].command !== command.className) {
3578
+ error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
3579
+ }
3580
+ }
3581
+ }
3582
+ return diagnostics;
3583
+ }
3584
+ function generateFeatureSource(spec) {
3585
+ const errors = validateFeatureSpec(spec);
3586
+ if (errors.length)
3587
+ throw new Error(errors.map((error) => error.message).join(`
3588
+ `));
3589
+ if (spec.transitions.some((transition) => !transition.permission)) {
3590
+ throw new Error("Spec-to-Code requires an explicit permission for every transition.");
3591
+ }
3592
+ const transitions = spec.transitions.map((transition, index) => ({
3593
+ ...transition,
3594
+ command: `Transition${index + 1}Command`
3595
+ }));
3596
+ if (transitions.some((transition) => transition.route)) {
3597
+ throw new Error("Generate the command slice first; existing HTTP routes require explicit schema and handler implementations.");
3598
+ }
3599
+ const sourceSpec = { name: spec.name, states: spec.states, transitions: Object.fromEntries(transitions.map((transition) => {
3600
+ const { name, ...options } = transition;
3601
+ return [name, options];
3602
+ })) };
3603
+ return [
3604
+ 'import { Command, defineFeatureSlice, defineFeatureSpec } from "@supacloud/app";',
3605
+ "",
3606
+ `export const featureSpec = defineFeatureSpec(${JSON.stringify(sourceSpec, null, 2)});`,
3607
+ `export type FeatureState = typeof featureSpec.states[number];`,
3608
+ "",
3609
+ ...transitions.flatMap((transition) => [
3610
+ `@Command(${JSON.stringify({
3611
+ name: `${spec.name}.${transition.name}`,
3612
+ permission: transition.permission,
3613
+ transaction: transition.transaction ?? "none",
3614
+ idempotency: transition.idempotency ?? "none",
3615
+ audit: transition.audit
3616
+ })})`,
3617
+ `export class ${transition.command} {`,
3618
+ ` execute(state: FeatureState): never {`,
3619
+ ` if (state !== ${JSON.stringify(transition.from)}) throw new Error("Invalid transition state");`,
3620
+ ` throw new Error(${JSON.stringify(`Implement ${spec.name}.${transition.name}: persist state ${transition.to}`)});`,
3621
+ " }",
3622
+ ""
3623
+ ]),
3624
+ "export const FeatureSlice = defineFeatureSlice({",
3625
+ ` name: ${JSON.stringify(spec.name)},`,
3626
+ ' tags: ["type:feature"],',
3627
+ " spec: featureSpec,",
3628
+ ` providers: [${transitions.map((transition) => transition.command).join(", ")}],`,
3629
+ "});",
3630
+ ""
3631
+ ].join(`
3632
+ `);
3633
+ }
3634
+
3447
3635
  // src/validate.ts
3448
3636
  var SCOPE_LIFETIME_RANK = {
3449
3637
  application: 0,
@@ -3475,6 +3663,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3475
3663
  "invalid-http-method-body": { code: "SC3004", docsUrl: "https://supacloud.dev/errors/SC3004" },
3476
3664
  "unmatched-route-parameter": { code: "SC3005", docsUrl: "https://supacloud.dev/errors/SC3005" },
3477
3665
  "missing-route-parameter-binding": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
3666
+ "missing-path-param": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
3478
3667
  "duplicate-route": { code: "SC3007", docsUrl: "https://supacloud.dev/errors/SC3007" },
3479
3668
  "missing-body-schema": { code: "SC3008", docsUrl: "https://supacloud.dev/errors/SC3008" },
3480
3669
  "unused-route-schema": { code: "SC3009", docsUrl: "https://supacloud.dev/errors/SC3009" },
@@ -3485,6 +3674,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3485
3674
  "unmatched-path-param-decorator": { code: "SC3014", docsUrl: "https://supacloud.dev/errors/SC3014" },
3486
3675
  "invalid-query-default-type": { code: "SC3015", docsUrl: "https://supacloud.dev/errors/SC3015" },
3487
3676
  "disallowed-body-on-get-delete": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
3677
+ "invalid-body-binding": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
3488
3678
  "duplicate-query-param-binding": { code: "SC3017", docsUrl: "https://supacloud.dev/errors/SC3017" },
3489
3679
  "conflicting-route-method": { code: "SC3018", docsUrl: "https://supacloud.dev/errors/SC3018" },
3490
3680
  "missing-param-colon": { code: "SC3019", docsUrl: "https://supacloud.dev/errors/SC3019" },
@@ -3500,7 +3690,15 @@ var COMPILER_DIAGNOSTIC_CODES = {
3500
3690
  "invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
3501
3691
  "dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
3502
3692
  "invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
3503
- "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
3693
+ "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" },
3694
+ "invalid-feature-states": { code: "SC6001", docsUrl: "https://supacloud.dev/errors/SC6001" },
3695
+ "duplicate-feature-transition": { code: "SC6002", docsUrl: "https://supacloud.dev/errors/SC6002" },
3696
+ "invalid-feature-transition": { code: "SC6003", docsUrl: "https://supacloud.dev/errors/SC6003" },
3697
+ "feature-command-unresolved": { code: "SC6004", docsUrl: "https://supacloud.dev/errors/SC6004" },
3698
+ "feature-governance-drift": { code: "SC6005", docsUrl: "https://supacloud.dev/errors/SC6005" },
3699
+ "feature-route-unresolved": { code: "SC6006", docsUrl: "https://supacloud.dev/errors/SC6006" },
3700
+ "feature-route-drift": { code: "SC6007", docsUrl: "https://supacloud.dev/errors/SC6007" },
3701
+ "invalid-feature-spec": { code: "SC6008", docsUrl: "https://supacloud.dev/errors/SC6008" }
3504
3702
  };
3505
3703
  function validateGraph(graph, options = false) {
3506
3704
  const strict = typeof options === "boolean" ? options : options.strict ?? false;
@@ -3556,7 +3754,7 @@ function validateGraph(graph, options = false) {
3556
3754
  }
3557
3755
  return;
3558
3756
  }
3559
- const error = (code, message, file, line, suggestion) => {
3757
+ const error = (code, message, file, line, suggestion, fix) => {
3560
3758
  const meta = COMPILER_DIAGNOSTIC_CODES[code];
3561
3759
  diagnostics.push({
3562
3760
  severity: "error",
@@ -3566,10 +3764,11 @@ function validateGraph(graph, options = false) {
3566
3764
  line,
3567
3765
  suggestion,
3568
3766
  errorCode: meta?.code,
3569
- docsUrl: meta?.docsUrl
3767
+ docsUrl: meta?.docsUrl,
3768
+ fix
3570
3769
  });
3571
3770
  };
3572
- const warn2 = (code, message, file, line, suggestion) => {
3771
+ const warn2 = (code, message, file, line, suggestion, fix) => {
3573
3772
  const meta = COMPILER_DIAGNOSTIC_CODES[code];
3574
3773
  diagnostics.push({
3575
3774
  severity: strict ? "error" : "warn",
@@ -3579,7 +3778,8 @@ function validateGraph(graph, options = false) {
3579
3778
  line,
3580
3779
  suggestion,
3581
3780
  errorCode: meta?.code,
3582
- docsUrl: meta?.docsUrl
3781
+ docsUrl: meta?.docsUrl,
3782
+ fix
3583
3783
  });
3584
3784
  };
3585
3785
  const modulesByName = new Map;
@@ -3587,6 +3787,12 @@ function validateGraph(graph, options = false) {
3587
3787
  const routesByKey = new Map;
3588
3788
  const declaredRoutes = [];
3589
3789
  for (const module of graph.modules) {
3790
+ if (module.featureSpec) {
3791
+ for (const diagnostic of validateFeatureSpec(module.featureSpec, module)) {
3792
+ const meta = COMPILER_DIAGNOSTIC_CODES[diagnostic.code];
3793
+ diagnostics.push({ ...diagnostic, errorCode: meta?.code, docsUrl: meta?.docsUrl });
3794
+ }
3795
+ }
3590
3796
  const previousModule = modulesByName.get(module.name);
3591
3797
  if (previousModule) {
3592
3798
  error("duplicate-module", `模块名 ${module.name} 重复(首次声明于 ${previousModule.file}:${previousModule.line})`, module.file, module.line);
@@ -3679,7 +3885,14 @@ function validateGraph(graph, options = false) {
3679
3885
  if (paramBindings.length > 0) {
3680
3886
  for (const param of pathParams) {
3681
3887
  if (!paramBindings.includes(param)) {
3682
- warn2("missing-path-param", `Route path '${route.path}' defines parameter ':${param}', but handler ${controller.className}.${route.handler} does not bind it with @Param('${param}').`, controller.file, undefined, `Add @Param('${param}') to ${route.handler} arguments.`);
3888
+ warn2("missing-path-param", `Route path '${route.path}' defines parameter ':${param}', but handler ${controller.className}.${route.handler} does not bind it with @Param('${param}').`, controller.file, undefined, `Add @Param('${param}') to ${route.handler} arguments.`, {
3889
+ type: "add_route_parameter_binding",
3890
+ targetFile: controller.file,
3891
+ controller: controller.className,
3892
+ route: route.handler,
3893
+ parameter: param,
3894
+ binding: "param"
3895
+ });
3683
3896
  }
3684
3897
  }
3685
3898
  }
@@ -3706,10 +3919,20 @@ function validateGraph(graph, options = false) {
3706
3919
  }
3707
3920
  }
3708
3921
  if ((route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS" || route.method === "DELETE") && (route.hasBodyBinding || route.body)) {
3709
- error("disallowed-body-on-get-delete", `Route handler ${controller.className}.${route.handler} binds @Body() or declares body schema on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`);
3922
+ error("disallowed-body-on-get-delete", `Route handler ${controller.className}.${route.handler} binds @Body() or declares body schema on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`, {
3923
+ type: "remove_route_body_binding",
3924
+ targetFile: controller.file,
3925
+ controller: controller.className,
3926
+ route: route.handler
3927
+ });
3710
3928
  }
3711
3929
  if (route.hasBodyBinding && (route.method === "GET" || route.method === "HEAD" || route.method === "OPTIONS")) {
3712
- error("invalid-body-binding", `Route handler ${controller.className}.${route.handler} binds @Body() on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`);
3930
+ error("invalid-body-binding", `Route handler ${controller.className}.${route.handler} binds @Body() on HTTP ${route.method} route '${route.path}'. Request bodies are not supported on ${route.method} requests.`, controller.file, undefined, `Use POST, PUT, or PATCH for routes accepting a request body, or bind parameters via @Query() / @Param().`, {
3931
+ type: "remove_route_body_binding",
3932
+ targetFile: controller.file,
3933
+ controller: controller.className,
3934
+ route: route.handler
3935
+ });
3713
3936
  } else if (route.hasBodyBinding && !route.body) {
3714
3937
  warn2("missing-body-schema", `Route handler ${controller.className}.${route.handler} binds @Body() on route '${route.path}', but route definition does not specify a body validation schema.`, controller.file, undefined, `Add schema to route options (e.g. body: Schema) for compile-time and runtime validation.`);
3715
3938
  } else if (route.body && !route.hasBodyBinding && !route.command) {
@@ -3850,23 +4073,51 @@ function validateGraph(graph, options = false) {
3850
4073
  const owner = globalProviders.get(dep);
3851
4074
  if (!owner)
3852
4075
  continue;
3853
- error("module-boundary", `模块 ${module.name} 的 provider ${provider.token} 依赖 ${dep},该 token 由模块 ${owner.module.name} 提供但未被 import`, provider.file, provider.line, `Import module '${owner.module.name}' in '${module.name}', add '${dep}' to '${owner.module.name}' exports, or mark @Injectable({ providedIn: 'root' }).`);
4076
+ error("module-boundary", `模块 ${module.name} 的 provider ${provider.token} 依赖 ${dep},该 token 由模块 ${owner.module.name} 提供但未被 import`, provider.file, provider.line, `Import module '${owner.module.name}' in '${module.name}', add '${dep}' to '${owner.module.name}' exports, or mark @Injectable({ providedIn: 'root' }).`, {
4077
+ type: "add_module_import",
4078
+ targetFile: module.file,
4079
+ module: owner.module.name,
4080
+ provider: dep,
4081
+ symbol: owner.module.className,
4082
+ targetModule: module.name,
4083
+ importPath: (() => {
4084
+ const value = relative2(dirname2(module.file), owner.module.file).replace(/\.(tsx?|mts|cts)$/, "").split(sep2).join("/");
4085
+ return value.startsWith(".") ? value : `./${value}`;
4086
+ })()
4087
+ });
3854
4088
  } else if (dep.includes("TOKEN") || dep.endsWith("Token") || dep.length > 2 && dep === dep.toUpperCase()) {
3855
4089
  error("missing-token-factory", `InjectionToken '${dep}' referenced by provider '${provider.token}' has no provider in module '${module.name}' and no default factory function.`, provider.file, provider.line, `Provide '${dep}' in @Module({ providers: [...] }) or declare it with new InjectionToken('${dep}', { factory: () => ... }).`);
3856
4090
  } else {
3857
- error("unresolved-token", `模块 ${module.name} 的 provider ${provider.token} 依赖的 token ${dep} 无法解析`, provider.file, provider.line, `Provide '${dep}' in a module, mark constructor parameter @Optional(), or define @Injectable({ providedIn: 'root' }).`);
4091
+ error("unresolved-token", `模块 ${module.name} 的 provider ${provider.token} 依赖的 token ${dep} 无法解析`, provider.file, provider.line, `Provide '${dep}' in a module, mark constructor parameter @Optional(), or define @Injectable({ providedIn: 'root' }).`, {
4092
+ type: "add_provider",
4093
+ targetFile: module.file,
4094
+ token: dep,
4095
+ module: module.name
4096
+ });
3858
4097
  }
3859
4098
  }
3860
4099
  continue;
3861
4100
  }
3862
4101
  if (SCOPE_LIFETIME_RANK[resolved.provider.scope] > SCOPE_LIFETIME_RANK[provider.scope]) {
3863
- error("scope-violation", `模块 ${module.name} 的 ${provider.scope} provider ${provider.token} 不能依赖 ${resolved.provider.scope} provider ${dep}`, provider.file, provider.line, `Change provider '${provider.token}' scope to '${resolved.provider.scope}', or inject a factory/context instead.`);
4102
+ error("scope-violation", `模块 ${module.name} 的 ${provider.scope} provider ${provider.token} 不能依赖 ${resolved.provider.scope} provider ${dep}`, provider.file, provider.line, `Change provider '${provider.token}' scope to '${resolved.provider.scope}', or inject a factory/context instead.`, {
4103
+ type: "change_provider_scope",
4104
+ targetFile: provider.file,
4105
+ provider: provider.token,
4106
+ from: provider.scope,
4107
+ to: resolved.provider.scope
4108
+ });
3864
4109
  }
3865
4110
  }
3866
4111
  }
3867
4112
  for (const command of module.commands) {
3868
4113
  if (!command.permission) {
3869
- error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line, "Add 'permission: string' to @Command({ ... }) or configure command execution capabilities permission=false.");
4114
+ error("command-missing-permission", `模块 ${module.name} 的 command ${command.name} (${command.className}) 未声明 permission`, module.file, module.line, "Add 'permission: string' to @Command({ ... }) or configure command execution capabilities permission=false.", {
4115
+ type: "add_command_permission",
4116
+ targetFile: module.providers.find((provider) => provider.useClass === command.className || provider.token === command.className)?.file ?? module.file,
4117
+ command: command.className,
4118
+ module: module.name,
4119
+ permission: `${module.name}.${command.name}`
4120
+ });
3870
4121
  }
3871
4122
  if (typeof options === "object" && options.commandCapabilities) {
3872
4123
  const caps = options.commandCapabilities;
@@ -4220,7 +4471,7 @@ import { join as join4 } from "node:path";
4220
4471
 
4221
4472
  // src/type-safety.ts
4222
4473
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
4223
- import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
4474
+ import { dirname as dirname3, join as join3, relative as relative3, resolve as resolve2, sep as sep3 } from "node:path";
4224
4475
  import * as ts4 from "@typescript/typescript6";
4225
4476
  var DEFAULT_EXCLUDES = [
4226
4477
  "**/*.test.ts",
@@ -4342,7 +4593,7 @@ function readProjectConfig2(configPath) {
4342
4593
  const config = ts4.readConfigFile(configPath, (file) => readFileSync2(file, "utf8"));
4343
4594
  if (config.error)
4344
4595
  return { options: {}, errors: [config.error] };
4345
- const parsed = ts4.parseJsonConfigFileContent(config.config, ts4.sys, dirname2(configPath));
4596
+ const parsed = ts4.parseJsonConfigFileContent(config.config, ts4.sys, dirname3(configPath));
4346
4597
  return { options: parsed.options, errors: parsed.errors };
4347
4598
  }
4348
4599
  function isProductionSource(rootDir, sourceFile, excludes, outDir) {
@@ -4425,7 +4676,7 @@ function makeDiagnostic(code, message, fileOrSourceFile, node, strict, rootDir)
4425
4676
  };
4426
4677
  }
4427
4678
  function normalizeRelative(rootDir, filePath) {
4428
- return relative2(rootDir, filePath).split(sep2).join("/").replace(/^\.\//, "");
4679
+ return relative3(rootDir, filePath).split(sep3).join("/").replace(/^\.\//, "");
4429
4680
  }
4430
4681
  function isAnyKeyword(node) {
4431
4682
  return node.kind === ts4.SyntaxKind.AnyKeyword;
@@ -4615,6 +4866,61 @@ function explainGraph(graph, subject) {
4615
4866
  const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
4616
4867
  throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
4617
4868
  }
4869
+ function createContextPack(graph, subject) {
4870
+ const subjectModule = graph.modules.find((module) => module.name === subject);
4871
+ if (!subjectModule) {
4872
+ throw new Error(`No module named "${subject}". Context packs require a module name.`);
4873
+ }
4874
+ const byName = new Map(graph.modules.map((module) => [module.name, module]));
4875
+ 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);
4892
+ }
4893
+ }
4894
+ }
4895
+ const modules = graph.modules.filter((module) => selected.has(module.name));
4896
+ const files = [...new Set(modules.flatMap((module) => [
4897
+ module.file,
4898
+ ...module.providers.map((provider) => provider.file),
4899
+ ...module.controllers.map((controller) => controller.file)
4900
+ ]))].sort();
4901
+ const referencedTokens = new Set;
4902
+ for (const module of modules) {
4903
+ for (const provider of module.providers) {
4904
+ for (const token of provider.deps)
4905
+ referencedTokens.add(token);
4906
+ }
4907
+ for (const controller of module.controllers) {
4908
+ for (const token of controller.deps)
4909
+ referencedTokens.add(token);
4910
+ }
4911
+ }
4912
+ return {
4913
+ version: 1,
4914
+ subject: subjectModule.name,
4915
+ modules,
4916
+ files,
4917
+ externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
4918
+ relatedModules: {
4919
+ imports: subjectModule.imports.filter((name) => selected.has(name)),
4920
+ importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
4921
+ }
4922
+ };
4923
+ }
4618
4924
  function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
4619
4925
  const checks = [
4620
4926
  {
@@ -4715,12 +5021,12 @@ function exportGraphDot(graph) {
4715
5021
 
4716
5022
  // src/watch.ts
4717
5023
  import { watch } from "node:fs";
4718
- import { relative as relative4, resolve as resolve4 } from "node:path";
5024
+ import { relative as relative5, resolve as resolve4 } from "node:path";
4719
5025
 
4720
5026
  // src/incremental.ts
4721
5027
  import { createHash as createHash5 } from "node:crypto";
4722
5028
  import { access as access2, readdir, readFile } from "node:fs/promises";
4723
- import { isAbsolute, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path";
5029
+ import { isAbsolute, relative as relative4, resolve as resolve3, sep as sep4 } from "node:path";
4724
5030
  function createDependencyGraphCache() {
4725
5031
  return {
4726
5032
  modules: new Map,
@@ -4799,12 +5105,12 @@ async function updateSnapshot(previous, options, changedPaths) {
4799
5105
  const files = { ...previous.files };
4800
5106
  for (const changedPath of changedPaths) {
4801
5107
  const absolutePath = isAbsolute(changedPath) ? resolve3(changedPath) : resolve3(rootDir, changedPath);
4802
- const relativeChangedPath = relative3(rootDir, absolutePath);
4803
- if (relativeChangedPath === ".." || relativeChangedPath.startsWith(`..${sep3}`))
5108
+ const relativeChangedPath = relative4(rootDir, absolutePath);
5109
+ if (relativeChangedPath === ".." || relativeChangedPath.startsWith(`..${sep4}`))
4804
5110
  continue;
4805
5111
  if (absolutePath === outDir || absolutePath.startsWith(`${outDir}/`))
4806
5112
  continue;
4807
- const relativePath = relative3(rootDir, absolutePath).split(sep3).join("/");
5113
+ const relativePath = relative4(rootDir, absolutePath).split(sep4).join("/");
4808
5114
  try {
4809
5115
  await access2(absolutePath);
4810
5116
  const content = await readFile(absolutePath);
@@ -4822,7 +5128,7 @@ async function createSnapshot(options) {
4822
5128
  const files = {};
4823
5129
  for (const path of paths) {
4824
5130
  const content = await readFile(path);
4825
- files[relative3(rootDir, path).split(sep3).join("/")] = createHash5("sha256").update(content).digest("hex");
5131
+ files[relative4(rootDir, path).split(sep4).join("/")] = createHash5("sha256").update(content).digest("hex");
4826
5132
  }
4827
5133
  return { files, optionsKey: optionsKeyOf(options) };
4828
5134
  }
@@ -5059,11 +5365,11 @@ function watchProject(options) {
5059
5365
  if (!filename)
5060
5366
  return schedule();
5061
5367
  const changedPath = resolve4(rootDir, filename.toString());
5062
- const relativePath = relative4(outDir, changedPath);
5368
+ const relativePath = relative5(outDir, changedPath);
5063
5369
  if (!relativePath.startsWith("..") && relativePath !== "")
5064
5370
  return;
5065
5371
  if (/\.(tsx?|mts|cts)$/.test(changedPath))
5066
- schedule(relative4(rootDir, changedPath));
5372
+ schedule(relative5(rootDir, changedPath));
5067
5373
  });
5068
5374
  if (initialEvent)
5069
5375
  resolveReady(initialEvent);
@@ -5147,6 +5453,196 @@ function compileOptionsFromConfig(config, cwd = process.cwd()) {
5147
5453
  };
5148
5454
  }
5149
5455
 
5456
+ // src/fixes.ts
5457
+ import { randomUUID } from "node:crypto";
5458
+ import { lstat, readFile as readFile2, realpath, rename as rename2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
5459
+ import { dirname as dirname4, isAbsolute as isAbsolute2, relative as relative6, resolve as resolve6, sep as sep5 } from "node:path";
5460
+ import * as ts5 from "@typescript/typescript6";
5461
+ async function applyDiagnosticFix(fix, options = {}) {
5462
+ if (!fix || typeof fix.targetFile !== "string")
5463
+ throw new Error("Invalid DiagnosticFix");
5464
+ const root = await realpath(options.rootDir ?? process.cwd());
5465
+ const file = resolve6(root, fix.targetFile);
5466
+ const stat = await lstat(file);
5467
+ const resolved = await realpath(file);
5468
+ const relativePath = relative6(root, resolved);
5469
+ if (stat.isSymbolicLink() || !stat.isFile() || isAbsolute2(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep5}`)) {
5470
+ throw new Error("Fix target must be a regular file inside rootDir");
5471
+ }
5472
+ const original = await readFile2(file, "utf8");
5473
+ let source = parse(file, original);
5474
+ let content;
5475
+ switch (fix.type) {
5476
+ case "add_module_import": {
5477
+ if (!fix.importPath || !fix.symbol)
5478
+ throw new Error("Module fix requires importPath and symbol");
5479
+ identifier(fix.symbol);
5480
+ const withImport = importSymbol(source, fix.importPath, fix.symbol);
5481
+ source = parse(file, withImport);
5482
+ const object = unique(moduleObjects(source).filter((candidate) => !fix.targetModule || stringProperty(candidate, "name") === fix.targetModule), "target module");
5483
+ const imports = property(object, "imports");
5484
+ if (imports && !ts5.isArrayLiteralExpression(imports.initializer)) {
5485
+ throw new Error("Module imports must be a static array");
5486
+ }
5487
+ const values = imports && ts5.isArrayLiteralExpression(imports.initializer) ? imports.initializer.elements : [];
5488
+ if (values.some(ts5.isSpreadElement))
5489
+ throw new Error("Module imports cannot contain spread elements");
5490
+ content = values.some((value) => ts5.isIdentifier(value) && value.text === fix.symbol) ? withImport : replaceProperty(source, object, "imports", ts5.factory.createArrayLiteralExpression([
5491
+ ...values,
5492
+ ts5.factory.createIdentifier(fix.symbol)
5493
+ ]));
5494
+ break;
5495
+ }
5496
+ case "add_command_permission": {
5497
+ const permission = options.permission ?? fix.permission;
5498
+ if (!permission?.trim()) {
5499
+ throw new Error("Permission fix requires an explicit permission; privileges are never inferred");
5500
+ }
5501
+ const command = findClass(source, fix.command);
5502
+ const object = decoratorObject(command, "Command");
5503
+ const current = property(object, "permission");
5504
+ if (current && (!ts5.isStringLiteral(current.initializer) || current.initializer.text !== permission)) {
5505
+ throw new Error("Command permission already exists with a different value");
5506
+ }
5507
+ content = current ? original : replaceProperty(source, object, "permission", ts5.factory.createStringLiteral(permission));
5508
+ break;
5509
+ }
5510
+ case "add_route_parameter_binding": {
5511
+ const controller = findClass(source, fix.controller);
5512
+ const method = unique(controller.members.filter((member) => ts5.isMethodDeclaration(member) && nameOf(member.name) === fix.route), "route handler");
5513
+ const parameter = unique(method.parameters.filter((candidate) => ts5.isIdentifier(candidate.name) && candidate.name.text === fix.parameter), "same-named route parameter");
5514
+ const binding = fix.binding === "param" ? "Param" : fix.binding === "query" ? "Query" : undefined;
5515
+ if (!binding)
5516
+ throw new Error("Invalid route binding");
5517
+ const decorators = ts5.getDecorators(parameter) ?? [];
5518
+ if (decorators.length > 0)
5519
+ throw new Error("Parameter already has a decorator");
5520
+ const framework = unique(source.statements.filter((statement) => ts5.isImportDeclaration(statement) && statement.importClause?.namedBindings && ts5.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some((element) => ["Controller", "Get", "Post", "Put", "Patch", "Delete", "Head", "Options"].includes(element.name.text))), "framework import");
5521
+ if (!ts5.isImportDeclaration(framework) || !ts5.isStringLiteral(framework.moduleSpecifier)) {
5522
+ throw new Error("Framework import must be static");
5523
+ }
5524
+ const edited = original.slice(0, parameter.getStart(source)) + `@${binding}(${JSON.stringify(fix.parameter)}) ` + original.slice(parameter.getStart(source));
5525
+ content = importSymbol(parse(file, edited), framework.moduleSpecifier.text, binding);
5526
+ break;
5527
+ }
5528
+ default:
5529
+ throw new Error(`Diagnostic fix '${fix.type}' requires a manual semantic decision; no files changed`);
5530
+ }
5531
+ parse(file, content);
5532
+ const result = { file, changed: content !== original, content };
5533
+ if (options.dryRun === false && result.changed) {
5534
+ const temporary = `${file}.supacloud-fix-${randomUUID()}`;
5535
+ try {
5536
+ await writeFile2(temporary, content, { encoding: "utf8", flag: "wx", mode: stat.mode });
5537
+ if (await readFile2(file, "utf8") !== original)
5538
+ throw new Error("Target changed while preparing the fix");
5539
+ await rename2(temporary, file);
5540
+ } finally {
5541
+ await unlink2(temporary).catch(() => {});
5542
+ }
5543
+ }
5544
+ return result;
5545
+ }
5546
+ function parse(file, text) {
5547
+ const result = ts5.transpileModule(text, {
5548
+ fileName: file,
5549
+ reportDiagnostics: true,
5550
+ compilerOptions: { target: ts5.ScriptTarget.ESNext, experimentalDecorators: true }
5551
+ });
5552
+ if (result.diagnostics?.some((item) => item.category === ts5.DiagnosticCategory.Error)) {
5553
+ throw new Error("Cannot fix syntactically invalid TypeScript");
5554
+ }
5555
+ return ts5.createSourceFile(file, text, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
5556
+ }
5557
+ function unique(items, description) {
5558
+ if (items.length !== 1)
5559
+ throw new Error(`Expected exactly one ${description}; found ${items.length}`);
5560
+ return items[0];
5561
+ }
5562
+ function identifier(value) {
5563
+ if (!/^[A-Za-z_$][\w$]*$/.test(value))
5564
+ throw new Error(`Invalid identifier '${value}'`);
5565
+ }
5566
+ function nameOf(name) {
5567
+ if (!name)
5568
+ return "";
5569
+ return ts5.isIdentifier(name) || ts5.isStringLiteral(name) || ts5.isNumericLiteral(name) ? name.text : "";
5570
+ }
5571
+ function property(object, key) {
5572
+ if (object.properties.some((item) => !ts5.isPropertyAssignment(item) || ts5.isComputedPropertyName(item.name))) {
5573
+ throw new Error("Fix requires explicit static object properties");
5574
+ }
5575
+ const values = object.properties.filter((item) => ts5.isPropertyAssignment(item) && nameOf(item.name) === key);
5576
+ if (values.length > 1)
5577
+ throw new Error(`Duplicate '${key}' property`);
5578
+ return values[0];
5579
+ }
5580
+ function stringProperty(object, key) {
5581
+ const value = property(object, key)?.initializer;
5582
+ return value && ts5.isStringLiteral(value) ? value.text : undefined;
5583
+ }
5584
+ function replaceProperty(source, object, key, value) {
5585
+ const previous = property(object, key);
5586
+ const replacement = ts5.factory.createPropertyAssignment(key, value);
5587
+ const properties = object.properties.map((item) => item === previous ? replacement : item);
5588
+ if (!previous)
5589
+ properties.push(replacement);
5590
+ const updated = ts5.factory.updateObjectLiteralExpression(object, properties);
5591
+ return source.text.slice(0, object.getStart(source)) + ts5.createPrinter().printNode(ts5.EmitHint.Expression, updated, source) + source.text.slice(object.end);
5592
+ }
5593
+ function findClass(source, name) {
5594
+ return unique(source.statements.filter((statement) => ts5.isClassDeclaration(statement) && statement.name?.text === name), `class '${name}'`);
5595
+ }
5596
+ function decoratorObject(node, name) {
5597
+ const decorator = unique((ts5.getDecorators(node) ?? []).filter((item) => ts5.isCallExpression(item.expression) && item.expression.expression.getText() === name), `@${name} decorator`);
5598
+ const argument = ts5.isCallExpression(decorator.expression) ? decorator.expression.arguments[0] : undefined;
5599
+ if (!argument || !ts5.isObjectLiteralExpression(argument))
5600
+ throw new Error(`@${name} requires a static object`);
5601
+ return argument;
5602
+ }
5603
+ function moduleObjects(source) {
5604
+ const result = [];
5605
+ for (const statement of source.statements) {
5606
+ if (ts5.isClassDeclaration(statement) && (ts5.getDecorators(statement) ?? []).some((item) => ts5.isCallExpression(item.expression) && item.expression.expression.getText() === "Module")) {
5607
+ result.push(decoratorObject(statement, "Module"));
5608
+ }
5609
+ if (ts5.isVariableStatement(statement)) {
5610
+ for (const declaration of statement.declarationList.declarations) {
5611
+ const call = declaration.initializer;
5612
+ if (call && ts5.isCallExpression(call) && ["defineModule", "defineFeatureSlice"].includes(call.expression.getText()) && call.arguments[0] && ts5.isObjectLiteralExpression(call.arguments[0])) {
5613
+ result.push(call.arguments[0]);
5614
+ }
5615
+ }
5616
+ }
5617
+ }
5618
+ return result;
5619
+ }
5620
+ function importSymbol(source, path, symbol) {
5621
+ identifier(symbol);
5622
+ const current = resolve6(source.fileName).replace(/\.(tsx?|mts|cts)$/, "");
5623
+ const target = resolve6(dirname4(source.fileName), path).replace(/\.(tsx?|mts|cts)$/, "");
5624
+ if (current === target)
5625
+ return source.text;
5626
+ const matches = source.statements.filter((item) => ts5.isImportDeclaration(item) && ts5.isStringLiteral(item.moduleSpecifier) && item.moduleSpecifier.text === path);
5627
+ if (matches.length > 1)
5628
+ throw new Error(`Ambiguous imports from '${path}'`);
5629
+ const match = matches[0];
5630
+ if (match && ts5.isImportDeclaration(match) && match.importClause?.namedBindings && ts5.isNamedImports(match.importClause.namedBindings) && !match.importClause.isTypeOnly) {
5631
+ if (match.importClause.namedBindings.elements.some((item) => item.name.text === symbol))
5632
+ return source.text;
5633
+ const bindings = match.importClause.namedBindings;
5634
+ const updated = ts5.factory.updateNamedImports(bindings, [
5635
+ ...bindings.elements,
5636
+ ts5.factory.createImportSpecifier(false, undefined, ts5.factory.createIdentifier(symbol))
5637
+ ]);
5638
+ return source.text.slice(0, bindings.getStart(source)) + ts5.createPrinter().printNode(ts5.EmitHint.Unspecified, updated, source) + source.text.slice(bindings.end);
5639
+ }
5640
+ if (match)
5641
+ throw new Error(`Import from '${path}' is not a named value import`);
5642
+ return `import { ${symbol} } from ${JSON.stringify(path)};
5643
+ ${source.text}`;
5644
+ }
5645
+
5150
5646
  // src/cli.ts
5151
5647
  function isModuleBoundaryPresetName(value) {
5152
5648
  return value === "modular-monolith" || value === "feature-slices" || value === "vertical-slices" || value === "angular-enterprise" || value === "angular" || value === "clean-architecture" || value === "domain-driven";
@@ -5161,7 +5657,9 @@ Usage:
5161
5657
  supacloud-compiler dev [rootDir] [options]
5162
5658
  supacloud-compiler graph [rootDir] [options]
5163
5659
  supacloud-compiler explain <name> [rootDir] [options]
5660
+ supacloud-compiler context <module> [rootDir] [options]
5164
5661
  supacloud-compiler doctor [rootDir] [options]
5662
+ supacloud-compiler fix <fix.json> [options]
5165
5663
 
5166
5664
  Commands:
5167
5665
  compile Compile application modules and generate artifacts
@@ -5169,6 +5667,7 @@ Commands:
5169
5667
  dev Watch source files and recompile on changes
5170
5668
  graph Print the discovered application graph
5171
5669
  explain Explain a module, provider, or external token
5670
+ context Extract an AI-sized module context pack
5172
5671
  doctor Run project and generated-artifact health checks
5173
5672
 
5174
5673
  Options:
@@ -5181,7 +5680,9 @@ Options:
5181
5680
  --permissions Generate typed permissions registry (default)
5182
5681
  --no-permissions Do not generate permissions.ts
5183
5682
  --debounce <ms> Debounce source changes in dev mode (default: 100)
5184
- --json Print machine-readable output for graph/explain/doctor
5683
+ --json Print machine-readable output for compile/check/graph/explain/context/doctor
5684
+ --dry-run Preview a fix without writing the target file
5685
+ --write Apply a fix to disk (fix is preview-only by default)
5185
5686
  --preset, -p <name> Architecture preset ('modular-monolith' | 'angular-enterprise' | 'clean-architecture')
5186
5687
  --help, -h Show this help
5187
5688
  `);
@@ -5193,7 +5694,7 @@ async function run() {
5193
5694
  process.exit(0);
5194
5695
  }
5195
5696
  const command = args[0];
5196
- if (!["compile", "check", "dev", "graph", "explain", "doctor"].includes(command)) {
5697
+ if (!["compile", "check", "dev", "graph", "explain", "context", "doctor", "fix"].includes(command)) {
5197
5698
  console.error(`Error: unknown command "${command}"`);
5198
5699
  printUsage();
5199
5700
  process.exit(1);
@@ -5207,6 +5708,7 @@ async function run() {
5207
5708
  let debounceMs = 100;
5208
5709
  let query;
5209
5710
  let json = false;
5711
+ let dryRun = true;
5210
5712
  for (let i = 1;i < args.length; i++) {
5211
5713
  const arg = args[i];
5212
5714
  if (arg === "--root" || arg === "-r") {
@@ -5233,6 +5735,10 @@ async function run() {
5233
5735
  }
5234
5736
  } else if (arg === "--json") {
5235
5737
  json = true;
5738
+ } else if (arg === "--dry-run") {
5739
+ dryRun = true;
5740
+ } else if (arg === "--write") {
5741
+ dryRun = false;
5236
5742
  } else if (arg === "--preset" || arg === "-p") {
5237
5743
  const presetArg = args[++i];
5238
5744
  if (!isModuleBoundaryPresetName(presetArg)) {
@@ -5241,18 +5747,18 @@ async function run() {
5241
5747
  }
5242
5748
  preset = presetArg;
5243
5749
  } else if (!arg.startsWith("-") && !rootDir) {
5244
- if (command === "explain" && !query)
5750
+ if ((command === "explain" || command === "context" || command === "fix") && !query)
5245
5751
  query = arg;
5246
5752
  else
5247
5753
  rootDir = arg;
5248
- } else if (!arg.startsWith("-") && command === "explain" && !query) {
5754
+ } else if (!arg.startsWith("-") && (command === "explain" || command === "context" || command === "fix") && !query) {
5249
5755
  query = arg;
5250
5756
  }
5251
5757
  }
5252
5758
  const loadedConfig = await loadSupacloudConfig(process.cwd());
5253
5759
  const defaults = resolveSupacloudConfig(loadedConfig, process.cwd());
5254
- const resolvedRoot = rootDir ? resolve6(process.cwd(), rootDir) : defaults.rootDir;
5255
- const resolvedOut = outDir ? resolve6(process.cwd(), outDir) : defaults.outDir;
5760
+ const resolvedRoot = rootDir ? resolve7(process.cwd(), rootDir) : defaults.rootDir;
5761
+ const resolvedOut = outDir ? resolve7(process.cwd(), outDir) : defaults.outDir;
5256
5762
  const configured = compileOptionsFromConfig({
5257
5763
  ...loadedConfig,
5258
5764
  root: resolvedRoot,
@@ -5265,38 +5771,69 @@ async function run() {
5265
5771
  ...configured,
5266
5772
  moduleBoundaryPreset: preset ?? configured.moduleBoundaryPreset
5267
5773
  };
5268
- if (command === "compile") {
5774
+ if (command === "fix") {
5775
+ if (!query)
5776
+ throw new Error("fix requires a JSON file containing one DiagnosticFix");
5777
+ const fix = JSON.parse(await readFile3(resolve7(process.cwd(), query), "utf8"));
5778
+ const result = await applyDiagnosticFix(fix, { rootDir: process.cwd(), dryRun });
5779
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
5780
+ } else if (command === "compile") {
5269
5781
  const result = await compileProject(compileDefaults);
5270
- printDiagnostics(result.diagnostics);
5271
5782
  const errors = result.diagnostics.filter((d) => d.severity === "error");
5783
+ if (json) {
5784
+ console.log(JSON.stringify({
5785
+ ok: errors.length === 0,
5786
+ diagnostics: result.diagnostics,
5787
+ written: result.written,
5788
+ stats: result.stats
5789
+ }, null, 2));
5790
+ } else {
5791
+ printDiagnostics(result.diagnostics);
5792
+ }
5272
5793
  if (errors.length > 0) {
5273
- console.error(`
5794
+ if (!json)
5795
+ console.error(`
5274
5796
  Compilation failed with ${errors.length} error(s).`);
5275
5797
  process.exit(1);
5276
5798
  }
5277
- console.log(`
5799
+ if (!json) {
5800
+ console.log(`
5278
5801
  Compilation succeeded. Generated artifacts:
5279
5802
  ${result.written.map((f) => ` - ${f}`).join(`
5280
5803
  `)}`);
5804
+ }
5281
5805
  } else if (command === "check") {
5282
5806
  const result = await checkProject(compileDefaults);
5283
- printDiagnostics(result.diagnostics);
5284
5807
  const errors = result.diagnostics.filter((d) => d.severity === "error");
5808
+ if (json) {
5809
+ console.log(JSON.stringify({
5810
+ ok: errors.length === 0 && result.upToDate,
5811
+ upToDate: result.upToDate,
5812
+ mismatches: result.mismatches,
5813
+ diagnostics: result.diagnostics
5814
+ }, null, 2));
5815
+ } else {
5816
+ printDiagnostics(result.diagnostics);
5817
+ }
5285
5818
  if (errors.length > 0) {
5286
- console.error(`
5819
+ if (!json)
5820
+ console.error(`
5287
5821
  Governance checks failed with ${errors.length} error(s).`);
5288
5822
  process.exit(1);
5289
5823
  }
5290
5824
  if (!result.upToDate) {
5291
- console.error(`
5825
+ if (!json) {
5826
+ console.error(`
5292
5827
  Artifact drift detected:`);
5293
- for (const mismatch of result.mismatches) {
5294
- console.error(` - ${mismatch}`);
5828
+ for (const mismatch of result.mismatches) {
5829
+ console.error(` - ${mismatch}`);
5830
+ }
5831
+ console.error("Run the compile command and commit the updated generated artifacts.");
5295
5832
  }
5296
- console.error("Run the compile command and commit the updated generated artifacts.");
5297
5833
  process.exit(1);
5298
5834
  }
5299
- console.log("Artifact check passed: disk files match compiler output with no drift.");
5835
+ if (!json)
5836
+ console.log("Artifact check passed: disk files match compiler output with no drift.");
5300
5837
  } else if (command === "dev") {
5301
5838
  const handle = watchProject({
5302
5839
  ...compileDefaults,
@@ -5350,6 +5887,38 @@ Source change detected; compiling...`);
5350
5887
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
5351
5888
  process.exit(1);
5352
5889
  }
5890
+ } else if (command === "context") {
5891
+ if (!query) {
5892
+ console.error("Error: context requires a module name");
5893
+ process.exit(1);
5894
+ }
5895
+ try {
5896
+ const graph = await analyzeProject(resolvedRoot);
5897
+ const pack = createContextPack(graph, query);
5898
+ if (json) {
5899
+ console.log(JSON.stringify(pack, null, 2));
5900
+ } else {
5901
+ console.log([
5902
+ `CONTEXT ${pack.subject}`,
5903
+ ` modules: ${pack.modules.map((module) => module.name).join(", ") || "-"}`,
5904
+ ` files: ${pack.files.join(", ") || "-"}`,
5905
+ ` external tokens: ${pack.externalTokens.join(", ") || "-"}`,
5906
+ ` imports: ${pack.relatedModules.imports.join(", ") || "-"}`,
5907
+ ` imported by: ${pack.relatedModules.importedBy.join(", ") || "-"}`
5908
+ ].join(`
5909
+ `));
5910
+ }
5911
+ } catch (error) {
5912
+ if (json) {
5913
+ console.log(JSON.stringify({
5914
+ ok: false,
5915
+ error: error instanceof Error ? error.message : String(error)
5916
+ }, null, 2));
5917
+ } else {
5918
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
5919
+ }
5920
+ process.exit(1);
5921
+ }
5353
5922
  } else {
5354
5923
  const result = await checkProject(compileDefaults);
5355
5924
  const doctor = doctorProject(resolvedRoot, resolvedOut, result.graph, result.upToDate, result.diagnostics);