05 — Manifest emitter
Article 04 opened the EmitterRegistry and shipped the first emitter through it: the grammar emitter, producing a TextMate grammar JSON from IRToken[]. That emitter turned a slice of the IR into one file. This article writes the emitter that turns the whole shape of the language — its id, its file extensions, its display name — into the one file VSCode reads first when it loads an extension: package.json. Everything else in a VSIX — the grammar JSON, the snippets JSON, the LSP server bundle, the icon — is dead weight until package.json says, under contributes.*, that those files exist and what role they play.
The manifest emitter is therefore the keystone of the pack. If the grammar emitter writes a file VSCode never sees, the manifest emitter is the reason VSCode never sees it: a missing contributes.grammars[0].path entry is enough. Conversely, a manifest that claims a grammar file the grammar emitter has not written yields a VSIX that installs fine and silently does nothing the first time the user opens a .ex file. The two emitters are coupled through the filesystem, and the coupling is exactly the kind of implicit thing the IR contract from article 03 exists to make explicit.
The thesis of this article is that the manifest emitter has exactly two inputs and one validator. The first input is the LanguageIR — everything derivable from the language surface itself: id, display name, file extensions, grammar path, snippet path, activation events. The second input is a small, hand-written ide-forge.config.ts carrying the fields VSCode's manifest needs that have no equivalent in the language surface: publisher id, marketplace categories, VSIX version, icon path, extension display name. The validator is ajv compiled against VSCode's published extension schema at https://json.schemastore.org/vscode-extensions.json, which runs on the emitted object before the file is written — never after.
The design-series companion is 04 — SOLID in the monorepo patterns. The manifest emitter is a pointed case of that pattern: it does one projection and one validation, knows nothing about tokens, rules, snippets, or LSP features beyond the fields it copies into contributes.*, and if later articles extend the IR with a new contributes.commands surface, the emitter gets one new projection method — not a rewrite.
REQ-IDEDSL-MANIFEST-DERIVATION-COMPLETE — the Requirement
REQ-IDEDSL-MANIFEST-DERIVATION-COMPLETE — The manifest emitter shall project a
LanguageIRand anIdeForgeConfiginto a VSCode extensionpackage.jsonsuch that: (a) every field derivable from the language surface —name(derived fromconfig.extensionId),contributes.languages[0].id,contributes.languages[0].aliases,contributes.languages[0].extensions,contributes.grammars[0].language,contributes.grammars[0].scopeName,contributes.grammars[0].path,contributes.snippets[0].language,contributes.snippets[0].path, andactivationEvents— comes from the IR without any field in the forge config being permitted to override it; (b) every field that cannot be derived —publisher,displayName,description,version,icon,categories,engines.vscode, andmain(fixed to./dist/extension.js) — comes from the forge config and is not permitted to appear in the IR; (c) the emitted object shall validate against the JSON Schema published athttps://json.schemastore.org/vscode-extensions.json, pinned to the version committed atpackages/ide-dsl/schemas/vscode-extensions.schema.json, viaajvat emit time, beforeFileSystem.writeFileis called.Rationale: the manifest is the one file VSCode reads unconditionally when an extension is installed; a typo in
contributes.grammars[0].scopeNamesilently disables syntax highlighting in production, and a misspelledonLanguage:exactivation event silently disables the LSP. None of those failures surfaces atvsce packagetime — VSCode's loader is permissive, andvsceonly validates its ownenginesand version fields. Splitting the derivation into two disjoint inputs makes the source of every field obvious in review: when a field is wrong, the first diagnostic is "is this coming from the IR or from the forge config?" — and the answer is in the type, not in the emitter. Validating against the published schema at emit time catches shape errors before they become a user-facing regression; pinning the schema file in-repo makes the validation reproducible on a fresh clone without a network round-trip.Fit criteria: given any fixture IR in
packages/ide-dsl/test/fixtures/and a minimal forge config, the emitter produces apackage.jsonwhosecontributes.languages[0].id === ir.idand whosecontributes.languages[0].extensionsequalsir.fileExtensions;ajv.compile(vscodeExtensionsSchema).validate(emitted)returnstruefor every such pair; any attempt to pass a forge config containing a key shared with the IR derivation set is rejected by the config loader with a typedParseError; the emitter never writes a file if validation fails.Verification: Test. Refines REQ-IDEDSL-IR-VERSIONED-CONTRACT.
This requirement refines the IR contract requirement from article 03 in a narrow sense: the IR contract fixed what a LanguageIR is; this one fixes how the manifest emitter reads it. The IR contract leaves open who writes which fields of the output. This requirement closes that, for the manifest, by partitioning every VSCode extension field into exactly one of two sources.
The partitioning is strict on purpose. A field is either in the IR (because it describes the language) or in the forge config (because it describes the marketplace identity of the VSIX). Nothing is in both. This removes the classic precedence bug where two sources carry overlapping fields and the emitter has to decide which wins — a decision that always has a wrong answer for some user. A user who wants a different extension display name than the language display name writes that in the forge config; a user who wants a different .ex file extension changes the @Language decorator. No override, no merge strategy, no fallback.
FEAT-IDEDSL-05 — the satisfying Feature
// packages/ide-dsl/requirements/features/manifest-emitter.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdedslManifestDerivationCompleteRequirement }
from '../requirements/req-idedsl-manifest-derivation-complete.js';
@Satisfies(ReqIdedslManifestDerivationCompleteRequirement)
export abstract class ManifestEmitterFeature extends Feature {
readonly id = 'FEAT-IDEDSL-05';
readonly title = 'VSCode manifest emitter: IR + ide-forge.config.ts → package.json';
readonly priority = Priority.Critical;
// ── Derivation (IR → manifest) ──
abstract languageIdComesFromTheIr(): ACResult;
abstract fileExtensionsComeFromTheIr(): ACResult;
abstract scopeNameIsDerivedAsSourceDotLanguageId(): ACResult;
abstract activationEventsAreOneOnLanguagePerFileExtension(): ACResult;
// ── Non-derivation (forge config → manifest) ──
abstract publisherComesFromTheForgeConfig(): ACResult;
abstract vsixVersionComesFromTheForgeConfigNotTheIr(): ACResult;
abstract extensionDisplayNameIsDistinctFromLanguageDisplayName(): ACResult;
abstract enginesVscodeComesFromTheForgeConfig(): ACResult;
// ── Validation ──
abstract ajvValidatesAgainstPinnedVscodeSchemaBeforeWrite(): ACResult;
abstract invalidManifestIsNeverWrittenToDisk(): ACResult;
abstract forgeConfigWithOverlappingKeyIsRejectedAtLoad(): ACResult;
}// packages/ide-dsl/requirements/features/manifest-emitter.ts
import { Feature, Priority, Satisfies, type ACResult } from '@frenchexdev/requirements';
import { ReqIdedslManifestDerivationCompleteRequirement }
from '../requirements/req-idedsl-manifest-derivation-complete.js';
@Satisfies(ReqIdedslManifestDerivationCompleteRequirement)
export abstract class ManifestEmitterFeature extends Feature {
readonly id = 'FEAT-IDEDSL-05';
readonly title = 'VSCode manifest emitter: IR + ide-forge.config.ts → package.json';
readonly priority = Priority.Critical;
// ── Derivation (IR → manifest) ──
abstract languageIdComesFromTheIr(): ACResult;
abstract fileExtensionsComeFromTheIr(): ACResult;
abstract scopeNameIsDerivedAsSourceDotLanguageId(): ACResult;
abstract activationEventsAreOneOnLanguagePerFileExtension(): ACResult;
// ── Non-derivation (forge config → manifest) ──
abstract publisherComesFromTheForgeConfig(): ACResult;
abstract vsixVersionComesFromTheForgeConfigNotTheIr(): ACResult;
abstract extensionDisplayNameIsDistinctFromLanguageDisplayName(): ACResult;
abstract enginesVscodeComesFromTheForgeConfig(): ACResult;
// ── Validation ──
abstract ajvValidatesAgainstPinnedVscodeSchemaBeforeWrite(): ACResult;
abstract invalidManifestIsNeverWrittenToDisk(): ACResult;
abstract forgeConfigWithOverlappingKeyIsRejectedAtLoad(): ACResult;
}Eleven acceptance criteria: four derivation, four non-derivation, three validation. The two first groups are the same size because they describe the same cut. The validator group is smaller because the validator is one pipe; the only variability is when it runs (before write), what it runs against (the pinned schema), and what it refuses to accept (configs that mix concerns, manifests that fail the schema).
Which fields come from the IR
The IR owns the language. The language, for the purposes of a VSCode extension's package.json, shows up in four places: the contributes.languages[0] block, the contributes.grammars[0] block, the contributes.snippets[0] block, and the activationEvents array. None is a free-form surface; each has a fixed set of keys, and each key is either copied from the IR verbatim, derived from the IR by a one-line convention, or absent.
Verbatim fields are the easy half. contributes.languages[0].id is ir.id. contributes.languages[0].aliases is [ir.displayName]. contributes.languages[0].extensions is ir.fileExtensions — article 02 settled on storing extensions with the leading dot, because the VSCode format wants the dot and storing it in the IR avoids re-deriving it twice. contributes.grammars[0].language is ir.id again. contributes.snippets[0].language is ir.id a third time. The emitter copies.
Derived fields are the interesting half. contributes.grammars[0].scopeName is, by textmate convention, source.${ir.id} — a value the grammar emitter also computes independently, a duplication the DRY lens below addresses. contributes.grammars[0].path is, by the file-layout convention assumed since article 01, ./syntaxes/${ir.id}.tmLanguage.json. contributes.snippets[0].path is ./snippets/${ir.id}.code-snippets. activationEvents collapses to a single onLanguage:${ir.id} entry because all extensions of the same language activate on the same language event.
The illustrative projection in the emitter reads like a set of field assignments, each with its source spelled out in the name:
// packages/ide-dsl/src/emitters/manifest-emitter.ts
import type { Emitter, FileSystem } from '../emitter.js';
import type { LanguageIR } from '../ir.js';
import type { IdeForgeConfig } from '../forge-config.js';
import { validateManifest } from './manifest-validator.js';
export class ManifestEmitter implements Emitter {
readonly kind = 'manifest' as const;
constructor(private readonly config: IdeForgeConfig) {}
async emit(ir: LanguageIR, fs: FileSystem, outDir: string): Promise<void> {
const manifest = this.project(ir);
const validation = validateManifest(manifest);
if (!validation.ok) {
throw new ManifestValidationError(validation.errors);
}
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(
`${outDir}/package.json`,
JSON.stringify(manifest, null, 2) + '\n',
);
}
private project(ir: LanguageIR): VscodeExtensionManifest {
return {
// ── forge-config side ──
name: this.config.extensionId,
displayName: this.config.extensionDisplayName,
description: this.config.extensionDescription,
version: this.config.version,
publisher: this.config.publisher,
categories: [...this.config.categories],
engines: { vscode: this.config.engines.vscode },
...(this.config.icon ? { icon: this.config.icon } : {}),
main: './dist/extension.js',
// ── IR side ──
activationEvents: [`onLanguage:${ir.id}`],
contributes: {
languages: [this.projectLanguage(ir)],
grammars: [this.projectGrammar(ir)],
snippets: [this.projectSnippets(ir)],
},
};
}
private projectLanguage(ir: LanguageIR) {
return {
id: ir.id,
aliases: [ir.displayName],
extensions: [...ir.fileExtensions],
};
}
private projectGrammar(ir: LanguageIR) {
return {
language: ir.id,
scopeName: `source.${ir.id}`,
path: `./syntaxes/${ir.id}.tmLanguage.json`,
};
}
private projectSnippets(ir: LanguageIR) {
return {
language: ir.id,
path: `./snippets/${ir.id}.code-snippets`,
};
}
}// packages/ide-dsl/src/emitters/manifest-emitter.ts
import type { Emitter, FileSystem } from '../emitter.js';
import type { LanguageIR } from '../ir.js';
import type { IdeForgeConfig } from '../forge-config.js';
import { validateManifest } from './manifest-validator.js';
export class ManifestEmitter implements Emitter {
readonly kind = 'manifest' as const;
constructor(private readonly config: IdeForgeConfig) {}
async emit(ir: LanguageIR, fs: FileSystem, outDir: string): Promise<void> {
const manifest = this.project(ir);
const validation = validateManifest(manifest);
if (!validation.ok) {
throw new ManifestValidationError(validation.errors);
}
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(
`${outDir}/package.json`,
JSON.stringify(manifest, null, 2) + '\n',
);
}
private project(ir: LanguageIR): VscodeExtensionManifest {
return {
// ── forge-config side ──
name: this.config.extensionId,
displayName: this.config.extensionDisplayName,
description: this.config.extensionDescription,
version: this.config.version,
publisher: this.config.publisher,
categories: [...this.config.categories],
engines: { vscode: this.config.engines.vscode },
...(this.config.icon ? { icon: this.config.icon } : {}),
main: './dist/extension.js',
// ── IR side ──
activationEvents: [`onLanguage:${ir.id}`],
contributes: {
languages: [this.projectLanguage(ir)],
grammars: [this.projectGrammar(ir)],
snippets: [this.projectSnippets(ir)],
},
};
}
private projectLanguage(ir: LanguageIR) {
return {
id: ir.id,
aliases: [ir.displayName],
extensions: [...ir.fileExtensions],
};
}
private projectGrammar(ir: LanguageIR) {
return {
language: ir.id,
scopeName: `source.${ir.id}`,
path: `./syntaxes/${ir.id}.tmLanguage.json`,
};
}
private projectSnippets(ir: LanguageIR) {
return {
language: ir.id,
path: `./snippets/${ir.id}.code-snippets`,
};
}
}Three private projectX methods is the shape the OCP lens below rewards. When contributes.commands arrives — the moment the first executor surfaces as a palette command — the diff is a projectCommands(ir) method and one line in contributes. Not a rewrite, not a conditional. The strict partitioning the requirement enforced keeps the diff small: every new contributes.* field is, by construction, on the IR side of the cut.
The path conventions — ./syntaxes/${ir.id}.tmLanguage.json, ./snippets/${ir.id}.code-snippets, ./dist/extension.js — are inlined in the emitter, not in the IR, because they are build-layout decisions, not language surface. If the grammar later moves to ./grammars/ instead of ./syntaxes/, the change is a one-line edit in the emitter and a matching edit in the grammar emitter, with one test asserting the two agree. Neither change reaches the IR.
The ide-forge.config.ts surface
Everything left over — the marketplace identity of the VSIX as distinct from the language it publishes — lives in a small hand-written ide-forge.config.ts that the forge loads once at the top of a build:
// packages/ide-dsl/src/forge-config.ts
export interface IdeForgeConfig {
readonly publisher: string; // marketplace publisher id
readonly extensionId: string; // package.json "name" for the vsix
readonly extensionDisplayName: string; // marketplace display name
readonly extensionDescription: string; // marketplace description
readonly version: string; // semver of the VSIX
readonly icon?: string; // relative path to icon.png
readonly categories: readonly string[]; // VSCode marketplace categories
readonly engines: { readonly vscode: string }; // e.g. '^1.90.0'
}// packages/ide-dsl/src/forge-config.ts
export interface IdeForgeConfig {
readonly publisher: string; // marketplace publisher id
readonly extensionId: string; // package.json "name" for the vsix
readonly extensionDisplayName: string; // marketplace display name
readonly extensionDescription: string; // marketplace description
readonly version: string; // semver of the VSIX
readonly icon?: string; // relative path to icon.png
readonly categories: readonly string[]; // VSCode marketplace categories
readonly engines: { readonly vscode: string }; // e.g. '^1.90.0'
}Eight fields, one optional. Each has a marketplace meaning a language cannot know. publisher identifies who owns the VSIX; two languages shipped by the same organization share a publisher but not a language id. extensionId is the VSIX name, typically ide-dsl-ex — different from the language id ex, a separate namespace VSCode uses internally. extensionDisplayName is what appears in the marketplace listing, often longer and more marketing-inflected than the language display name. version is the VSIX semver, distinct from the $schemaVersion date on the IR: the IR bumps on a breaking shape change; the VSIX version bumps every release.
The loader enforces the partition. The config file is a plain .ts file with a default export of type IdeForgeConfig, and the loader — another one-screen unit, illustrative only — reads it through a smart-constructor pattern that rejects any extra key at runtime:
// packages/ide-dsl/src/forge-config-loader.ts
import { z } from 'zod';
import type { Result } from '../result.js';
const IdeForgeConfigSchema = z.object({
publisher: z.string().min(1),
extensionId: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
extensionDisplayName: z.string().min(1),
extensionDescription: z.string().min(1),
version: z.string().regex(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/),
icon: z.string().optional(),
categories: z.array(z.string().min(1)).min(1),
engines: z.object({ vscode: z.string().min(1) }).strict(),
}).strict(); // .strict() rejects any key not listed above
export function loadForgeConfig(raw: unknown): Result<IdeForgeConfig, ParseError> {
const parsed = IdeForgeConfigSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, error: fromZodError(parsed.error) };
}
return { ok: true, value: parsed.data as IdeForgeConfig };
}// packages/ide-dsl/src/forge-config-loader.ts
import { z } from 'zod';
import type { Result } from '../result.js';
const IdeForgeConfigSchema = z.object({
publisher: z.string().min(1),
extensionId: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
extensionDisplayName: z.string().min(1),
extensionDescription: z.string().min(1),
version: z.string().regex(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/),
icon: z.string().optional(),
categories: z.array(z.string().min(1)).min(1),
engines: z.object({ vscode: z.string().min(1) }).strict(),
}).strict(); // .strict() rejects any key not listed above
export function loadForgeConfig(raw: unknown): Result<IdeForgeConfig, ParseError> {
const parsed = IdeForgeConfigSchema.safeParse(raw);
if (!parsed.success) {
return { ok: false, error: fromZodError(parsed.error) };
}
return { ok: true, value: parsed.data as IdeForgeConfig };
}The .strict() modifier is the point. If a user copies a language id into their forge config — someone always does — the loader rejects the config with a typed ParseError pointing at the offending key. The signal to the user is not "this key is ignored" but "this key belongs on the other side of the cut." Forgive-by-ignoring is how configuration surfaces silently rot.
Ajv validation against VSCode's published schema
VSCode's extension manifest schema is published at https://json.schemastore.org/vscode-extensions.json — a canonical URL curated by the JSON Schema Store project, which aggregates the schemas VSCode itself uses for settings-editor autocomplete on package.json. The schema tracks every contributes.* point VSCode adds as they land. Using it as the validation substrate means the forge's definition of "a valid VSCode manifest" is exactly VSCode's definition, not the subset the forge happens to know about today.
The tradeoff with pulling the schema from a URL at build time is the usual one: reproducibility requires the build not reach the network on a fresh clone, and drift must be observable in a diff. The settled pattern is to commit a pinned copy at packages/ide-dsl/schemas/vscode-extensions.schema.json, update it by a scripted fetch against the schemastore URL, and let the validator load only the committed copy. A weekly local job fetches fresh, diffs, and opens an issue on drift. Same policy as article 03 used for the IR JSON Schema.
The validator itself is one compile, one run:
// packages/ide-dsl/src/emitters/manifest-validator.ts
import Ajv, { type ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import vscodeExtensionsSchema from '../../schemas/vscode-extensions.schema.json';
const ajv = addFormats(new Ajv({ allErrors: true, strict: false }));
const validate = ajv.compile(vscodeExtensionsSchema);
export type ValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly errors: readonly ErrorObject[] };
export function validateManifest(manifest: unknown): ValidationResult {
const ok = validate(manifest);
if (ok) return { ok: true };
return { ok: false, errors: validate.errors ?? [] };
}// packages/ide-dsl/src/emitters/manifest-validator.ts
import Ajv, { type ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import vscodeExtensionsSchema from '../../schemas/vscode-extensions.schema.json';
const ajv = addFormats(new Ajv({ allErrors: true, strict: false }));
const validate = ajv.compile(vscodeExtensionsSchema);
export type ValidationResult =
| { readonly ok: true }
| { readonly ok: false; readonly errors: readonly ErrorObject[] };
export function validateManifest(manifest: unknown): ValidationResult {
const ok = validate(manifest);
if (ok) return { ok: true };
return { ok: false, errors: validate.errors ?? [] };
}strict: false is a concession to the VSCode schema's own use of keywords ajv's strict mode complains about; allErrors: true reports every field problem at once rather than bailing on the first. The validator runs before FileSystem.writeFile is called. The file never exists on disk in an invalid state; there is no half-written-then-rolled-back window where a concurrent vsce package could pick up garbage.
The prior art worth engaging is three-wide. The VSCode Extension Manifest reference at code.visualstudio.com is the human-readable canon — the place where a contributor confirms that contributes.grammars[0].scopeName is really called that and not scope. The schema at json.schemastore.org, maintained by the SchemaStore.org project, is the machine-readable canon, the one ajv runs against. The vsce tool is the official packager; it validates a narrow slice — name, publisher, version, engines — and ignores the rest. Leaning on vsce alone catches the fields users mistype least often and misses the ones they mistype most often. That is the gap ajv against the full JSON Schema closes.
Field-derivation flowchart
Two disjoint columns on the left, one on the right. No arrow crosses from CFG into a contributes.* box, and no arrow crosses from IR into the marketplace-identity boxes. The constant main field has no arrow because its source is neither input — it is a build-layout convention hardcoded in the emitter. The picture is the partition the requirement demands.
The manifest emitter inside EmitterRegistry.runAll
The second mermaid shifts scale: where does the manifest emit fit in the larger pipeline the EmitterRegistry from article 04 runs?
Two properties of the sequence are worth naming. First, the manifest emitter runs last, not first, even though the manifest is the first file VSCode reads. The manifest references paths — ./syntaxes/ex.tmLanguage.json, ./snippets/ex.code-snippets, ./dist/extension.js — that must exist for VSCode to do anything with the extension. The run order is "write the referenced files before the file that references them," which makes a half-built outDir invalid in the friendliest way: a missing package.json means "not yet built."
Second, validation happens between project(ir) and writeFile, inside the emitter, not in the registry. The registry has no per-emitter validator notion because each output format is different — grammar vs TextMate schema, snippets vs VSCode snippets schema, manifest vs extension schema. The common pattern is "validate before write," but the validator is private to the emitter that uses it. SRP as a shape, not a slogan.
Test snippet
// packages/ide-dsl/test/unit/emitters/manifest-emitter.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { ManifestEmitter } from '../../../src/emitters/manifest-emitter.js';
import { validateManifest } from '../../../src/emitters/manifest-validator.js';
import { ManifestEmitterFeature } from '../../../requirements/features/manifest-emitter.js';
import { makeFakeFs, type FakeFs } from '../../support/fake-fs.js';
import { exIrFixture, minimalForgeConfig } from '../../fixtures/manifest.js';
@FeatureTest(ManifestEmitterFeature)
export class ManifestEmitterTests {
private fs!: FakeFs;
setup() { this.fs = makeFakeFs(); }
@Verifies('languageIdComesFromTheIr')
async languageIdComesFromTheIr() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
expect(written.contributes.languages[0].id).toBe(exIrFixture.id);
expect(written.contributes.languages[0].extensions).toEqual(
[...exIrFixture.fileExtensions],
);
}
@Verifies('activationEventsAreOneOnLanguagePerFileExtension')
async activationEventsAreOneOnLanguagePerFileExtension() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
expect(written.activationEvents).toEqual([`onLanguage:${exIrFixture.id}`]);
}
@Verifies('ajvValidatesAgainstPinnedVscodeSchemaBeforeWrite')
async ajvValidatesEmittedManifest() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
const result = validateManifest(written);
expect(result.ok).toBe(true);
}
@Verifies('invalidManifestIsNeverWrittenToDisk')
async invalidManifestIsNeverWrittenToDisk() {
const brokenConfig = { ...minimalForgeConfig, version: 'not-a-semver' };
const emitter = new ManifestEmitter(brokenConfig);
await expect(emitter.emit(exIrFixture, this.fs, '/out')).rejects.toThrow();
expect(this.fs.exists('/out/package.json')).toBe(false);
}
}// packages/ide-dsl/test/unit/emitters/manifest-emitter.test.ts
import { expect } from 'vitest';
import { FeatureTest, Verifies } from '@frenchexdev/requirements';
import { ManifestEmitter } from '../../../src/emitters/manifest-emitter.js';
import { validateManifest } from '../../../src/emitters/manifest-validator.js';
import { ManifestEmitterFeature } from '../../../requirements/features/manifest-emitter.js';
import { makeFakeFs, type FakeFs } from '../../support/fake-fs.js';
import { exIrFixture, minimalForgeConfig } from '../../fixtures/manifest.js';
@FeatureTest(ManifestEmitterFeature)
export class ManifestEmitterTests {
private fs!: FakeFs;
setup() { this.fs = makeFakeFs(); }
@Verifies('languageIdComesFromTheIr')
async languageIdComesFromTheIr() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
expect(written.contributes.languages[0].id).toBe(exIrFixture.id);
expect(written.contributes.languages[0].extensions).toEqual(
[...exIrFixture.fileExtensions],
);
}
@Verifies('activationEventsAreOneOnLanguagePerFileExtension')
async activationEventsAreOneOnLanguagePerFileExtension() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
expect(written.activationEvents).toEqual([`onLanguage:${exIrFixture.id}`]);
}
@Verifies('ajvValidatesAgainstPinnedVscodeSchemaBeforeWrite')
async ajvValidatesEmittedManifest() {
const emitter = new ManifestEmitter(minimalForgeConfig);
await emitter.emit(exIrFixture, this.fs, '/out');
const written = JSON.parse(this.fs.read('/out/package.json'));
const result = validateManifest(written);
expect(result.ok).toBe(true);
}
@Verifies('invalidManifestIsNeverWrittenToDisk')
async invalidManifestIsNeverWrittenToDisk() {
const brokenConfig = { ...minimalForgeConfig, version: 'not-a-semver' };
const emitter = new ManifestEmitter(brokenConfig);
await expect(emitter.emit(exIrFixture, this.fs, '/out')).rejects.toThrow();
expect(this.fs.exists('/out/package.json')).toBe(false);
}
}Four @Verifies here; the remaining seven ACs follow the same shape — one fixture, one assertion against the emitted JSON. The invalid-manifest case is worth pausing on: the assertion is not that the error was thrown and the file was left broken, but that the file never materialized. That is what makes the emitter atomic from the outside.
SOLID lens
Single responsibility. The manifest emitter has one job: project LanguageIR × IdeForgeConfig into package.json and validate. It does not fetch schemas, package VSIXes, resolve icon paths, or enforce marketplace policy. The ManifestEmitter class fits on one screen minus its private projection methods, which also fit on one screen each. SRP is visible in the line count.
Open/closed. Adding contributes.commands once the IR exposes executor palette bindings is one new projectCommands(ir) private method and one new line in the contributes literal. No existing code changes. The pinned VSCode schema admits the new field automatically because the schema is the source of truth for what contributes.* keys exist. The OCP payoff shows up in the narrowness of the diff.
Liskov. The ManifestEmitter is substitutable for any other emitter at the EmitterRegistry boundary: same Emitter port, same emit(ir, fs, outDir) signature. The registry's runAll has no case analysis on emitter.kind; it iterates and delegates.
Interface segregation. The manifest emitter depends on the FileSystem port from article 04, which exposes exactly writeFile, mkdir, and exists. A future fat-filesystem port used by the LSP emitter would not back-contaminate this one.
Dependency inversion. The concrete emitter depends on two abstract inputs — LanguageIR and IdeForgeConfig — and one abstract port — FileSystem. The top-level build-forge.ts composition root is the only place that wires a concrete nodeFs into a concrete ManifestEmitter. The emitter in isolation has no I/O edge.
DRY lens
The one duplication risk in this emitter is the scopeName field. The manifest says source.${ir.id}; the grammar emitter of article 04 independently stamps source.${ir.id} inside its TextMate JSON. Two emitters computing the same string is, narrowly, a duplication: if the convention changes to ex.${ir.id} someday, two files have to change in lockstep or highlighting stops working.
The fix is not to centralize the computation in a third module — that solves duplication by adding a layer, which is worse than the duplication it removes. The fix is to test the invariant: a single cross-emitter test that runs both emitters on the same fixture and asserts that the scopeName the manifest claims matches the scopeName the grammar file declares. One test covers the drift; neither emitter has to know the other exists; the convention lives at the call sites where it is obvious in review. That is a DRY satisfied by verification rather than by extraction, which is the form DRY most often should take when the alternative is a premature abstraction.
More broadly, the pinned VSCode schema is the one source of truth for what a valid manifest is. The emitter does not duplicate that knowledge in hand-coded shape assertions. If the VSCode team adds contributes.debuggers[] next month, the schema update flows through the pin, the validator accepts manifests with or without the field, and the emitter — which does not yet produce the field — remains valid. The DRY payoff here is that the forge never has to mirror VSCode's release cadence; it just tracks the schema.
Cross-link to the design series
This article's structural argument — strict partitioning of two inputs, one projection, one validator, composed inside a registry that does no case analysis — is the concrete instance of the general pattern laid out in 04 — SOLID in the monorepo patterns. That article named the shape: a registry of single-responsibility strategies, each conforming to a narrow port, composed by a root that knows nothing about any strategy's internals. The manifest emitter pays the SRP tax — it does only its own projection and its own validation — and in exchange the registry pays no case-analysis cost. The next two emitters in this series, the snippets emitter (article 06) and the LSP emitter (article 07), drop into the same slot.
Proposal (write-in-public). The
.strict()loader policy forIdeForgeConfigis the one thing in this design still up for revision. The current version rejects any extra key outright. A softer alternative — warn on unknown keys, reject on known-but-misplaced keys (a languageidin the forge config) — would be friendlier to an iterative edit loop where a user is copy-pasting from old examples. The argument for the softer version is that "unknown" and "misplaced" are meaningfully different errors: misplaced is a known failure mode the forge should catch loudly; unknown is forward-compatibility territory where the forge should stay out of the way. If a later article surfaces a concrete case where the strict policy hurts more than it helps, the softening will happen there.
What comes next
The manifest is now the keystone: it declares which files exist and what they mean. Article 06 writes the emitter that produces one of those files — the snippets JSON — from IRSnippet[], with the same port, the same registry, the same validate-before-write discipline. Article 07 writes the LSP emitter, which is where the shape of the build stops being cosmetic and starts being a running server: hover, go-to-definition, diagnostics, all projected from IRLspFeature[] into handlers the VSCode client talks to.
The thread through all three emitters is the same: the IR is the contract, the forge config is the marketplace surface, the registry is the composer, and the schema — whichever one is canonical for the output format — is the validator. The manifest emitter makes that thread visible because its output, uniquely, has to reference the output of every other emitter. Get that one right and the rest of the pack has a well-defined place to land.
- Previous build article: 04 —
EmitterRegistry+ grammar emitter — the registry this emitter registers into, and the first concrete strategy alongside it. - Next build article: 13 — Snippets emitter — same port, same validate-before-write discipline, the
IRSnippet[]slice of the IR to.code-snippetsJSON. - Design companion: 04 — SOLID in the monorepo patterns.
- IR contract: 03 —
LanguageIRas contract. - Bootstrapping context: 01 — Bootstrapping Ide.Dsl, 02 — The extractor.