@supacloud/compiler 0.8.0 → 0.9.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,9 +1022,72 @@ function parseModule(candidate, nameByNode, ctx) {
1007
1022
  jobs,
1008
1023
  queries,
1009
1024
  ...aspects.length > 0 ? { aspects } : {},
1010
- exports
1025
+ exports,
1026
+ ...featureSpec ? { featureSpec } : {}
1011
1027
  };
1012
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;
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) };
1090
+ }
1013
1091
  function commandModeProp(object, name) {
1014
1092
  const value = stringLiteralProp(object, name);
1015
1093
  return value === "required" || value === "none" ? value : undefined;
@@ -3277,6 +3355,9 @@ function renderPermissions(graph) {
3277
3355
  `);
3278
3356
  }
3279
3357
 
3358
+ // src/validate.ts
3359
+ import { dirname as dirname2, relative as relative2, sep as sep2 } from "node:path";
3360
+
3280
3361
  // src/profiles.ts
3281
3362
  var MODULAR_MONOLITH_RULES = [
3282
3363
  {
@@ -3444,6 +3525,106 @@ function resolveModuleBoundaries(options) {
3444
3525
  return merged.length > 0 ? merged : undefined;
3445
3526
  }
3446
3527
 
3528
+ // src/feature.ts
3529
+ function validateFeatureSpec(spec, module) {
3530
+ const diagnostics = [];
3531
+ const error = (code, message) => {
3532
+ diagnostics.push({ severity: "error", code, message, file: spec.file, line: spec.line });
3533
+ };
3534
+ if (!spec.name.trim() || spec.states.length === 0 || spec.states.some((state) => !state.trim()) || new Set(spec.states).size !== spec.states.length) {
3535
+ error("invalid-feature-states", "Feature name and states must be non-empty; states must be unique.");
3536
+ }
3537
+ const names = new Set;
3538
+ for (const transition of spec.transitions) {
3539
+ if (!transition.name.trim() || names.has(transition.name)) {
3540
+ error("duplicate-feature-transition", `Feature ${spec.name} has duplicate/empty transition '${transition.name}'.`);
3541
+ }
3542
+ names.add(transition.name);
3543
+ if (!spec.states.includes(transition.from) || !spec.states.includes(transition.to)) {
3544
+ error("invalid-feature-transition", `Transition ${transition.name} references an undeclared state.`);
3545
+ }
3546
+ if (transition.permission !== undefined && !transition.permission.trim()) {
3547
+ error("feature-governance-drift", `Transition ${transition.name} declares an empty permission.`);
3548
+ }
3549
+ if (!module)
3550
+ continue;
3551
+ const commands = module.commands.filter((command2) => command2.className === transition.command || command2.name === transition.command);
3552
+ const command = commands[0];
3553
+ if (transition.command && commands.length !== 1) {
3554
+ error("feature-command-unresolved", `Transition ${transition.name} must reference exactly one command in module ${module.name}.`);
3555
+ }
3556
+ if (!transition.command && [transition.permission, transition.transaction, transition.idempotency, transition.audit].some((value) => value !== undefined)) {
3557
+ error("feature-command-unresolved", `Transition ${transition.name} declares governance without a command binding.`);
3558
+ }
3559
+ if (command) {
3560
+ for (const key of ["permission", "transaction", "idempotency", "audit"]) {
3561
+ if (transition[key] !== undefined && transition[key] !== command[key]) {
3562
+ error("feature-governance-drift", `Transition ${transition.name} ${key} differs from command ${command.className}.`);
3563
+ }
3564
+ }
3565
+ }
3566
+ if (transition.route) {
3567
+ const routes = module.controllers.flatMap((controller) => controller.routes.filter((route) => `${route.method} ${joinRoutePaths(controller.path, route.path)}` === transition.route));
3568
+ if (routes.length !== 1) {
3569
+ error("feature-route-unresolved", `Transition ${transition.name} must reference exactly one route '${transition.route}' in module ${module.name}.`);
3570
+ } else if (command && routes[0].command !== command.className) {
3571
+ error("feature-route-drift", `Route ${transition.route} is not bound to ${command.className}.`);
3572
+ }
3573
+ }
3574
+ }
3575
+ return diagnostics;
3576
+ }
3577
+ function generateFeatureSource(spec) {
3578
+ const errors = validateFeatureSpec(spec);
3579
+ if (errors.length)
3580
+ throw new Error(errors.map((error) => error.message).join(`
3581
+ `));
3582
+ if (spec.transitions.some((transition) => !transition.permission)) {
3583
+ throw new Error("Spec-to-Code requires an explicit permission for every transition.");
3584
+ }
3585
+ const transitions = spec.transitions.map((transition, index) => ({
3586
+ ...transition,
3587
+ command: `Transition${index + 1}Command`
3588
+ }));
3589
+ if (transitions.some((transition) => transition.route)) {
3590
+ throw new Error("Generate the command slice first; existing HTTP routes require explicit schema and handler implementations.");
3591
+ }
3592
+ const sourceSpec = { name: spec.name, states: spec.states, transitions: Object.fromEntries(transitions.map((transition) => {
3593
+ const { name, ...options } = transition;
3594
+ return [name, options];
3595
+ })) };
3596
+ return [
3597
+ 'import { Command, defineFeatureSlice, defineFeatureSpec } from "@supacloud/app";',
3598
+ "",
3599
+ `export const featureSpec = defineFeatureSpec(${JSON.stringify(sourceSpec, null, 2)});`,
3600
+ `export type FeatureState = typeof featureSpec.states[number];`,
3601
+ "",
3602
+ ...transitions.flatMap((transition) => [
3603
+ `@Command(${JSON.stringify({
3604
+ name: `${spec.name}.${transition.name}`,
3605
+ permission: transition.permission,
3606
+ transaction: transition.transaction ?? "none",
3607
+ idempotency: transition.idempotency ?? "none",
3608
+ audit: transition.audit
3609
+ })})`,
3610
+ `export class ${transition.command} {`,
3611
+ ` execute(state: FeatureState): never {`,
3612
+ ` if (state !== ${JSON.stringify(transition.from)}) throw new Error("Invalid transition state");`,
3613
+ ` throw new Error(${JSON.stringify(`Implement ${spec.name}.${transition.name}: persist state ${transition.to}`)});`,
3614
+ " }",
3615
+ ""
3616
+ ]),
3617
+ "export const FeatureSlice = defineFeatureSlice({",
3618
+ ` name: ${JSON.stringify(spec.name)},`,
3619
+ ' tags: ["type:feature"],',
3620
+ " spec: featureSpec,",
3621
+ ` providers: [${transitions.map((transition) => transition.command).join(", ")}],`,
3622
+ "});",
3623
+ ""
3624
+ ].join(`
3625
+ `);
3626
+ }
3627
+
3447
3628
  // src/validate.ts
3448
3629
  var SCOPE_LIFETIME_RANK = {
3449
3630
  application: 0,
@@ -3475,6 +3656,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3475
3656
  "invalid-http-method-body": { code: "SC3004", docsUrl: "https://supacloud.dev/errors/SC3004" },
3476
3657
  "unmatched-route-parameter": { code: "SC3005", docsUrl: "https://supacloud.dev/errors/SC3005" },
3477
3658
  "missing-route-parameter-binding": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
3659
+ "missing-path-param": { code: "SC3006", docsUrl: "https://supacloud.dev/errors/SC3006" },
3478
3660
  "duplicate-route": { code: "SC3007", docsUrl: "https://supacloud.dev/errors/SC3007" },
3479
3661
  "missing-body-schema": { code: "SC3008", docsUrl: "https://supacloud.dev/errors/SC3008" },
3480
3662
  "unused-route-schema": { code: "SC3009", docsUrl: "https://supacloud.dev/errors/SC3009" },
@@ -3485,6 +3667,7 @@ var COMPILER_DIAGNOSTIC_CODES = {
3485
3667
  "unmatched-path-param-decorator": { code: "SC3014", docsUrl: "https://supacloud.dev/errors/SC3014" },
3486
3668
  "invalid-query-default-type": { code: "SC3015", docsUrl: "https://supacloud.dev/errors/SC3015" },
3487
3669
  "disallowed-body-on-get-delete": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
3670
+ "invalid-body-binding": { code: "SC3016", docsUrl: "https://supacloud.dev/errors/SC3016" },
3488
3671
  "duplicate-query-param-binding": { code: "SC3017", docsUrl: "https://supacloud.dev/errors/SC3017" },
3489
3672
  "conflicting-route-method": { code: "SC3018", docsUrl: "https://supacloud.dev/errors/SC3018" },
3490
3673
  "missing-param-colon": { code: "SC3019", docsUrl: "https://supacloud.dev/errors/SC3019" },
@@ -3500,7 +3683,15 @@ var COMPILER_DIAGNOSTIC_CODES = {
3500
3683
  "invalid-job-scope": { code: "SC4007", docsUrl: "https://supacloud.dev/errors/SC4007" },
3501
3684
  "dynamic-aspect-reference": { code: "SC4010", docsUrl: "https://supacloud.dev/errors/SC4010" },
3502
3685
  "invalid-aspect-reference": { code: "SC4011", docsUrl: "https://supacloud.dev/errors/SC4011" },
3503
- "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" }
3686
+ "unused-root-provider": { code: "SC5001", docsUrl: "https://supacloud.dev/errors/SC5001" },
3687
+ "invalid-feature-states": { code: "SC6001", docsUrl: "https://supacloud.dev/errors/SC6001" },
3688
+ "duplicate-feature-transition": { code: "SC6002", docsUrl: "https://supacloud.dev/errors/SC6002" },
3689
+ "invalid-feature-transition": { code: "SC6003", docsUrl: "https://supacloud.dev/errors/SC6003" },
3690
+ "feature-command-unresolved": { code: "SC6004", docsUrl: "https://supacloud.dev/errors/SC6004" },
3691
+ "feature-governance-drift": { code: "SC6005", docsUrl: "https://supacloud.dev/errors/SC6005" },
3692
+ "feature-route-unresolved": { code: "SC6006", docsUrl: "https://supacloud.dev/errors/SC6006" },
3693
+ "feature-route-drift": { code: "SC6007", docsUrl: "https://supacloud.dev/errors/SC6007" },
3694
+ "invalid-feature-spec": { code: "SC6008", docsUrl: "https://supacloud.dev/errors/SC6008" }
3504
3695
  };
3505
3696
  function validateGraph(graph, options = false) {
3506
3697
  const strict = typeof options === "boolean" ? options : options.strict ?? false;
@@ -3556,7 +3747,7 @@ function validateGraph(graph, options = false) {
3556
3747
  }
3557
3748
  return;
3558
3749
  }
3559
- const error = (code, message, file, line, suggestion) => {
3750
+ const error = (code, message, file, line, suggestion, fix) => {
3560
3751
  const meta = COMPILER_DIAGNOSTIC_CODES[code];
3561
3752
  diagnostics.push({
3562
3753
  severity: "error",
@@ -3566,10 +3757,11 @@ function validateGraph(graph, options = false) {
3566
3757
  line,
3567
3758
  suggestion,
3568
3759
  errorCode: meta?.code,
3569
- docsUrl: meta?.docsUrl
3760
+ docsUrl: meta?.docsUrl,
3761
+ fix
3570
3762
  });
3571
3763
  };
3572
- const warn2 = (code, message, file, line, suggestion) => {
3764
+ const warn2 = (code, message, file, line, suggestion, fix) => {
3573
3765
  const meta = COMPILER_DIAGNOSTIC_CODES[code];
3574
3766
  diagnostics.push({
3575
3767
  severity: strict ? "error" : "warn",
@@ -3579,7 +3771,8 @@ function validateGraph(graph, options = false) {
3579
3771
  line,
3580
3772
  suggestion,
3581
3773
  errorCode: meta?.code,
3582
- docsUrl: meta?.docsUrl
3774
+ docsUrl: meta?.docsUrl,
3775
+ fix
3583
3776
  });
3584
3777
  };
3585
3778
  const modulesByName = new Map;
@@ -3587,6 +3780,12 @@ function validateGraph(graph, options = false) {
3587
3780
  const routesByKey = new Map;
3588
3781
  const declaredRoutes = [];
3589
3782
  for (const module of graph.modules) {
3783
+ if (module.featureSpec) {
3784
+ for (const diagnostic of validateFeatureSpec(module.featureSpec, module)) {
3785
+ const meta = COMPILER_DIAGNOSTIC_CODES[diagnostic.code];
3786
+ diagnostics.push({ ...diagnostic, errorCode: meta?.code, docsUrl: meta?.docsUrl });
3787
+ }
3788
+ }
3590
3789
  const previousModule = modulesByName.get(module.name);
3591
3790
  if (previousModule) {
3592
3791
  error("duplicate-module", `模块名 ${module.name} 重复(首次声明于 ${previousModule.file}:${previousModule.line})`, module.file, module.line);
@@ -3679,7 +3878,14 @@ function validateGraph(graph, options = false) {
3679
3878
  if (paramBindings.length > 0) {
3680
3879
  for (const param of pathParams) {
3681
3880
  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.`);
3881
+ 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.`, {
3882
+ type: "add_route_parameter_binding",
3883
+ targetFile: controller.file,
3884
+ controller: controller.className,
3885
+ route: route.handler,
3886
+ parameter: param,
3887
+ binding: "param"
3888
+ });
3683
3889
  }
3684
3890
  }
3685
3891
  }
@@ -3706,10 +3912,20 @@ function validateGraph(graph, options = false) {
3706
3912
  }
3707
3913
  }
3708
3914
  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().`);
3915
+ 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().`, {
3916
+ type: "remove_route_body_binding",
3917
+ targetFile: controller.file,
3918
+ controller: controller.className,
3919
+ route: route.handler
3920
+ });
3710
3921
  }
3711
3922
  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().`);
3923
+ 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().`, {
3924
+ type: "remove_route_body_binding",
3925
+ targetFile: controller.file,
3926
+ controller: controller.className,
3927
+ route: route.handler
3928
+ });
3713
3929
  } else if (route.hasBodyBinding && !route.body) {
3714
3930
  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
3931
  } else if (route.body && !route.hasBodyBinding && !route.command) {
@@ -3850,23 +4066,51 @@ function validateGraph(graph, options = false) {
3850
4066
  const owner = globalProviders.get(dep);
3851
4067
  if (!owner)
3852
4068
  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' }).`);
4069
+ 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' }).`, {
4070
+ type: "add_module_import",
4071
+ targetFile: module.file,
4072
+ module: owner.module.name,
4073
+ provider: dep,
4074
+ symbol: owner.module.className,
4075
+ targetModule: module.name,
4076
+ importPath: (() => {
4077
+ const value = relative2(dirname2(module.file), owner.module.file).replace(/\.(tsx?|mts|cts)$/, "").split(sep2).join("/");
4078
+ return value.startsWith(".") ? value : `./${value}`;
4079
+ })()
4080
+ });
3854
4081
  } else if (dep.includes("TOKEN") || dep.endsWith("Token") || dep.length > 2 && dep === dep.toUpperCase()) {
3855
4082
  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
4083
  } 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' }).`);
4084
+ 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' }).`, {
4085
+ type: "add_provider",
4086
+ targetFile: module.file,
4087
+ token: dep,
4088
+ module: module.name
4089
+ });
3858
4090
  }
3859
4091
  }
3860
4092
  continue;
3861
4093
  }
3862
4094
  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.`);
4095
+ 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.`, {
4096
+ type: "change_provider_scope",
4097
+ targetFile: provider.file,
4098
+ provider: provider.token,
4099
+ from: provider.scope,
4100
+ to: resolved.provider.scope
4101
+ });
3864
4102
  }
3865
4103
  }
3866
4104
  }
3867
4105
  for (const command of module.commands) {
3868
4106
  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.");
4107
+ 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.", {
4108
+ type: "add_command_permission",
4109
+ targetFile: module.providers.find((provider) => provider.useClass === command.className || provider.token === command.className)?.file ?? module.file,
4110
+ command: command.className,
4111
+ module: module.name,
4112
+ permission: `${module.name}.${command.name}`
4113
+ });
3870
4114
  }
3871
4115
  if (typeof options === "object" && options.commandCapabilities) {
3872
4116
  const caps = options.commandCapabilities;
@@ -4220,7 +4464,7 @@ import { join as join4 } from "node:path";
4220
4464
 
4221
4465
  // src/type-safety.ts
4222
4466
  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";
4467
+ import { dirname as dirname3, join as join3, relative as relative3, resolve as resolve2, sep as sep3 } from "node:path";
4224
4468
  import * as ts4 from "@typescript/typescript6";
4225
4469
  var DEFAULT_EXCLUDES = [
4226
4470
  "**/*.test.ts",
@@ -4342,7 +4586,7 @@ function readProjectConfig2(configPath) {
4342
4586
  const config = ts4.readConfigFile(configPath, (file) => readFileSync2(file, "utf8"));
4343
4587
  if (config.error)
4344
4588
  return { options: {}, errors: [config.error] };
4345
- const parsed = ts4.parseJsonConfigFileContent(config.config, ts4.sys, dirname2(configPath));
4589
+ const parsed = ts4.parseJsonConfigFileContent(config.config, ts4.sys, dirname3(configPath));
4346
4590
  return { options: parsed.options, errors: parsed.errors };
4347
4591
  }
4348
4592
  function isProductionSource(rootDir, sourceFile, excludes, outDir) {
@@ -4425,7 +4669,7 @@ function makeDiagnostic(code, message, fileOrSourceFile, node, strict, rootDir)
4425
4669
  };
4426
4670
  }
4427
4671
  function normalizeRelative(rootDir, filePath) {
4428
- return relative2(rootDir, filePath).split(sep2).join("/").replace(/^\.\//, "");
4672
+ return relative3(rootDir, filePath).split(sep3).join("/").replace(/^\.\//, "");
4429
4673
  }
4430
4674
  function isAnyKeyword(node) {
4431
4675
  return node.kind === ts4.SyntaxKind.AnyKeyword;
@@ -4615,6 +4859,61 @@ function explainGraph(graph, subject) {
4615
4859
  const known = [...graph.modules.map((item) => item.name), ...graph.externalTokens].sort();
4616
4860
  throw new Error(`No module, provider, or external token named "${subject}". Known names: ${known.join(", ") || "(none)"}`);
4617
4861
  }
4862
+ function createContextPack(graph, subject) {
4863
+ const subjectModule = graph.modules.find((module) => module.name === subject);
4864
+ if (!subjectModule) {
4865
+ throw new Error(`No module named "${subject}". Context packs require a module name.`);
4866
+ }
4867
+ const byName = new Map(graph.modules.map((module) => [module.name, module]));
4868
+ const selected = new Set([subjectModule.name]);
4869
+ const queue = [subjectModule.name];
4870
+ while (queue.length > 0) {
4871
+ const current = queue.shift();
4872
+ if (!current)
4873
+ continue;
4874
+ const module = byName.get(current);
4875
+ if (!module)
4876
+ continue;
4877
+ const neighbors = [
4878
+ ...module.imports,
4879
+ ...graph.modules.filter((candidate) => candidate.imports.includes(module.name)).map((candidate) => candidate.name)
4880
+ ];
4881
+ for (const neighbor of neighbors) {
4882
+ if (!selected.has(neighbor) && byName.has(neighbor)) {
4883
+ selected.add(neighbor);
4884
+ queue.push(neighbor);
4885
+ }
4886
+ }
4887
+ }
4888
+ const modules = graph.modules.filter((module) => selected.has(module.name));
4889
+ const files = [...new Set(modules.flatMap((module) => [
4890
+ module.file,
4891
+ ...module.providers.map((provider) => provider.file),
4892
+ ...module.controllers.map((controller) => controller.file)
4893
+ ]))].sort();
4894
+ const referencedTokens = new Set;
4895
+ for (const module of modules) {
4896
+ for (const provider of module.providers) {
4897
+ for (const token of provider.deps)
4898
+ referencedTokens.add(token);
4899
+ }
4900
+ for (const controller of module.controllers) {
4901
+ for (const token of controller.deps)
4902
+ referencedTokens.add(token);
4903
+ }
4904
+ }
4905
+ return {
4906
+ version: 1,
4907
+ subject: subjectModule.name,
4908
+ modules,
4909
+ files,
4910
+ externalTokens: graph.externalTokens.filter((token) => referencedTokens.has(token)),
4911
+ relatedModules: {
4912
+ imports: subjectModule.imports.filter((name) => selected.has(name)),
4913
+ importedBy: graph.modules.filter((module) => module.imports.includes(subjectModule.name)).map((module) => module.name).sort()
4914
+ }
4915
+ };
4916
+ }
4618
4917
  function doctorProject(rootDir, outDir, graph, upToDate, diagnostics = []) {
4619
4918
  const checks = [
4620
4919
  {
@@ -4715,12 +5014,12 @@ function exportGraphDot(graph) {
4715
5014
 
4716
5015
  // src/watch.ts
4717
5016
  import { watch } from "node:fs";
4718
- import { relative as relative4, resolve as resolve4 } from "node:path";
5017
+ import { relative as relative5, resolve as resolve4 } from "node:path";
4719
5018
 
4720
5019
  // src/incremental.ts
4721
5020
  import { createHash as createHash5 } from "node:crypto";
4722
5021
  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";
5022
+ import { isAbsolute, relative as relative4, resolve as resolve3, sep as sep4 } from "node:path";
4724
5023
  function createDependencyGraphCache() {
4725
5024
  return {
4726
5025
  modules: new Map,
@@ -4799,12 +5098,12 @@ async function updateSnapshot(previous, options, changedPaths) {
4799
5098
  const files = { ...previous.files };
4800
5099
  for (const changedPath of changedPaths) {
4801
5100
  const absolutePath = isAbsolute(changedPath) ? resolve3(changedPath) : resolve3(rootDir, changedPath);
4802
- const relativeChangedPath = relative3(rootDir, absolutePath);
4803
- if (relativeChangedPath === ".." || relativeChangedPath.startsWith(`..${sep3}`))
5101
+ const relativeChangedPath = relative4(rootDir, absolutePath);
5102
+ if (relativeChangedPath === ".." || relativeChangedPath.startsWith(`..${sep4}`))
4804
5103
  continue;
4805
5104
  if (absolutePath === outDir || absolutePath.startsWith(`${outDir}/`))
4806
5105
  continue;
4807
- const relativePath = relative3(rootDir, absolutePath).split(sep3).join("/");
5106
+ const relativePath = relative4(rootDir, absolutePath).split(sep4).join("/");
4808
5107
  try {
4809
5108
  await access2(absolutePath);
4810
5109
  const content = await readFile(absolutePath);
@@ -4822,7 +5121,7 @@ async function createSnapshot(options) {
4822
5121
  const files = {};
4823
5122
  for (const path of paths) {
4824
5123
  const content = await readFile(path);
4825
- files[relative3(rootDir, path).split(sep3).join("/")] = createHash5("sha256").update(content).digest("hex");
5124
+ files[relative4(rootDir, path).split(sep4).join("/")] = createHash5("sha256").update(content).digest("hex");
4826
5125
  }
4827
5126
  return { files, optionsKey: optionsKeyOf(options) };
4828
5127
  }
@@ -5059,11 +5358,11 @@ function watchProject(options) {
5059
5358
  if (!filename)
5060
5359
  return schedule();
5061
5360
  const changedPath = resolve4(rootDir, filename.toString());
5062
- const relativePath = relative4(outDir, changedPath);
5361
+ const relativePath = relative5(outDir, changedPath);
5063
5362
  if (!relativePath.startsWith("..") && relativePath !== "")
5064
5363
  return;
5065
5364
  if (/\.(tsx?|mts|cts)$/.test(changedPath))
5066
- schedule(relative4(rootDir, changedPath));
5365
+ schedule(relative5(rootDir, changedPath));
5067
5366
  });
5068
5367
  if (initialEvent)
5069
5368
  resolveReady(initialEvent);
@@ -5147,6 +5446,196 @@ function compileOptionsFromConfig(config, cwd = process.cwd()) {
5147
5446
  };
5148
5447
  }
5149
5448
 
5449
+ // src/fixes.ts
5450
+ import { randomUUID } from "node:crypto";
5451
+ import { lstat, readFile as readFile2, realpath, rename as rename2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
5452
+ import { dirname as dirname4, isAbsolute as isAbsolute2, relative as relative6, resolve as resolve6, sep as sep5 } from "node:path";
5453
+ import * as ts5 from "@typescript/typescript6";
5454
+ async function applyDiagnosticFix(fix, options = {}) {
5455
+ if (!fix || typeof fix.targetFile !== "string")
5456
+ throw new Error("Invalid DiagnosticFix");
5457
+ const root = await realpath(options.rootDir ?? process.cwd());
5458
+ const file = resolve6(root, fix.targetFile);
5459
+ const stat = await lstat(file);
5460
+ const resolved = await realpath(file);
5461
+ const relativePath = relative6(root, resolved);
5462
+ if (stat.isSymbolicLink() || !stat.isFile() || isAbsolute2(relativePath) || relativePath === ".." || relativePath.startsWith(`..${sep5}`)) {
5463
+ throw new Error("Fix target must be a regular file inside rootDir");
5464
+ }
5465
+ const original = await readFile2(file, "utf8");
5466
+ let source = parse(file, original);
5467
+ let content;
5468
+ switch (fix.type) {
5469
+ case "add_module_import": {
5470
+ if (!fix.importPath || !fix.symbol)
5471
+ throw new Error("Module fix requires importPath and symbol");
5472
+ identifier(fix.symbol);
5473
+ const withImport = importSymbol(source, fix.importPath, fix.symbol);
5474
+ source = parse(file, withImport);
5475
+ const object = unique(moduleObjects(source).filter((candidate) => !fix.targetModule || stringProperty(candidate, "name") === fix.targetModule), "target module");
5476
+ const imports = property(object, "imports");
5477
+ if (imports && !ts5.isArrayLiteralExpression(imports.initializer)) {
5478
+ throw new Error("Module imports must be a static array");
5479
+ }
5480
+ const values = imports && ts5.isArrayLiteralExpression(imports.initializer) ? imports.initializer.elements : [];
5481
+ if (values.some(ts5.isSpreadElement))
5482
+ throw new Error("Module imports cannot contain spread elements");
5483
+ content = values.some((value) => ts5.isIdentifier(value) && value.text === fix.symbol) ? withImport : replaceProperty(source, object, "imports", ts5.factory.createArrayLiteralExpression([
5484
+ ...values,
5485
+ ts5.factory.createIdentifier(fix.symbol)
5486
+ ]));
5487
+ break;
5488
+ }
5489
+ case "add_command_permission": {
5490
+ const permission = options.permission ?? fix.permission;
5491
+ if (!permission?.trim()) {
5492
+ throw new Error("Permission fix requires an explicit permission; privileges are never inferred");
5493
+ }
5494
+ const command = findClass(source, fix.command);
5495
+ const object = decoratorObject(command, "Command");
5496
+ const current = property(object, "permission");
5497
+ if (current && (!ts5.isStringLiteral(current.initializer) || current.initializer.text !== permission)) {
5498
+ throw new Error("Command permission already exists with a different value");
5499
+ }
5500
+ content = current ? original : replaceProperty(source, object, "permission", ts5.factory.createStringLiteral(permission));
5501
+ break;
5502
+ }
5503
+ case "add_route_parameter_binding": {
5504
+ const controller = findClass(source, fix.controller);
5505
+ const method = unique(controller.members.filter((member) => ts5.isMethodDeclaration(member) && nameOf(member.name) === fix.route), "route handler");
5506
+ const parameter = unique(method.parameters.filter((candidate) => ts5.isIdentifier(candidate.name) && candidate.name.text === fix.parameter), "same-named route parameter");
5507
+ const binding = fix.binding === "param" ? "Param" : fix.binding === "query" ? "Query" : undefined;
5508
+ if (!binding)
5509
+ throw new Error("Invalid route binding");
5510
+ const decorators = ts5.getDecorators(parameter) ?? [];
5511
+ if (decorators.length > 0)
5512
+ throw new Error("Parameter already has a decorator");
5513
+ 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");
5514
+ if (!ts5.isImportDeclaration(framework) || !ts5.isStringLiteral(framework.moduleSpecifier)) {
5515
+ throw new Error("Framework import must be static");
5516
+ }
5517
+ const edited = original.slice(0, parameter.getStart(source)) + `@${binding}(${JSON.stringify(fix.parameter)}) ` + original.slice(parameter.getStart(source));
5518
+ content = importSymbol(parse(file, edited), framework.moduleSpecifier.text, binding);
5519
+ break;
5520
+ }
5521
+ default:
5522
+ throw new Error(`Diagnostic fix '${fix.type}' requires a manual semantic decision; no files changed`);
5523
+ }
5524
+ parse(file, content);
5525
+ const result = { file, changed: content !== original, content };
5526
+ if (options.dryRun === false && result.changed) {
5527
+ const temporary = `${file}.supacloud-fix-${randomUUID()}`;
5528
+ try {
5529
+ await writeFile2(temporary, content, { encoding: "utf8", flag: "wx", mode: stat.mode });
5530
+ if (await readFile2(file, "utf8") !== original)
5531
+ throw new Error("Target changed while preparing the fix");
5532
+ await rename2(temporary, file);
5533
+ } finally {
5534
+ await unlink2(temporary).catch(() => {});
5535
+ }
5536
+ }
5537
+ return result;
5538
+ }
5539
+ function parse(file, text) {
5540
+ const result = ts5.transpileModule(text, {
5541
+ fileName: file,
5542
+ reportDiagnostics: true,
5543
+ compilerOptions: { target: ts5.ScriptTarget.ESNext, experimentalDecorators: true }
5544
+ });
5545
+ if (result.diagnostics?.some((item) => item.category === ts5.DiagnosticCategory.Error)) {
5546
+ throw new Error("Cannot fix syntactically invalid TypeScript");
5547
+ }
5548
+ return ts5.createSourceFile(file, text, ts5.ScriptTarget.Latest, true, ts5.ScriptKind.TS);
5549
+ }
5550
+ function unique(items, description) {
5551
+ if (items.length !== 1)
5552
+ throw new Error(`Expected exactly one ${description}; found ${items.length}`);
5553
+ return items[0];
5554
+ }
5555
+ function identifier(value) {
5556
+ if (!/^[A-Za-z_$][\w$]*$/.test(value))
5557
+ throw new Error(`Invalid identifier '${value}'`);
5558
+ }
5559
+ function nameOf(name) {
5560
+ if (!name)
5561
+ return "";
5562
+ return ts5.isIdentifier(name) || ts5.isStringLiteral(name) || ts5.isNumericLiteral(name) ? name.text : "";
5563
+ }
5564
+ function property(object, key) {
5565
+ if (object.properties.some((item) => !ts5.isPropertyAssignment(item) || ts5.isComputedPropertyName(item.name))) {
5566
+ throw new Error("Fix requires explicit static object properties");
5567
+ }
5568
+ const values = object.properties.filter((item) => ts5.isPropertyAssignment(item) && nameOf(item.name) === key);
5569
+ if (values.length > 1)
5570
+ throw new Error(`Duplicate '${key}' property`);
5571
+ return values[0];
5572
+ }
5573
+ function stringProperty(object, key) {
5574
+ const value = property(object, key)?.initializer;
5575
+ return value && ts5.isStringLiteral(value) ? value.text : undefined;
5576
+ }
5577
+ function replaceProperty(source, object, key, value) {
5578
+ const previous = property(object, key);
5579
+ const replacement = ts5.factory.createPropertyAssignment(key, value);
5580
+ const properties = object.properties.map((item) => item === previous ? replacement : item);
5581
+ if (!previous)
5582
+ properties.push(replacement);
5583
+ const updated = ts5.factory.updateObjectLiteralExpression(object, properties);
5584
+ return source.text.slice(0, object.getStart(source)) + ts5.createPrinter().printNode(ts5.EmitHint.Expression, updated, source) + source.text.slice(object.end);
5585
+ }
5586
+ function findClass(source, name) {
5587
+ return unique(source.statements.filter((statement) => ts5.isClassDeclaration(statement) && statement.name?.text === name), `class '${name}'`);
5588
+ }
5589
+ function decoratorObject(node, name) {
5590
+ const decorator = unique((ts5.getDecorators(node) ?? []).filter((item) => ts5.isCallExpression(item.expression) && item.expression.expression.getText() === name), `@${name} decorator`);
5591
+ const argument = ts5.isCallExpression(decorator.expression) ? decorator.expression.arguments[0] : undefined;
5592
+ if (!argument || !ts5.isObjectLiteralExpression(argument))
5593
+ throw new Error(`@${name} requires a static object`);
5594
+ return argument;
5595
+ }
5596
+ function moduleObjects(source) {
5597
+ const result = [];
5598
+ for (const statement of source.statements) {
5599
+ if (ts5.isClassDeclaration(statement) && (ts5.getDecorators(statement) ?? []).some((item) => ts5.isCallExpression(item.expression) && item.expression.expression.getText() === "Module")) {
5600
+ result.push(decoratorObject(statement, "Module"));
5601
+ }
5602
+ if (ts5.isVariableStatement(statement)) {
5603
+ for (const declaration of statement.declarationList.declarations) {
5604
+ const call = declaration.initializer;
5605
+ if (call && ts5.isCallExpression(call) && ["defineModule", "defineFeatureSlice"].includes(call.expression.getText()) && call.arguments[0] && ts5.isObjectLiteralExpression(call.arguments[0])) {
5606
+ result.push(call.arguments[0]);
5607
+ }
5608
+ }
5609
+ }
5610
+ }
5611
+ return result;
5612
+ }
5613
+ function importSymbol(source, path, symbol) {
5614
+ identifier(symbol);
5615
+ const current = resolve6(source.fileName).replace(/\.(tsx?|mts|cts)$/, "");
5616
+ const target = resolve6(dirname4(source.fileName), path).replace(/\.(tsx?|mts|cts)$/, "");
5617
+ if (current === target)
5618
+ return source.text;
5619
+ const matches = source.statements.filter((item) => ts5.isImportDeclaration(item) && ts5.isStringLiteral(item.moduleSpecifier) && item.moduleSpecifier.text === path);
5620
+ if (matches.length > 1)
5621
+ throw new Error(`Ambiguous imports from '${path}'`);
5622
+ const match = matches[0];
5623
+ if (match && ts5.isImportDeclaration(match) && match.importClause?.namedBindings && ts5.isNamedImports(match.importClause.namedBindings) && !match.importClause.isTypeOnly) {
5624
+ if (match.importClause.namedBindings.elements.some((item) => item.name.text === symbol))
5625
+ return source.text;
5626
+ const bindings = match.importClause.namedBindings;
5627
+ const updated = ts5.factory.updateNamedImports(bindings, [
5628
+ ...bindings.elements,
5629
+ ts5.factory.createImportSpecifier(false, undefined, ts5.factory.createIdentifier(symbol))
5630
+ ]);
5631
+ return source.text.slice(0, bindings.getStart(source)) + ts5.createPrinter().printNode(ts5.EmitHint.Unspecified, updated, source) + source.text.slice(bindings.end);
5632
+ }
5633
+ if (match)
5634
+ throw new Error(`Import from '${path}' is not a named value import`);
5635
+ return `import { ${symbol} } from ${JSON.stringify(path)};
5636
+ ${source.text}`;
5637
+ }
5638
+
5150
5639
  // src/cli.ts
5151
5640
  function isModuleBoundaryPresetName(value) {
5152
5641
  return value === "modular-monolith" || value === "feature-slices" || value === "vertical-slices" || value === "angular-enterprise" || value === "angular" || value === "clean-architecture" || value === "domain-driven";
@@ -5161,7 +5650,9 @@ Usage:
5161
5650
  supacloud-compiler dev [rootDir] [options]
5162
5651
  supacloud-compiler graph [rootDir] [options]
5163
5652
  supacloud-compiler explain <name> [rootDir] [options]
5653
+ supacloud-compiler context <module> [rootDir] [options]
5164
5654
  supacloud-compiler doctor [rootDir] [options]
5655
+ supacloud-compiler fix <fix.json> [options]
5165
5656
 
5166
5657
  Commands:
5167
5658
  compile Compile application modules and generate artifacts
@@ -5169,6 +5660,7 @@ Commands:
5169
5660
  dev Watch source files and recompile on changes
5170
5661
  graph Print the discovered application graph
5171
5662
  explain Explain a module, provider, or external token
5663
+ context Extract an AI-sized module context pack
5172
5664
  doctor Run project and generated-artifact health checks
5173
5665
 
5174
5666
  Options:
@@ -5181,7 +5673,9 @@ Options:
5181
5673
  --permissions Generate typed permissions registry (default)
5182
5674
  --no-permissions Do not generate permissions.ts
5183
5675
  --debounce <ms> Debounce source changes in dev mode (default: 100)
5184
- --json Print machine-readable output for graph/explain/doctor
5676
+ --json Print machine-readable output for compile/check/graph/explain/context/doctor
5677
+ --dry-run Preview a fix without writing the target file
5678
+ --write Apply a fix to disk (fix is preview-only by default)
5185
5679
  --preset, -p <name> Architecture preset ('modular-monolith' | 'angular-enterprise' | 'clean-architecture')
5186
5680
  --help, -h Show this help
5187
5681
  `);
@@ -5193,7 +5687,7 @@ async function run() {
5193
5687
  process.exit(0);
5194
5688
  }
5195
5689
  const command = args[0];
5196
- if (!["compile", "check", "dev", "graph", "explain", "doctor"].includes(command)) {
5690
+ if (!["compile", "check", "dev", "graph", "explain", "context", "doctor", "fix"].includes(command)) {
5197
5691
  console.error(`Error: unknown command "${command}"`);
5198
5692
  printUsage();
5199
5693
  process.exit(1);
@@ -5207,6 +5701,7 @@ async function run() {
5207
5701
  let debounceMs = 100;
5208
5702
  let query;
5209
5703
  let json = false;
5704
+ let dryRun = true;
5210
5705
  for (let i = 1;i < args.length; i++) {
5211
5706
  const arg = args[i];
5212
5707
  if (arg === "--root" || arg === "-r") {
@@ -5233,6 +5728,10 @@ async function run() {
5233
5728
  }
5234
5729
  } else if (arg === "--json") {
5235
5730
  json = true;
5731
+ } else if (arg === "--dry-run") {
5732
+ dryRun = true;
5733
+ } else if (arg === "--write") {
5734
+ dryRun = false;
5236
5735
  } else if (arg === "--preset" || arg === "-p") {
5237
5736
  const presetArg = args[++i];
5238
5737
  if (!isModuleBoundaryPresetName(presetArg)) {
@@ -5241,18 +5740,18 @@ async function run() {
5241
5740
  }
5242
5741
  preset = presetArg;
5243
5742
  } else if (!arg.startsWith("-") && !rootDir) {
5244
- if (command === "explain" && !query)
5743
+ if ((command === "explain" || command === "context" || command === "fix") && !query)
5245
5744
  query = arg;
5246
5745
  else
5247
5746
  rootDir = arg;
5248
- } else if (!arg.startsWith("-") && command === "explain" && !query) {
5747
+ } else if (!arg.startsWith("-") && (command === "explain" || command === "context" || command === "fix") && !query) {
5249
5748
  query = arg;
5250
5749
  }
5251
5750
  }
5252
5751
  const loadedConfig = await loadSupacloudConfig(process.cwd());
5253
5752
  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;
5753
+ const resolvedRoot = rootDir ? resolve7(process.cwd(), rootDir) : defaults.rootDir;
5754
+ const resolvedOut = outDir ? resolve7(process.cwd(), outDir) : defaults.outDir;
5256
5755
  const configured = compileOptionsFromConfig({
5257
5756
  ...loadedConfig,
5258
5757
  root: resolvedRoot,
@@ -5265,38 +5764,69 @@ async function run() {
5265
5764
  ...configured,
5266
5765
  moduleBoundaryPreset: preset ?? configured.moduleBoundaryPreset
5267
5766
  };
5268
- if (command === "compile") {
5767
+ if (command === "fix") {
5768
+ if (!query)
5769
+ throw new Error("fix requires a JSON file containing one DiagnosticFix");
5770
+ const fix = JSON.parse(await readFile3(resolve7(process.cwd(), query), "utf8"));
5771
+ const result = await applyDiagnosticFix(fix, { rootDir: process.cwd(), dryRun });
5772
+ console.log(JSON.stringify({ ok: true, ...result }, null, 2));
5773
+ } else if (command === "compile") {
5269
5774
  const result = await compileProject(compileDefaults);
5270
- printDiagnostics(result.diagnostics);
5271
5775
  const errors = result.diagnostics.filter((d) => d.severity === "error");
5776
+ if (json) {
5777
+ console.log(JSON.stringify({
5778
+ ok: errors.length === 0,
5779
+ diagnostics: result.diagnostics,
5780
+ written: result.written,
5781
+ stats: result.stats
5782
+ }, null, 2));
5783
+ } else {
5784
+ printDiagnostics(result.diagnostics);
5785
+ }
5272
5786
  if (errors.length > 0) {
5273
- console.error(`
5787
+ if (!json)
5788
+ console.error(`
5274
5789
  Compilation failed with ${errors.length} error(s).`);
5275
5790
  process.exit(1);
5276
5791
  }
5277
- console.log(`
5792
+ if (!json) {
5793
+ console.log(`
5278
5794
  Compilation succeeded. Generated artifacts:
5279
5795
  ${result.written.map((f) => ` - ${f}`).join(`
5280
5796
  `)}`);
5797
+ }
5281
5798
  } else if (command === "check") {
5282
5799
  const result = await checkProject(compileDefaults);
5283
- printDiagnostics(result.diagnostics);
5284
5800
  const errors = result.diagnostics.filter((d) => d.severity === "error");
5801
+ if (json) {
5802
+ console.log(JSON.stringify({
5803
+ ok: errors.length === 0 && result.upToDate,
5804
+ upToDate: result.upToDate,
5805
+ mismatches: result.mismatches,
5806
+ diagnostics: result.diagnostics
5807
+ }, null, 2));
5808
+ } else {
5809
+ printDiagnostics(result.diagnostics);
5810
+ }
5285
5811
  if (errors.length > 0) {
5286
- console.error(`
5812
+ if (!json)
5813
+ console.error(`
5287
5814
  Governance checks failed with ${errors.length} error(s).`);
5288
5815
  process.exit(1);
5289
5816
  }
5290
5817
  if (!result.upToDate) {
5291
- console.error(`
5818
+ if (!json) {
5819
+ console.error(`
5292
5820
  Artifact drift detected:`);
5293
- for (const mismatch of result.mismatches) {
5294
- console.error(` - ${mismatch}`);
5821
+ for (const mismatch of result.mismatches) {
5822
+ console.error(` - ${mismatch}`);
5823
+ }
5824
+ console.error("Run the compile command and commit the updated generated artifacts.");
5295
5825
  }
5296
- console.error("Run the compile command and commit the updated generated artifacts.");
5297
5826
  process.exit(1);
5298
5827
  }
5299
- console.log("Artifact check passed: disk files match compiler output with no drift.");
5828
+ if (!json)
5829
+ console.log("Artifact check passed: disk files match compiler output with no drift.");
5300
5830
  } else if (command === "dev") {
5301
5831
  const handle = watchProject({
5302
5832
  ...compileDefaults,
@@ -5350,6 +5880,38 @@ Source change detected; compiling...`);
5350
5880
  console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
5351
5881
  process.exit(1);
5352
5882
  }
5883
+ } else if (command === "context") {
5884
+ if (!query) {
5885
+ console.error("Error: context requires a module name");
5886
+ process.exit(1);
5887
+ }
5888
+ try {
5889
+ const graph = await analyzeProject(resolvedRoot);
5890
+ const pack = createContextPack(graph, query);
5891
+ if (json) {
5892
+ console.log(JSON.stringify(pack, null, 2));
5893
+ } else {
5894
+ console.log([
5895
+ `CONTEXT ${pack.subject}`,
5896
+ ` modules: ${pack.modules.map((module) => module.name).join(", ") || "-"}`,
5897
+ ` files: ${pack.files.join(", ") || "-"}`,
5898
+ ` external tokens: ${pack.externalTokens.join(", ") || "-"}`,
5899
+ ` imports: ${pack.relatedModules.imports.join(", ") || "-"}`,
5900
+ ` imported by: ${pack.relatedModules.importedBy.join(", ") || "-"}`
5901
+ ].join(`
5902
+ `));
5903
+ }
5904
+ } catch (error) {
5905
+ if (json) {
5906
+ console.log(JSON.stringify({
5907
+ ok: false,
5908
+ error: error instanceof Error ? error.message : String(error)
5909
+ }, null, 2));
5910
+ } else {
5911
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
5912
+ }
5913
+ process.exit(1);
5914
+ }
5353
5915
  } else {
5354
5916
  const result = await checkProject(compileDefaults);
5355
5917
  const doctor = doctorProject(resolvedRoot, resolvedOut, result.graph, result.upToDate, result.diagnostics);