Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/library-linter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<library-name>/<name>` 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.
18 changes: 18 additions & 0 deletions packages/library-linter/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<library-name>/<name>".`,
},
},
"extraneous-documentation": {
severity: "warning",
messages: {
Expand Down
2 changes: 2 additions & 0 deletions packages/library-linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ 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();

validateNoExportAtRoot(program, root);
validateDecoratorSignature(program);
validateDocumentation(program);
validateRuleSets(program);
}

function validateNoExportAtRoot(program: Program, root: Namespace) {
Expand Down
129 changes: 129 additions & 0 deletions packages/library-linter/src/validate-rulesets.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const knownRuleSets = new Set<string>();
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<string>;
readonly knownRules: ReadonlySet<string>;
readonly knownRuleSets: ReadonlySet<string>;
}

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;
}
120 changes: 120 additions & 0 deletions packages/library-linter/test/validate-rulesets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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);
});
});
Loading