Decision
Four files per project, nothing personal in any of them. extensions.json recommends the extensions the gate's checks assume (C#, C# Dev Kit, Prettier, Oxc, Vitest, Playwright Test, EditorConfig, and the Containers and Bicep extensions for what the site runs on). settings.json names the default solution, turns on format on save with the same Prettier and the same .editorconfig the gate checks against, pins the project's own TypeScript, and keeps the editor's watcher and search off bin/, obj/, node_modules/ and the build outputs, for the same reason vite.config.ts keeps Vite's watcher off api/: dotnet holds locks on obj/ while it builds. tasks.json holds every command the README gives, as a task with its command shown beside it in the picker, and one compound that runs the checks the gate runs in the order it runs them, without the browser. launch.json holds F5: the API under the .NET debugger on the port launchSettings.json names, the page in Chrome through Vite on the port the browser suite names, a compound of both, and The Shed with the browser opening on its listening line. Colour themes, keybindings and fonts stay in the user's profile, where they belong.
{
"version": "0.2.0",
"configurations": [
{
"name": "Yard: API",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Yard: build API",
"program": "${workspaceFolder}/api/TheYard.Api/bin/Debug/net10.0/TheYard.Api.dll",
"cwd": "${workspaceFolder}/api/TheYard.Api",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5210"
},
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": "Yard: web in Chrome",
"type": "chrome",
"request": "launch",
"preLaunchTask": "Yard: web only (Vite)",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}"
},
{
"name": "Shed: run",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Shed: build",
"program": "${workspaceFolder}/samples/theshed/bin/Debug/net10.0/TestProject.dll",
"cwd": "${workspaceFolder}/samples/theshed",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5120"
},
"console": "internalConsole",
"checkForDevCert": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
}
}
],
"compounds": [
{
"name": "Yard: API + web (F5)",
"configurations": ["Yard: API", "Yard: web in Chrome"],
"stopAll": true
}
]
}
Live from .vscode/launch.json, region *, read from this build at 565827b.
The configuration is held to the code. WorkspaceTests reads the three files with comments allowed and resolves every name: a task that runs npm run x fails when package.json has no x, a task that runs dotnet build p fails when p is not a project, a launch configuration fails when its DLL path does not match a project in the tree and that project's target framework and assembly name, when its port is not the one in that project's launchSettings.json, or when the task it runs first is not in tasks.json; the Chrome configuration fails when its port is not Vite's. The same test holds the page to the files: every task and every launch configuration is named on Working in VS Code, through the live sample of tasks.json the page carries and the prose around it, so a task added to the file shows on the page that explains it.
[Theory]
[InlineData(".vscode")]
[InlineData("samples/theshed/.vscode")]
public void Every_launch_configuration_runs_a_project_in_the_tree_on_the_port_its_own_settings_name(string folder)
{
string root = Repo.Root();
string home = Path.GetDirectoryName(Path.Combine(root, folder))!;
using var launch = Read(root, folder, "launch.json");
using var tasks = Read(root, folder, "tasks.json");
var taskLabels = tasks.RootElement.GetProperty("tasks").EnumerateArray()
.Select(task => task.GetProperty("label").GetString()!)
.ToHashSet(StringComparer.Ordinal);
var names = new List<string>();
var wrong = new List<string>();
foreach (var configuration in launch.RootElement.GetProperty("configurations").EnumerateArray())
{
string name = configuration.GetProperty("name").GetString()!;
names.Add(name);
if (configuration.TryGetProperty("preLaunchTask", out var pre) && !taskLabels.Contains(pre.GetString()!))
{
wrong.Add($"'{name}' runs the task '{pre.GetString()}' first, which {folder}/tasks.json does not define");
}
string type = configuration.GetProperty("type").GetString()!;
if (type == "coreclr")
{
string program = configuration.GetProperty("program").GetString()!.Replace("${workspaceFolder}", home, StringComparison.Ordinal);
var shape = Regex.Match(program.Replace('\\', '/'), @"^(?<project>.+)/bin/Debug/(?<tfm>net\d+\.\d+)/(?<assembly>[^/]+)\.dll$");
Assert.True(shape.Success, $"'{name}' runs {program}, which is not a bin/Debug/<tfm>/<Assembly>.dll path");
string projectFolder = shape.Groups["project"].Value;
string? csproj = Directory.Exists(projectFolder)
? Directory.EnumerateFiles(projectFolder, "*.csproj").FirstOrDefault()
: null;
if (csproj is null)
{
wrong.Add($"'{name}' runs a DLL under {projectFolder}, which holds no project");
continue;
}
string projectText = File.ReadAllText(csproj);
string framework = Regex.Match(projectText, @"<TargetFramework>([^<]+)</TargetFramework>").Groups[1].Value;
if (framework != shape.Groups["tfm"].Value)
{
wrong.Add($"'{name}' expects {shape.Groups["tfm"].Value} and {Path.GetFileName(csproj)} targets {framework}");
}
string assembly = Regex.Match(projectText, @"<AssemblyName>([^<]+)</AssemblyName>") is { Success: true } named
? named.Groups[1].Value
: Path.GetFileNameWithoutExtension(csproj);
if (assembly != shape.Groups["assembly"].Value)
{
wrong.Add($"'{name}' runs {shape.Groups["assembly"].Value}.dll and the project builds {assembly}.dll");
}
int configured = PortOf(configuration.GetProperty("env").GetProperty("ASPNETCORE_URLS").GetString()!);
int settings = LaunchSettingsPort(root, Path.GetRelativePath(root, projectFolder));
if (configured != settings)
{
wrong.Add($"'{name}' listens on {configured} and the project's launchSettings.json says {settings}");
}
}
else if (type == "chrome")
{
string config = File.ReadAllText(Path.Combine(root, "playwright.config.ts"));
int vite = PortOf(Regex.Match(config, @"baseURL: '(http://localhost:\d+)'").Groups[1].Value);
int configured = PortOf(configuration.GetProperty("url").GetString()!);
if (configured != vite)
{
wrong.Add($"'{name}' opens port {configured} and Vite serves {vite}");
}
}
else
{
wrong.Add($"'{name}' is of type '{type}', which this test does not know how to resolve");
}
}
if (launch.RootElement.TryGetProperty("compounds", out var compounds))
{
foreach (var compound in compounds.EnumerateArray())
{
foreach (var member in compound.GetProperty("configurations").EnumerateArray())
{
if (!names.Contains(member.GetString()!))
{
wrong.Add($"the compound '{compound.GetProperty("name").GetString()}' names '{member.GetString()}', which is not a configuration");
}
}
}
}
Assert.True(names.Count >= 1, $"{folder}/launch.json holds no configuration");
Assert.True(wrong.Count == 0, string.Join(Environment.NewLine, wrong));
}
Live from api/TheYard.Tests/WorkspaceTests.cs, region launch, read from this build at 565827b.
The page shows the real files. .vscode/ joined the roots a live block may read from, and the runtime image copies the folder beside the other sources the records read, so the extensions file, the launch file and the tasks file on the page are the ones in the build, not a paste. .dockerignore had excluded the folder as editor metadata; it no longer does, because it is documentation now.
What was left out, on purpose. No .code-workspace file (above). No devcontainer: the three installs are one winget line and the page says so. No per-project omnisharp.json or Dev Kit settings beyond the solution: the defaults are right for this tree. No task for the full gate: the gate is the ship's, runs on the machine that ships, and takes ten minutes; what a developer runs before a commit is the compound without the browser, about two minutes, and the browser suite as its own task.