From b9b2fae6e522fe82b8213ef1856cc95b4d4e9660 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 28 May 2026 09:05:09 -0400 Subject: [PATCH 1/5] Add feature flags --- ...oject-compiler-feature-flags-2026-05-28.md | 15 +++++++ packages/compiler/src/config/config-loader.ts | 26 +++++++++++ packages/compiler/src/config/config-schema.ts | 5 +++ .../compiler/src/config/config-to-options.ts | 1 + packages/compiler/src/config/types.ts | 12 +++++ packages/compiler/src/core/checker.ts | 17 ++++--- packages/compiler/src/core/features.ts | 44 +++++++++++++++++++ packages/compiler/src/core/messages.ts | 6 +++ packages/compiler/src/core/modifiers.ts | 6 ++- .../compiler/test/checker/internal.test.ts | 40 +++++++++++++++++ packages/compiler/test/cli.test.ts | 18 ++++++++ packages/compiler/test/config/config.test.ts | 32 ++++++++++++++ .../features-no-project/tspconfig.yaml | 2 + .../scenarios/project-features/tspconfig.yaml | 3 ++ .../project-unknown-feature/tspconfig.yaml | 3 ++ 15 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 .chronus/changes/project-compiler-feature-flags-2026-05-28.md create mode 100644 packages/compiler/src/core/features.ts create mode 100644 packages/compiler/test/config/scenarios/features-no-project/tspconfig.yaml create mode 100644 packages/compiler/test/config/scenarios/project-features/tspconfig.yaml create mode 100644 packages/compiler/test/config/scenarios/project-unknown-feature/tspconfig.yaml diff --git a/.chronus/changes/project-compiler-feature-flags-2026-05-28.md b/.chronus/changes/project-compiler-feature-flags-2026-05-28.md new file mode 100644 index 00000000000..c130adf72f4 --- /dev/null +++ b/.chronus/changes/project-compiler-feature-flags-2026-05-28.md @@ -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. + +```yaml title=tspconfig.yaml +kind: project +features: + - internal-modifier + - function-declarations +``` diff --git a/packages/compiler/src/config/config-loader.ts b/packages/compiler/src/config/config-loader.ts index b58e0554398..0b59e1e3154 100644 --- a/packages/compiler/src/config/config-loader.ts +++ b/packages/compiler/src/config/config-loader.ts @@ -1,3 +1,4 @@ +import { isCompilerFeatureName } from "../core/features.js"; import { createDiagnostic } from "../core/messages.js"; import { getBaseFileName, @@ -205,6 +206,30 @@ async function loadConfigFile( ); } + if (data.features !== undefined && data.kind !== "project") { + diagnostics.push( + createDiagnostic({ + code: "config-project-only-option", + format: { option: "features" }, + target: NoTarget, + }), + ); + } + + if (data.kind === "project" && data.features !== undefined) { + for (const feature of data.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; @@ -216,6 +241,7 @@ async function loadConfigFile( extends: data.extends, kind: data.kind, entrypoint: data.entrypoint, + features: data.features, environmentVariables: data["environment-variables"], parameters: data.parameters, outputDir: data["output-dir"] ?? "{cwd}/tsp-output", diff --git a/packages/compiler/src/config/config-schema.ts b/packages/compiler/src/config/config-schema.ts index 9e11976c81d..f0452d1bad1 100644 --- a/packages/compiler/src/config/config-schema.ts +++ b/packages/compiler/src/config/config-schema.ts @@ -27,6 +27,11 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType = { type: "string", nullable: true, }, + features: { + type: "array", + nullable: true, + items: { type: "string" }, + }, "environment-variables": { type: "object", nullable: true, diff --git a/packages/compiler/src/config/config-to-options.ts b/packages/compiler/src/config/config-to-options.ts index e91cbe313e5..9a00f72914d 100644 --- a/packages/compiler/src/config/config-to-options.ts +++ b/packages/compiler/src/config/config-to-options.ts @@ -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; } } diff --git a/packages/compiler/src/config/types.ts b/packages/compiler/src/config/types.ts index 6accca96310..c0f748fcca2 100644 --- a/packages/compiler/src/config/types.ts +++ b/packages/compiler/src/config/types.ts @@ -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; @@ -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; parameters?: Record; diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index cd23221098d..8fd575da60f 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -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"; @@ -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( diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts new file mode 100644 index 00000000000..d6aabd4216b --- /dev/null +++ b/packages/compiler/src/core/features.ts @@ -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; + +export type CompilerFeatureName = keyof typeof compilerFeatures; + +export const compilerFeatureNames = Object.keys(compilerFeatures) as CompilerFeatureName[]; + +const compilerFeatureNameSet = new Set(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"; +} diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 457ae774915..edfe4f85afb 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -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: { diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 6e4b49f4941..61adf2f988e 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -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"; @@ -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( diff --git a/packages/compiler/test/checker/internal.test.ts b/packages/compiler/test/checker/internal.test.ts index d4798b7dfcd..48b67df4344 100644 --- a/packages/compiler/test/checker/internal.test.ts +++ b/packages/compiler/test/checker/internal.test.ts @@ -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", () => { diff --git a/packages/compiler/test/cli.test.ts b/packages/compiler/test/cli.test.ts index 4ff1e287ad1..0511f1b9552 100644 --- a/packages/compiler/test/cli.test.ts +++ b/packages/compiler/test/cli.test.ts @@ -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", diff --git a/packages/compiler/test/config/config.test.ts b/packages/compiler/test/config/config.test.ts index 859d29c1250..f9fc6363b7a 100644 --- a/packages/compiler/test/config/config.test.ts +++ b/packages/compiler/test/config/config.test.ts @@ -153,6 +153,16 @@ describe("compiler: config file loading", () => { emit: ["openapi"], }); }); + + it("loads config with kind: project and features", async () => { + const config = await loadTestConfig("project-features"); + deepStrictEqual(config, { + diagnostics: [], + outputDir: "{cwd}/tsp-output", + kind: "project", + features: ["internal-modifier"], + }); + }); }); describe("validation", () => { @@ -198,6 +208,16 @@ describe("compiler: config file loading", () => { deepStrictEqual(validate({ kind: "project", entrypoint: "src/service.tsp" }), []); }); + it("succeeds with kind: project and features", () => { + deepStrictEqual(validate({ kind: "project", features: ["internal-modifier"] }), []); + }); + + it("fails with non-string features", () => { + const diagnostics = validate({ kind: "project", features: [123] } as any); + strictEqual(diagnostics.length, 1); + strictEqual(diagnostics[0].code, "invalid-schema"); + }); + it("fails with invalid kind value", () => { const diagnostics = validate({ kind: "invalid" } as any); strictEqual(diagnostics.length, 1); @@ -225,6 +245,18 @@ describe("compiler: config file loading", () => { strictEqual(config.diagnostics[0].code, "config-project-only-option"); }); + it("errors when features is used without kind: project", async () => { + const config = await loadTestConfigFile("features-no-project"); + strictEqual(config.diagnostics.length, 1); + strictEqual(config.diagnostics[0].code, "config-project-only-option"); + }); + + it("errors when project config references unknown features", async () => { + const config = await loadTestConfigFile("project-unknown-feature"); + strictEqual(config.diagnostics.length, 1); + strictEqual(config.diagnostics[0].code, "config-unknown-feature"); + }); + it("allows kind: project in tspconfig.yaml", async () => { const config = await loadTestConfigFile("project-basic"); strictEqual(config.diagnostics.length, 0); diff --git a/packages/compiler/test/config/scenarios/features-no-project/tspconfig.yaml b/packages/compiler/test/config/scenarios/features-no-project/tspconfig.yaml new file mode 100644 index 00000000000..dead613dc4f --- /dev/null +++ b/packages/compiler/test/config/scenarios/features-no-project/tspconfig.yaml @@ -0,0 +1,2 @@ +features: + - internal-modifier diff --git a/packages/compiler/test/config/scenarios/project-features/tspconfig.yaml b/packages/compiler/test/config/scenarios/project-features/tspconfig.yaml new file mode 100644 index 00000000000..771dc586e44 --- /dev/null +++ b/packages/compiler/test/config/scenarios/project-features/tspconfig.yaml @@ -0,0 +1,3 @@ +kind: project +features: + - internal-modifier diff --git a/packages/compiler/test/config/scenarios/project-unknown-feature/tspconfig.yaml b/packages/compiler/test/config/scenarios/project-unknown-feature/tspconfig.yaml new file mode 100644 index 00000000000..41e3f61073c --- /dev/null +++ b/packages/compiler/test/config/scenarios/project-unknown-feature/tspconfig.yaml @@ -0,0 +1,3 @@ +kind: project +features: + - unknown-feature From 92c2013afc570a11d94ea2a90300075ecf0a97b5 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 28 May 2026 09:20:56 -0400 Subject: [PATCH 2/5] Add cli info --- ...oject-compiler-feature-flags-2026-05-28.md | 2 +- .../compiler/src/core/cli/actions/info.ts | 33 +++++++++++++++++++ packages/compiler/src/core/cli/cli.ts | 4 +-- .../test/core/cli/actions/info.test.ts | 28 ++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/core/cli/actions/info.test.ts diff --git a/.chronus/changes/project-compiler-feature-flags-2026-05-28.md b/.chronus/changes/project-compiler-feature-flags-2026-05-28.md index c130adf72f4..5fceb830b6b 100644 --- a/.chronus/changes/project-compiler-feature-flags-2026-05-28.md +++ b/.chronus/changes/project-compiler-feature-flags-2026-05-28.md @@ -5,7 +5,7 @@ packages: --- Add project-scoped compiler feature flags to `tspconfig.yaml`. Compiler feature definitions -are tracked internally with descriptions. +are tracked internally with descriptions and can be listed with `tsp info features`. ```yaml title=tspconfig.yaml kind: project diff --git a/packages/compiler/src/core/cli/actions/info.ts b/packages/compiler/src/core/cli/actions/info.ts index f5cb25a66c6..69c48a49ac0 100644 --- a/packages/compiler/src/core/cli/actions/info.ts +++ b/packages/compiler/src/core/cli/actions/info.ts @@ -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"; @@ -16,6 +19,12 @@ export async function printInfoAction( host: CompilerHost, args: InfoCliArgs, ): Promise { + 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); } @@ -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; +} diff --git a/packages/compiler/src/core/cli/cli.ts b/packages/compiler/src/core/cli/cli.ts index 0bdd6030cc9..86f8f8b6ab8 100644 --- a/packages/compiler/src/core/cli/cli.ts +++ b/packages/compiler/src/core/cli/cli.ts @@ -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", }); }, diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts new file mode 100644 index 00000000000..2642d2479c1 --- /dev/null +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { formatCompilerFeatures } from "../../../../src/core/cli/actions/info.js"; + +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("formatCompilerFeatures", () => { + it("lists available compiler features and marks enabled features", () => { + const output = stripAnsi( + formatCompilerFeatures({ + diagnostics: [], + outputDir: "{cwd}/tsp-output", + projectRoot: "", + kind: "project", + features: ["internal-modifier"], + }).join("\n"), + ).split("\n"); + + expect(output).toEqual([ + "Compiler Features", + "", + " enabled internal-modifier Allows use of the internal modifier without experimental warnings in project code.", + " disabled function-declarations Allows use of function declarations without experimental warnings in project code.", + ]); + }); +}); From 82fd02864a04faa848c3c168072a6ffd7bc95fa4 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 28 May 2026 09:23:19 -0400 Subject: [PATCH 3/5] test: update tspconfig feature completions --- packages/compiler/test/server/completion.tspconfig.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 5e4d6438975..08386e2bebc 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -10,6 +10,7 @@ const rootOptions = [ "extends", "kind", "entrypoint", + "features", "environment-variables", "parameters", "output-dir", @@ -441,6 +442,7 @@ describe("Test completion items for extends", () => { "emit", "entrypoint", "environment-variables", + "features", "imports", "kind", "linter", From a6321052cafe1c468eb37f097a774187da906d6a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 28 May 2026 10:03:14 -0400 Subject: [PATCH 4/5] fix crash --- packages/compiler/src/config/config-loader.ts | 7 ++--- packages/compiler/test/config/config.test.ts | 26 ++++++++++++++++++- .../test/server/server-file-handling.test.ts | 16 ++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/config/config-loader.ts b/packages/compiler/src/config/config-loader.ts index 0b59e1e3154..9a8d63a4e2e 100644 --- a/packages/compiler/src/config/config-loader.ts +++ b/packages/compiler/src/config/config-loader.ts @@ -216,8 +216,9 @@ async function loadConfigFile( ); } - if (data.kind === "project" && data.features !== undefined) { - for (const feature of data.features) { + 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({ @@ -241,7 +242,7 @@ async function loadConfigFile( extends: data.extends, kind: data.kind, entrypoint: data.entrypoint, - features: data.features, + features, environmentVariables: data["environment-variables"], parameters: data.parameters, outputDir: data["output-dir"] ?? "{cwd}/tsp-output", diff --git a/packages/compiler/test/config/config.test.ts b/packages/compiler/test/config/config.test.ts index f9fc6363b7a..57bfee19ae7 100644 --- a/packages/compiler/test/config/config.test.ts +++ b/packages/compiler/test/config/config.test.ts @@ -7,7 +7,8 @@ import { NodeHost } from "../../src/core/node-host.js"; import { createJSONSchemaValidator } from "../../src/core/schema-validator.js"; import { createSourceFile } from "../../src/core/source-file.js"; import { resolvePath } from "../../src/index.js"; -import { findTestPackageRoot } from "../../src/testing/test-utils.js"; +import { createTestFileSystem } from "../../src/testing/fs.js"; +import { findTestPackageRoot, resolveVirtualPath } from "../../src/testing/test-utils.js"; const scenarioRoot = resolvePath( await findTestPackageRoot(import.meta.url), @@ -163,6 +164,29 @@ describe("compiler: config file loading", () => { features: ["internal-modifier"], }); }); + + it("loads config with kind: project and blank features", async () => { + const fs = createTestFileSystem(); + fs.addTypeSpecFile( + "project/tspconfig.yaml", + ` + kind: project + features: + `, + ); + + const { filename, projectRoot, file, ...config } = await loadTypeSpecConfigForPath( + fs.compilerHost, + resolveVirtualPath("project/tspconfig.yaml"), + true, + false, + ); + deepStrictEqual(config, { + diagnostics: [], + outputDir: "{cwd}/tsp-output", + kind: "project", + }); + }); }); describe("validation", () => { diff --git a/packages/compiler/test/server/server-file-handling.test.ts b/packages/compiler/test/server/server-file-handling.test.ts index 0aeb0fa8c75..43cac8673ef 100644 --- a/packages/compiler/test/server/server-file-handling.test.ts +++ b/packages/compiler/test/server/server-file-handling.test.ts @@ -125,4 +125,20 @@ describe("compiler: server: main file", () => { await host.server.checkChange({ document }); deepStrictEqual(host.getDiagnostics("./test.tsp"), [], "No diagnostics expected"); }); + + it("does not crash when project tspconfig has blank features", async () => { + const host = await createTestServerHost(); + + const document = host.addOrUpdateDocument( + "./tspconfig.yaml", + ` + kind: project + features: + `, + ); + host.addTypeSpecFile("./main.tsp", "model Test {}"); + + const result = await host.server.compile(document, undefined, { mode: "full" }); + deepStrictEqual(result?.program.diagnostics, [], "No diagnostics expected"); + }); }); From 50471934d3cb129f71e94aa0406b822636d62e73 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 28 May 2026 10:14:48 -0400 Subject: [PATCH 5/5] add completion for features --- .../src/server/tspconfig/completion.ts | 17 +++++++++ .../test/server/completion.tspconfig.test.ts | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/compiler/src/server/tspconfig/completion.ts b/packages/compiler/src/server/tspconfig/completion.ts index dddd7833851..4ca7df26969 100644 --- a/packages/compiler/src/server/tspconfig/completion.ts +++ b/packages/compiler/src/server/tspconfig/completion.ts @@ -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, @@ -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 && @@ -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); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 08386e2bebc..41f69c2c65c 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -130,6 +130,41 @@ describe("Test completion items for options and emitters", () => { }); }); +describe("Test completion items for features", () => { + it.each([ + { + config: `features:\n - ┆`, + expected: ['"function-declarations"', '"internal-modifier"'], + }, + { + config: `features:\n - "┆"`, + expected: ["function-declarations", "internal-modifier"], + }, + { + config: `features:\n - "internal┆"`, + expected: ["function-declarations", "internal-modifier"], + }, + { + config: `features:\n - internal-modifier\n - ┆`, + expected: ['"function-declarations"'], + }, + ])("#%# Test features: $config", async ({ config, expected }) => { + await checkCompletionItems(config, true, expected); + }); + + it("includes feature descriptions", async () => { + await checkCompletionItems( + `features:\n - ┆`, + true, + [ + "Allows use of function declarations without experimental warnings in project code.", + "Allows use of the internal modifier without experimental warnings in project code.", + ], + true, + ); + }); +}); + describe("Test completion items for emitters options", () => { it.each([ {