From 579048b6a9bc6180610ab3349b7cf6b3f11c0960 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 4 Sep 2026 22:13:20 -0400 Subject: [PATCH 1/2] feat(library-linter): validate rules and rulesets referenced by a library's rulesets --- ...nter-validate-rulesets-2026-8-4-22-15-0.md | 22 +++ packages/library-linter/README.md | 7 + packages/library-linter/src/lib.ts | 18 +++ packages/library-linter/src/linter.ts | 2 + .../library-linter/src/validate-rulesets.ts | 129 ++++++++++++++++++ .../test/validate-rulesets.test.ts | 120 ++++++++++++++++ 6 files changed, 298 insertions(+) create mode 100644 .chronus/changes/library-linter-validate-rulesets-2026-8-4-22-15-0.md create mode 100644 packages/library-linter/src/validate-rulesets.ts create mode 100644 packages/library-linter/test/validate-rulesets.test.ts diff --git a/.chronus/changes/library-linter-validate-rulesets-2026-8-4-22-15-0.md b/.chronus/changes/library-linter-validate-rulesets-2026-8-4-22-15-0.md new file mode 100644 index 00000000000..e8617540358 --- /dev/null +++ b/.chronus/changes/library-linter-validate-rulesets-2026-8-4-22-15-0.md @@ -0,0 +1,22 @@ +--- +changeKind: feature +packages: + - "@typespec/library-linter" +--- + +Validate that rules and rulesets referenced by the rulesets a library defines actually exist. Previously a dangling reference was only reported when a consumer happened to extend the offending ruleset. + +```ts +export const $linter = defineLinter({ + rules: [casingRule], + ruleSets: { + recommended: { + // warning: Rule 'removed-rule' referenced by ruleset '@typespec/best-practices/recommended' + // is not defined in library '@typespec/best-practices'. + enable: { "@typespec/best-practices/removed-rule": true }, + }, + }, +}); +``` + +References to a library that is not part of the compilation are skipped, and only the rulesets of the library being compiled are validated. diff --git a/packages/library-linter/README.md b/packages/library-linter/README.md index 5223fd50230..89dab082e01 100644 --- a/packages/library-linter/README.md +++ b/packages/library-linter/README.md @@ -25,6 +25,13 @@ tsp compile . --import @typespec/library-linter | `missing-signature` | Validate that every exported JS decorator function has a matching `extern dec` declaration. | | `missing-documentation` | Validate that every public declaration and member (properties, enum members, parameters, template parameters) has documentation. | | `extraneous-documentation` | Validate that doc comments do not document things that do not exist, such as an unknown `@param` name or an unrecognized doc tag. | +| `unknown-rule` | Validate that every rule referenced by a ruleset the library defines actually exists. | +| `unknown-rule-set` | Validate that every ruleset referenced by a ruleset the library defines actually exists. | +| `invalid-rule-reference` | Validate that references in a ruleset use the `/` format. | Declarations in a namespace named `Private` and declarations marked `internal` are not part of the public surface of a library and are excluded from the documentation rules. + +Rulesets are only validated for the library being compiled, not for its dependencies. A reference to +a library that is not part of the compilation is skipped, since there is nothing to resolve it +against. diff --git a/packages/library-linter/src/lib.ts b/packages/library-linter/src/lib.ts index ee19a927268..096bcbd3242 100644 --- a/packages/library-linter/src/lib.ts +++ b/packages/library-linter/src/lib.ts @@ -22,6 +22,24 @@ export const libDef = { member: paramMessage`Missing documentation for ${"kind"} '${"name"}' of '${"container"}'. Add a doc comment describing it.`, }, }, + "unknown-rule": { + severity: "warning", + messages: { + default: paramMessage`Rule '${"name"}' referenced by ruleset '${"ruleSetName"}' is not defined in library '${"libraryName"}'.`, + }, + }, + "unknown-rule-set": { + severity: "warning", + messages: { + default: paramMessage`Ruleset '${"name"}' referenced by ruleset '${"ruleSetName"}' is not defined in library '${"libraryName"}'.`, + }, + }, + "invalid-rule-reference": { + severity: "warning", + messages: { + default: paramMessage`Reference '${"ref"}' in ruleset '${"ruleSetName"}' is invalid. It must be in the format "/".`, + }, + }, "extraneous-documentation": { severity: "warning", messages: { diff --git a/packages/library-linter/src/linter.ts b/packages/library-linter/src/linter.ts index 8e57a833cc5..f8171677d5e 100644 --- a/packages/library-linter/src/linter.ts +++ b/packages/library-linter/src/linter.ts @@ -2,6 +2,7 @@ import type { Namespace, Program, Type } from "@typespec/compiler"; import { SyntaxKind } from "@typespec/compiler/ast"; import { reportDiagnostic } from "./lib.js"; import { validateDocumentation } from "./validate-docs.js"; +import { validateRuleSets } from "./validate-rulesets.js"; export function $onValidate(program: Program) { const root = program.getGlobalNamespaceType(); @@ -9,6 +10,7 @@ export function $onValidate(program: Program) { validateNoExportAtRoot(program, root); validateDecoratorSignature(program); validateDocumentation(program); + validateRuleSets(program); } function validateNoExportAtRoot(program: Program, root: Namespace) { diff --git a/packages/library-linter/src/validate-rulesets.ts b/packages/library-linter/src/validate-rulesets.ts new file mode 100644 index 00000000000..4e882f76ee7 --- /dev/null +++ b/packages/library-linter/src/validate-rulesets.ts @@ -0,0 +1,129 @@ +import { + NoTarget, + resolveLinterDefinition, + type LinterResolvedDefinition, + type LinterRuleSet, + type Program, +} from "@typespec/compiler"; +import { reportDiagnostic } from "./lib.js"; + +interface LoadedLinter { + readonly libName: string; + readonly linter: LinterResolvedDefinition; + /** Whether this linter belongs to the library being compiled as opposed to one of its dependencies. */ + readonly isProject: boolean; +} + +/** + * Validate that every rule and ruleset referenced by the rulesets of the library being compiled + * actually exists. Without this a dangling reference is only reported when a consumer happens to + * extend the offending ruleset. + */ +export function validateRuleSets(program: Program) { + const linters = collectLinters(program); + const knownLibraries = new Set(linters.map((x) => x.libName)); + const knownRules = new Set(); + const knownRuleSets = new Set(); + for (const { libName, linter } of linters) { + for (const rule of linter.rules) { + knownRules.add(rule.id); + } + for (const name of Object.keys(linter.ruleSets)) { + knownRuleSets.add(`${libName}/${name}`); + } + } + + for (const { libName, linter, isProject } of linters) { + if (!isProject) continue; + for (const [name, ruleSet] of Object.entries(linter.ruleSets)) { + validateRuleSet(program, `${libName}/${name}`, ruleSet, { + knownLibraries, + knownRules, + knownRuleSets, + }); + } + } +} + +interface KnownReferences { + readonly knownLibraries: ReadonlySet; + readonly knownRules: ReadonlySet; + readonly knownRuleSets: ReadonlySet; +} + +function validateRuleSet( + program: Program, + ruleSetName: string, + ruleSet: LinterRuleSet, + known: KnownReferences, +) { + for (const ref of ruleSet.extends ?? []) { + validateReference(program, ruleSetName, ref, "ruleset", known); + } + for (const ref of Object.keys(ruleSet.enable ?? {})) { + validateReference(program, ruleSetName, ref, "rule", known); + } + for (const ref of Object.keys(ruleSet.disable ?? {})) { + validateReference(program, ruleSetName, ref, "rule", known); + } +} + +function validateReference( + program: Program, + ruleSetName: string, + ref: string, + kind: "rule" | "ruleset", + known: KnownReferences, +) { + const parsed = parseReference(ref); + if (parsed === undefined) { + reportDiagnostic(program, { + code: "invalid-rule-reference", + format: { ref, ruleSetName }, + target: NoTarget, + }); + return; + } + + // The referenced library is not part of this compilation, so there is nothing to check against. + // This happens when a ruleset references a library that the current library does not import. + if (!known.knownLibraries.has(parsed.libraryName)) { + return; + } + + const exists = kind === "rule" ? known.knownRules.has(ref) : known.knownRuleSets.has(ref); + if (!exists) { + reportDiagnostic(program, { + code: kind === "rule" ? "unknown-rule" : "unknown-rule-set", + format: { name: parsed.name, libraryName: parsed.libraryName, ruleSetName }, + target: NoTarget, + }); + } +} + +function parseReference(ref: string): { libraryName: string; name: string } | undefined { + const segments = ref.split("/"); + const name = segments.pop(); + const libraryName = segments.join("/"); + if (!libraryName || !name) { + return undefined; + } + return { libraryName, name }; +} + +function collectLinters(program: Program): LoadedLinter[] { + const linters: LoadedLinter[] = []; + for (const jsFile of program.jsSourceFiles.values()) { + const lib = jsFile.esmExports.$lib; + const linter = jsFile.esmExports.$linter; + if (linter === undefined || typeof lib?.name !== "string") { + continue; + } + linters.push({ + libName: lib.name, + linter: resolveLinterDefinition(lib.name, linter), + isProject: program.getSourceFileLocationContext(jsFile.file).type === "project", + }); + } + return linters; +} diff --git a/packages/library-linter/test/validate-rulesets.test.ts b/packages/library-linter/test/validate-rulesets.test.ts new file mode 100644 index 00000000000..2a725c0c16d --- /dev/null +++ b/packages/library-linter/test/validate-rulesets.test.ts @@ -0,0 +1,120 @@ +import { mockFile } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { Tester } from "./test-host.js"; + +function libFile(name: string, linter: unknown) { + return mockFile.js({ + $lib: { name }, + $linter: linter, + }); +} + +const casingRule = { + name: "casing", + severity: "warning", + description: "casing", + messages: { default: "casing" }, + create: () => ({}), +}; + +async function diagnoseLib(linter: unknown, extraFiles: Record = {}) { + const imports = ["./mylib.js", ...Object.keys(extraFiles)] + .map((x) => `import "${x}";`) + .join("\n"); + const diagnostics = await Tester.files({ + "./mylib.js": libFile("@test/mylib", linter), + ...extraFiles, + }).diagnose(imports); + return diagnostics.filter((x) => x.code.startsWith("@typespec/library-linter/unknown")); +} + +describe("validate rulesets", () => { + it("emits no diagnostic when a ruleset references a rule of its own library", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { enable: { "@test/mylib/casing": true } } }, + }); + expect(diagnostics).toHaveLength(0); + }); + + it("emits a diagnostic when a ruleset enables a rule that does not exist", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { enable: { "@test/mylib/removed": true } } }, + }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule"); + expect(diagnostics[0].message).toBe( + "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", + ); + }); + + it("emits a diagnostic when a ruleset disables a rule that does not exist", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { disable: { "@test/mylib/removed": "gone" } } }, + }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule"); + }); + + it("emits a diagnostic when a ruleset extends a ruleset that does not exist", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { extends: ["@test/mylib/missing"] } }, + }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule-set"); + expect(diagnostics[0].message).toBe( + "Ruleset 'missing' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", + ); + }); + + it("resolves references to the auto generated `all` ruleset", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { extends: ["@test/mylib/all"] } }, + }); + expect(diagnostics).toHaveLength(0); + }); + + it("resolves references to rules of another library in the compilation", async () => { + const diagnostics = await diagnoseLib( + { rules: [], ruleSets: { recommended: { enable: { "@test/other/casing": true } } } }, + { "./other.js": libFile("@test/other", { rules: [casingRule] }) }, + ); + expect(diagnostics).toHaveLength(0); + }); + + it("emits a diagnostic for a missing rule of another library in the compilation", async () => { + const diagnostics = await diagnoseLib( + { rules: [], ruleSets: { recommended: { enable: { "@test/other/removed": true } } } }, + { "./other.js": libFile("@test/other", { rules: [casingRule] }) }, + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].message).toBe( + "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/other'.", + ); + }); + + it("validates every ruleset defined in the project being compiled", async () => { + const diagnostics = await diagnoseLib( + { rules: [casingRule] }, + { + "./other.js": libFile("@test/other", { + rules: [], + ruleSets: { recommended: { enable: { "@test/other/removed": true } } }, + }), + }, + ); + expect(diagnostics).toHaveLength(1); + }); + + it("ignores references to a library that is not part of the compilation", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { enable: { "@test/not-installed/some-rule": true } } }, + }); + expect(diagnostics).toHaveLength(0); + }); +}); From a0d155853cf9abc70a2b4c8744ee131cb6ba04ae Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 8 Sep 2026 10:16:23 -0400 Subject: [PATCH 2/2] fix(library-linter): address review feedback on ruleset validation --- packages/library-linter/README.md | 2 +- .../library-linter/src/validate-rulesets.ts | 41 ++++++--- .../test/validate-rulesets.test.ts | 83 +++++++++++++------ 3 files changed, 86 insertions(+), 40 deletions(-) diff --git a/packages/library-linter/README.md b/packages/library-linter/README.md index 89dab082e01..d55e2ae8e55 100644 --- a/packages/library-linter/README.md +++ b/packages/library-linter/README.md @@ -33,5 +33,5 @@ Declarations in a namespace named `Private` and declarations marked `internal` a public surface of a library and are excluded from the documentation rules. Rulesets are only validated for the library being compiled, not for its dependencies. A reference to -a library that is not part of the compilation is skipped, since there is nothing to resolve it +a library that is not loaded in the compilation is skipped, since there is nothing to resolve it against. diff --git a/packages/library-linter/src/validate-rulesets.ts b/packages/library-linter/src/validate-rulesets.ts index 4e882f76ee7..fccb2a50d31 100644 --- a/packages/library-linter/src/validate-rulesets.ts +++ b/packages/library-linter/src/validate-rulesets.ts @@ -1,15 +1,17 @@ import { - NoTarget, resolveLinterDefinition, type LinterResolvedDefinition, type LinterRuleSet, type Program, } from "@typespec/compiler"; +import type { JsSourceFileNode } from "@typespec/compiler/ast"; import { reportDiagnostic } from "./lib.js"; interface LoadedLinter { readonly libName: string; readonly linter: LinterResolvedDefinition; + /** JS file declaring the linter. Used as the diagnostic target so reports have a location. */ + readonly node: JsSourceFileNode; /** Whether this linter belongs to the library being compiled as opposed to one of its dependencies. */ readonly isProject: boolean; } @@ -20,8 +22,7 @@ interface LoadedLinter { * extend the offending ruleset. */ export function validateRuleSets(program: Program) { - const linters = collectLinters(program); - const knownLibraries = new Set(linters.map((x) => x.libName)); + const { linters, knownLibraries } = collectLibraries(program); const knownRules = new Set(); const knownRuleSets = new Set(); for (const { libName, linter } of linters) { @@ -33,10 +34,10 @@ export function validateRuleSets(program: Program) { } } - for (const { libName, linter, isProject } of linters) { + for (const { libName, linter, node, isProject } of linters) { if (!isProject) continue; for (const [name, ruleSet] of Object.entries(linter.ruleSets)) { - validateRuleSet(program, `${libName}/${name}`, ruleSet, { + validateRuleSet(program, `${libName}/${name}`, ruleSet, node, { knownLibraries, knownRules, knownRuleSets, @@ -55,16 +56,17 @@ function validateRuleSet( program: Program, ruleSetName: string, ruleSet: LinterRuleSet, + target: JsSourceFileNode, known: KnownReferences, ) { for (const ref of ruleSet.extends ?? []) { - validateReference(program, ruleSetName, ref, "ruleset", known); + validateReference(program, ruleSetName, ref, "ruleset", target, known); } for (const ref of Object.keys(ruleSet.enable ?? {})) { - validateReference(program, ruleSetName, ref, "rule", known); + validateReference(program, ruleSetName, ref, "rule", target, known); } for (const ref of Object.keys(ruleSet.disable ?? {})) { - validateReference(program, ruleSetName, ref, "rule", known); + validateReference(program, ruleSetName, ref, "rule", target, known); } } @@ -73,6 +75,7 @@ function validateReference( ruleSetName: string, ref: string, kind: "rule" | "ruleset", + target: JsSourceFileNode, known: KnownReferences, ) { const parsed = parseReference(ref); @@ -80,7 +83,7 @@ function validateReference( reportDiagnostic(program, { code: "invalid-rule-reference", format: { ref, ruleSetName }, - target: NoTarget, + target, }); return; } @@ -96,7 +99,7 @@ function validateReference( reportDiagnostic(program, { code: kind === "rule" ? "unknown-rule" : "unknown-rule-set", format: { name: parsed.name, libraryName: parsed.libraryName, ruleSetName }, - target: NoTarget, + target, }); } } @@ -111,19 +114,31 @@ function parseReference(ref: string): { libraryName: string; name: string } | un return { libraryName, name }; } -function collectLinters(program: Program): LoadedLinter[] { +function collectLibraries(program: Program): { + linters: LoadedLinter[]; + knownLibraries: Set; +} { const linters: LoadedLinter[] = []; + // Every library loaded in this compilation, including those defining no linter: a reference into + // such a library is known to be broken, unlike one pointing at a library that was never loaded. + const knownLibraries = new Set(); for (const jsFile of program.jsSourceFiles.values()) { const lib = jsFile.esmExports.$lib; + if (typeof lib?.name !== "string") { + continue; + } + knownLibraries.add(lib.name); + const linter = jsFile.esmExports.$linter; - if (linter === undefined || typeof lib?.name !== "string") { + if (linter === undefined) { continue; } linters.push({ libName: lib.name, linter: resolveLinterDefinition(lib.name, linter), + node: jsFile, isProject: program.getSourceFileLocationContext(jsFile.file).type === "project", }); } - return linters; + return { linters, knownLibraries }; } diff --git a/packages/library-linter/test/validate-rulesets.test.ts b/packages/library-linter/test/validate-rulesets.test.ts index 2a725c0c16d..899c3a9e71a 100644 --- a/packages/library-linter/test/validate-rulesets.test.ts +++ b/packages/library-linter/test/validate-rulesets.test.ts @@ -1,11 +1,11 @@ -import { mockFile } from "@typespec/compiler/testing"; -import { describe, expect, it } from "vitest"; +import { expectDiagnosticEmpty, expectDiagnostics, mockFile } from "@typespec/compiler/testing"; +import { describe, it } from "vitest"; import { Tester } from "./test-host.js"; -function libFile(name: string, linter: unknown) { +function libFile(name: string, linter?: unknown) { return mockFile.js({ $lib: { name }, - $linter: linter, + ...(linter === undefined ? {} : { $linter: linter }), }); } @@ -21,11 +21,10 @@ async function diagnoseLib(linter: unknown, extraFiles: Record = {} const imports = ["./mylib.js", ...Object.keys(extraFiles)] .map((x) => `import "${x}";`) .join("\n"); - const diagnostics = await Tester.files({ + return Tester.files({ "./mylib.js": libFile("@test/mylib", linter), ...extraFiles, }).diagnose(imports); - return diagnostics.filter((x) => x.code.startsWith("@typespec/library-linter/unknown")); } describe("validate rulesets", () => { @@ -34,7 +33,7 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { enable: { "@test/mylib/casing": true } } }, }); - expect(diagnostics).toHaveLength(0); + expectDiagnosticEmpty(diagnostics); }); it("emits a diagnostic when a ruleset enables a rule that does not exist", async () => { @@ -42,11 +41,12 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { enable: { "@test/mylib/removed": true } } }, }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule"); - expect(diagnostics[0].message).toBe( - "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", - ); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule", + severity: "warning", + message: + "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", + }); }); it("emits a diagnostic when a ruleset disables a rule that does not exist", async () => { @@ -54,8 +54,11 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { disable: { "@test/mylib/removed": "gone" } } }, }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule"); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule", + message: + "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", + }); }); it("emits a diagnostic when a ruleset extends a ruleset that does not exist", async () => { @@ -63,11 +66,22 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { extends: ["@test/mylib/missing"] } }, }); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].code).toBe("@typespec/library-linter/unknown-rule-set"); - expect(diagnostics[0].message).toBe( - "Ruleset 'missing' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", - ); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule-set", + message: + "Ruleset 'missing' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/mylib'.", + }); + }); + + it("emits a diagnostic when a reference is not in the '/' format", async () => { + const diagnostics = await diagnoseLib({ + rules: [casingRule], + ruleSets: { recommended: { enable: { removed: true } } }, + }); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/invalid-rule-reference", + message: `Reference 'removed' in ruleset '@test/mylib/recommended' is invalid. It must be in the format "/".`, + }); }); it("resolves references to the auto generated `all` ruleset", async () => { @@ -75,7 +89,7 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { extends: ["@test/mylib/all"] } }, }); - expect(diagnostics).toHaveLength(0); + expectDiagnosticEmpty(diagnostics); }); it("resolves references to rules of another library in the compilation", async () => { @@ -83,7 +97,7 @@ describe("validate rulesets", () => { { rules: [], ruleSets: { recommended: { enable: { "@test/other/casing": true } } } }, { "./other.js": libFile("@test/other", { rules: [casingRule] }) }, ); - expect(diagnostics).toHaveLength(0); + expectDiagnosticEmpty(diagnostics); }); it("emits a diagnostic for a missing rule of another library in the compilation", async () => { @@ -91,10 +105,23 @@ describe("validate rulesets", () => { { rules: [], ruleSets: { recommended: { enable: { "@test/other/removed": true } } } }, { "./other.js": libFile("@test/other", { rules: [casingRule] }) }, ); - expect(diagnostics).toHaveLength(1); - expect(diagnostics[0].message).toBe( - "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/other'.", + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule", + message: + "Rule 'removed' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/other'.", + }); + }); + + it("emits a diagnostic for a rule of a library in the compilation that defines no linter", async () => { + const diagnostics = await diagnoseLib( + { rules: [], ruleSets: { recommended: { enable: { "@test/other/casing": true } } } }, + { "./other.js": libFile("@test/other") }, ); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule", + message: + "Rule 'casing' referenced by ruleset '@test/mylib/recommended' is not defined in library '@test/other'.", + }); }); it("validates every ruleset defined in the project being compiled", async () => { @@ -107,7 +134,11 @@ describe("validate rulesets", () => { }), }, ); - expect(diagnostics).toHaveLength(1); + expectDiagnostics(diagnostics, { + code: "@typespec/library-linter/unknown-rule", + message: + "Rule 'removed' referenced by ruleset '@test/other/recommended' is not defined in library '@test/other'.", + }); }); it("ignores references to a library that is not part of the compilation", async () => { @@ -115,6 +146,6 @@ describe("validate rulesets", () => { rules: [casingRule], ruleSets: { recommended: { enable: { "@test/not-installed/some-rule": true } } }, }); - expect(diagnostics).toHaveLength(0); + expectDiagnosticEmpty(diagnostics); }); });