diff --git a/.chronus/changes/linter-ruleset-file-2026-8-3-11-52-0.md b/.chronus/changes/linter-ruleset-file-2026-8-3-11-52-0.md new file mode 100644 index 00000000000..26c92a3127a --- /dev/null +++ b/.chronus/changes/linter-ruleset-file-2026-8-3-11-52-0.md @@ -0,0 +1,24 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add support for defining a linter ruleset in a standalone yaml file and referencing it with the `file:` prefix in `linter.extends`. This lets a repository share and version a set of linter rules without depending on a library release. + +```yaml +# tspconfig.yaml +linter: + extends: + - "file:../common-rules.yaml" +``` + +```yaml +# common-rules.yaml +extends: + - "@typespec/best-practices/recommended" +enable: + "@typespec/best-practices/new-rule": true +disable: + "@typespec/best-practices/foo": "This rule is too strict for this repository" +``` diff --git a/packages/compiler/src/config/config-loader.ts b/packages/compiler/src/config/config-loader.ts index 6c1f6c58dae..e8b8d693020 100644 --- a/packages/compiler/src/config/config-loader.ts +++ b/packages/compiler/src/config/config-loader.ts @@ -1,4 +1,5 @@ import { isCompilerFeatureName } from "../core/features.js"; +import { linterRuleSetFilePrefix } from "../core/linter-ruleset-file.js"; import { createDiagnostic } from "../core/messages.js"; import { getBaseFileName, @@ -9,7 +10,7 @@ import { } from "../core/path-utils.js"; import { createJSONSchemaValidator } from "../core/schema-validator.js"; import { createSourceFile } from "../core/source-file.js"; -import type { Diagnostic, SourceFile, SystemHost } from "../core/types.js"; +import type { Diagnostic, RuleSetFileRef, SourceFile, SystemHost } from "../core/types.js"; import { NoTarget } from "../core/types.js"; import { doIO } from "../utils/io.js"; import { deepFreeze, omitUndefined } from "../utils/misc.js"; @@ -17,8 +18,7 @@ import { getLocationInYamlScript } from "../yaml/index.js"; import { parseYaml } from "../yaml/parser.js"; import type { YamlScript } from "../yaml/types.js"; import { TypeSpecConfigJsonSchema } from "./config-schema.js"; -import type { TypeSpecConfig, TypeSpecRawConfig } from "./types.js"; - +import type { LinterConfig, TypeSpecConfig, TypeSpecRawConfig } from "./types.js"; export const TypeSpecConfigFilename = "tspconfig.yaml"; export const defaultConfig = deepFreeze({ @@ -252,10 +252,33 @@ async function loadConfigFile( trace: typeof data.trace === "string" ? [data.trace] : data.trace, emit, options, - linter: data.linter, + linter: resolveLinterConfigFileRefs(data.linter, getDirectoryPath(filename)), }); } +/** + * Make `file:` ruleset references absolute so they stay resolvable relative to the config file + * that declared them even when that config is extended from another directory. + */ +function resolveLinterConfigFileRefs( + linter: LinterConfig | undefined, + configDir: string, +): LinterConfig | undefined { + if (!Array.isArray(linter?.extends)) { + return linter; + } + return { + ...linter, + extends: linter.extends.map((ref) => { + if (typeof ref !== "string" || !ref.startsWith(linterRuleSetFilePrefix)) { + return ref; + } + const path = ref.slice(linterRuleSetFilePrefix.length); + return `${linterRuleSetFilePrefix}${resolvePath(configDir, path)}` as RuleSetFileRef; + }), + }; +} + export function validateConfigPathsAbsolute(config: TypeSpecConfig): readonly Diagnostic[] { const diagnostics: Diagnostic[] = []; diff --git a/packages/compiler/src/config/types.ts b/packages/compiler/src/config/types.ts index c0f748fcca2..e5472f09fa7 100644 --- a/packages/compiler/src/config/types.ts +++ b/packages/compiler/src/config/types.ts @@ -1,4 +1,4 @@ -import type { Diagnostic, LinterRuleEnableValue, RuleRef } from "../core/types.js"; +import type { Diagnostic, LinterRuleEnableValue, RuleRef, RuleSetRef } from "../core/types.js"; import type { YamlScript } from "../yaml/types.js"; /** @@ -138,7 +138,7 @@ export type EmitterOptions = Record & { }; export interface LinterConfig { - extends?: RuleRef[]; + extends?: RuleSetRef[]; enable?: Record; disable?: Record; } diff --git a/packages/compiler/src/core/linter-ruleset-file.ts b/packages/compiler/src/core/linter-ruleset-file.ts new file mode 100644 index 00000000000..f58ffafcbc9 --- /dev/null +++ b/packages/compiler/src/core/linter-ruleset-file.ts @@ -0,0 +1,86 @@ +import { doIO } from "../utils/io.js"; +import { parseYaml } from "../yaml/parser.js"; +import type { YamlScript } from "../yaml/types.js"; +import { createJSONSchemaValidator } from "./schema-validator.js"; +import type { + Diagnostic, + DiagnosticTarget, + JSONSchemaType, + LinterRuleSet, + SystemHost, +} from "./types.js"; +import { NoTarget } from "./types.js"; + +/** Prefix used in `extends` to reference a ruleset defined in a yaml file. */ +export const linterRuleSetFilePrefix = "file:"; + +export const LinterRuleSetFileJsonSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + required: [], + properties: { + extends: { + type: "array", + nullable: true, + items: { type: "string" }, + }, + enable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { + oneOf: [{ type: "boolean" }, { type: "object" }], + }, + }, + disable: { + type: "object", + required: [], + nullable: true, + additionalProperties: { type: "string" }, + }, + }, +} as any; // ajv type system doesn't like the string templates + +const ruleSetFileValidator = createJSONSchemaValidator(LinterRuleSetFileJsonSchema); + +/** A linter ruleset loaded from a yaml file. */ +export interface LoadedLinterRuleSetFile { + readonly ruleSet: LinterRuleSet; + /** Parsed yaml, used to locate diagnostics reported for entries of this ruleset. */ + readonly script: YamlScript; +} + +/** + * Load a linter ruleset defined in a yaml file. + * @param host Host used to read the file. + * @param path Absolute path to the yaml file. + * @param target Target to report the file loading errors on. Typically the reference that led to this file. + */ +export async function loadLinterRuleSetFile( + host: SystemHost, + path: string, + target: DiagnosticTarget | typeof NoTarget = NoTarget, +): Promise<[LoadedLinterRuleSetFile | undefined, readonly Diagnostic[]]> { + const diagnostics: Diagnostic[] = []; + const reportDiagnostic = (d: Diagnostic) => diagnostics.push(d); + const file = await doIO(host.readFile, path, reportDiagnostic, { diagnosticTarget: target }); + if (file === undefined) { + return [undefined, diagnostics]; + } + + const [script, yamlDiagnostics] = parseYaml(file); + diagnostics.push(...yamlDiagnostics); + if (yamlDiagnostics.some((d) => d.severity === "error")) { + return [undefined, diagnostics]; + } + + // An empty yaml file is a valid, empty, ruleset. + const data = script.value ?? {}; + const validationDiagnostics = ruleSetFileValidator.validate(data, script); + diagnostics.push(...validationDiagnostics); + if (validationDiagnostics.some((d) => d.severity === "error")) { + return [undefined, diagnostics]; + } + + return [{ ruleSet: data as LinterRuleSet, script }, diagnostics]; +} diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 810887ae6a1..281291810e9 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -1,4 +1,6 @@ import { isPromise } from "../utils/misc.js"; +import { getLocationInYamlScript } from "../yaml/diagnostics.js"; +import type { YamlScript } from "../yaml/types.js"; import type { DiagnosticCodeResolver } from "./diagnostic-code.js"; import { formatShortNameCandidates } from "./diagnostic-code.js"; import type { DiagnosticCollector } from "./diagnostics.js"; @@ -7,7 +9,9 @@ import { getLocationContext } from "./helpers/location-context.js"; import { defineLinter } from "./library.js"; import { createUnusedTemplateParameterLinterRule } from "./linter-rules/unused-template-parameter.rule.js"; import { createUnusedUsingLinterRule } from "./linter-rules/unused-using.rule.js"; +import { linterRuleSetFilePrefix, loadLinterRuleSetFile } from "./linter-ruleset-file.js"; import { createDiagnostic } from "./messages.js"; +import { getDirectoryPath, resolvePath } from "./path-utils.js"; import { perf } from "./perf.js"; import type { Program } from "./program.js"; import { createJSONSchemaValidator } from "./schema-validator.js"; @@ -15,6 +19,7 @@ import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./ import type { Diagnostic, DiagnosticMessages, + DiagnosticTarget, LinterDefinition, LinterResolvedDefinition, LinterRule, @@ -29,8 +34,41 @@ import { NoTarget } from "./types.js"; type LinterLibraryInstance = { linter: LinterResolvedDefinition }; +/** + * Where a ruleset came from. `file:` references need a directory to resolve against, so they are + * only supported for rulesets anchored to a location on disk (the project config or another ruleset + * file). Rulesets defined by a library are not anchored anywhere and cannot use them. + */ +type RuleSetOrigin = + | { + readonly kind: "local"; + /** Directory relative `file:` references are resolved against. */ + readonly baseDir: string; + /** Yaml this ruleset was parsed from, used to locate diagnostics on the offending entry. */ + readonly source?: RuleSetYamlSource; + } + | { readonly kind: "library"; readonly ruleSetId: string }; + +/** Yaml a ruleset was defined in, used to locate diagnostics on the offending entry. */ +export interface RuleSetYamlSource { + readonly script: YamlScript; + /** Path of the ruleset within the yaml. Empty in a ruleset file, `["linter"]` in `tspconfig.yaml`. */ + readonly path: readonly string[]; +} + +export interface ExtendRuleSetOptions { + /** Directory used to resolve relative `file:` ruleset references. Defaults to the program project root. */ + readonly baseDir?: string; + /** Yaml the ruleset was defined in. */ + readonly source?: RuleSetYamlSource; +} + export interface Linter { - extendRuleSet(ruleSet: LinterRuleSet): Promise; + /** Extend the current set of enabled rules with the given ruleset. */ + extendRuleSet( + ruleSet: LinterRuleSet, + options?: ExtendRuleSetOptions, + ): Promise; registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void; lint(): Promise; } @@ -118,11 +156,58 @@ export function createLinter( lint, }; - async function extendRuleSet(ruleSet: LinterRuleSet): Promise { + async function extendRuleSet( + ruleSet: LinterRuleSet, + options?: ExtendRuleSetOptions, + ): Promise { + const origin: RuleSetOrigin = { + kind: "local", + baseDir: options?.baseDir ?? program.projectRoot, + source: options?.source, + }; + return extendRuleSetInternal(ruleSet, origin, []); + } + + async function extendRuleSetInternal( + ruleSet: LinterRuleSet, + origin: RuleSetOrigin, + fileStack: readonly string[], + ): Promise { tracer.trace("extend-rule-set.start", JSON.stringify(ruleSet, null, 2)); const diagnostics = createDiagnosticCollector(); if (ruleSet.extends) { - for (const extendingRuleSetName of ruleSet.extends) { + for (const [index, extendingRuleSetName] of ruleSet.extends.entries()) { + if (extendingRuleSetName.startsWith(linterRuleSetFilePrefix)) { + if (origin.kind === "library") { + // A library ruleset is not anchored to a directory, so there is nothing to resolve against. + diagnostics.add( + createDiagnostic({ + code: "ruleset-file-in-library", + format: { ruleSetName: origin.ruleSetId, ref: extendingRuleSetName }, + target: NoTarget, + }), + ); + continue; + } + // Blame the `extends` entry that referenced the file when we know where it came from. + // Looked up by index because config loading rewrites `file:` refs to absolute paths. + const target = origin.source + ? getLocationInYamlScript(origin.source.script, [ + ...origin.source.path, + "extends", + String(index), + ]) + : NoTarget; + for (const diagnostic of await extendRuleSetFile( + extendingRuleSetName.slice(linterRuleSetFilePrefix.length), + origin.baseDir, + fileStack, + target, + )) { + diagnostics.add(diagnostic); + } + continue; + } if (reportIfAmbiguous(extendingRuleSetName, diagnostics)) { continue; } @@ -134,7 +219,13 @@ export function createLinter( const libLinterDefinition = library?.linter; const extendingRuleSet = libLinterDefinition?.ruleSets?.[ref.name]; if (extendingRuleSet) { - await extendRuleSet(extendingRuleSet); + for (const diagnostic of await extendRuleSetInternal( + extendingRuleSet, + { kind: "library", ruleSetId: `${ref.libraryName}/${ref.name}` }, + fileStack, + )) { + diagnostics.add(diagnostic); + } } else { diagnostics.add( createDiagnostic({ @@ -299,6 +390,43 @@ export function createLinter( return { diagnostics: diagnostics.diagnostics, stats }; } + /** Load and apply a ruleset defined in a yaml file. */ + async function extendRuleSetFile( + path: string, + baseDir: string, + fileStack: readonly string[], + target: DiagnosticTarget | typeof NoTarget, + ): Promise { + const resolvedPath = resolvePath(baseDir, path); + tracer.trace("extend-rule-set.file", resolvedPath); + if (fileStack.includes(resolvedPath)) { + return [ + createDiagnostic({ + code: "circular-ruleset-file", + format: { path: resolvedPath }, + target, + }), + ]; + } + + const [loaded, diagnostics] = await loadLinterRuleSetFile(program.host, resolvedPath, target); + if (loaded === undefined) { + return diagnostics; + } + return [ + ...diagnostics, + ...(await extendRuleSetInternal( + loaded.ruleSet, + { + kind: "local", + baseDir: getDirectoryPath(resolvedPath), + source: { script: loaded.script, path: [] }, + }, + [...fileStack, resolvedPath], + )), + ]; + } + async function resolveLibrary(name: string): Promise { const loadedLibrary = linterLibraries.get(name); if (loadedLibrary === undefined) { diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index a60c765a8ae..54556aff4ba 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -877,6 +877,18 @@ const diagnostics = { default: paramMessage`Invalid options for rule "${"ruleName"}": ${"details"}`, }, }, + "circular-ruleset-file": { + severity: "error", + messages: { + default: paramMessage`Linter ruleset file "${"path"}" is extending itself, either directly or indirectly.`, + }, + }, + "ruleset-file-in-library": { + severity: "error", + messages: { + default: paramMessage`Ruleset "${"ruleSetName"}" is defined in a library and cannot extend the ruleset file "${"ref"}". "file:" references can only be used in "tspconfig.yaml" or in another ruleset file.`, + }, + }, /** * Formatter diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ac8d28a7d9f..4517ea0254c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -426,7 +426,14 @@ async function createProgram( ); linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); if (options.linterRuleSet) { - program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); + program.reportDiagnostics( + await linter.extendRuleSet(options.linterRuleSet, { + baseDir: program.projectRoot, + source: options.configFile?.file + ? { script: options.configFile.file, path: ["linter"] } + : undefined, + }), + ); } program.checker = createChecker(program, resolver); diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index cd7ad7fef85..c204ab81830 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2699,6 +2699,16 @@ export type LinterRule< /** Reference to a rule. In this format `:` */ export type RuleRef = `${string}/${string}`; +/** + * Reference to a ruleset defined in a yaml file. In this format `file:`. + * A relative path is resolved relative to the file declaring it (`tspconfig.yaml` or another ruleset file). + * Only valid in `tspconfig.yaml` or in another ruleset file, not in a ruleset defined by a library. + */ +export type RuleSetFileRef = `file:${string}`; + +/** Reference to a ruleset. Either a ruleset defined in a library or one defined in a local yaml file. */ +export type RuleSetRef = RuleRef | RuleSetFileRef; + /** * Value for enabling a linter rule. * - `true` enables the rule with default options. @@ -2708,7 +2718,7 @@ export type LinterRuleEnableValue = boolean | Record; export interface LinterRuleSet { /** Other ruleset this ruleset extends */ - extends?: RuleRef[]; + extends?: RuleSetRef[]; /** Rules to enable/configure */ enable?: Record; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 65735c4b6f1..61e0459d972 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -442,6 +442,8 @@ export type { ReplaceTextCodeFixEdit, RmOptions, RuleRef, + RuleSetFileRef, + RuleSetRef, Scalar, ScalarConstructor, ScalarValue, diff --git a/packages/compiler/src/yaml/diagnostics.ts b/packages/compiler/src/yaml/diagnostics.ts index 783274d52f1..b60b1737b7a 100644 --- a/packages/compiler/src/yaml/diagnostics.ts +++ b/packages/compiler/src/yaml/diagnostics.ts @@ -33,7 +33,10 @@ function findYamlNode( if (isLast) { if (kind === "value" || !isMap(current)) { if (Array.isArray(current.items) && current.items.every((item) => isScalar(item))) { - return current.items.find((m: any) => m.source && m.source === key) as any; + const match = current.items.find((m: any) => m.source && m.source === key) as any; + // Fall back to an index lookup so callers that only know the position of an entry + // (e.g. when the value was rewritten after parsing) can still locate it. + return match ?? (current.get(key, true) as any); } else { return current.get(key, true); } diff --git a/packages/compiler/test/config/config.test.ts b/packages/compiler/test/config/config.test.ts index dd5d7e719b5..d76232c9494 100644 --- a/packages/compiler/test/config/config.test.ts +++ b/packages/compiler/test/config/config.test.ts @@ -187,6 +187,57 @@ describe("file discovery", () => { kind: "project", }); }); + + describe("linter ruleset file references", () => { + async function loadLinterConfig(files: Record, configPath: string) { + const fs = createTestFileSystem(); + for (const [path, content] of Object.entries(files)) { + fs.addTypeSpecFile(path, content); + } + const config = await loadTypeSpecConfigForPath( + fs.compilerHost, + resolveVirtualPath(configPath), + true, + false, + ); + return config.linter; + } + + it("resolves `file:` references relative to the config file", async () => { + const linter = await loadLinterConfig( + { + "project/tspconfig.yaml": ` + linter: + extends: + - "file:./rules/base.yaml" + - "@typespec/best-practices/recommended" + `, + }, + "project/tspconfig.yaml", + ); + deepStrictEqual(linter?.extends, [ + `file:${resolveVirtualPath("project/rules/base.yaml")}`, + "@typespec/best-practices/recommended", + ]); + }); + + it("resolves `file:` references relative to the config that declared them when extending", async () => { + const linter = await loadLinterConfig( + { + "base/tspconfig.yaml": ` + linter: + extends: + - "file:./rules.yaml" + `, + "project/tspconfig.yaml": ` + extends: "../base/tspconfig.yaml" + `, + }, + "project/tspconfig.yaml", + ); + deepStrictEqual(linter?.extends, [`file:${resolveVirtualPath("base/rules.yaml")}`]); + }); + }); }); describe("validation", () => { diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 0858e91f5ef..beb23045efb 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -1,3 +1,4 @@ +import { strictEqual } from "assert"; import { describe, expect, it, vi } from "vitest"; import type { createDiagnosticCodeResolver } from "../../src/core/diagnostic-code.js"; @@ -5,13 +6,20 @@ import { createLinterRule, createTypeSpecLibrary } from "../../src/core/library. import type { Linter } from "../../src/core/linter.js"; import { createLinter, resolveLinterDefinition } from "../../src/core/linter.js"; import { + type Diagnostic, type Interface, type LibraryInstance, type LinterDefinition, type LinterRuleContext, + type SourceLocation, } from "../../src/index.js"; import type { MockFile } from "../../src/testing/index.js"; -import { expectDiagnosticEmpty, expectDiagnostics, mockFile } from "../../src/testing/index.js"; +import { + expectDiagnosticEmpty, + expectDiagnostics, + mockFile, + resolveVirtualPath, +} from "../../src/testing/index.js"; import { Tester } from "../tester.js"; const noModelFoo = createLinterRule({ @@ -841,3 +849,265 @@ describe("rule options", () => { }); }); }); + +/** + * Assert a diagnostic points at `expectedText` inside the given file, so that ruleset diagnostics + * are locatable in an editor rather than being reported globally. + */ +function expectTargetsSourceText( + diagnostic: Diagnostic, + fileName: string, + fileContent: string, + expectedText: string, +) { + const target = diagnostic.target as SourceLocation; + strictEqual(target.file?.path, resolveVirtualPath(fileName)); + strictEqual(fileContent.slice(target.pos, target.end), expectedText); +} + +describe("extending a ruleset defined in a file", () => { + async function createLinterWithFiles(files: Record) { + return await createTestLinter( + { "main.tsp": `model Foo {}`, ...files }, + { + rules: [noModelFoo], + ruleSets: { + custom: { enable: { "@typespec/test-linter/no-model-foo": true } }, + }, + }, + ); + } + + it("enables the rules defined in the file", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +enable: + "@typespec/test-linter/no-model-foo": true +`, + }); + expectDiagnosticEmpty(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] })); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("resolves path without ./ prefix", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +enable: + "@typespec/test-linter/no-model-foo": true +`, + }); + expectDiagnosticEmpty(await linter.extendRuleSet({ extends: ["file:rules.yaml"] })); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("can extend a ruleset defined in a library", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +extends: + - "@typespec/test-linter/custom" +`, + }); + expectDiagnosticEmpty(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] })); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("can disable a rule enabled by an extended ruleset", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +extends: + - "@typespec/test-linter/custom" +disable: + "@typespec/test-linter/no-model-foo": "Not applicable here" +`, + }); + expectDiagnosticEmpty(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] })); + expectDiagnosticEmpty((await linter.lint()).diagnostics); + }); + + it("can enable a rule that an extended file set to `enable: false`", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +enable: + "@typespec/test-linter/no-model-foo": false +`, + }); + expectDiagnosticEmpty( + await linter.extendRuleSet({ + extends: ["file:./rules.yaml"], + enable: { "@typespec/test-linter/no-model-foo": true }, + }), + ); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("can re-enable a rule disabled by an extended file", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +extends: + - "@typespec/test-linter/custom" +disable: + "@typespec/test-linter/no-model-foo": "Not applicable here" +`, + }); + expectDiagnosticEmpty( + await linter.extendRuleSet({ + extends: ["file:./rules.yaml"], + enable: { "@typespec/test-linter/no-model-foo": true }, + }), + ); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("emits a diagnostic when a library ruleset tries to extend a file", async () => { + const linter = await createTestLinter(`model Foo {}`, { + rules: [noModelFoo], + ruleSets: { + custom: { extends: ["file:./rules.yaml"] }, + }, + }); + expectDiagnostics(await linter.extendRuleSet({ extends: ["@typespec/test-linter/custom"] }), { + code: "ruleset-file-in-library", + message: + 'Ruleset "@typespec/test-linter/custom" is defined in a library and cannot extend the ruleset file "file:./rules.yaml". "file:" references can only be used in "tspconfig.yaml" or in another ruleset file.', + }); + expectDiagnosticEmpty((await linter.lint()).diagnostics); + }); + + it("emits a diagnostic when a library ruleset reached through a ruleset file extends a file", async () => { + const linter = await createTestLinter( + { + "main.tsp": `model Foo {}`, + "rules.yaml": `extends:\n - "@typespec/test-linter/custom"\n`, + }, + { + rules: [noModelFoo], + ruleSets: { + custom: { extends: ["file:./other.yaml"] }, + }, + }, + ); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }), { + code: "ruleset-file-in-library", + }); + }); + + it("resolves nested file reference relative to the ruleset file", async () => { + const linter = await createLinterWithFiles({ + "rulesets/main.yaml": ` +extends: + - "file:./base.yaml" +`, + "rulesets/base.yaml": ` +enable: + "@typespec/test-linter/no-model-foo": true +`, + }); + expectDiagnosticEmpty(await linter.extendRuleSet({ extends: ["file:./rulesets/main.yaml"] })); + expectDiagnostics((await linter.lint()).diagnostics, { + code: "@typespec/test-linter/no-model-foo", + }); + }); + + it("emits a diagnostic when the file doesn't exist", async () => { + const linter = await createLinterWithFiles({}); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./not-found.yaml"] }), { + code: "file-not-found", + }); + }); + + it("locates a missing file on the `extends` entry that referenced it", async () => { + const content = ` +extends: + - "file:./not-found.yaml" +`; + const linter = await createLinterWithFiles({ "rules.yaml": content }); + const diagnostics = await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }); + expectDiagnostics(diagnostics, { code: "file-not-found" }); + expectTargetsSourceText(diagnostics[0], "rules.yaml", content, `"file:./not-found.yaml"`); + }); + + it("emits a diagnostic when the file is not a valid ruleset", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +notARuleSetProperty: true +`, + }); + const diagnostics = await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }); + expectDiagnostics(diagnostics, { code: "invalid-schema" }); + // Diagnostics must point at the ruleset file itself, not ``. + strictEqual((diagnostics[0].target as any).file.path, resolveVirtualPath("rules.yaml")); + }); + + it("emits a diagnostic pointing at the ruleset file when the yaml is malformed", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +enable: + - "a/b": [ +`, + }); + const diagnostics = await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }); + expectDiagnostics(diagnostics, { code: "yaml-bad-indent" }); + strictEqual((diagnostics[0].target as any).file.path, resolveVirtualPath("rules.yaml")); + }); + + it("emits a diagnostic when the file extends itself", async () => { + const content = ` +extends: + - "file:./rules.yaml" +`; + const linter = await createLinterWithFiles({ "rules.yaml": content }); + const diagnostics = await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }); + expectDiagnostics(diagnostics, { code: "circular-ruleset-file" }); + // The diagnostic is located on the `extends` entry that closes the cycle. + expectTargetsSourceText(diagnostics[0], "rules.yaml", content, `"file:./rules.yaml"`); + }); + + it("emits a diagnostic when there is a circular reference between files", async () => { + const bContent = ` +extends: + - "file:./a.yaml" +`; + const linter = await createLinterWithFiles({ + "a.yaml": ` +extends: + - "file:./b.yaml" +`, + "b.yaml": bContent, + }); + const diagnostics = await linter.extendRuleSet({ extends: ["file:./a.yaml"] }); + expectDiagnostics(diagnostics, { code: "circular-ruleset-file" }); + // Blames `b.yaml`, the file that references back into the cycle. + expectTargetsSourceText(diagnostics[0], "b.yaml", bContent, `"file:./a.yaml"`); + }); +}); + +describe("(integration) linter ruleset file in tspconfig", () => { + it("loads the ruleset relative to the config file", async () => { + const diagnostics = await Tester.files({ + "node_modules/my-lib/package.json": JSON.stringify({ name: "my-lib", main: "index.js" }), + "node_modules/my-lib/index.js": mockFile.js({ + $lib: createTypeSpecLibrary({ name: "my-lib", diagnostics: {} }), + $linter: { rules: [noModelFoo] }, + }), + "rules.yaml": ` +enable: + "my-lib/no-model-foo": true +`, + }).diagnose(`model Foo {}`, { + compilerOptions: { + linterRuleSet: { extends: ["file:./rules.yaml"] }, + }, + }); + expectDiagnostics(diagnostics, { code: "my-lib/no-model-foo" }); + }); +}); diff --git a/website/src/content/docs/docs/handbook/configuration/configuration.mdx b/website/src/content/docs/docs/handbook/configuration/configuration.mdx index 0e9e6e605b7..6f874cea42f 100644 --- a/website/src/content/docs/docs/handbook/configuration/configuration.mdx +++ b/website/src/content/docs/docs/handbook/configuration/configuration.mdx @@ -86,7 +86,7 @@ model TypeSpecProjectSchema { } model LinterConfig { - extends?: RuleRef[]; + extends?: RuleSetRef[]; enable?: Record; disable?: Record; } @@ -465,6 +465,32 @@ linter: When an object is provided, the values are merged with the rule's default options. See each rule's documentation for available options. +#### Extending a ruleset defined in a file + +A ruleset can also be defined in a standalone YAML file and referenced with the `file:` prefix. This is useful to share a common set of rules across multiple projects, or to version a ruleset independently of the libraries providing the rules. + +```yaml title=tspconfig.yaml +linter: + extends: + - "file:../common-rules.yaml" +``` + +The referenced file has the same shape as the `linter` config: + +```yaml title=common-rules.yaml +extends: + - "@typespec/best-practices/recommended" + - "@typespec/http/recommended" +enable: + "@typespec/best-practices/new-rule": true +disable: + "@typespec/best-practices/foo": "This rule is too strict for this repository" +``` + +Relative paths are resolved relative to the file declaring them: a `file:` reference in `tspconfig.yaml` resolves relative to the config file, and a `file:` reference inside a ruleset file resolves relative to that ruleset file. Ruleset files may extend other ruleset files, and rulesets defined in libraries, in any combination. + +`file:` references can only be used in `tspconfig.yaml` or in another ruleset file. A ruleset defined in a library cannot use them, as it is not anchored to any directory the path could be resolved against. + ## CLI Flags for Emitter Control ### `--no-emit`