@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/README.md +63 -1
- package/dist/cli.js +606 -44
- package/dist/feature.d.ts +8 -0
- package/dist/fixes.d.ts +13 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +573 -83
- package/dist/inspect.d.ts +17 -1
- package/dist/traits.d.ts +1 -1
- package/dist/types.d.ts +68 -0
- package/package.json +2 -1
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
|
|
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)
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
4803
|
-
if (relativeChangedPath === ".." || relativeChangedPath.startsWith(`..${
|
|
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 =
|
|
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[
|
|
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 =
|
|
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(
|
|
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 ?
|
|
5255
|
-
const resolvedOut = 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 === "
|
|
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
|
-
|
|
5787
|
+
if (!json)
|
|
5788
|
+
console.error(`
|
|
5274
5789
|
Compilation failed with ${errors.length} error(s).`);
|
|
5275
5790
|
process.exit(1);
|
|
5276
5791
|
}
|
|
5277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5818
|
+
if (!json) {
|
|
5819
|
+
console.error(`
|
|
5292
5820
|
Artifact drift detected:`);
|
|
5293
|
-
|
|
5294
|
-
|
|
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
|
-
|
|
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);
|