Skip to content
24 changes: 24 additions & 0 deletions .chronus/changes/linter-ruleset-file-2026-8-3-11-52-0.md
Original file line number Diff line number Diff line change
@@ -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"
```
31 changes: 27 additions & 4 deletions packages/compiler/src/config/config-loader.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,16 +10,15 @@ 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";
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({
Expand Down Expand Up @@ -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[] = [];

Expand Down
4 changes: 2 additions & 2 deletions packages/compiler/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -138,7 +138,7 @@ export type EmitterOptions = Record<string, unknown> & {
};

export interface LinterConfig {
extends?: RuleRef[];
extends?: RuleSetRef[];
enable?: Record<RuleRef, LinterRuleEnableValue>;
disable?: Record<RuleRef, string>;
}
86 changes: 86 additions & 0 deletions packages/compiler/src/core/linter-ruleset-file.ts
Original file line number Diff line number Diff line change
@@ -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<LinterRuleSet> = {
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];
}
136 changes: 132 additions & 4 deletions packages/compiler/src/core/linter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -7,14 +9,17 @@ 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";
import { EventEmitter, mapEventEmitterToNodeListener, navigateProgram } from "./semantic-walker.js";
import type {
Diagnostic,
DiagnosticMessages,
DiagnosticTarget,
LinterDefinition,
LinterResolvedDefinition,
LinterRule,
Expand All @@ -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<readonly Diagnostic[]>;
/** Extend the current set of enabled rules with the given ruleset. */
extendRuleSet(
ruleSet: LinterRuleSet,
options?: ExtendRuleSetOptions,
): Promise<readonly Diagnostic[]>;
registerLinterLibrary(name: string, lib?: LinterLibraryInstance): void;
lint(): Promise<LinterResult>;
}
Expand Down Expand Up @@ -118,11 +156,58 @@ export function createLinter(
lint,
};

async function extendRuleSet(ruleSet: LinterRuleSet): Promise<readonly Diagnostic[]> {
async function extendRuleSet(
ruleSet: LinterRuleSet,
options?: ExtendRuleSetOptions,
): Promise<readonly Diagnostic[]> {
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<readonly Diagnostic[]> {
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;
}
Expand All @@ -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({
Expand Down Expand Up @@ -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<readonly Diagnostic[]> {
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,
}),
Comment thread
timotheeguerin marked this conversation as resolved.
];
}

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<LinterLibraryInstance | undefined> {
const loadedLibrary = linterLibraries.get(name);
if (loadedLibrary === undefined) {
Expand Down
12 changes: 12 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading