From 0119ad9e6979f2bbfc8604289107d143538f4db4 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 3 Sep 2026 11:55:30 -0400 Subject: [PATCH 1/6] feat(compiler): allow defining a linter ruleset in a yaml file Support referencing a standalone yaml ruleset from `linter.extends` with the `file:` prefix. Relative paths resolve against the file declaring them, ruleset files can extend other files and library rulesets, and circular references are reported. Fixes microsoft/typespec#3011 --- .../linter-ruleset-file-2026-8-3-11-52-0.md | 24 +++ packages/compiler/src/config/config-loader.ts | 31 +++- packages/compiler/src/config/types.ts | 4 +- .../compiler/src/core/linter-ruleset-file.ts | 69 ++++++++ packages/compiler/src/core/linter.ts | 65 +++++++- packages/compiler/src/core/messages.ts | 6 + packages/compiler/src/core/program.ts | 4 +- packages/compiler/src/core/types.ts | 11 +- packages/compiler/src/index.ts | 2 + packages/compiler/test/config/config.test.ts | 51 ++++++ packages/compiler/test/core/linter.test.ts | 150 ++++++++++++++++++ .../handbook/configuration/configuration.mdx | 26 ++- 12 files changed, 431 insertions(+), 12 deletions(-) create mode 100644 .chronus/changes/linter-ruleset-file-2026-8-3-11-52-0.md create mode 100644 packages/compiler/src/core/linter-ruleset-file.ts 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..c6de02a494a --- /dev/null +++ b/packages/compiler/src/core/linter-ruleset-file.ts @@ -0,0 +1,69 @@ +import { doIO } from "../utils/io.js"; +import { parseYaml } from "../yaml/parser.js"; +import { createJSONSchemaValidator } from "./schema-validator.js"; +import type { Diagnostic, JSONSchemaType, LinterRuleSet, SystemHost } 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); + +/** + * 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. + */ +export async function loadLinterRuleSetFile( + host: SystemHost, + path: string, +): Promise<[LinterRuleSet | undefined, readonly Diagnostic[]]> { + const diagnostics: Diagnostic[] = []; + const reportDiagnostic = (d: Diagnostic) => diagnostics.push(d); + const file = await doIO(host.readFile, path, reportDiagnostic); + if (file === undefined) { + return [undefined, diagnostics]; + } + + const [yamlScript, 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 = yamlScript.value ?? {}; + const validationDiagnostics = ruleSetFileValidator.validate(data, yamlScript); + diagnostics.push(...validationDiagnostics); + if (validationDiagnostics.some((d) => d.severity === "error")) { + return [undefined, diagnostics]; + } + + return [data as LinterRuleSet, diagnostics]; +} diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 810887ae6a1..51dc6df036b 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -7,7 +7,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"; @@ -30,7 +32,12 @@ import { NoTarget } from "./types.js"; type LinterLibraryInstance = { linter: LinterResolvedDefinition }; export interface Linter { - extendRuleSet(ruleSet: LinterRuleSet): Promise; + /** + * Extend the current set of enabled rules with the given ruleset. + * @param ruleSet Ruleset to extend. + * @param baseDir Directory used to resolve relative `file:` ruleset references. Defaults to the program project root. + */ + extendRuleSet(ruleSet: LinterRuleSet, baseDir?: string): Promise; registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void; lint(): Promise; } @@ -118,11 +125,32 @@ export function createLinter( lint, }; - async function extendRuleSet(ruleSet: LinterRuleSet): Promise { + async function extendRuleSet( + ruleSet: LinterRuleSet, + baseDir: string = program.projectRoot, + ): Promise { + return extendRuleSetInternal(ruleSet, baseDir, []); + } + + async function extendRuleSetInternal( + ruleSet: LinterRuleSet, + baseDir: string, + 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) { + if (extendingRuleSetName.startsWith(linterRuleSetFilePrefix)) { + for (const diagnostic of await extendRuleSetFile( + extendingRuleSetName.slice(linterRuleSetFilePrefix.length), + baseDir, + fileStack, + )) { + diagnostics.add(diagnostic); + } + continue; + } if (reportIfAmbiguous(extendingRuleSetName, diagnostics)) { continue; } @@ -134,7 +162,7 @@ export function createLinter( const libLinterDefinition = library?.linter; const extendingRuleSet = libLinterDefinition?.ruleSets?.[ref.name]; if (extendingRuleSet) { - await extendRuleSet(extendingRuleSet); + await extendRuleSetInternal(extendingRuleSet, baseDir, fileStack); } else { diagnostics.add( createDiagnostic({ @@ -299,6 +327,37 @@ 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[], + ): 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: NoTarget, + }), + ]; + } + + const [ruleSet, diagnostics] = await loadLinterRuleSetFile(program.host, resolvedPath); + if (ruleSet === undefined) { + return diagnostics; + } + return [ + ...diagnostics, + ...(await extendRuleSetInternal(ruleSet, getDirectoryPath(resolvedPath), [ + ...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 c91a2528634..83261efb30c 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -863,6 +863,12 @@ 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.`, + }, + }, /** * Formatter diff --git a/packages/compiler/src/core/program.ts b/packages/compiler/src/core/program.ts index ac8d28a7d9f..47ee26ef48a 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -426,7 +426,9 @@ async function createProgram( ); linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); if (options.linterRuleSet) { - program.reportDiagnostics(await linter.extendRuleSet(options.linterRuleSet)); + program.reportDiagnostics( + await linter.extendRuleSet(options.linterRuleSet, program.projectRoot), + ); } program.checker = createChecker(program, resolver); diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 518eed2a991..5c43d5dd687 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2671,6 +2671,15 @@ 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). + */ +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. @@ -2680,7 +2689,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/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..73e7ca2e99f 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -841,3 +841,153 @@ describe("rule options", () => { }); }); }); + +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("resolve 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("resolve 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("emit a diagnostic when the file doesn't exists", async () => { + const linter = await createLinterWithFiles({}); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./not-found.yaml"] }), { + code: "file-not-found", + }); + }); + + it("emit a diagnostic when the file is not a valid ruleset", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +notARuleSetProperty: true +`, + }); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }), { + code: "invalid-schema", + }); + }); + + it("emit a diagnostic when the file extends itself", async () => { + const linter = await createLinterWithFiles({ + "rules.yaml": ` +extends: + - "file:./rules.yaml" +`, + }); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }), { + code: "circular-ruleset-file", + }); + }); + + it("emit a diagnostic when there is a circular reference between files", async () => { + const linter = await createLinterWithFiles({ + "a.yaml": ` +extends: + - "file:./b.yaml" +`, + "b.yaml": ` +extends: + - "file:./a.yaml" +`, + }); + expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./a.yaml"] }), { + code: "circular-ruleset-file", + }); + }); +}); + +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..cbe1fca535b 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,30 @@ 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. + ## CLI Flags for Emitter Control ### `--no-emit` From 6fbaf3ff8a81adbfc72336169a9e1eaa2faa749d Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 3 Sep 2026 13:18:55 -0400 Subject: [PATCH 2/6] Fix test description for path resolution Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/compiler/test/core/linter.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 73e7ca2e99f..f388b836dd1 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -868,7 +868,7 @@ enable: }); }); - it("resolve path without ./ prefix", async () => { + it("resolves path without ./ prefix", async () => { const linter = await createLinterWithFiles({ "rules.yaml": ` enable: From 280019eff158716c5552647abf7701e0e5791051 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 3 Sep 2026 13:31:07 -0400 Subject: [PATCH 3/6] test: assert ruleset file diagnostics point at the ruleset file path --- packages/compiler/src/core/types.ts | 2 +- packages/compiler/test/core/linter.test.ts | 35 +++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 5c43d5dd687..d3acf984377 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2673,7 +2673,7 @@ 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). + * A relative path is resolved relative to the file declaring it (`tspconfig.yaml` or another ruleset file). */ export type RuleSetFileRef = `file:${string}`; diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index f388b836dd1..8ba8a1a1c9b 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"; @@ -11,7 +12,12 @@ import { type LinterRuleContext, } 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({ @@ -907,7 +913,7 @@ disable: expectDiagnosticEmpty((await linter.lint()).diagnostics); }); - it("resolve nested file reference relative to the ruleset file", async () => { + it("resolves nested file reference relative to the ruleset file", async () => { const linter = await createLinterWithFiles({ "rulesets/main.yaml": ` extends: @@ -924,25 +930,38 @@ enable: }); }); - it("emit a diagnostic when the file doesn't exists", async () => { + 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("emit a diagnostic when the file is not a valid ruleset", async () => { + it("emits a diagnostic when the file is not a valid ruleset", async () => { const linter = await createLinterWithFiles({ "rules.yaml": ` notARuleSetProperty: true `, }); - expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }), { - code: "invalid-schema", + 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("emit a diagnostic when the file extends itself", async () => { + it("emits a diagnostic when the file extends itself", async () => { const linter = await createLinterWithFiles({ "rules.yaml": ` extends: @@ -954,7 +973,7 @@ extends: }); }); - it("emit a diagnostic when there is a circular reference between files", async () => { + it("emits a diagnostic when there is a circular reference between files", async () => { const linter = await createLinterWithFiles({ "a.yaml": ` extends: From 7eddd8cac62c860f6b11b9375a6af071de149959 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 10:48:04 -0400 Subject: [PATCH 4/6] fix(compiler): locate ruleset file diagnostics on the extends entry that caused them --- .../compiler/src/core/linter-ruleset-file.ts | 31 +++++++--- packages/compiler/src/core/linter.ts | 28 ++++++--- packages/compiler/test/core/linter.test.ts | 58 ++++++++++++++----- 3 files changed, 88 insertions(+), 29 deletions(-) diff --git a/packages/compiler/src/core/linter-ruleset-file.ts b/packages/compiler/src/core/linter-ruleset-file.ts index c6de02a494a..f58ffafcbc9 100644 --- a/packages/compiler/src/core/linter-ruleset-file.ts +++ b/packages/compiler/src/core/linter-ruleset-file.ts @@ -1,7 +1,15 @@ 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, JSONSchemaType, LinterRuleSet, SystemHost } from "./types.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:"; @@ -35,35 +43,44 @@ export const LinterRuleSetFileJsonSchema: JSONSchemaType = { 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, -): Promise<[LinterRuleSet | undefined, readonly Diagnostic[]]> { + 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); + const file = await doIO(host.readFile, path, reportDiagnostic, { diagnosticTarget: target }); if (file === undefined) { return [undefined, diagnostics]; } - const [yamlScript, yamlDiagnostics] = parseYaml(file); + 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 = yamlScript.value ?? {}; - const validationDiagnostics = ruleSetFileValidator.validate(data, yamlScript); + const data = script.value ?? {}; + const validationDiagnostics = ruleSetFileValidator.validate(data, script); diagnostics.push(...validationDiagnostics); if (validationDiagnostics.some((d) => d.severity === "error")) { return [undefined, diagnostics]; } - return [data as LinterRuleSet, 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 51dc6df036b..3756f95a0ad 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"; @@ -17,6 +19,7 @@ import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./ import type { Diagnostic, DiagnosticMessages, + DiagnosticTarget, LinterDefinition, LinterResolvedDefinition, LinterRule, @@ -136,16 +139,22 @@ export function createLinter( ruleSet: LinterRuleSet, baseDir: string, fileStack: readonly string[], + source?: YamlScript, ): Promise { tracer.trace("extend-rule-set.start", JSON.stringify(ruleSet, null, 2)); const diagnostics = createDiagnosticCollector(); if (ruleSet.extends) { for (const extendingRuleSetName of ruleSet.extends) { if (extendingRuleSetName.startsWith(linterRuleSetFilePrefix)) { + // Blame the `extends` entry that referenced the file when we know where it came from. + const target = source + ? getLocationInYamlScript(source, ["extends", extendingRuleSetName]) + : NoTarget; for (const diagnostic of await extendRuleSetFile( extendingRuleSetName.slice(linterRuleSetFilePrefix.length), baseDir, fileStack, + target, )) { diagnostics.add(diagnostic); } @@ -162,7 +171,7 @@ export function createLinter( const libLinterDefinition = library?.linter; const extendingRuleSet = libLinterDefinition?.ruleSets?.[ref.name]; if (extendingRuleSet) { - await extendRuleSetInternal(extendingRuleSet, baseDir, fileStack); + await extendRuleSetInternal(extendingRuleSet, baseDir, fileStack, source); } else { diagnostics.add( createDiagnostic({ @@ -332,6 +341,7 @@ export function createLinter( path: string, baseDir: string, fileStack: readonly string[], + target: DiagnosticTarget | typeof NoTarget, ): Promise { const resolvedPath = resolvePath(baseDir, path); tracer.trace("extend-rule-set.file", resolvedPath); @@ -340,21 +350,23 @@ export function createLinter( createDiagnostic({ code: "circular-ruleset-file", format: { path: resolvedPath }, - target: NoTarget, + target, }), ]; } - const [ruleSet, diagnostics] = await loadLinterRuleSetFile(program.host, resolvedPath); - if (ruleSet === undefined) { + const [loaded, diagnostics] = await loadLinterRuleSetFile(program.host, resolvedPath, target); + if (loaded === undefined) { return diagnostics; } return [ ...diagnostics, - ...(await extendRuleSetInternal(ruleSet, getDirectoryPath(resolvedPath), [ - ...fileStack, - resolvedPath, - ])), + ...(await extendRuleSetInternal( + loaded.ruleSet, + getDirectoryPath(resolvedPath), + [...fileStack, resolvedPath], + loaded.script, + )), ]; } diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 8ba8a1a1c9b..1c097d950f9 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -6,10 +6,12 @@ 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 { @@ -848,6 +850,21 @@ 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( @@ -937,6 +954,17 @@ enable: }); }); + 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": ` @@ -962,31 +990,33 @@ enable: }); it("emits a diagnostic when the file extends itself", async () => { - const linter = await createLinterWithFiles({ - "rules.yaml": ` + const content = ` extends: - "file:./rules.yaml" -`, - }); - expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./rules.yaml"] }), { - code: "circular-ruleset-file", - }); +`; + 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": ` -extends: - - "file:./a.yaml" -`, - }); - expectDiagnostics(await linter.extendRuleSet({ extends: ["file:./a.yaml"] }), { - code: "circular-ruleset-file", + "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"`); }); }); From d22440338e6859e4c2a2c43652646febc216e232 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 19:52:03 -0400 Subject: [PATCH 5/6] test: cover re-enabling a rule that an extended ruleset file turned off --- packages/compiler/test/core/linter.test.ts | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/compiler/test/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 1c097d950f9..64efaa3c569 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -930,6 +930,44 @@ disable: 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("resolves nested file reference relative to the ruleset file", async () => { const linter = await createLinterWithFiles({ "rulesets/main.yaml": ` From b168c0f342ddcea463f043cac7fa3755fed2ba94 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 20:21:24 -0400 Subject: [PATCH 6/6] fix(compiler): disallow file: ruleset refs in libraries and locate config diagnostics - Library-defined rulesets cannot use `file:` refs (new ruleset-file-in-library diagnostic) - Propagate diagnostics from nested library rulesets, which were previously dropped - Target file: diagnostics declared in tspconfig.yaml on the offending extends entry --- packages/compiler/src/core/linter.ts | 91 +++++++++++++++---- packages/compiler/src/core/messages.ts | 6 ++ packages/compiler/src/core/program.ts | 7 +- packages/compiler/src/core/types.ts | 1 + packages/compiler/src/yaml/diagnostics.ts | 5 +- packages/compiler/test/core/linter.test.ts | 33 +++++++ .../handbook/configuration/configuration.mdx | 2 + 7 files changed, 126 insertions(+), 19 deletions(-) diff --git a/packages/compiler/src/core/linter.ts b/packages/compiler/src/core/linter.ts index 3756f95a0ad..281291810e9 100644 --- a/packages/compiler/src/core/linter.ts +++ b/packages/compiler/src/core/linter.ts @@ -34,13 +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 { - /** - * Extend the current set of enabled rules with the given ruleset. - * @param ruleSet Ruleset to extend. - * @param baseDir Directory used to resolve relative `file:` ruleset references. Defaults to the program project root. - */ - extendRuleSet(ruleSet: LinterRuleSet, baseDir?: string): 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; } @@ -130,29 +158,49 @@ export function createLinter( async function extendRuleSet( ruleSet: LinterRuleSet, - baseDir: string = program.projectRoot, + options?: ExtendRuleSetOptions, ): Promise { - return extendRuleSetInternal(ruleSet, baseDir, []); + const origin: RuleSetOrigin = { + kind: "local", + baseDir: options?.baseDir ?? program.projectRoot, + source: options?.source, + }; + return extendRuleSetInternal(ruleSet, origin, []); } async function extendRuleSetInternal( ruleSet: LinterRuleSet, - baseDir: string, + origin: RuleSetOrigin, fileStack: readonly string[], - source?: YamlScript, ): 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. - const target = source - ? getLocationInYamlScript(source, ["extends", extendingRuleSetName]) + // 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), - baseDir, + origin.baseDir, fileStack, target, )) { @@ -171,7 +219,13 @@ export function createLinter( const libLinterDefinition = library?.linter; const extendingRuleSet = libLinterDefinition?.ruleSets?.[ref.name]; if (extendingRuleSet) { - await extendRuleSetInternal(extendingRuleSet, baseDir, fileStack, source); + for (const diagnostic of await extendRuleSetInternal( + extendingRuleSet, + { kind: "library", ruleSetId: `${ref.libraryName}/${ref.name}` }, + fileStack, + )) { + diagnostics.add(diagnostic); + } } else { diagnostics.add( createDiagnostic({ @@ -363,9 +417,12 @@ export function createLinter( ...diagnostics, ...(await extendRuleSetInternal( loaded.ruleSet, - getDirectoryPath(resolvedPath), + { + kind: "local", + baseDir: getDirectoryPath(resolvedPath), + source: { script: loaded.script, path: [] }, + }, [...fileStack, resolvedPath], - loaded.script, )), ]; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 12dff1843a1..54556aff4ba 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -883,6 +883,12 @@ const diagnostics = { 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 47ee26ef48a..4517ea0254c 100644 --- a/packages/compiler/src/core/program.ts +++ b/packages/compiler/src/core/program.ts @@ -427,7 +427,12 @@ async function createProgram( linter.registerLinterLibrary(builtInLinterLibraryName, createBuiltInLinterLibrary()); if (options.linterRuleSet) { program.reportDiagnostics( - await linter.extendRuleSet(options.linterRuleSet, program.projectRoot), + await linter.extendRuleSet(options.linterRuleSet, { + baseDir: program.projectRoot, + source: options.configFile?.file + ? { script: options.configFile.file, path: ["linter"] } + : undefined, + }), ); } diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 0426865ba7e..c204ab81830 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -2702,6 +2702,7 @@ 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}`; 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/core/linter.test.ts b/packages/compiler/test/core/linter.test.ts index 64efaa3c569..beb23045efb 100644 --- a/packages/compiler/test/core/linter.test.ts +++ b/packages/compiler/test/core/linter.test.ts @@ -968,6 +968,39 @@ disable: }); }); + 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": ` diff --git a/website/src/content/docs/docs/handbook/configuration/configuration.mdx b/website/src/content/docs/docs/handbook/configuration/configuration.mdx index cbe1fca535b..6f874cea42f 100644 --- a/website/src/content/docs/docs/handbook/configuration/configuration.mdx +++ b/website/src/content/docs/docs/handbook/configuration/configuration.mdx @@ -489,6 +489,8 @@ disable: 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`