Skip to content
Open
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
15 changes: 15 additions & 0 deletions .chronus/changes/project-compiler-feature-flags-2026-05-28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add project-scoped compiler feature flags to `tspconfig.yaml`. Compiler feature definitions
are tracked internally with descriptions and can be listed with `tsp info features`.

```yaml title=tspconfig.yaml
kind: project
features:
- internal-modifier
- function-declarations
```
27 changes: 27 additions & 0 deletions packages/compiler/src/config/config-loader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isCompilerFeatureName } from "../core/features.js";
import { createDiagnostic } from "../core/messages.js";
import {
getBaseFileName,
Expand Down Expand Up @@ -205,6 +206,31 @@ async function loadConfigFile(
);
}

if (data.features !== undefined && data.kind !== "project") {
diagnostics.push(
createDiagnostic({
code: "config-project-only-option",
format: { option: "features" },
target: NoTarget,
}),
);
}

const features = Array.isArray(data.features) ? data.features : undefined;
if (data.kind === "project" && features !== undefined) {
for (const feature of features) {
if (!isCompilerFeatureName(feature)) {
diagnostics.push(
createDiagnostic({
code: "config-unknown-feature",
format: { feature },
target: getLocationInYamlScript(yamlScript, ["features", feature]),
}),
);
}
}
}

const emit = data.emit;
const options = data.options;

Expand All @@ -216,6 +242,7 @@ async function loadConfigFile(
extends: data.extends,
kind: data.kind,
entrypoint: data.entrypoint,
features,
environmentVariables: data["environment-variables"],
parameters: data.parameters,
outputDir: data["output-dir"] ?? "{cwd}/tsp-output",
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType<TypeSpecRawConfig> = {
type: "string",
nullable: true,
},
features: {
type: "array",
nullable: true,
items: { type: "string" },
},
"environment-variables": {
type: "object",
nullable: true,
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/config/config-to-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export async function resolveCompilerOptions(
const projectConfig = await findProjectConfig(host, entrypointDir);
if (projectConfig?.kind === "project") {
config.entrypoint ??= projectConfig.entrypoint;
config.features ??= projectConfig.features;
}
}

Expand Down
12 changes: 12 additions & 0 deletions packages/compiler/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export interface TypeSpecConfig {
*/
entrypoint?: string;

/**
* Compiler features enabled for this project.
* Only meaningful when `kind` is `"project"`.
*/
features?: string[];

/** Yaml file used in this configuration. */
file?: YamlScript;

Expand Down Expand Up @@ -99,6 +105,12 @@ export interface TypeSpecRawConfig {
*/
entrypoint?: string;

/**
* Compiler features enabled for this project.
* Only meaningful when `kind` is `"project"`.
*/
features?: string[];

"environment-variables"?: Record<string, ConfigEnvironmentVariable>;
parameters?: Record<string, ConfigParameter>;

Expand Down
17 changes: 10 additions & 7 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ignoreDiagnostics,
reportDeprecated,
} from "./diagnostics.js";
import { isCompilerFeatureEnabled } from "./features.js";
import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator-utils.js";
import { getLocationContext } from "./helpers/location-context.js";
import { explainStringTemplateNotSerializable } from "./helpers/string-template-utils.js";
Expand Down Expand Up @@ -2149,13 +2150,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

checkModifiers(program, node);

reportCheckerDiagnostic(
createDiagnostic({
code: "experimental-feature",
messageId: "functionDeclarations",
target: node,
}),
);
if (!isCompilerFeatureEnabled(program, "function-declarations", node)) {
reportCheckerDiagnostic(
createDiagnostic({
code: "experimental-feature",
messageId: "functionDeclarations",
target: node,
}),
);
}

const namespace = getParentNamespaceType(node);
compilerAssert(
Expand Down
33 changes: 33 additions & 0 deletions packages/compiler/src/core/cli/actions/info.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable no-console */
import pc from "picocolors";
import { fileURLToPath } from "url";
import { stringify } from "yaml";
import { loadTypeSpecConfigForPath } from "../../../config/config-loader.js";
import type { TypeSpecConfig } from "../../../config/types.js";
import { compilerFeatureNames, compilerFeatures } from "../../features.js";
import { CompilerHost, Diagnostic } from "../../types.js";
import { printEmitterOptionsAction } from "./info/emitter-options.js";

Expand All @@ -16,6 +19,12 @@ export async function printInfoAction(
host: CompilerHost,
args: InfoCliArgs,
): Promise<readonly Diagnostic[]> {
if (args.emitter === "features") {
const config = await loadTypeSpecConfigForPath(host, process.cwd(), false, true);
console.log(formatCompilerFeatures(config).join("\n"));
return config.diagnostics;
}

if (args.emitter) {
return printEmitterOptionsAction(host, args.emitter);
}
Expand All @@ -32,3 +41,27 @@ export async function printInfoAction(
console.log("-----------");
return config.diagnostics;
}

export function formatCompilerFeatures(config?: TypeSpecConfig): string[] {
const enabledFeatures = new Set(config?.features ?? []);
const lines: string[] = [];

lines.push(pc.bold("Compiler Features"));
lines.push("");

const statusWidth = "disabled".length;
const nameWidth = Math.max(...compilerFeatureNames.map((name) => name.length));

for (const name of compilerFeatureNames) {
const enabled = enabledFeatures.has(name);
const status = enabled
? pc.green("enabled".padEnd(statusWidth))
: pc.dim("disabled".padEnd(statusWidth));
const featureName = name.padEnd(nameWidth);
const description = compilerFeatures[name].description;

lines.push(` ${status} ${pc.bold(pc.cyan(featureName))} ${description}`);
}

return lines;
}
4 changes: 2 additions & 2 deletions packages/compiler/src/core/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,10 @@ async function main() {
)
.command(
"info [emitter]",
"Show information about the current TypeSpec compiler, or about a specific emitter.",
"Show information about the current TypeSpec compiler, compiler features, or a specific emitter.",
(cmd) => {
return cmd.positional("emitter", {
description: "The emitter package name to show options for.",
description: "The emitter package name to show options for, or 'features'.",
type: "string",
});
},
Expand Down
44 changes: 44 additions & 0 deletions packages/compiler/src/core/features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getLocationContext } from "./helpers/location-context.js";
import type { Program } from "./program.js";
import type { DiagnosticTarget } from "./types.js";

export interface CompilerFeatureDefinition {
readonly description: string;
}

export const compilerFeatures = {
"internal-modifier": {
description:
"Allows use of the internal modifier without experimental warnings in project code.",
},
"function-declarations": {
description:
"Allows use of function declarations without experimental warnings in project code.",
},
} as const satisfies Record<string, CompilerFeatureDefinition>;

export type CompilerFeatureName = keyof typeof compilerFeatures;

export const compilerFeatureNames = Object.keys(compilerFeatures) as CompilerFeatureName[];

const compilerFeatureNameSet = new Set<string>(compilerFeatureNames);

export function isCompilerFeatureName(feature: string): feature is CompilerFeatureName {
return compilerFeatureNameSet.has(feature);
}

export function isCompilerFeatureEnabled(
program: Program,
feature: CompilerFeatureName,
target?: DiagnosticTarget,
): boolean {
if (!program.compilerOptions.configFile?.features?.includes(feature)) {
return false;
}

if (target === undefined) {
return true;
}

return getLocationContext(program, target).type === "project";
}
6 changes: 6 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,12 @@ const diagnostics = {
default: paramMessage`Property "${"option"}" can only be used in a project config (with \`kind: project\`).`,
},
},
"config-unknown-feature": {
severity: "error",
messages: {
default: paramMessage`Unknown compiler feature "${"feature"}".`,
},
},
"config-project-not-as-cli-config": {
severity: "error",
messages: {
Expand Down
6 changes: 5 additions & 1 deletion packages/compiler/src/core/modifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

import { compilerAssert } from "./diagnostics.js";
import { isCompilerFeatureEnabled } from "./features.js";
import { createDiagnostic } from "./messages.js";
import { Program } from "./program.js";
import { Declaration, Modifier, ModifierFlags, SyntaxKind } from "./types.js";
Expand Down Expand Up @@ -67,7 +68,10 @@ export function checkModifiers(program: Program, node: Declaration): boolean {
let isValid = true;

// Emit experimental warning for any use of the 'internal' modifier.
if (node.modifierFlags & ModifierFlags.Internal) {
if (
node.modifierFlags & ModifierFlags.Internal &&
!isCompilerFeatureEnabled(program, "internal-modifier", node)
) {
const internalModifiers = filterModifiersByFlags(node.modifiers, ModifierFlags.Internal);
for (const modifier of internalModifiers) {
program.reportDiagnostic(
Expand Down
17 changes: 17 additions & 0 deletions packages/compiler/src/server/tspconfig/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "vscode-languageserver/node.js";
import { Document, isMap, isPair, Node } from "yaml";
import { emitterOptionsSchema, TypeSpecConfigJsonSchema } from "../../config/config-schema.js";
import { compilerFeatureNames, compilerFeatures } from "../../core/features.js";
import {
getDirectoryPath,
getNormalizedAbsolutePath,
Expand Down Expand Up @@ -76,6 +77,7 @@ export async function provideTspconfigCompletionItems(
const CONFIG_PATH_LENGTH_FOR_LINTER_LIST = 2;
const CONFIG_PATH_LENGTH_FOR_EXTENDS = 1;
const CONFIG_PATH_LENGTH_FOR_IMPORTS = 2;
const CONFIG_PATH_LENGTH_FOR_FEATURES = 2;

if (
(nodePath.length === CONFIG_PATH_LENGTH_FOR_EMITTER_LIST &&
Expand Down Expand Up @@ -187,6 +189,21 @@ export async function provideTspconfigCompletionItems(
});
}
return items;
} else if (
nodePath.length === CONFIG_PATH_LENGTH_FOR_FEATURES &&
nodePath[0] === "features" &&
targetType === "arr-item"
) {
return compilerFeatureNames
.filter((name) => !siblings.includes(name))
.map((name) =>
createCompletionItemWithQuote(
name,
compilerFeatures[name].description,
tspConfigPosition,
target,
),
);
} else if (nodePath.length === CONFIG_PATH_LENGTH_FOR_EXTENDS && nodePath[0] === "extends") {
const currentFolder = getDirectoryPath(tspConfigFile);
const newFolderPath = joinPaths(currentFolder, source);
Expand Down
40 changes: 40 additions & 0 deletions packages/compiler/test/checker/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ describe("modifier validation", () => {
const diagnostics = await Tester.diagnose(`model Foo {}`);
expectDiagnosticEmpty(diagnostics);
});

it("does not emit experimental warning for project code when internal-modifier feature is enabled", async () => {
const diagnostics = await Tester.diagnose(`internal model Foo {}`, {
compilerOptions: {
configFile: {
diagnostics: [],
outputDir: "{cwd}/tsp-output",
projectRoot: "",
kind: "project",
features: ["internal-modifier"],
},
},
});
expectDiagnosticEmpty(diagnostics);
});

it("still emits experimental warning for library code when project enables internal-modifier feature", async () => {
const diagnostics = await Tester.files({
"node_modules/my-lib/package.json": JSON.stringify({
name: "my-lib",
version: "1.0.0",
exports: { ".": { typespec: "./main.tsp" } },
}),
"node_modules/my-lib/main.tsp": "internal model LibModel {}",
})
.import("my-lib")
.diagnose(`model Foo {}`, {
compilerOptions: {
configFile: {
diagnostics: [],
outputDir: "{cwd}/tsp-output",
projectRoot: "",
kind: "project",
features: ["internal-modifier"],
},
},
});

expectDiagnostics(diagnostics, { code: "experimental-feature" });
});
});

describe("access control", () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,24 @@ describe("compiler: cli", () => {
strictEqual(options.configFile?.entrypoint, "src/service.tsp");
});

it("auto-inherits features from project tspconfig when using --config", async () => {
host.addTypeSpecFile(
"ws/tspconfig.yaml",
stringify({ kind: "project", features: ["internal-modifier"] }),
);
host.addTypeSpecFile("ws/tspconfig.build.yaml", stringify({ emit: ["openapi"] }));
const [options, diagnostics] = await getCompilerOptions(
host.compilerHost,
"ws/main.tsp",
cwd,
{ config: "tspconfig.build.yaml" },
{},
);
expectDiagnosticEmpty(diagnostics);
ok(options, "Options should have been set.");
deepStrictEqual(options.configFile?.features, ["internal-modifier"]);
});

it("does not auto-inherit build config from project tspconfig", async () => {
host.addTypeSpecFile(
"ws/tspconfig.yaml",
Expand Down
Loading
Loading