diff --git a/eng/tools/eslint-plugin-tsv/package.json b/eng/tools/eslint-plugin-tsv/package.json new file mode 100644 index 000000000000..b124f469f81f --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/package.json @@ -0,0 +1,32 @@ +{ + "name": "@azure-tools/eslint-plugin-tsv", + "private": true, + "type": "module", + "main": "src/index.js", + "dependencies": { + "ajv": "^8.17.1", + "yaml-eslint-parser": "^1.2.3" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + }, + "devDependencies": { + "@types/node": "^18.19.31", + "@vitest/coverage-v8": "^2.0.4", + "eslint": "^9.17.0", + "memfs": "^4.15.0", + "rimraf": "^5.0.10", + "typescript": "~5.6.2", + "vitest": "^2.0.4" + }, + "scripts": { + "build": "tsc --build", + "cbt": "npm run clean && npm run build && npm run test:ci", + "clean": "rimraf ./dist ./temp", + "test": "vitest", + "test:ci": "vitest run --coverage --reporter=verbose" + }, + "engines": { + "node": ">= 18.0.0" + } +} diff --git a/eng/tools/eslint-plugin-tsv/src/config/config-schema.ts b/eng/tools/eslint-plugin-tsv/src/config/config-schema.ts new file mode 100644 index 000000000000..e92b473ebf20 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/config/config-schema.ts @@ -0,0 +1,117 @@ +// Copied from https://github.com/microsoft/typespec/blob/main/packages/compiler/src/config/config-schema.ts + +import type { JSONSchemaType } from "ajv"; +import { EmitterOptions, TypeSpecRawConfig } from "./types.js"; + +export const emitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: true, + required: [], + properties: { + "emitter-output-dir": { type: "string", nullable: true } as any, + }, +}; + +export const TypeSpecConfigJsonSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + extends: { + type: "string", + nullable: true, + }, + "environment-variables": { + type: "object", + nullable: true, + required: [], + additionalProperties: { + type: "object", + properties: { + default: { type: "string" }, + }, + required: ["default"], + }, + }, + parameters: { + type: "object", + nullable: true, + required: [], + additionalProperties: { + type: "object", + properties: { + default: { type: "string" }, + }, + required: ["default"], + }, + }, + + "output-dir": { + type: "string", + nullable: true, + }, + "warn-as-error": { + type: "boolean", + nullable: true, + }, + trace: { + oneOf: [ + { type: "string" }, + { + type: "array", + items: { type: "string" }, + }, + ], + } as any, // Issue with AJV optional property typing https://github.com/ajv-validator/ajv/issues/1664 + imports: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + emit: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + options: { + type: "object", + nullable: true, + required: [], + additionalProperties: emitterOptionsSchema, + }, + emitters: { + type: "object", + nullable: true, + deprecated: true, + required: [], + additionalProperties: { + oneOf: [{ type: "boolean" }, emitterOptionsSchema], + }, + }, + + linter: { + type: "object", + nullable: true, + required: [], + additionalProperties: false, + properties: { + extends: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + enable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { type: "boolean" }, + }, + disable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { type: "string" }, + }, + }, + } as any, // ajv type system doesn't like the string templates + }, +}; diff --git a/eng/tools/eslint-plugin-tsv/src/config/types.ts b/eng/tools/eslint-plugin-tsv/src/config/types.ts new file mode 100644 index 000000000000..575e3b6df553 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/config/types.ts @@ -0,0 +1,112 @@ +// Copied from https://github.com/microsoft/typespec/blob/main/packages/compiler/src/config/types.ts + +import type { Diagnostic, RuleRef } from "@typespec/compiler"; +import type { YamlScript } from "../yaml/types.js"; + +/** + * Represent the normalized user configuration. + */ +export interface TypeSpecConfig { + /** + * Project root. + */ + projectRoot: string; + + /** Yaml file used in this configuration. */ + file?: YamlScript; + + /** + * Path to the config file used to create this configuration. + */ + filename?: string; + + /** + * Diagnostics reported while loading the configuration + */ + diagnostics: Diagnostic[]; + + /** + * Path to another TypeSpec config to extend. + */ + extends?: string; + + /** + * Environment variables configuration + */ + environmentVariables?: Record; + + /** + * Parameters that can be used + */ + parameters?: Record; + + /** + * Treat warning as error. + */ + warnAsError?: boolean; + + /** + * Output directory + */ + outputDir: string; + + /** + * Trace options. + */ + trace?: string[]; + + /** + * Additional imports. + */ + imports?: string[]; + + /** + * Name of emitters or path to emitters that should be used. + */ + emit?: string[]; + + /** + * Name of emitters or path to emitters that should be used. + */ + options?: Record; + + linter?: LinterConfig; +} + +/** + * Represent the configuration that can be provided in a config file. + */ +export interface TypeSpecRawConfig { + extends?: string; + "environment-variables"?: Record; + parameters?: Record; + + "warn-as-error"?: boolean; + "output-dir"?: string; + trace?: string | string[]; + imports?: string[]; + + emit?: string[]; + options?: Record; + emitters?: Record; + + linter?: LinterConfig; +} + +export interface ConfigEnvironmentVariable { + default: string; +} + +export interface ConfigParameter { + default: string; +} + +export type EmitterOptions = Record & { + "emitter-output-dir"?: string; +}; + +export interface LinterConfig { + extends?: RuleRef[]; + enable?: Record; + disable?: Record; +} diff --git a/eng/tools/eslint-plugin-tsv/src/eslint-plugin-tsv.ts b/eng/tools/eslint-plugin-tsv/src/eslint-plugin-tsv.ts new file mode 100644 index 000000000000..027cee59ab7a --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/eslint-plugin-tsv.ts @@ -0,0 +1,35 @@ +import parser from "yaml-eslint-parser"; +import { NamedESLint } from "./interfaces/named-eslint.js"; +import emitAutorest from "./rules/emit-autorest.js"; +import kebabCaseOrg from "./rules/kebab-case-org.js"; +import tspconfigValidationRules from "./rules/tspconfig-validation-rules.js"; + +const plugin: NamedESLint.Plugin = { + configs: { recommended: {} }, + name: "tsv", + rules: { + [kebabCaseOrg.name]: kebabCaseOrg, + [emitAutorest.name]: emitAutorest, + }, +}; + +plugin.configs.recommended = { + plugins: { + [plugin.name]: plugin, + }, + files: ["*.yaml", "**/*.yaml"], + rules: { + [`${plugin.name}/${kebabCaseOrg.name}`]: "error", + [`${plugin.name}/${emitAutorest.name}`]: "error", + }, + languageOptions: { + parser: parser, + }, +}; + +tspconfigValidationRules.forEach((rule) => { + plugin.rules![rule.name] = rule; + plugin.configs.recommended.rules![`${plugin.name}/${rule.name}`] = "error"; +}); + +export default plugin; diff --git a/eng/tools/eslint-plugin-tsv/src/index.ts b/eng/tools/eslint-plugin-tsv/src/index.ts new file mode 100644 index 000000000000..6e6e166c1421 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/index.ts @@ -0,0 +1,5 @@ +import { ESLint } from "eslint"; +import tsvPlugin from "./eslint-plugin-tsv.js"; + +export { ESLint }; +export default tsvPlugin; diff --git a/eng/tools/eslint-plugin-tsv/src/interfaces/named-eslint.ts b/eng/tools/eslint-plugin-tsv/src/interfaces/named-eslint.ts new file mode 100644 index 000000000000..a09d8d27f150 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/interfaces/named-eslint.ts @@ -0,0 +1,17 @@ +import { ESLint, Linter, Rule } from "eslint"; + +// ESLint with names for convenience + +export namespace NamedRule { + export interface RuleModule extends Rule.RuleModule { + name: string; + } +} + +export namespace NamedESLint { + export interface Plugin extends ESLint.Plugin { + configs: { recommended: Linter.Config }; + name: string; + rules?: Record; + } +} diff --git a/eng/tools/eslint-plugin-tsv/src/interfaces/rule-interfaces.ts b/eng/tools/eslint-plugin-tsv/src/interfaces/rule-interfaces.ts new file mode 100644 index 000000000000..d45d6ab20597 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/interfaces/rule-interfaces.ts @@ -0,0 +1,34 @@ +import { Rule } from "eslint"; +import { TypeSpecConfig } from "../config/types.js"; + +export enum KeyType { + EmitterOption, + Parameter, +} + +export interface RuleDocument { + description: string; + error: string; + action: string; + example: string; +} + +export interface RuleInfo { + name: string; + documentation: RuleDocument; + functions: { + messages: () => { [messageId: string]: string } | undefined; + condition: (tspconfig: TypeSpecConfig, context: Rule.RuleContext) => boolean; + validation: (tspconfig: TypeSpecConfig, context: Rule.RuleContext, node: Rule.Node) => void; + }; +} + +export interface CreateCodeGenSDKRuleArgs { + rule: string; + type: KeyType; + key: string; + expectedValue: string | boolean | RegExp; + exampleValue: string | boolean; + extraExplanation?: string; + condition?: (tspconfig: TypeSpecConfig, context: Rule.RuleContext) => boolean; +} \ No newline at end of file diff --git a/eng/tools/eslint-plugin-tsv/src/rules/emit-autorest.ts b/eng/tools/eslint-plugin-tsv/src/rules/emit-autorest.ts new file mode 100644 index 000000000000..b64953d1ccbf --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/rules/emit-autorest.ts @@ -0,0 +1,59 @@ +import { Ajv } from "ajv"; +import { Rule } from "eslint"; +import { AST, getStaticYAMLValue } from "yaml-eslint-parser"; +import { TypeSpecConfigJsonSchema } from "../config/config-schema.js"; +import { TypeSpecConfig } from "../config/types.js"; +import { NamedRule } from "../interfaces/named-eslint.js"; + +export const rule: NamedRule.RuleModule = { + name: "emit-autorest", + meta: { + type: "problem", + docs: { + description: + "Requires emitter 'typespec-autorest' to be enabled by default, and requires emitted autorest to match content in repo", + }, + schema: [], + messages: { + invalid: "tspconfig.yaml is invalid per the schema: {{errors}}", + missing: + 'tspconfig.yaml must include the following emitter by default:\n\nemit:\n - "@azure-tools/typespec-autorest"', + // disabled: "Path does not match format '.*/specification/{orgName}/': ''{{filename}}'", + // autorestDiff: "Emitted autorest does not match content in repo", + }, + }, + create(context) { + return { + YAMLDocument(node: Rule.Node) { + const yamlDocument = node as unknown as AST.YAMLDocument; + + // If config yaml is empty, use empty object instead of "null" + const config = getStaticYAMLValue(yamlDocument) || {}; + + const ajv = new Ajv(); + const valid = ajv.validate(TypeSpecConfigJsonSchema, config); + + if (!valid) { + context.report({ + node, + messageId: "invalid", + data: { errors: ajv.errorsText(ajv.errors) }, + }); + return; + } + + const typedConfig = config as unknown as TypeSpecConfig; + if (!typedConfig.emit?.includes("@azure-tools/typespec-autorest")) { + // TODO: Move error message to "emit:" node + context.report({ + node, + messageId: "missing", + }); + return; + } + }, + }; + }, +}; + +export default rule; diff --git a/eng/tools/eslint-plugin-tsv/src/rules/kebab-case-org.ts b/eng/tools/eslint-plugin-tsv/src/rules/kebab-case-org.ts new file mode 100644 index 000000000000..fcc81c116138 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/rules/kebab-case-org.ts @@ -0,0 +1,55 @@ +import path from "path"; +import { NamedRule } from "../interfaces/named-eslint.js"; + +// Valid: /specification/kebab-case/Kebab.Case/tspconfig.yaml +// Invalid: /specification/Not-Kebab-Case/Not.KebabCase/tspconfig.yaml + +export const rule: NamedRule.RuleModule = { + name: "kebab-case-org", + meta: { + type: "problem", + docs: { + description: + "Requires kebab-case for'organization' name (first path segment after 'specification')", + }, + schema: [], + messages: { + invalid: "Path does not match format '.*/specification/{orgName}/': ''{{filename}}'", + kebab: + "Organization name (first path segment after 'specification') does not use kebab-case: '{{orgName}}'", + }, + }, + create(context) { + return { + Program(node) { + const filename = path.resolve(context.filename as string); + const pathSegments = filename.split(path.sep); + const specificationIndex = pathSegments.indexOf("specification"); + const pathValid = specificationIndex >= 0 && specificationIndex < pathSegments.length - 1; + + if (!pathValid) { + context.report({ + node, + messageId: "invalid", + data: { filename: filename }, + }); + return; + } + + const orgName = pathSegments[specificationIndex + 1]; + const kebabCaseRegex = /^[a-z0-9]+(-[a-z0-9]+)*$/; + const orgNameKebabCase = orgName.match(kebabCaseRegex); + + if (!orgNameKebabCase) { + context.report({ + node, + messageId: "kebab", + data: { orgName: orgName }, + }); + } + }, + }; + }, +}; + +export default rule; diff --git a/eng/tools/eslint-plugin-tsv/src/rules/tspconfig-validation-rules.ts b/eng/tools/eslint-plugin-tsv/src/rules/tspconfig-validation-rules.ts new file mode 100644 index 000000000000..83f193b825f7 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/rules/tspconfig-validation-rules.ts @@ -0,0 +1,233 @@ +import { Rule } from "eslint"; +import { TypeSpecConfig } from "../config/types.js"; +import { createCodeGenSDKRule, isManagementSDK } from "../utils/rule-creator.js"; +import { emitters } from "../utils/constants.js"; +import { CreateCodeGenSDKRuleArgs, KeyType } from "../interfaces/rule-interfaces.js"; + +const tsIsManagementCondition = (tspconfig: TypeSpecConfig, context: Rule.RuleContext) => { + const emitterName = emitters.ts; + const isModularLibrary = tspconfig.options?.[emitterName]?.isModularLibrary as + | boolean + | undefined; + return isManagementSDK(context) && isModularLibrary !== false; +}; + +const args: CreateCodeGenSDKRuleArgs[] = [ + // common + { + rule: "tspconfig-common-az-service-dir-match-pattern", + key: "service-dir", + type: KeyType.Parameter, + expectedValue: /^sdk\/[^\/]*$/, + exampleValue: "sdk/aaa", + extraExplanation: + "The 'service-dir' should be a string that starts with 'sdk/', followed by zero or more characters that are not a '/', and ends there", + condition: (_: TypeSpecConfig, _1: Rule.RuleContext) => true, + }, + // ts + { + rule: "tspconfig-ts-mgmt-modular-generate-metadata-true", + key: "generateMetadata", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: tsIsManagementCondition, + }, + { + rule: "tspconfig-ts-mgmt-modular-hierarchy-client-false", + key: "hierarchyClient", + type: KeyType.EmitterOption, + expectedValue: false, + exampleValue: false, + condition: tsIsManagementCondition, + }, + { + rule: "tspconfig-ts-mgmt-modular-experimental-extensible-enums-true", + key: "experimentalExtensibleEnums", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: tsIsManagementCondition, + }, + { + rule: "tspconfig-ts-mgmt-modular-enable-operation-group-true", + key: "enableOperationGroup", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: tsIsManagementCondition, + }, + { + rule: "tspconfig-ts-mgmt-modular-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^arm(?:-[a-z]+)+$/, + exampleValue: "arm-aaa-bbb", + extraExplanation: + "The 'package-dir' should be a string that starts with 'arm' and is followed by one or more groups of a hyphen (-) and lowercase letters", + condition: tsIsManagementCondition, + }, + { + rule: "tspconfig-ts-mgmt-modular-package-name-match-pattern", + key: "packageDetails.name", + type: KeyType.EmitterOption, + expectedValue: /^\@azure\/arm(?:-[a-z]+)+$/, + exampleValue: "@azure/arm-aaa-bbb", + extraExplanation: + "The package name should be a string that starts with '@azure/arm' and is followed by one or more groups of a hyphen (-) and lowercase letters", + condition: tsIsManagementCondition, + }, + // go + { + rule: "tspconfig-go-mgmt-service-dir-match-pattern", + key: "service-dir", + type: KeyType.EmitterOption, + expectedValue: /^sdk\/resourcemanager\/[^\/]*$/, + exampleValue: "sdk/resourcemanager/aaa", + extraExplanation: + "The 'service-dir' should be a string that starts with 'sdk/resourcemanager/', followed by zero or more characters that are not a '/', and ends there", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^arm[^\/]*$/, + exampleValue: "armaaa", + extraExplanation: + "The 'package-dir' should be a string that starts with 'arm' and do not contain a forward slash (/) after it", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-module-equal-string", + key: "module", + type: KeyType.EmitterOption, + expectedValue: "github.com/Azure/azure-sdk-for-go/{service-dir}/{package-dir}", + exampleValue: "github.com/Azure/azure-sdk-for-go/{service-dir}/{package-dir}", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-fix-const-stuttering-true", + key: "fix-const-stuttering", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-generate-examples-true", + key: "generate-examples", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-generate-fakes-true", + key: "generate-fakes", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-head-as-boolean-true", + key: "head-as-boolean", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-go-mgmt-inject-spans-true", + key: "inject-spans", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + // java + { + rule: "tspconfig-java-az-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^azure(-\w+)+$/, + exampleValue: "azure-aaa", + extraExplanation: + "The 'package-dir' should be a string that starts with 'azure', followed by one or more '-' segments. Each segment can contains letters, digits, or underscores", + condition: (_: TypeSpecConfig, _1: Rule.RuleContext) => true, + }, + // python + { + rule: "tspconfig-python-mgmt-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^azure-mgmt(-[a-z]+){1,2}$/, + exampleValue: "azure-mgmt-aaa", + extraExplanation: + "The 'package-dir' should be a string that starts with 'azure-mgmt', followed by 1 or 2 hyphen-separated lowercase alphabetic segments", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-python-mgmt-package-name-equal-string", + key: "package-name", + type: KeyType.EmitterOption, + expectedValue: "{package-dir}", + exampleValue: "{package-dir}", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-python-mgmt-generate-test-true", + key: "generate-test", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + { + rule: "tspconfig-python-mgmt-generate-sample-true", + key: "generate-sample", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, + // csharp + { + rule: "tspconfig-csharp-az-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^Azure\./, + exampleValue: "Azure.aaa", + extraExplanation: "The 'package-dir' should be a string that starts with 'Azure.'", + condition: (_: TypeSpecConfig, _1: Rule.RuleContext) => true, + }, + { + rule: "tspconfig-csharp-az-namespace-equal-string", + key: "namespace", + type: KeyType.EmitterOption, + expectedValue: "{package-dir}", + exampleValue: "{package-dir}", + condition: (_: TypeSpecConfig, _1: Rule.RuleContext) => true, + }, + { + rule: "tspconfig-csharp-az-clear-output-folder-true", + key: "clear-output-folder", + type: KeyType.EmitterOption, + expectedValue: true, + exampleValue: true, + condition: (_: TypeSpecConfig, _1: Rule.RuleContext) => true, + }, + { + rule: "tspconfig-csharp-mgmt-package-dir-match-pattern", + key: "package-dir", + type: KeyType.EmitterOption, + expectedValue: /^Azure\.ResourceManager\./, + exampleValue: "Azure.ResourceManager.aaa", + extraExplanation: + "The 'package-dir' should be a string that starts with 'Azure.ResourceManager.'", + condition: (_: TypeSpecConfig, context: Rule.RuleContext) => isManagementSDK(context), + }, +]; + +export default args.map((a) => createCodeGenSDKRule(a)); diff --git a/eng/tools/eslint-plugin-tsv/src/utils/constants.ts b/eng/tools/eslint-plugin-tsv/src/utils/constants.ts new file mode 100644 index 000000000000..aff4758de554 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/utils/constants.ts @@ -0,0 +1,13 @@ +export const emitters = { + ts: "@azure-tools/typespec-ts", + java: "@azure-tools/typespec-java", + csharp: "@azure-tools/typespec-csharp", + python: "@azure-tools/typespec-python", + go: "@azure-tools/typespec-go", + autorest: "@azure-tools/typespec-autorest", + common: "", +}; + +export const defaultMessageId = "problem"; + +export const defaultRuleType = "problem"; diff --git a/eng/tools/eslint-plugin-tsv/src/utils/npm.ts b/eng/tools/eslint-plugin-tsv/src/utils/npm.ts new file mode 100644 index 000000000000..0d36838a1f8b --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/utils/npm.ts @@ -0,0 +1,32 @@ +import { dirname, join, resolve } from "path"; +import { stat, access } from "fs/promises"; + +export class Npm { + // Simulates `npm prefix` by finding the nearest parent directory containing `package.json` or `node_modules`. + // If neither exist in any parent directories, returns the directory containing the path itself. + // Always returns an absolute path. + static async prefix(path: string): Promise { + path = resolve(path); + + const initialDir = (await stat(path)).isDirectory() ? path : dirname(path); + + for ( + var currentDir = initialDir; + dirname(currentDir) != currentDir; + currentDir = dirname(currentDir) + ) { + try { + await access(join(currentDir, "package.json")); + return currentDir; + } catch {} + + try { + await access(join(currentDir, "node_modules")); + return currentDir; + } catch {} + } + + // Neither found in an parent dir + return initialDir; + } +} diff --git a/eng/tools/eslint-plugin-tsv/src/utils/rule-creator.ts b/eng/tools/eslint-plugin-tsv/src/utils/rule-creator.ts new file mode 100644 index 000000000000..5040e54b07bd --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/utils/rule-creator.ts @@ -0,0 +1,127 @@ +import { Rule } from "eslint"; +import { TypeSpecConfig } from "../config/types.js"; +import { + CreateCodeGenSDKRuleArgs, + KeyType, + RuleDocument, + RuleInfo, +} from "../interfaces/rule-interfaces.js"; +import { defaultMessageId, defaultRuleType, emitters } from "./constants.js"; +import { NamedRule } from "../interfaces/named-eslint.js"; +import { AST, getStaticYAMLValue } from "yaml-eslint-parser"; +import { createRuleDocument } from "./rule-doc.js"; + +export function createRule(ruleContext: RuleInfo): NamedRule.RuleModule { + const rule: NamedRule.RuleModule = { + name: ruleContext.name, + meta: { + type: defaultRuleType, + docs: { + description: ruleContext.documentation.description, + }, + schema: [], + messages: ruleContext.functions.messages(), + }, + create(context) { + return { + YAMLDocument(node: Rule.Node) { + const yamlDocument = node as unknown as AST.YAMLDocument; + const rawConfig = getStaticYAMLValue(yamlDocument) || {}; + const config = rawConfig as unknown as TypeSpecConfig; + + if (!ruleContext.functions.condition(config, context)) return; + ruleContext.functions.validation(config, context, node); + }, + }; + }, + }; + return rule; +} + +export function createRuleMessages(messageId: string, docs: RuleDocument) { + return { + [messageId]: `${docs.error}.\n${docs.action}.\n${docs.example}`, + }; +} + +export function isManagementSDK(context: Rule.RuleContext) { + const filename = context.filename; + return filename.includes(".Management"); +} + +function validateValue( + context: Rule.RuleContext, + node: Rule.Node, + actual: string | boolean | undefined, + expected: boolean | string | RegExp, +) { + switch (typeof expected) { + case "boolean": + case "string": + if (actual !== expected) context.report({ node, messageId: defaultMessageId }); + break; + case "object": + if (typeof actual !== "string" || !expected.test(actual)) + context.report({ node, messageId: defaultMessageId }); + break; + case "undefined": + context.report({ node, messageId: defaultMessageId }); + break; + default: + // TODO: log not supported + break; + } +} + +// TODO: add logs +export function createCodeGenSDKRule(args: CreateCodeGenSDKRuleArgs): NamedRule.RuleModule { + const language = args.rule.split("-")[1]! as keyof typeof emitters; + const emitterName = emitters[language]; + const documentation = createRuleDocument( + emitterName, + args.type, + args.key, + args.expectedValue, + args.exampleValue, + args.extraExplanation ?? "", + ); + + const ruleInfo: RuleInfo = { + name: args.rule, + documentation: documentation!, + functions: { + messages: () => createRuleMessages(defaultMessageId, documentation), + condition: (tspconfig, context) => { + if (args.condition) return args.condition(tspconfig, context); + return true; + }, + validation: (tspconfig, context, node) => { + switch (args.type) { + case KeyType.EmitterOption: { + let option: Record | undefined = tspconfig.options?.[emitterName]; + for (const segment of args.key.split(".")) { + if (option && segment in option) option = option![segment]; + } + validateValue( + context, + node, + option as undefined | string | boolean, + args.expectedValue, + ); + break; + } + case KeyType.Parameter: { + const parameter = tspconfig.parameters?.[args.key].default; + validateValue(context, node, parameter, args.expectedValue); + break; + } + default: + // TODO: log not supported + break; + } + }, + }, + }; + + return createRule(ruleInfo); +} diff --git a/eng/tools/eslint-plugin-tsv/src/utils/rule-doc.ts b/eng/tools/eslint-plugin-tsv/src/utils/rule-doc.ts new file mode 100644 index 000000000000..1ffef72bee77 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/utils/rule-doc.ts @@ -0,0 +1,113 @@ +import { KeyType, RuleDocument } from "../interfaces/rule-interfaces.js"; +import { stringify } from "yaml"; + +function createDescriptionDocumentBlock( + displayName: string, + expectedValue: string | boolean | RegExp, + extraExplanation: string, +): string { + switch (typeof expectedValue) { + case "object": + return `Validate whether '${displayName}' matches regex pattern '${expectedValue}' in tspconfig.yaml. ${extraExplanation}`; + default: + case "string": + case "boolean": + return `Validate whether '${displayName}' is set to '${expectedValue}' in tspconfig.yaml`; + } +} + +function createErrorDocumentBlock( + displayName: string, + expectedValue: string | boolean | RegExp, +): string { + switch (typeof expectedValue) { + case "object": + return `'${displayName}' does NOT match regex pattern '${expectedValue}' in tspconfig.yaml`; + default: + case "string": + case "boolean": + return `'${displayName}' is NOT set to '${expectedValue}' in tspconfig.yaml`; + } +} + +function createActionDocumentBlock( + displayName: string, + expectedValue: string | boolean | RegExp, +): string { + switch (typeof expectedValue) { + case "object": + return `Set '${displayName}' to a value that matches regex pattern '${expectedValue}' in tspconfig.yaml`; + default: + case "string": + case "boolean": + return `Set '${displayName}' to '${expectedValue}' in tspconfig.yaml`; + } +} + +export function createRuleDocument( + emitterName: string, + keyType: KeyType, + key: string, + expectedValue: string | boolean | RegExp, + exampleValue: string | boolean, + extraExplanation: string, +): RuleDocument { + let displayName = key; + let example = ""; + switch (keyType) { + case KeyType.EmitterOption: + displayName = `options.${emitterName}.${key}`; + example = createEmitterOptionExample(emitterName, { key: key, value: exampleValue }); + break; + case KeyType.Parameter: + displayName = `parameters.${key}`; + example = createParameterExample({ key: key, value: exampleValue }); + break; + default: + // TODO: log not supported + displayName = key; + } + const description = createDescriptionDocumentBlock(displayName, expectedValue, extraExplanation); + const error = createErrorDocumentBlock(displayName, expectedValue); + const action = createActionDocumentBlock(displayName, expectedValue); + + const document: RuleDocument = { + description, + error, + action, + example, + }; + return document; +} + +export function createParameterExample(...pairs: { key: string; value: string | boolean | {} }[]) { + const obj: Record = { parameters: {} }; + for (const pair of pairs) { + obj.parameters[pair.key] = { default: pair.value }; + } + const content = stringify(obj); + return content; +} + +export function createEmitterOptionExample( + emitter: string, + ...pairs: { key: string; value: string | boolean | {} }[] +) { + const obj = { options: { [emitter]: {} } }; + for (const pair of pairs) { + const segments = pair.key.split("."); + let cur: Record = obj.options[emitter]; + for (const [i, segment] of segments.entries()) { + if (i === segments.length - 1) { + cur[segment] = pair.value; + break; + } + if (!(segment in cur)) { + cur[segment] = {}; + } + cur = cur[segment]; + } + } + const content = stringify(obj); + return content; +} diff --git a/eng/tools/eslint-plugin-tsv/src/yaml/types.ts b/eng/tools/eslint-plugin-tsv/src/yaml/types.ts new file mode 100644 index 000000000000..2c73c7c0da1e --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/src/yaml/types.ts @@ -0,0 +1,24 @@ +// Copied from https://github.com/microsoft/typespec/blob/main/packages/compiler/src/yaml/types.ts + +import { SourceFile } from "@typespec/compiler"; +import { Document } from "yaml"; + +export interface YamlScript { + readonly kind: "yaml-script"; + readonly file: SourceFile; + /** Value of the yaml script. */ + readonly value: unknown; + + /** @internal yaml library document. We do not expose this as the "yaml" library is not part of the contract. */ + readonly doc: Document.Parsed; +} + +/** + * Represent the location of a value in a yaml script. + */ +export interface YamlPathTarget { + kind: "path-target"; + script: YamlScript; + path: string[]; +} +export type YamlDiagnosticTargetType = "value" | "key"; diff --git a/eng/tools/eslint-plugin-tsv/test/e2e.test.ts b/eng/tools/eslint-plugin-tsv/test/e2e.test.ts new file mode 100644 index 000000000000..e6da7e107a9d --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/test/e2e.test.ts @@ -0,0 +1,66 @@ +import { ESLint } from "eslint"; +import { join, resolve } from "path"; +import { describe, expect, it } from "vitest"; +import eslintPluginTsv from "../src/eslint-plugin-tsv.js"; + +function createESLint() { + return new ESLint({ + cwd: join(__dirname, "../../../../"), + overrideConfig: eslintPluginTsv.configs.recommended, + overrideConfigFile: true, + }); +} + +describe("lint-text", () => { + it("Not-Kebab-Case/Not.KebabCase", async () => { + const filePath = "/specification/Not-Kebab-Case/Not.KebabCase/tspconfig.yaml"; + const eslint = createESLint(); + + const results = await eslint.lintText("", { filePath: filePath }); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].messages[0].ruleId).toBe("tsv/kebab-case-org"); + expect(results[0].messages[0].messageId).toBe("kebab"); + }); + + it("Not-Kebab-Case-Disabled/Not.KebabCase", async () => { + const filePath = "/specification/Not-Kebab-Case-Disabled/Not.KebabCase/tspconfig.yaml"; + const eslint = createESLint(); + + const results = await eslint.lintText( + "# eslint-disable tsv/kebab-case-org, tsv/emit-autorest\n", + { + filePath: filePath, + }, + ); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].messages).toHaveLength(0); + }); +}); + +describe("lint-files", () => { + const specsFolder = resolve(__filename, "../../../../../specification"); + + it("contosowidgetmanager/Contso.WidgetManager", async () => { + const eslint = createESLint(); + const filePath = join(specsFolder, "contosowidgetmanager/Contoso.WidgetManager/tspconfig.yaml"); + const results = await eslint.lintFiles(filePath); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].messages).toHaveLength(0); + }); + + it("contosowidgetmanager/Contso.Management", async () => { + const eslint = createESLint(); + const filePath = join(specsFolder, "contosowidgetmanager/Contoso.Management/tspconfig.yaml"); + const results = await eslint.lintFiles(filePath); + + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe(filePath); + expect(results[0].messages).toHaveLength(0); + }); +}); diff --git a/eng/tools/eslint-plugin-tsv/test/rules/emit-autorest.test.ts b/eng/tools/eslint-plugin-tsv/test/rules/emit-autorest.test.ts new file mode 100644 index 000000000000..7f8938378634 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/test/rules/emit-autorest.test.ts @@ -0,0 +1,32 @@ +import { Rule, RuleTester } from "eslint"; +import { test } from "vitest"; +import parser from "yaml-eslint-parser"; + +import emitAutorest from "../../src/rules/emit-autorest.js"; + +test("RuleTester", () => { + const ruleTester = new RuleTester({ + languageOptions: { + parser: parser, + }, + }); + + ruleTester.run(emitAutorest.name, emitAutorest as Rule.RuleModule, { + valid: [ + { + code: 'emit:\n - "@azure-tools/typespec-autorest"', + }, + ], + invalid: [ + { + code: "", + errors: [{ messageId: "missing" }], + }, + { + code: "emit:\n - foo", + errors: [{ messageId: "missing" }], + }, + { code: "not: valid", errors: [{ messageId: "invalid" }] }, + ], + }); +}); diff --git a/eng/tools/eslint-plugin-tsv/test/rules/kebab-case-org.test.ts b/eng/tools/eslint-plugin-tsv/test/rules/kebab-case-org.test.ts new file mode 100644 index 000000000000..b7a0092f3282 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/test/rules/kebab-case-org.test.ts @@ -0,0 +1,35 @@ +import { Rule, RuleTester } from "eslint"; +import { test } from "vitest"; +import parser from "yaml-eslint-parser"; + +import kebabCaseOrg from "../../src/rules/kebab-case-org.js"; + +test("RuleTester", () => { + const ruleTester = new RuleTester({ + languageOptions: { + parser: parser, + }, + }); + + ruleTester.run(kebabCaseOrg.name, kebabCaseOrg as Rule.RuleModule, { + valid: [ + { code: "", filename: "/specification/contoso/Contoso.WidgetManager/tspconfig.yaml" }, + { + code: `# eslint-disable rule-to-test/${kebabCaseOrg.name}`, + filename: "/specification/Not-Kebab-Case/Not.KebabCase/tspconfig.yaml", + }, + ], + invalid: [ + { + code: "", + filename: "/specification/Not-Kebab-Case/Not.KebabCase/tspconfig.yaml", + errors: [{ messageId: "kebab" }], + }, + { + code: "", + filename: "tspconfig.yaml", + errors: [{ messageId: "invalid" }], + }, + ], + }); +}); diff --git a/eng/tools/eslint-plugin-tsv/test/rules/tspconfig-options-validation.test.ts b/eng/tools/eslint-plugin-tsv/test/rules/tspconfig-options-validation.test.ts new file mode 100644 index 000000000000..9e0bb1ea2228 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/test/rules/tspconfig-options-validation.test.ts @@ -0,0 +1,397 @@ +import { Rule, RuleTester } from "eslint"; +import { describe, it } from "vitest"; +import parser from "yaml-eslint-parser"; +import { defaultMessageId, emitters } from "../../src/utils/constants.js"; +import { NamedRule } from "../../src/interfaces/named-eslint.js"; +import { createEmitterOptionExample, createParameterExample } from "../../src/utils/rule-doc.js"; + +interface Case { + description: string; + rulePath: string; + ruleName: string; + fileName?: string; + yamlContent: string; + shouldReportError: boolean; +} + +const managementTspconfigPath = "contosowidgetmanager/Contoso.Management/tspconfig.yaml"; +const rulePath = "../../src/rules/tspconfig-validation-rules.js"; + +const commonAzureServiceDirTestCases = createParameterTestCases( + rulePath, + "tspconfig-common-az-service-dir-match-pattern", + "", + "service-dir", + "sdk/aaa", + "sdka/aaa", +); + +const tsManagementGenerateMetadataTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-generate-metadata-true", + managementTspconfigPath, + "generateMetadata", + true, + false, +); + +const tsManagementHierarchyClientTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-hierarchy-client-false", + managementTspconfigPath, + "hierarchyClient", + false, + true, +); + +const tsManagementExperimentalExtensibleEnumsTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-experimental-extensible-enums-true", + managementTspconfigPath, + "experimentalExtensibleEnums", + true, + false, +); + +const tsManagementEnableOperationGroupTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-enable-operation-group-true", + managementTspconfigPath, + "enableOperationGroup", + true, + false, +); + +const tsManagementPackageDirTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-package-dir-match-pattern", + managementTspconfigPath, + "package-dir", + "arm-aaa-bbb", + "aaa-bbb", +); + +const tsManagementPackageNameTestCases = createEmitterOptionTestCases( + emitters.ts, + rulePath, + "tspconfig-ts-mgmt-modular-package-name-match-pattern", + managementTspconfigPath, + "packageDetails.name", + "@azure/arm-aaa-bbb", + "@azure/aaa-bbb", +); + +const goManagementServiceDirTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-service-dir-match-pattern", + managementTspconfigPath, + "service-dir", + "sdk/resourcemanager/aaa", + "sdk/manager/aaa", +); + +const goManagementPackageDirTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-package-dir-match-pattern", + managementTspconfigPath, + "package-dir", + "armaaa", + "aaa", +); + +const goManagementModuleTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-module-equal-string", + managementTspconfigPath, + "module", + "github.com/Azure/azure-sdk-for-go/{service-dir}/{package-dir}", + "github.com/Azure/azure-sdk-for-java/{service-dir}/{package-dir}", +); + +const goManagementFixConstStutteringTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-fix-const-stuttering-true", + managementTspconfigPath, + "fix-const-stuttering", + true, + false, +); + +const goManagementGenerateExamplesTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-generate-examples-true", + managementTspconfigPath, + "generate-examples", + true, + false, +); + +const goManagementGenerateFakesTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-generate-fakes-true", + managementTspconfigPath, + "generate-fakes", + true, + false, +); + +const goManagementHeadAsBooleanTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-head-as-boolean-true", + managementTspconfigPath, + "head-as-boolean", + true, + false, +); + +const goManagementInjectSpansTestCases = createEmitterOptionTestCases( + emitters.go, + rulePath, + "tspconfig-go-mgmt-inject-spans-true", + managementTspconfigPath, + "inject-spans", + true, + false, +); + +const javaManagementPackageDirTestCases = createEmitterOptionTestCases( + emitters.java, + rulePath, + "tspconfig-java-az-package-dir-match-pattern", + "", + "package-dir", + "azure-aaa", + "aaa", +); + +const pythonManagementPackageDirTestCases = createEmitterOptionTestCases( + emitters.python, + rulePath, + "tspconfig-python-mgmt-package-dir-match-pattern", + managementTspconfigPath, + "package-dir", + "azure-mgmt-aaa", + "azure-aaa", +); + +const pythonManagementPackageNameTestCases = createEmitterOptionTestCases( + emitters.python, + rulePath, + "tspconfig-python-mgmt-package-name-equal-string", + managementTspconfigPath, + "package-name", + "{package-dir}", + "aaa", +); + +const pythonManagementGenerateTestTestCases = createEmitterOptionTestCases( + emitters.python, + rulePath, + "tspconfig-python-mgmt-generate-test-true", + managementTspconfigPath, + "generate-test", + true, + false, +); + +const pythonManagementGenerateSampleTestCases = createEmitterOptionTestCases( + emitters.python, + rulePath, + "tspconfig-python-mgmt-generate-sample-true", + managementTspconfigPath, + "generate-sample", + true, + false, +); + +const csharpAzPackageDirTestCases = createEmitterOptionTestCases( + emitters.csharp, + rulePath, + "tspconfig-csharp-az-package-dir-match-pattern", + "", + "package-dir", + "Azure.AAA", + "AAA", +); + +const csharpAzNamespaceTestCases = createEmitterOptionTestCases( + emitters.csharp, + rulePath, + "tspconfig-csharp-az-namespace-equal-string", + "", + "namespace", + "{package-dir}", + "AAA", +); + +const csharpAzClearOutputFolderTestCases = createEmitterOptionTestCases( + emitters.csharp, + rulePath, + "tspconfig-csharp-az-clear-output-folder-true", + "", + "clear-output-folder", + true, + false, +); + +const csharpMgmtPackageDirTestCases = createEmitterOptionTestCases( + emitters.csharp, + rulePath, + "tspconfig-csharp-mgmt-package-dir-match-pattern", + managementTspconfigPath, + "package-dir", + "Azure.ResourceManager.AAA", + "Azure.Management.AAA", +); + +describe("Tspconfig emitter options validation", () => { + it.each([ + // common + ...commonAzureServiceDirTestCases, + // ts + ...tsManagementGenerateMetadataTestCases, + ...tsManagementHierarchyClientTestCases, + ...tsManagementExperimentalExtensibleEnumsTestCases, + ...tsManagementEnableOperationGroupTestCases, + ...tsManagementPackageDirTestCases, + ...tsManagementPackageNameTestCases, + // go + ...goManagementServiceDirTestCases, + ...goManagementPackageDirTestCases, + ...goManagementModuleTestCases, + ...goManagementFixConstStutteringTestCases, + ...goManagementGenerateExamplesTestCases, + ...goManagementGenerateFakesTestCases, + ...goManagementHeadAsBooleanTestCases, + ...goManagementInjectSpansTestCases, + // java + ...javaManagementPackageDirTestCases, + // python + ...pythonManagementPackageDirTestCases, + ...pythonManagementPackageNameTestCases, + ...pythonManagementGenerateTestTestCases, + ...pythonManagementGenerateSampleTestCases, + // csharp + ...csharpAzPackageDirTestCases, + ...csharpAzNamespaceTestCases, + ...csharpAzClearOutputFolderTestCases, + ...csharpMgmtPackageDirTestCases, + ])("$ruleName - $description", async (c: Case) => { + const ruleTester = new RuleTester({ + languageOptions: { + parser: parser, + }, + }); + + const ruleModule = await import(c.rulePath); + const rule = ruleModule.default.find((r: NamedRule.RuleModule) => r.name === c.ruleName); + const tests = c.shouldReportError + ? { + valid: [], + invalid: [ + { + filename: c.fileName, + code: c.yamlContent, + errors: [{ messageId: defaultMessageId }], + }, + ], + } + : { + valid: [ + { + filename: c.fileName, + code: c.yamlContent, + }, + ], + invalid: [], + }; + ruleTester.run(rule.name, rule as Rule.RuleModule, tests); + }); +}); + +function createEmitterOptionTestCases( + emitterName: string, + rulePath: string, + ruleName: string, + fileName: string, + key: string, + validValue: boolean | string, + invalidValue: boolean | string, +): Case[] { + const managementGenerateMetadataTestCases: Case[] = [ + { + description: `valid: ${key} is ${validValue}`, + rulePath, + ruleName, + fileName, + yamlContent: createEmitterOptionExample(emitterName, { key: key, value: validValue }), + shouldReportError: false, + }, + { + description: `invalid: ${key} is ${invalidValue}`, + rulePath, + ruleName, + fileName, + yamlContent: createEmitterOptionExample(emitterName, { key: key, value: invalidValue }), + shouldReportError: true, + }, + { + description: `invalid: ${key} is undefined`, + rulePath, + ruleName, + fileName, + yamlContent: createEmitterOptionExample(emitterName), + shouldReportError: true, + }, + ]; + return managementGenerateMetadataTestCases; +} + +function createParameterTestCases( + rulePath: string, + ruleName: string, + fileName: string, + key: string, + validValue: boolean | string, + invalidValue: boolean | string, +): Case[] { + const managementGenerateMetadataTestCases: Case[] = [ + { + description: `valid: ${key} is ${validValue}`, + rulePath, + ruleName, + fileName, + yamlContent: createParameterExample({ key: key, value: validValue }), + shouldReportError: false, + }, + { + description: `invalid: ${key} is ${invalidValue}`, + rulePath, + ruleName, + fileName, + yamlContent: createParameterExample({ key: key, value: invalidValue }), + shouldReportError: true, + }, + { + description: `invalid: ${key} is undefined`, + rulePath, + ruleName, + fileName, + yamlContent: "", + shouldReportError: true, + }, + ]; + return managementGenerateMetadataTestCases; +} diff --git a/eng/tools/eslint-plugin-tsv/test/utils/npm.test.ts b/eng/tools/eslint-plugin-tsv/test/utils/npm.test.ts new file mode 100644 index 000000000000..28e54924170a --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/test/utils/npm.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { vol } from "memfs"; +import { resolve } from "path"; +import { Npm } from "../../src/utils/npm.js"; + +vi.mock("fs/promises", async () => { + const memfs = await import("memfs"); + return { + ...memfs.fs.promises, + }; +}); + +describe("prefix", () => { + beforeEach(() => { + vol.reset(); + }); + + describe("returns current directory if no match", () => { + it.each([ + ["/foo/bar/tspconfig.yaml", "/foo/bar"], + ["/foo/bar", "/foo/bar"], + ])("%s", async (path, expected) => { + vol.fromJSON({ + "/foo/bar/tspconfig.yaml": "", + }); + + expect(await Npm.prefix(path)).toBe(resolve(expected)); + }); + }); + + describe("returns first match", () => { + it.each([ + ["/pj", "/pj"], + ["/pj/none", "/pj"], + ["/pj/none/none/none", "/pj"], + ["/pj/nm", "/pj/nm"], + ["/pj/nm/none", "/pj/nm"], + ["/pj/pj", "/pj/pj"], + ["/pj/nm/pj", "/pj/nm/pj"], + ["/pj/pj/nm", "/pj/pj/nm"], + ])("%s", async (path, expected) => { + vol.fromJSON({ + "/pj/package.json": "", + "/pj/none": null, + "/pj/none/none/none": null, + "/pj/nm/node_modules": null, + "/pj/nm/none": null, + "/pj/pj/package.json": "", + "/pj/nm/pj/package.json": "", + "/pj/pj/nm/node_modules": null, + }); + + expect(await Npm.prefix(path)).toBe(resolve(expected)); + }); + }); +}); diff --git a/eng/tools/eslint-plugin-tsv/tsconfig.json b/eng/tools/eslint-plugin-tsv/tsconfig.json new file mode 100644 index 000000000000..c16578a92bf1 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/eng/tools/eslint-plugin-tsv/vitest.config.ts b/eng/tools/eslint-plugin-tsv/vitest.config.ts new file mode 100644 index 000000000000..785acc9b7335 --- /dev/null +++ b/eng/tools/eslint-plugin-tsv/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + include: ["src"], + exclude: ["src/interfaces"], + }, + }, +}); diff --git a/eng/tools/package.json b/eng/tools/package.json index 1385766802eb..4e1e559da5a0 100644 --- a/eng/tools/package.json +++ b/eng/tools/package.json @@ -1,6 +1,7 @@ { "name": "azure-rest-api-specs-eng-tools", "devDependencies": { + "@azure-tools/eslint-plugin-tsv": "file:eslint-plugin-tsv", "@azure-tools/specs-model": "file:specs-model", "@azure-tools/suppressions": "file:suppressions", "@azure-tools/tsp-client-tests": "file:tsp-client-tests", diff --git a/eng/tools/tsconfig.json b/eng/tools/tsconfig.json index eca3d4cdace7..44787ca9f682 100644 --- a/eng/tools/tsconfig.json +++ b/eng/tools/tsconfig.json @@ -11,6 +11,7 @@ "composite": true, }, "references": [ + { "path": "./eslint-plugin-tsv" }, { "path": "./specs-model" }, { "path": "./suppressions" }, { "path": "./tsp-client-tests" }, diff --git a/eng/tools/typespec-validation/package.json b/eng/tools/typespec-validation/package.json index c4cdbafaa805..c2516ce524f2 100644 --- a/eng/tools/typespec-validation/package.json +++ b/eng/tools/typespec-validation/package.json @@ -10,6 +10,7 @@ "globby": "^14.0.1", "simple-git": "^3.24.0", "suppressions": "file:../suppressions", + "eslint-plugin-tsv": "file:../eslint-plugin-tsv", "yaml": "^2.4.2" }, "devDependencies": { diff --git a/eng/tools/typespec-validation/src/index.ts b/eng/tools/typespec-validation/src/index.ts index b72ea7616531..b1ac12500eb7 100755 --- a/eng/tools/typespec-validation/src/index.ts +++ b/eng/tools/typespec-validation/src/index.ts @@ -1,13 +1,14 @@ import { parseArgs, ParseArgsConfig } from "node:util"; import { CompileRule } from "./rules/compile.js"; import { EmitAutorestRule } from "./rules/emit-autorest.js"; -import { FlavorAzureRule } from "./rules/flavor-azure.js"; import { FolderStructureRule } from "./rules/folder-structure.js"; import { FormatRule } from "./rules/format.js"; import { LinterRulesetRule } from "./rules/linter-ruleset.js"; import { NpmPrefixRule } from "./rules/npm-prefix.js"; import { TsvRunnerHost } from "./tsv-runner-host.js"; import { getSuppressions, Suppression } from "suppressions"; +import tspconfigRules from "./rules/tspconfig-validation-rules.js"; +import { Rule } from "./rule.js"; export async function main() { const host = new TsvRunnerHost(); @@ -39,15 +40,16 @@ export async function main() { return; } - const rules = [ + let rules: Rule[] = [ new FolderStructureRule(), new NpmPrefixRule(), new EmitAutorestRule(), - new FlavorAzureRule(), new LinterRulesetRule(), new CompileRule(), new FormatRule(), ]; + rules.push(...tspconfigRules); + let success = true; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; diff --git a/eng/tools/typespec-validation/src/rules/flavor-azure.ts b/eng/tools/typespec-validation/src/rules/flavor-azure.ts deleted file mode 100644 index 1c01955448b3..000000000000 --- a/eng/tools/typespec-validation/src/rules/flavor-azure.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { parse as yamlParse } from "yaml"; -import { Rule } from "../rule.js"; -import { RuleResult } from "../rule-result.js"; -import { TsvHost } from "../tsv-host.js"; - -export class FlavorAzureRule implements Rule { - readonly name = "FlavorAzure"; - - readonly description = "Client emitters must set 'flavor:azure'"; - - async execute(host: TsvHost, folder: string): Promise { - let success = true; - let stdOutput = ""; - let errorOutput = ""; - - const configText = await host.readTspConfig(folder); - const config = yamlParse(configText); - - const options = config?.options; - for (const emitter in options) { - if (this.isClientEmitter(emitter)) { - const flavor = options[emitter]?.flavor; - - stdOutput += `"${emitter}":\n`; - stdOutput += ` flavor: ${flavor}\n`; - - if (flavor !== "azure") { - success = false; - errorOutput += - "tspconfig.yaml must define the following property:\n" + - "\n" + - "options:\n" + - ` "${emitter}":\n` + - " flavor: azure\n\n"; - } - } - } - - return { - success: success, - stdOutput: stdOutput, - errorOutput: errorOutput, - }; - } - - isClientEmitter(name: string): boolean { - const regex = new RegExp( - "^(@azure-tools/typespec-(csharp|java|python|ts)|@typespec/http-client-.+)$", - ); - - return regex.test(name); - } -} diff --git a/eng/tools/typespec-validation/src/rules/tspconfig-java-package-dir.ts b/eng/tools/typespec-validation/src/rules/tspconfig-java-package-dir.ts deleted file mode 100644 index 28c8d544f13b..000000000000 --- a/eng/tools/typespec-validation/src/rules/tspconfig-java-package-dir.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { join } from "path"; -import { parse as yamlParse } from "yaml"; -import { Rule } from "../rule.js"; -import { RuleResult } from "../rule-result.js"; -import { TsvHost } from "../tsv-host.js"; - -export class TspConfigJavaPackageDirectoryRule implements Rule { - pattern = new RegExp(/^azure(-\w+)+$/); - - readonly name = "tspconfig-java-package-dir"; - readonly description = `"options.@azure-tools/typespec-java.package-dir" must match ${this.pattern}.`; - readonly action = `Please update "options.@azure-tools/typespec-java.package-dir" to start with "azure", followed by one or more "-" segments. Each segment can contains letters, digits, or underscores. For example: "azure-test".`; - // TODO: provide link to the rule details and full sample - readonly link = ""; - async execute(host: TsvHost, folder: string): Promise { - const tspconfigExists = await host.checkFileExists(join(folder, "tspconfig.yaml")); - if (!tspconfigExists) - return this.createFailedResult(`Failed to find ${join(folder, "tspconfig.yaml")}`); - - let config = undefined; - try { - const configText = await host.readTspConfig(folder); - config = yamlParse(configText); - } catch (error) { - // TODO: append content " Check tpsconfig-file-exists rule for more details." when it's ready - return this.createFailedResult(`Failed to parse ${join(folder, "tspconfig.yaml")}`); - } - - const javaEmitterOptions = config?.options?.["@azure-tools/typespec-java"]; - - if (!javaEmitterOptions) - return this.createFailedResult(`Failed to find "options.@azure-tools/typespec-java"`); - - const packageDir = javaEmitterOptions?.["package-dir"]; - if (!packageDir) - return this.createFailedResult( - `Failed to find "options.@azure-tools/typespec-java.package-dir"`, - ); - - if (!this.pattern.test(packageDir)) { - return this.createFailedResult( - `package-dir "${packageDir}" does not match "${this.pattern}"`, - ); - } - return { success: true, stdOutput: `[${this.name}]: validation passed.` }; - } - - createFailedResult(errorMessage: string): RuleResult { - return { - success: false, - errorOutput: `[${this.name}]: ${errorMessage}. ${this.description} ${this.action} For more information and full samples, see ${this.link}.`, - }; - } -} diff --git a/eng/tools/typespec-validation/src/rules/tspconfig-validation-rules.ts b/eng/tools/typespec-validation/src/rules/tspconfig-validation-rules.ts new file mode 100644 index 000000000000..f5628dac4f3e --- /dev/null +++ b/eng/tools/typespec-validation/src/rules/tspconfig-validation-rules.ts @@ -0,0 +1,57 @@ +// Note: temporary workaround to convert new rules to old rules to provides suggestion to correct tspconfig + +import { join } from "path"; +import { Rule } from "../rule.js"; +import { RuleResult } from "../rule-result.js"; +import { TsvHost } from "../tsv-host.js"; + +import tsvPlugin, { ESLint } from "eslint-plugin-tsv"; + +async function runESLint(content: string, folder: string, ruleName: string) { + const config = tsvPlugin.configs.recommended; + for (const key in config.rules) { + if (key !== "tsv/" + ruleName) delete config.rules[key]; + } + const eslint = new ESLint({ + cwd: join(__dirname, "../../../../"), + overrideConfig: tsvPlugin.configs.recommended, + overrideConfigFile: true, + }); + const results = await eslint.lintText(content, { filePath: join(folder, "tspconfig.yaml") }); + return results; +} + +// NOTE: This is a workaround to convert the new rules to old rules +// To be removed when the new TSV framework is ready +function convertToOldRules() { + let oldRules = []; + for (const [_, rule] of Object.entries(tsvPlugin.rules ?? {})) { + if (!rule.name.startsWith("tspconfig-")) continue; + const oldRule: Rule = { + name: rule.name, + description: rule.meta?.docs?.description ?? "", + async execute(host: TsvHost, folder: string): Promise { + const configText = await host.readTspConfig(folder); + const results = await runESLint(configText, folder, rule.name); + if (results.length > 0 && results[0].messages.length > 0) { + return { + errorOutput: results[0].messages[0].message, + // Only used to provide suggestion to correct tspconfig + success: true, + }; + } + + return { + stdOutput: `[${rule.name}]: validation passed.`, + success: true, + }; + }, + }; + oldRules.push(oldRule); + } + return oldRules; +} + +const rules = convertToOldRules(); + +export default rules; diff --git a/eng/tools/typespec-validation/test/flavor-azure.test.ts b/eng/tools/typespec-validation/test/flavor-azure.test.ts deleted file mode 100644 index e282d9364989..000000000000 --- a/eng/tools/typespec-validation/test/flavor-azure.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it } from "vitest"; -import { FlavorAzureRule } from "../src/rules/flavor-azure.js"; -import { TsvTestHost } from "./tsv-test-host.js"; -import { strict as assert } from "node:assert"; - -describe("flavor-azure", function () { - const clientEmitterNames = [ - "@azure-tools/typespec-csharp", - "@azure-tools/typespec-java", - "@azure-tools/typespec-python", - "@azure-tools/typespec-ts", - "@typespec/http-client-foo", - ]; - - const nonClientEmitterNames = ["@azure-tools/typespec-autorest", "@typespec/openapi3"]; - - clientEmitterNames.forEach(function (emitter) { - it(`should fail if "${emitter}" is missing flavor`, async function () { - let host = new TsvTestHost(); - - host.readTspConfig = async (_folder: string) => ` - options: - "${emitter}": - package-dir: "foo" - `; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(!result.success); - }); - - it(`should fail if "${emitter}" flavor is not "azure"`, async function () { - let host = new TsvTestHost(); - host.readTspConfig = async (_folder: string) => ` - options: - "${emitter}": - package-dir: "foo" - flavor: not-azure - `; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(!result.success); - }); - - it(`should succeed if ${emitter} flavor is "azure"`, async function () { - let host = new TsvTestHost(); - host.readTspConfig = async (_folder: string) => ` - options: - "${emitter}": - package-dir: "foo" - flavor: azure - `; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(result.success); - }); - }); - - nonClientEmitterNames.forEach(function (emitter) { - it(`should succeed if ${emitter} is missing flavor`, async function () { - let host = new TsvTestHost(); - host.readTspConfig = async (_folder: string) => ` - options: - "${emitter}": - azure-resource-provider-folder: "data-plane" - `; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(result.success); - }); - }); - - it("should succeed if config is empty", async function () { - let host = new TsvTestHost(); - host.readTspConfig = async (_folder: string) => ""; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(result.success); - }); - - it("should succeed if config has no options", async function () { - let host = new TsvTestHost(); - host.readTspConfig = async (_folder: string) => ` -emit: - - "@azure-tools/typespec-autorest" -`; - const result = await new FlavorAzureRule().execute(host, TsvTestHost.folder); - assert(result.success); - }); -}); diff --git a/eng/tools/typespec-validation/test/tspconfig.test.ts b/eng/tools/typespec-validation/test/tspconfig.test.ts index 3be4e4f1deb3..e7716d587c9a 100644 --- a/eng/tools/typespec-validation/test/tspconfig.test.ts +++ b/eng/tools/typespec-validation/test/tspconfig.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "vitest"; import { join } from "path"; -import { TspConfigJavaPackageDirectoryRule } from "../src/rules/tspconfig-java-package-dir.js"; +import tspconfigRules from "../src/rules/tspconfig-validation-rules.js"; import { TsvTestHost } from "./tsv-test-host.js"; import { strict as assert, strictEqual } from "node:assert"; import { Rule } from "../src/rule.js"; @@ -13,88 +13,49 @@ interface TestCase { folder: string; } -const testCases: TestCase[] = [ - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "package-dir \"azure-abc\" is valid", - tspconfig: ` -options: - "@azure-tools/typespec-java": - package-dir: azure-abc -`, - expectedResult: true, - }, - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "tspconfig.yaml is not a valid yaml", - tspconfig: `aaa`, - expectedResult: false, - }, - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "java emitter has no options", - tspconfig: ` -options: - "@azure-tools/typespec-ts": - package-dir: com.azure.test -`, - expectedResult: false, - }, - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "java emitter options have no package-dir", - tspconfig: ` -options: - "@azure-tools/typespec-java": - x: com.azure.test -`, - expectedResult: false, - }, - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "package-dir \"azure.test\" is invalid", - tspconfig: ` -options: - "@azure-tools/typespec-java": - package-dir: azure.test -`, - expectedResult: false, - }, - { - rule: new TspConfigJavaPackageDirectoryRule(), - folder: TsvTestHost.folder, - when: "package-dir \"azure-\" is invalid", - tspconfig: ` -options: - "@azure-tools/typespec-java": - package-dir: azure- -`, - expectedResult: false, - }, -]; - -describe("tspconfig", function () { - it.each(testCases)( - `should be $expectedResult for rule $rule.name when $when`, - async (c: TestCase) => { - let host = new TsvTestHost(); - host.checkFileExists = async (file: string) => { - return file === join(TsvTestHost.folder, "tspconfig.yaml"); - }; - host.readTspConfig = async (_folder: string) => c.tspconfig; - const result = await c.rule.execute(host, TsvTestHost.folder); - strictEqual(result.success, c.expectedResult); - if (!c.expectedResult) { - // TODO: assert link when ready - assert(result.errorOutput?.includes(c.rule.name)); - assert(result.errorOutput?.includes(c.rule.description)); - assert(result.errorOutput?.includes(c.rule.action!)); - } +describe("tspconfig rules", () => { + it.each([ + { + rule: tspconfigRules.find((r) => r.name === "tspconfig-java-az-package-dir-match-pattern")!, + folder: "aaa/bbb/", + when: 'package-dir "azure-" is invalid', + tspconfig: ` + options: + "@azure-tools/typespec-java": + package-dir: xxxxx + flavor: azure + `, + expectedResult: false, + }, + { + rule: tspconfigRules.find((r) => r.name === "tspconfig-java-az-package-dir-match-pattern")!, + folder: "aaa/aaa.Management/", + when: 'package-dir "azure-" is invalid', + tspconfig: ` + options: + "@azure-tools/typespec-java": + package-dir: azure-test + flavor: azure + `, + expectedResult: true, }, - ); + ])(`should be $expectedResult for new rule $rule.name when $when`, async (c: TestCase) => { + let host = new TsvTestHost(); + host.checkFileExists = async (file: string) => { + return file === join(TsvTestHost.folder, "tspconfig.yaml"); + }; + host.readTspConfig = async (_folder: string) => c.tspconfig; + const result = await c.rule.execute(host, c.folder); + strictEqual(result.success, true); + assert( + (c.expectedResult && + result.stdOutput && + result.stdOutput.length > 0 && + result.errorOutput === undefined) || + (!c.expectedResult && + result.stdOutput === undefined && + result.errorOutput && + result.errorOutput.length > 0), + ); + }); }); diff --git a/package-lock.json b/package-lock.json index f1ffe4f00aa3..7037acc8b484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "dev": true, "hasInstallScript": true, "devDependencies": { + "@azure-tools/eslint-plugin-tsv": "file:eslint-plugin-tsv", "@azure-tools/sdk-suppressions": "file:sdk-suppressions", "@azure-tools/specs-model": "file:specs-model", "@azure-tools/suppressions": "file:suppressions", @@ -48,6 +49,799 @@ "@azure-tools/typespec-validation": "file:typespec-validation" } }, + "eng/tools/eslint-plugin-tsv": { + "name": "@azure-tools/eslint-plugin-tsv", + "dev": true, + "dependencies": { + "ajv": "^8.17.1", + "yaml-eslint-parser": "^1.2.3" + }, + "devDependencies": { + "@types/node": "^18.19.31", + "@vitest/coverage-v8": "^2.0.4", + "eslint": "^9.17.0", + "memfs": "^4.15.0", + "rimraf": "^5.0.10", + "typescript": "~5.6.2", + "vitest": "^2.0.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/coverage-v8": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz", + "integrity": "sha512-2Y7BPlKH18mAZYAW1tYByudlCYrQyl5RGvnnDYJKW5tCiO5qg3KSAy3XAxcxKz900a0ZXxWtKrMuZLe3lKBpJw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.8", + "vitest": "2.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/expect": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", + "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/runner": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", + "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.8", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/snapshot": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", + "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/spy": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", + "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/@vitest/utils": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", + "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "eng/tools/eslint-plugin-tsv/node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/vite-node": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", + "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/vite-node/node_modules/vite": { + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/vitest": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", + "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.8", + "@vitest/mocker": "2.1.8", + "@vitest/pretty-format": "^2.1.8", + "@vitest/runner": "2.1.8", + "@vitest/snapshot": "2.1.8", + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.8", + "@vitest/ui": "2.1.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", + "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "eng/tools/eslint-plugin-tsv/node_modules/vitest/node_modules/vite": { + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "eng/tools/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "eng/tools/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "eng/tools/node_modules/@types/node": { "version": "18.19.71", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", @@ -386,6 +1180,7 @@ "name": "@azure-tools/typespec-validation", "dev": true, "dependencies": { + "eslint-plugin-tsv": "file:../eslint-plugin-tsv", "globby": "^14.0.1", "simple-git": "^3.24.0", "suppressions": "file:../suppressions", @@ -587,6 +1382,10 @@ "node": ">=12.0.0" } }, + "node_modules/@azure-tools/eslint-plugin-tsv": { + "resolved": "eng/tools/eslint-plugin-tsv", + "link": true + }, "node_modules/@azure-tools/openapi-tools-common": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@azure-tools/openapi-tools-common/-/openapi-tools-common-1.2.2.tgz", @@ -2360,6 +3159,60 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.1.tgz", + "integrity": "sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==", + "dev": true, + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", + "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", @@ -4839,6 +5692,10 @@ } } }, + "node_modules/eslint-plugin-tsv": { + "resolved": "eng/tools/eslint-plugin-tsv", + "link": true + }, "node_modules/eslint-plugin-unicorn": { "version": "56.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-56.0.1.tgz", @@ -5992,6 +6849,15 @@ "dev": true, "license": "Unlicense" }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "engines": { + "node": ">=10.18" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -6821,6 +7687,25 @@ "dev": true, "license": "MIT" }, + "node_modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==", + "dev": true, + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8372,6 +9257,65 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.31.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz", @@ -9047,6 +9991,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9128,6 +10084,22 @@ "dev": true, "license": "MIT" }, + "node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "dev": true, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -9872,6 +10844,35 @@ "node": ">= 14" } }, + "node_modules/yaml-eslint-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.2.3.tgz", + "integrity": "sha512-4wZWvE398hCP7O8n3nXKu/vdq1HcH01ixYlCREaJL5NUMwQ0g3MaGFUBNSlmBtKmhbtVG/Cm6lyYmSVTEVil8A==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.0.0", + "lodash": "^4.17.21", + "yaml": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/yaml-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",