Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions eng/tools/typespec-validation/src/rule-result.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export enum RuleFailureType {
NotFind = "not_find",
Mismatch = "mismatch",
ParseError = "parse_error",
TypeError = "type_error",
}

export interface RuleResult {
readonly success: boolean;
readonly type?: RuleFailureType;
readonly stdOutput?: string;
readonly errorOutput?: string;
}
197 changes: 180 additions & 17 deletions eng/tools/typespec-validation/src/rules/sdk-tspconfig-validation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable */
// TODO: Enable eslint, fix errors

import { join } from "path";
import { Suppression } from "suppressions";
import { parse as yamlParse } from "yaml";
import { RuleResult } from "../rule-result.js";
import { RuleFailureType, RuleResult } from "../rule-result.js";
import { Rule } from "../rule.js";
import { fileExists, getSuppressions, readTspConfig } from "../utils.js";

Expand All @@ -26,6 +26,7 @@
return this.createFailedResult(
`Failed to find ${join(folder, "tspconfig.yaml")}`,
"Please add tspconfig.yaml",
RuleFailureType.ParseError,
);

let config = undefined;
Expand All @@ -36,6 +37,7 @@
return this.createFailedResult(
`Failed to parse ${join(folder, "tspconfig.yaml")}`,
"Please add tspconfig.yaml.",
RuleFailureType.ParseError,
);
}

Expand Down Expand Up @@ -68,9 +70,10 @@
}
}

protected createFailedResult(error: string, action: string): RuleResult {
protected createFailedResult(error: string, action: string, type: RuleFailureType): RuleResult {
return {
success: false,
type: type,
errorOutput: `- ${error}. ${action}.`,
};
}
Expand All @@ -90,12 +93,14 @@
return this.createFailedResult(
`Failed to find "parameters.${this.keyToValidate}.default" with expected value "${this.expectedValue}"`,
`Please add "parameters.${this.keyToValidate}.default" with expected value "${this.expectedValue}".`,
RuleFailureType.NotFind,
);

if (!this.validateValue(parameter, this.expectedValue))
return this.createFailedResult(
`The value of parameters.${this.keyToValidate}.default "${parameter}" does not match "${this.expectedValue}"`,
`Please update the value of "parameters.${this.keyToValidate}.default" to match "${this.expectedValue}".`,
RuleFailureType.Mismatch,
);

return { success: true };
Expand Down Expand Up @@ -128,19 +133,106 @@
return option;
}

protected resolveVariables(value: string, config: any): { resolved: string; error?: string } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is to resolve the issue #38964.

let resolvedValue = value;
const variablePattern = /\{([^}]+)\}/g;
const maxIterations = 10; // Prevent infinite loops
let iterations = 0;

// Keep resolving until no more variables are found or max iterations reached
while (resolvedValue.includes("{") && iterations < maxIterations) {
iterations++;
let hasUnresolvedVariables = false;
const currentValue = resolvedValue;

// Reset regex lastIndex for each iteration
variablePattern.lastIndex = 0;
let match;

while ((match = variablePattern.exec(currentValue)) !== null) {
const variableName = match[1];

// Try to resolve variable from multiple sources:
// 1. From the emitter's options (e.g., namespace, package-name)
// 2. From parameters (e.g., service-dir, output-dir)
// 3. From global options
let variableValue: string | undefined;

// Check emitter options first
variableValue = config?.options?.[this.emitterName]?.[variableName];

// If not found, check parameters
if (!variableValue) {
variableValue = config?.parameters?.[variableName]?.default;
}

// If not found, check global options (for variables like output-dir)
if (!variableValue) {
variableValue = config?.[variableName];
}

if (variableValue && typeof variableValue === "string") {
resolvedValue = resolvedValue.replace(`{${variableName}}`, variableValue);
} else {
hasUnresolvedVariables = true;
}
}

// If no progress was made in this iteration and there are still unresolved variables, return error
if (hasUnresolvedVariables && resolvedValue === currentValue) {
const unresolvedMatch = resolvedValue.match(/\{([^}]+)\}/);
const unresolvedVar = unresolvedMatch ? unresolvedMatch[1] : "unknown";
return {
resolved: resolvedValue,
error: `Could not resolve variable {${unresolvedVar}}. The variable is not defined in options.${this.emitterName}, parameters, or config`,
};
}

// If no more variables to resolve, break
if (!resolvedValue.includes("{")) {
break;
}
}

if (iterations >= maxIterations && resolvedValue.includes("{")) {
return {
resolved: resolvedValue,
error: `Maximum resolution depth reached. Possible circular reference in variable resolution.`,
};
}

return { resolved: resolvedValue };
}

protected validate(config: any): RuleResult {
const option = this.tryFindOption(config);
if (option === undefined)
return this.createFailedResult(
`Failed to find "options.${this.emitterName}.${this.keyToValidate}" with expected value "${this.expectedValue}"`,
`Please add "options.${this.emitterName}.${this.keyToValidate}" with expected value "${this.expectedValue}"`,
RuleFailureType.NotFind,
);

const actualValue = option as unknown as undefined | string | boolean;
let actualValue = option as unknown as undefined | string | boolean;

// Resolve variables if the value is a string
if (typeof actualValue === "string" && actualValue.includes("{")) {
const { resolved, error } = this.resolveVariables(actualValue, config);
if (error) {
return this.createFailedResult(
error,
`Please define the variable in your configuration or use a direct value`,
RuleFailureType.Mismatch,
);
}
actualValue = resolved;
}

if (!this.validateValue(actualValue, this.expectedValue))
return this.createFailedResult(
`The value of options.${this.emitterName}.${this.keyToValidate} "${actualValue}" does not match "${this.expectedValue}"`,
`Please update the value of "options.${this.emitterName}.${this.keyToValidate}" to match "${this.expectedValue}"`,
RuleFailureType.Mismatch,
);

return { success: true };
Expand Down Expand Up @@ -170,13 +262,15 @@
return this.createFailedResult(
`Failed to find "options.${this.emitterName}.${this.keyToValidate}"`,
`Please add "options.${this.emitterName}.${this.keyToValidate}" with a path matching the SDK naming convention "${this.expectedValue}"`,
RuleFailureType.NotFind,
);

const actualValue = option as unknown as undefined | string | boolean;
if (typeof actualValue !== "string") {
return this.createFailedResult(
`The value of options.${this.emitterName}.${this.keyToValidate} "${actualValue}" must be a string`,
`Please update the value of "options.${this.emitterName}.${this.keyToValidate}" to be a string path`,
RuleFailureType.TypeError,
);
}

Expand All @@ -186,6 +280,7 @@
// Format 1: {output-dir}/{service-dir}/azure-mgmt-advisor
// Format 2: {service-dir}/azure-mgmt-advisor where service-dir might include {output-dir}
// Format 3: {output-dir}/{service-dir}/azadmin/settings where we need to validate "azadmin/settings"
// Format 4: {output-dir}/sdk/dellstorage/Azure.ResourceManager.Dell.Storage - validate last part only

if (!actualValue.includes("/")) {
pathToValidate = actualValue;
Expand All @@ -194,7 +289,20 @@
const filteredParts = pathParts.filter(
(part) => !(part === "{output-dir}" || part === "{service-dir}"),
);
pathToValidate = filteredParts.join("/");

// Strategy: Remove common directory prefixes (sdk, sdk/xxx) and validate the remaining path
// This handles:
// - "sdk/dellstorage/Azure.ResourceManager.Dell.Storage" -> "Azure.ResourceManager.Dell.Storage"
// - "azadmin/settings" -> "azadmin/settings" (no sdk prefix, keep as is)
if (filteredParts.length > 1 && filteredParts[0] === "sdk") {
// Remove "sdk" and any intermediate directory, validate only the last segment
// Example: ["sdk", "dellstorage", "Azure.ResourceManager.Dell"] -> "Azure.ResourceManager.Dell"
pathToValidate = filteredParts[filteredParts.length - 1];
} else {
// Keep the full remaining path for validation
// Example: ["azadmin", "settings"] -> "azadmin/settings"
pathToValidate = filteredParts.join("/");
}
}

// Skip validation if pathToValidate is exactly {namespace} and skipValidateNamespace is true
Expand All @@ -203,27 +311,23 @@
}

// Resolve any variables in the pathToValidate
// Check if pathToValidate contains variables like {namespace}
const variableMatch = pathToValidate.match(/\{([^}]+)\}/);
if (variableMatch) {
const variableName = variableMatch[1];
const variableValue = config?.options?.[this.emitterName]?.[variableName];

if (variableValue && typeof variableValue === "string") {
// Replace the variable with its value
pathToValidate = pathToValidate.replace(`{${variableName}}`, variableValue);
} else {
if (pathToValidate.includes("{")) {
const { resolved, error } = this.resolveVariables(pathToValidate, config);
if (error) {
return this.createFailedResult(
`Could not resolve variable {${variableName}} in path "${pathToValidate}". The variable is not defined in options.${this.emitterName}`,
`Please define the ${variableName} variable in your configuration or use a direct path value`,
error,
`Please define the variable in your configuration or use a direct path value`,
RuleFailureType.Mismatch,
);
}
pathToValidate = resolved;
}

if (!this.validateValue(pathToValidate, this.expectedValue))
return this.createFailedResult(
`The path part "${pathToValidate}" in options.${this.emitterName}.${this.keyToValidate} does not match the required format "${this.expectedValue}"`,
`Please update the emitter-output-dir path to follow the SDK naming convention`,
RuleFailureType.Mismatch,
);

return { success: true };
Expand Down Expand Up @@ -417,6 +521,7 @@
return this.createFailedResult(
`Neither "options.${this.emitterName}.module" nor "options.${this.emitterName}.containing-module" is defined`,
`Please add either "options.${this.emitterName}.module" or "options.${this.emitterName}.containing-module" with a value matching the pattern "${this.expectedValue}"`,
RuleFailureType.NotFind,
);
}
if (module === undefined) return { success: true };
Expand All @@ -439,6 +544,7 @@
return this.createFailedResult(
`Neither "options.${this.emitterName}.module" nor "options.${this.emitterName}.containing-module" is defined`,
`Please add either "options.${this.emitterName}.module" or "options.${this.emitterName}.containing-module" with a value matching the pattern "${this.expectedValue}"`,
RuleFailureType.NotFind,
);
}
if (containingModule === undefined) return { success: true };
Expand Down Expand Up @@ -628,6 +734,45 @@
}
}

// new Csharp sub rules should be added above this line
export class TspConfigHttpClientCsharpAzEmitterOutputDirSubRule extends TspconfigEmitterOptionsEmitterOutputDirSubRuleBase {
constructor() {
super("@azure-typespec/http-client-csharp", "emitter-output-dir", new RegExp(/^Azure\./));
}
}

export class TspConfigHttpClientCsharpAzNamespaceSubRule extends TspconfigEmitterOptionsSubRuleBase {
constructor() {
super("@azure-typespec/http-client-csharp", "namespace", new RegExp(/^Azure\./));
}
}

export class TspConfigHttpClientCsharpMgmtEmitterOutputDirSubRule extends TspconfigEmitterOptionsEmitterOutputDirSubRuleBase {
constructor() {
super(
"@azure-typespec/http-client-csharp-mgmt",
"emitter-output-dir",
new RegExp(/^Azure\.ResourceManager\./),
);
}
protected skip(_: any, folder: string) {
return skipForDataPlane(folder);
}
}

export class TspConfigHttpClientCsharpMgmtNamespaceSubRule extends TspconfigEmitterOptionsSubRuleBase {
constructor() {
super(
"@azure-typespec/http-client-csharp-mgmt",
"namespace",
new RegExp(/^Azure\.ResourceManager\./),
);
}
protected skip(_: any, folder: string) {
return skipForDataPlane(folder);
}
}

export const defaultRules = [
new TspConfigCommonAzServiceDirMatchPatternSubRule(),
new TspConfigJavaAzEmitterOutputDirMatchPatternSubRule(),
Expand Down Expand Up @@ -659,6 +804,10 @@
new TspConfigCsharpMgmtNamespaceSubRule(),
new TspConfigCsharpAzEmitterOutputDirSubRule(),
new TspConfigCsharpMgmtEmitterOutputDirSubRule(),
new TspConfigHttpClientCsharpAzNamespaceSubRule(),
new TspConfigHttpClientCsharpAzEmitterOutputDirSubRule(),
new TspConfigHttpClientCsharpMgmtNamespaceSubRule(),
new TspConfigHttpClientCsharpMgmtEmitterOutputDirSubRule(),
];

export class SdkTspConfigValidationRule implements Rule {
Expand Down Expand Up @@ -699,7 +848,21 @@
const emitterName = emitterOptionSubRule.getEmitterName();
if (emitterName === "@azure-tools/typespec-csharp" && isSubRuleSuccess === false) {
console.warn(
`Validation on option "${emitterOptionSubRule.getPathOfKeyToValidate()}" in "${emitterName}" are failed. However, per ${emitterName}’s decision, we will treat it as passed, please refer to https://eng.ms/docs/products/azure-developer-experience/onboard/request-exception`,
`Validation on option "${emitterOptionSubRule.getPathOfKeyToValidate()}" in "${emitterName}" are failed. However, per ${emitterName}'s decision, we will treat it as passed, please refer to https://eng.ms/docs/products/azure-developer-experience/onboard/request-exception`,
);
isSubRuleSuccess = true;
}

// For @azure-typespec/http-client-csharp and @azure-typespec/http-client-csharp-mgmt,
// only ignore validation when the option is not found (missing configuration)
if (
(emitterName === "@azure-typespec/http-client-csharp" ||
emitterName === "@azure-typespec/http-client-csharp-mgmt") &&
isSubRuleSuccess === false &&
result.type === RuleFailureType.NotFind
) {
console.warn(
`Validation on option "${emitterOptionSubRule.getPathOfKeyToValidate()}" in "${emitterName}" is skipped because the option is not configured.`,
);
isSubRuleSuccess = true;
}
Expand Down
Loading
Loading