From 9bfd449ad28019f769a2e100d38f3f39e26149db Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 10 Oct 2025 00:15:09 +0100 Subject: [PATCH] add @external decorator --- packages/emitter/lib/decorators.tsp | 15 +++++++++++++++ packages/emitter/src/decorators.ts | 22 ++++++++++++++++++++++ packages/emitter/src/emitter.ts | 6 ++++++ packages/emitter/src/tsp-index.ts | 2 ++ 4 files changed, 45 insertions(+) diff --git a/packages/emitter/lib/decorators.tsp b/packages/emitter/lib/decorators.tsp index 7034f4d..8354b5a 100644 --- a/packages/emitter/lib/decorators.tsp +++ b/packages/emitter/lib/decorators.tsp @@ -1,5 +1,7 @@ import "../dist/tsp-index.js"; +using TypeSpec.Reflection; + /** * Specifies the maximum number of graphemes (user-perceived characters) allowed. * Used alongside maxLength for proper Unicode text handling. @@ -159,3 +161,16 @@ extern dec encoding(target: unknown, mime: valueof string); * ``` */ extern dec errors(target: unknown, ...errors: unknown[]); + +/** + * Marks a namespace as external, preventing it from emitting JSON output. + * This decorator can only be applied to namespaces. + * Useful for importing definitions from other lexicons without re-emitting them. + * + * @example + * ```typespec + * @external + * namespace com.atproto.repo.defs; + * ``` + */ +extern dec external(target: Namespace); diff --git a/packages/emitter/src/decorators.ts b/packages/emitter/src/decorators.ts index c86d100..41aa54a 100644 --- a/packages/emitter/src/decorators.ts +++ b/packages/emitter/src/decorators.ts @@ -24,6 +24,7 @@ const encodingKey = Symbol("encoding"); const inlineKey = Symbol("inline"); const maxBytesKey = Symbol("maxBytes"); const minBytesKey = Symbol("minBytes"); +const externalKey = Symbol("external"); /** * @maxBytes decorator for maximum length of bytes type @@ -294,3 +295,24 @@ export function $readOnly(context: DecoratorContext, target: Type) { export function isReadOnly(program: Program, target: Type): boolean { return program.stateSet(readOnlyKey).has(target); } + +/** + * @external decorator for marking a namespace as external + * External namespaces are skipped during emission and don't produce JSON files + */ +export function $external(context: DecoratorContext, target: Type) { + if (target.kind !== "Namespace") { + context.program.reportDiagnostic({ + code: "external-not-on-namespace", + severity: "error", + message: "@external decorator can only be applied to namespaces", + target: target, + }); + return; + } + context.program.stateSet(externalKey).add(target); +} + +export function isExternal(program: Program, target: Type): boolean { + return program.stateSet(externalKey).has(target); +} diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index 0f4fa93..c7ed03e 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -67,6 +67,7 @@ import { isInline, getMaxBytes, getMinBytes, + isExternal, } from "./decorators.js"; export interface EmitterOptions { @@ -121,6 +122,11 @@ export class TypelexEmitter { return; } + // Skip external namespaces - they don't emit JSON files + if (isExternal(this.program, ns)) { + return; + } + // Check for TypeSpec enum syntax and throw error if (ns.enums && ns.enums.size > 0) { for (const [_, enumType] of ns.enums) { diff --git a/packages/emitter/src/tsp-index.ts b/packages/emitter/src/tsp-index.ts index 5d93ede..f20114c 100644 --- a/packages/emitter/src/tsp-index.ts +++ b/packages/emitter/src/tsp-index.ts @@ -14,6 +14,7 @@ import { $inline, $maxBytes, $minBytes, + $external, } from "./decorators.js"; /** @internal */ @@ -34,5 +35,6 @@ export const $decorators = { inline: $inline, maxBytes: $maxBytes, minBytes: $minBytes, + external: $external, }, }; -- 2.43.0 From cc7be5690c61a5a5bfe2aa7b40712ea24074a6b0 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 10 Oct 2025 00:23:07 +0100 Subject: [PATCH] add test for @external --- packages/emitter/src/emitter.ts | 12 ++++++++-- packages/emitter/test/spec.test.ts | 24 ++++++++++++++++++- .../spec/basic/output/com/example/other.json | 21 ++++++++++++++++ .../spec/external/input/test/external.tsp | 13 ++++++++++ .../test/spec/external/input/test/normal.tsp | 7 ++++++ .../spec/external/output/test/normal.json | 14 +++++++++++ 6 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 packages/emitter/test/spec/basic/output/com/example/other.json create mode 100644 packages/emitter/test/spec/external/input/test/external.tsp create mode 100644 packages/emitter/test/spec/external/input/test/normal.tsp create mode 100644 packages/emitter/test/spec/external/output/test/normal.json diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index c7ed03e..2f72574 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -544,7 +544,10 @@ export class TypelexEmitter { // Model reference union (including empty union with unknown) if (variants.unionRefs.length > 0 || variants.hasUnknown) { - if (variants.stringLiterals.length > 0 || variants.knownValueRefs.length > 0) { + if ( + variants.stringLiterals.length > 0 || + variants.knownValueRefs.length > 0 + ) { this.program.reportDiagnostic({ code: "union-mixed-refs-literals", severity: "error", @@ -1483,7 +1486,12 @@ export class TypelexEmitter { model: Model, fullyQualified = false, ): string | null { - return this.getReference(model, model.name, model.namespace, fullyQualified); + return this.getReference( + model, + model.name, + model.namespace, + fullyQualified, + ); } private getUnionReference(union: Union): string | null { diff --git a/packages/emitter/test/spec.test.ts b/packages/emitter/test/spec.test.ts index 1c52edb..67150f0 100644 --- a/packages/emitter/test/spec.test.ts +++ b/packages/emitter/test/spec.test.ts @@ -106,9 +106,31 @@ describe("lexicon spec", function () { assert.deepStrictEqual(actual, expected); }); } else { - it.skip(`TODO: ${expectedPath} (add ${inputPath})`, function () {}); + it(`should emit ${expectedPath}`, function () { + assert.fail( + `Expected output file ${expectedPath} has no corresponding input file ${inputPath}. ` + + `Either add the input file or remove the expected output.` + ); + }); } } + + // Check for unexpected emitted files + it("should not emit unexpected files", function () { + const emittedFiles = Object.keys(emitResult.files).filter(f => f.endsWith(".json")); + const expectedPaths = Object.keys(expectedFiles) + .filter(f => f.endsWith(".json")) + .map(normalizePathToPosix); + + const unexpected = emittedFiles.filter(f => !expectedPaths.includes(f)); + + if (unexpected.length > 0) { + assert.fail( + `Unexpected files were emitted: ${unexpected.join(", ")}. ` + + `Either add expected output files or ensure these should not be emitted.` + ); + } + }); }); } }); diff --git a/packages/emitter/test/spec/basic/output/com/example/other.json b/packages/emitter/test/spec/basic/output/com/example/other.json new file mode 100644 index 0000000..fec4521 --- /dev/null +++ b/packages/emitter/test/spec/basic/output/com/example/other.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "com.example.other", + "defs": { + "main": { + "type": "object", + "properties": {} + }, + "someDef": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string" + } + } + } + } +} diff --git a/packages/emitter/test/spec/external/input/test/external.tsp b/packages/emitter/test/spec/external/input/test/external.tsp new file mode 100644 index 0000000..9a7115a --- /dev/null +++ b/packages/emitter/test/spec/external/input/test/external.tsp @@ -0,0 +1,13 @@ +import "@typelex/emitter"; + +@external +namespace test.external { + model Main { + shouldNotEmit: string; + } + + model AlsoNotEmitted { + @required + value: boolean; + } +} diff --git a/packages/emitter/test/spec/external/input/test/normal.tsp b/packages/emitter/test/spec/external/input/test/normal.tsp new file mode 100644 index 0000000..9d251ee --- /dev/null +++ b/packages/emitter/test/spec/external/input/test/normal.tsp @@ -0,0 +1,7 @@ +import "@typelex/emitter"; + +namespace test.normal { + model Main { + name?: string; + } +} diff --git a/packages/emitter/test/spec/external/output/test/normal.json b/packages/emitter/test/spec/external/output/test/normal.json new file mode 100644 index 0000000..08b53cb --- /dev/null +++ b/packages/emitter/test/spec/external/output/test/normal.json @@ -0,0 +1,14 @@ +{ + "lexicon": 1, + "id": "test.normal", + "defs": { + "main": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } +} -- 2.43.0 From 60e98db4d0d92f90fa52c558e345655412882e90 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 10 Oct 2025 00:33:28 +0100 Subject: [PATCH] document @external --- DOCS.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/DOCS.md b/DOCS.md index a56eeab..60952da 100644 --- a/DOCS.md +++ b/DOCS.md @@ -236,9 +236,9 @@ This becomes a fully qualified reference to another Lexicon: This works across files too—just remember to `import` the file with the definition. -### External Stubs +### External References -If you don't have TypeSpec definitions for external Lexicons, you can stub them out: +If you don't have TypeSpec definitions for external Lexicons, you can stub them out using the `@external` decorator: ```typescript import "@typelex/emitter"; @@ -249,13 +249,16 @@ namespace app.bsky.actor.profile { } } -// Empty stub (like .d.ts in TypeScript) +// External stub (like .d.ts in TypeScript) +@external namespace com.atproto.label.defs { model SelfLabels { } } ``` -You could collect stubs in one file and import them: +The `@external` decorator tells the emitter to skip JSON output for that namespace. This is useful when referencing definitions from other Lexicons that you don't want to re-emit. + +You could collect external stubs in one file and import them: ```typescript import "@typelex/emitter"; @@ -268,7 +271,24 @@ namespace app.bsky.actor.profile { } ``` -You'll want to replace the stubbed lexicons in the output folder with their real JSON before running codegen. +Then in `atproto-stubs.tsp`: + +```typescript +import "@typelex/emitter"; + +@external +namespace com.atproto.label.defs { + model SelfLabels { } +} + +@external +namespace com.atproto.repo.defs { + model StrongRef { } +} +// ... more stubs +``` + +You'll want to ensure the real JSON for external Lexicons is available before running codegen. ### Inline Models -- 2.43.0 From a6e8436f0d131cf4aac3721c46f6ed67d024e560 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 10 Oct 2025 01:42:41 +0100 Subject: [PATCH] replacing bundling with externals in playground --- DOCS.md | 1 + packages/playground/samples/build.js | 69 +++++++- packages/playground/samples/index.js | 242 +++++++++++++++++---------- 3 files changed, 218 insertions(+), 94 deletions(-) diff --git a/DOCS.md b/DOCS.md index 60952da..891cf22 100644 --- a/DOCS.md +++ b/DOCS.md @@ -253,6 +253,7 @@ namespace app.bsky.actor.profile { @external namespace com.atproto.label.defs { model SelfLabels { } + @token model SomeToken { } // use @token for tokens } ``` diff --git a/packages/playground/samples/build.js b/packages/playground/samples/build.js index bfff87b..0d231a7 100644 --- a/packages/playground/samples/build.js +++ b/packages/playground/samples/build.js @@ -1,16 +1,30 @@ // @ts-check -import { writeFileSync, mkdirSync } from "fs"; +import { writeFileSync, mkdirSync, readFileSync } from "fs"; import { dirname, resolve, join } from "path"; import { fileURLToPath } from "url"; +import { deepStrictEqual } from "assert"; import { lexicons, bundleLexicon } from "./index.js"; +import { createTestHost, findTestPackageRoot, resolveVirtualPath } from "@typespec/compiler/testing"; const __dirname = dirname(fileURLToPath(import.meta.url)); const outputDir = resolve(__dirname, "dist"); +const pkgRoot = await findTestPackageRoot(import.meta.url); + +// TypeSpec library setup for testing +const TypelexTestLibrary = { + name: "@typelex/emitter", + packageRoot: pkgRoot.replace("/playground", "/emitter"), + files: [ + { realDir: "", pattern: "package.json", virtualPath: "./node_modules/@typelex/emitter" }, + { realDir: "dist", pattern: "**/*.js", virtualPath: "./node_modules/@typelex/emitter/dist" }, + { realDir: "lib/", pattern: "*.tsp", virtualPath: "./node_modules/@typelex/emitter/lib" }, + ], +}; // Create output directory mkdirSync(outputDir, { recursive: true }); -// Write each bundled lexicon to disk +// Write each bundled lexicon to disk and verify it compiles correctly const samplesList = {}; for (const [namespace, lexicon] of lexicons) { @@ -20,6 +34,55 @@ for (const [namespace, lexicon] of lexicons) { writeFileSync(filepath, bundled); + const host = await createTestHost({ libraries: [TypelexTestLibrary] }); + host.addTypeSpecFile("main.tsp", bundled); + + const baseOutputPath = resolveVirtualPath("test-output/"); + const [, diagnostics] = await host.compileAndDiagnose("main.tsp", { + outputDir: baseOutputPath, + noEmit: false, + emit: ["@typelex/emitter"], + }); + + if (diagnostics.length > 0) { + console.error(`❌ ${namespace}: Compilation errors`); + diagnostics.forEach(d => console.error(` ${d.message}`)); + process.exit(1); + } + + // Get emitted JSON + const outputFiles = [...host.fs.entries()] + .filter(([name]) => name.startsWith(baseOutputPath)) + .map(([name, value]) => { + let relativePath = name.replace(baseOutputPath, ""); + if (relativePath.startsWith("@typelex/emitter/")) { + relativePath = relativePath.replace("@typelex/emitter/", ""); + } + return [relativePath, value]; + }); + + const expectedJsonPath = namespace.replace(/\./g, "/") + ".json"; + const emittedJson = outputFiles.find(([path]) => path === expectedJsonPath); + + if (!emittedJson) { + console.error(`❌ ${namespace}: No JSON output found (expected ${expectedJsonPath})`); + process.exit(1); + } + + // Compare with expected JSON + const expectedJsonFile = join( + pkgRoot.replace("/playground", "/emitter"), + "test/integration", + lexicon.suite, + "output", + lexicon.file.replace(".tsp", ".json") + ); + + const expectedJson = JSON.parse(readFileSync(expectedJsonFile, "utf-8")); + const actualJson = JSON.parse(emittedJson[1]); + + deepStrictEqual(actualJson, expectedJson); + samplesList[namespace] = { filename: `samples/dist/${filename}`, preferredEmitter: "@typelex/emitter", @@ -30,4 +93,4 @@ for (const [namespace, lexicon] of lexicons) { const samplesIndex = `export default ${JSON.stringify(samplesList, null, 2)};`; writeFileSync(join(outputDir, "samples.js"), samplesIndex); -console.log(`Wrote ${Object.keys(samplesList).length} bundled samples to disk`); +console.log(`\n✅ ${lexicons.size} samples verified successfully`); diff --git a/packages/playground/samples/index.js b/packages/playground/samples/index.js index 1d41b33..7e83c26 100644 --- a/packages/playground/samples/index.js +++ b/packages/playground/samples/index.js @@ -5,8 +5,8 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Get all tsp files -function getAllTspFiles(dir, baseDir = dir) { +// Get all tsp and json files +function getAllFiles(dir, baseDir = dir) { const files = []; const entries = readdirSync(dir); @@ -15,8 +15,8 @@ function getAllTspFiles(dir, baseDir = dir) { const stat = statSync(fullPath); if (stat.isDirectory()) { - files.push(...getAllTspFiles(fullPath, baseDir)); - } else if (entry.endsWith(".tsp")) { + files.push(...getAllFiles(fullPath, baseDir)); + } else if (entry.endsWith(".tsp") || entry.endsWith(".json")) { files.push(relative(baseDir, fullPath)); } } @@ -24,111 +24,171 @@ function getAllTspFiles(dir, baseDir = dir) { return files.sort(); } -// Extract dependencies from a file -function extractDependencies(content) { - const deps = new Set(); - // Match namespace references like "com.atproto.label.defs.Label" or "com.atproto.repo.strongRef.Main" - // Pattern: word.word.word... followed by dot and identifier starting with capital letter - const pattern = - /\b([a-z]+(?:\.[a-z]+)+(?:\.[a-z][a-zA-Z]*)*)\.[A-Z][a-zA-Z]*/g; - const withoutDeclaration = content.replace(/namespace\s+[a-z.]+\s*\{/, ""); - - const matches = withoutDeclaration.matchAll(pattern); - for (const match of matches) { - deps.add(match[1]); +// Extract all refs from JSON (recursively search for strings with #) +function extractRefsFromJson(obj, refs = new Map()) { + if (typeof obj === "string") { + // Match pattern like "foo.bar#baz" or "foo.barCamel#baz" (must have # to be a ref) + const match = obj.match(/^([a-z][a-zA-Z.]+)#([a-z][a-zA-Z]*)$/); + if (match) { + const ns = match[1]; + const def = match[2]; + const modelName = def.charAt(0).toUpperCase() + def.slice(1); + if (!refs.has(ns)) { + refs.set(ns, new Set()); + } + refs.get(ns).add(modelName); + } else { + // Also match plain namespace refs like "foo.bar.baz" or "foo.bar.bazCamel" (must have at least 2 dots) + const nsMatch = obj.match(/^([a-z][a-zA-Z]*(?:\.[a-z][a-zA-Z]*){2,})$/); + if (nsMatch) { + const ns = nsMatch[1]; + if (!refs.has(ns)) { + refs.set(ns, new Set()); + } + refs.get(ns).add("Main"); + } + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + extractRefsFromJson(item, refs); + } + } else if (obj && typeof obj === "object") { + for (const value of Object.values(obj)) { + extractRefsFromJson(value, refs); + } } - - return Array.from(deps); + return refs; } -const atprotoInputDir = join( - __dirname, - "../../emitter/test/integration/atproto/input", -); -const lexiconExamplesDir = join( - __dirname, - "../../emitter/test/integration/lexicon-examples/input", -); - -const atprotoFiles = getAllTspFiles(atprotoInputDir); -const lexiconExampleFiles = getAllTspFiles(lexiconExamplesDir); - -// Build dependency graph -const lexicons = new Map(); // namespace -> { file, content, deps } - -// Process atproto files -for (const file of atprotoFiles) { - const fullPath = join(atprotoInputDir, file); - const content = readFileSync(fullPath, "utf-8"); - const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); - const deps = extractDependencies(content); - - lexicons.set(namespace, { file: `atproto/${file}`, content, deps }); -} +const integrationDir = join(__dirname, "../../emitter/test/integration"); -// Process lexicon-examples files -for (const file of lexiconExampleFiles) { - const fullPath = join(lexiconExamplesDir, file); - const content = readFileSync(fullPath, "utf-8"); - const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); - const deps = extractDependencies(content); +// Get all test suite directories +const testSuites = readdirSync(integrationDir).filter((name) => { + const fullPath = join(integrationDir, name); + return statSync(fullPath).isDirectory() && !name.startsWith("."); +}); - lexicons.set(namespace, { file: `examples/${file}`, content, deps }); -} +// Build lexicons with refs extracted from JSON +const lexicons = new Map(); // namespace -> { file, content, refs, suite } -// Recursively collect all dependencies (topological sort) -function collectDependencies( - namespace, - collected = new Set(), - visiting = new Set(), -) { - if (collected.has(namespace)) return; - if (visiting.has(namespace)) return; // circular dependency +// Process all test suites +for (const suite of testSuites) { + const inputDir = join(integrationDir, suite, "input"); + const outputDir = join(integrationDir, suite, "output"); - const lexicon = lexicons.get(namespace); - if (!lexicon) return; + const inputFiles = getAllFiles(inputDir).filter((f) => f.endsWith(".tsp")); + + for (const file of inputFiles) { + const fullPath = join(inputDir, file); + const content = readFileSync(fullPath, "utf-8"); + const namespace = file.replace(/\.tsp$/, "").replace(/\//g, "."); - visiting.add(namespace); + // Find corresponding JSON output + const jsonFile = file.replace(/\.tsp$/, ".json"); + const jsonPath = join(outputDir, jsonFile); + const jsonContent = readFileSync(jsonPath, "utf-8"); + const jsonData = JSON.parse(jsonContent); + const refs = extractRefsFromJson(jsonData); - // First collect all dependencies - for (const dep of lexicon.deps) { - collectDependencies(dep, collected, visiting); + lexicons.set(namespace, { file, content, refs, suite }); } +} - visiting.delete(namespace); - collected.add(namespace); +// TypeSpec reserved keywords that need escaping +const TYPESPEC_KEYWORDS = new Set([ + "record", + "pub", + "interface", + "model", + "namespace", + "op", + "import", + "export", + "using", + "alias", + "enum", + "union", + "scalar", + "extends", +]); + +// Escape a namespace part if it's a reserved keyword +function escapeNamespacePart(part) { + return TYPESPEC_KEYWORDS.has(part) ? `\`${part}\`` : part; } -// Bundle a lexicon with all its dependencies -function bundleLexicon(namespace) { - const collected = new Set(); - collectDependencies(namespace, collected); +// Escape a full namespace path +function escapeNamespace(namespace) { + return namespace.split(".").map(escapeNamespacePart).join("."); +} - // Put the main lexicon FIRST, then its dependencies - const mainLexicon = lexicons.get(namespace); - const deps = Array.from(collected).filter((ns) => ns !== namespace); +// Get the JSON for a lexicon to check its definitions +function getLexiconJson(namespace) { + const lexicon = lexicons.get(namespace); + if (!lexicon) return null; + + const jsonPath = join( + integrationDir, + lexicon.suite, + "output", + lexicon.file.replace(".tsp", ".json"), + ); + + try { + return JSON.parse(readFileSync(jsonPath, "utf-8")); + } catch { + return null; + } +} - let bundled = 'import "@typelex/emitter";\n\n'; +// Check if a definition in JSON is a token +function isToken(lexiconJson, defName) { + if (!lexiconJson || !lexiconJson.defs) return false; + const def = lexiconJson.defs[defName]; + return def && def.type === "token"; +} - // Main lexicon first (so it shows in the playground) - if (mainLexicon) { - const contentWithoutImport = mainLexicon.content.replace( - /^import "@typelex\/emitter";\s*\n/, - "", - ); - bundled += `// ${mainLexicon.file}\n${contentWithoutImport}\n`; - } +// Bundle a lexicon with stubs for referenced types (from JSON) +function bundleLexicon(namespace) { + const mainLexicon = lexicons.get(namespace); + if (!mainLexicon) return ""; + + let bundled = mainLexicon.content; + + // Add stubs from refs extracted from JSON output (excluding self-references) + if (mainLexicon.refs.size > 0) { + let hasExternalRefs = false; + for (const [ns] of mainLexicon.refs) { + if (ns !== namespace) { + hasExternalRefs = true; + break; + } + } - // Then dependencies - for (const ns of deps) { - const lexicon = lexicons.get(ns); - if (!lexicon) continue; + if (hasExternalRefs) { + bundled += "\n// --- Externals ---\n"; + } - const contentWithoutImport = lexicon.content.replace( - /^import "@typelex\/emitter";\s*\n/, - "", - ); - bundled += `// ${lexicon.file}\n${contentWithoutImport}\n`; + for (const [ns, models] of mainLexicon.refs) { + // Skip if this is the current namespace + if (ns === namespace) continue; + + // Get the JSON for this referenced namespace to check for tokens + const refJson = getLexiconJson(ns); + + const escapedNs = escapeNamespace(ns); + bundled += `\n@external\nnamespace ${escapedNs} {\n`; + for (const model of models) { + // Check if this definition exists in the JSON and is a token + const defName = model.charAt(0).toLowerCase() + model.slice(1); + if (refJson && isToken(refJson, defName)) { + bundled += ` @token model ${model} { }\n`; + } else { + bundled += ` model ${model} { }\n`; + } + } + bundled += `}\n`; + } } return bundled; -- 2.43.0 From f523d56b890ad7f36926457b49d3b9f99754bc98 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Fri, 10 Oct 2025 02:03:10 +0100 Subject: [PATCH] enforce empty @externals --- packages/emitter/src/decorators.ts | 1 + packages/emitter/src/emitter.ts | 11 +++++++++++ .../test/spec/external/input/test/external.tsp | 9 ++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/emitter/src/decorators.ts b/packages/emitter/src/decorators.ts index 41aa54a..8d15668 100644 --- a/packages/emitter/src/decorators.ts +++ b/packages/emitter/src/decorators.ts @@ -310,6 +310,7 @@ export function $external(context: DecoratorContext, target: Type) { }); return; } + context.program.stateSet(externalKey).add(target); } diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index 2f72574..8b74710 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -124,6 +124,17 @@ export class TypelexEmitter { // Skip external namespaces - they don't emit JSON files if (isExternal(this.program, ns)) { + // Validate that all models in external namespaces are empty (stub-only) + for (const [_, model] of ns.models) { + if (model.properties && model.properties.size > 0) { + this.program.reportDiagnostic({ + code: "external-model-not-empty", + severity: "error", + message: `Models in @external namespaces must be empty stubs. Model '${model.name}' in namespace '${fullName}' has properties.`, + target: model, + }); + } + } return; } diff --git a/packages/emitter/test/spec/external/input/test/external.tsp b/packages/emitter/test/spec/external/input/test/external.tsp index 9a7115a..22d73b7 100644 --- a/packages/emitter/test/spec/external/input/test/external.tsp +++ b/packages/emitter/test/spec/external/input/test/external.tsp @@ -2,12 +2,7 @@ import "@typelex/emitter"; @external namespace test.external { - model Main { - shouldNotEmit: string; - } + model Main { } - model AlsoNotEmitted { - @required - value: boolean; - } + model AlsoNotEmitted { } } -- 2.43.0