diff --git a/packages/adapter-vite/__tests__/compiler-source-map-v3.test.ts b/packages/adapter-vite/__tests__/compiler-source-map-v3.test.ts new file mode 100644 index 000000000..f5383676a --- /dev/null +++ b/packages/adapter-vite/__tests__/compiler-source-map-v3.test.ts @@ -0,0 +1,438 @@ +/** + * @openelement/adapter-vite — real Source Map v3 emission for compiled + * elements (#1210, A10.2). + * + * These tests consume the compiler's source map through a STANDARD source-map + * consumer (@jridgewell/trace-mapping, already in the dependency tree) instead + * of asserting snapshot equality of the mappings string. Every assertion + * resolves a position in the generated module back to an independently + * computed file/line/column in the authored TSX source. + */ + +import { assert, assertEquals, assertNotEquals } from '@std/assert'; +import { eachMapping, originalPositionFor, TraceMap } from 'npm:@jridgewell/trace-mapping@0.3.31'; +import type { Plugin } from 'vite'; +import { + CompiledElementError, + compileElementProgram, +} from '../src/internal/compiler/semantic-core/compile.ts'; +import { compileElementModule } from '../src/internal/compiler/plugin.ts'; +import { createOpenPlugin } from '../src/plugin.ts'; + +const FILE = '/project/app/components/map-fixture.tsx'; + +/** + * Fixture grammar (one construct per line where possible so positions are + * independently computable): + * - decorator, verbatim import, island-config passthrough + * - plain/computed/multiline-initializer/boolean/array properties + * - two methods sharing an identical `this.count++;` line (one method carries + * the same line twice) + * - attr sink, JSX text part, prop sink, bool sink, method handler + * - two arrow handlers with identical `this.count++;` bodies (the MANDATORY + * duplicate-location vector) + * - a conditional (when) region and a keyed each region at nested tree paths + */ +const SOURCE = `import { computed, element, OpenElement, property } from '@openelement/element'; +import { defineIslandConfig as island } from '@openelement/app'; +export const openElement = island({ hydrate: 'load', ssr: true, dsd: false }); + +@element('oe-map-fixture') +export class MapFixture extends OpenElement { + @property({ reflect: true }) count = 0; + @property({ reflect: false }) label = 'ready'; + @property({ reflect: false, attribute: false }) doubled = computed(() => this.count * 2); + @property({ reflect: false }) config = { + step: 1, + parity: 'even', + }; + @property({ reflect: false }) busy = false; + @property({ reflect: false }) items: Array<{ id: string; text: string }> = []; + + bump(): void { + this.count++; + } + bumpTwice(): void { + this.count++; + this.count++; + } + + render() { + return ( +
+

Count: {this.count}

+ + + + + {this.count > 0 ?

positive

:

zero

} + +
+ ); + } +} +`; + +/** 1-based line / 0-based column of the nth (1-based) occurrence of needle. */ +function positionOf( + haystack: string, + needle: string, + occurrence = 1, +): { line: number; column: number } { + let from = 0; + let offset = -1; + for (let index = 0; index < occurrence; index++) { + offset = haystack.indexOf(needle, from); + assert(offset >= 0, `needle occurrence ${occurrence} not found: ${needle}`); + from = offset + needle.length; + } + const before = haystack.slice(0, offset); + return { + line: before.split('\n').length, + column: offset - (before.lastIndexOf('\n') + 1), + }; +} + +function decodeInlineMap(code: string): Record { + const marker = '//# sourceMappingURL=data:application/json;base64,'; + const line = code.split('\n').find((candidate) => candidate.startsWith(marker)); + assert(line, 'generated code must embed an inline base64 source map'); + return JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(line.slice(marker.length)), (c) => c.charCodeAt(0)), + ), + ); +} + +function traceOf(map: unknown): TraceMap { + return new TraceMap(map as ConstructorParameters[0]); +} + +/** Resolve the nth occurrence of needle in the generated code to the source. */ +function resolve( + trace: TraceMap, + code: string, + needle: string, + occurrence = 1, + columnNeedle?: string, +) { + const position = positionOf(code, needle, occurrence); + const lineText = code.split('\n')[position.line - 1]; + // Segments sit at the first mapped token of a line: query at the column of + // columnNeedle when given, otherwise at the needle's first non-space char. + const column = columnNeedle !== undefined + ? lineText.indexOf(columnNeedle) + : position.column + (needle.length - needle.trimStart().length); + return originalPositionFor(trace, { line: position.line, column }); +} + +/** Independently computed authored-source position of a needle. */ +function expectSource(needle: string, occurrence = 1, columnNeedle?: string) { + const position = positionOf(SOURCE, needle, occurrence); + return { + source: FILE, + line: position.line, + column: columnNeedle === undefined + ? position.column + : SOURCE.split('\n')[position.line - 1].indexOf(columnNeedle), + }; +} + +function assertResolves( + trace: TraceMap, + code: string, + needle: string, + occurrence: number, + expected: { source: string; line: number; column: number }, + columnNeedle?: string, +) { + const resolved = resolve(trace, code, needle, occurrence, columnNeedle); + assertEquals( + { source: resolved.source, line: resolved.line, column: resolved.column }, + expected, + `generated "${needle}" (occurrence ${occurrence}) must resolve to ${expected.source}:${expected.line}:${expected.column}`, + ); +} + +Deno.test('A10.2 compiler emits a REAL Source Map v3 (VLQ line+column segments)', () => { + const { code, map, program } = compileElementProgram(SOURCE, FILE); + + // The old substitute (mappings: '') is gone; a standard consumer decodes + // real segments from both the returned map and the inline artifact map. + assertEquals(typeof map.mappings, 'string'); + assertNotEquals(map.mappings, '', 'mappings must carry real VLQ segments'); + assertEquals(map.version, 3); + assertEquals(map.sources, [FILE]); + assertEquals(map.sourcesContent, [SOURCE]); + assertEquals(decodeInlineMap(code), map as unknown as Record); + + // x_openElement survives only as SUPPLEMENTARY metadata next to real + // segments, deep-equal to the Part Program's provenance records (#1209). + assertEquals(map.x_openElement, program.sourceMap); + + const trace = traceOf(map); + const decoded: Array = []; + eachMapping(trace, (mapping) => decoded.push(mapping)); + assert(decoded.length >= 30, `expected a dense segment table, decoded ${decoded.length}`); +}); + +Deno.test('A10.2 standard consumer resolves every program source record to its authored span', () => { + const { code, map, program } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + + // Locate each record's serialized entry inside the embedded program JSON + // (records serialize in order under the "records" key) and resolve that + // generated position through the generic consumer. This sweeps decorators' + // program payload, properties, JSX elements, text parts, attribute sinks, + // boolean/property sinks, handlers, conditional and keyed regions at nested + // tree paths — everything the compiler records provenance for. + let cursor = code.indexOf('"records": ['); + assert(cursor >= 0, 'generated code must embed the program source records'); + for (const record of program.sourceMap.records) { + const idNeedle = `"id": ${JSON.stringify(record.id)}`; + const offset = code.indexOf(idNeedle, cursor); + assert(offset >= 0, `generated program JSON must carry record ${record.id}`); + cursor = offset + idNeedle.length; + const before = code.slice(0, offset); + const line = before.split('\n').length; + const column = offset - (before.lastIndexOf('\n') + 1); + const resolved = originalPositionFor(trace, { line, column }); + assertEquals( + { source: resolved.source, line: resolved.line, column: resolved.column }, + { + source: FILE, + line: record.source.start.line, + column: record.source.start.column - 1, + }, + `record ${record.id} must resolve to its authored source span start`, + ); + } +}); + +Deno.test('A10.2 module scaffolding resolves to authored constructs', () => { + const { code, map } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + + // Verbatim/rewritten imports resolve to the authored import statements. + assertResolves(trace, code, `import { computed, OpenElement } from '@openelement/element';`, 1, { + source: FILE, + line: 1, + column: 0, + }); + assertResolves( + trace, + code, + `import { defineIslandConfig as island } from '@openelement/app';`, + 1, + { + source: FILE, + line: 2, + column: 0, + }, + ); + // Island-config passthrough is copied verbatim. + assertResolves( + trace, + code, + `export const openElement = island(`, + 1, + expectSource(`export const openElement = island(`), + ); + // The embedded program payload resolves to the render() that produced it; + // the serialized tag resolves to the @element decorator. + assertResolves(trace, code, 'const __partProgram = {', 1, expectSource('render() {')); + assertResolves( + trace, + code, + '"tag": "oe-map-fixture"', + 1, + expectSource("@element('oe-map-fixture')"), + ); + // Generated helper/scaffold consts resolve to the class they describe. + assertResolves( + trace, + code, + 'const __compiledProperties = ', + 1, + expectSource('MapFixture extends'), + ); + assertResolves(trace, code, 'const __compiledProps = {', 1, expectSource('MapFixture extends')); + // The generated class declaration resolves to the authored class name. + assertResolves( + trace, + code, + 'export class MapFixture extends OpenElement {', + 1, + expectSource('export class MapFixture extends OpenElement {', 1, 'MapFixture'), + 'MapFixture', + ); +}); + +Deno.test('A10.2 properties, computed fields, methods and multiline initializers resolve', () => { + const { code, map } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + + // Field declarations resolve to the authored field name (column-exact). + assertResolves(trace, code, ' count = 0;', 1, expectSource('count = 0;')); + assertResolves(trace, code, ` label = 'ready';`, 1, expectSource(`label = 'ready';`)); + // The same fields inside the generated __compiledProps helper resolve too. + assertResolves(trace, code, ' count: { type: Number', 1, expectSource('count = 0;')); + // Computed field factory resolves to the authored computed() initializer. + assertResolves( + trace, + code, + 'doubled: (__s) => computed(() => __s.count.value * 2),', + 1, + expectSource('computed(() => this.count * 2)'), + '(__s)', + ); + // Methods resolve to the authored method start. + assertResolves(trace, code, ' bump(): void {', 1, expectSource('bump(): void {')); + // Multiline initializer: continuation lines in BOTH generated copies (the + // __compiledProps default and the class field) resolve to the authored + // continuation line/column. + assertResolves(trace, code, ' step: 1,', 1, expectSource('step: 1,')); + assertResolves(trace, code, ' step: 1,', 2, expectSource('step: 1,')); + assertResolves(trace, code, ` parity: 'even',`, 2, expectSource(`parity: 'even',`)); +}); + +Deno.test('A10.2 identical repeated source lines map to their DISTINCT authored locations', () => { + const { code, map } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + + // Three identical method-body lines resolve to three distinct authored lines. + const first = resolve(trace, code, 'this.count++;', 1); + const second = resolve(trace, code, 'this.count++;', 2); + const third = resolve(trace, code, 'this.count++;', 3); + assertEquals( + { source: first.source, line: first.line, column: first.column }, + expectSource('this.count++;', 1), + ); + assertEquals( + { source: second.source, line: second.line, column: second.column }, + expectSource('this.count++;', 2), + ); + assertEquals( + { source: third.source, line: third.line, column: third.column }, + expectSource('this.count++;', 3), + ); + assertNotEquals(first.line, second.line); + assertNotEquals(second.line, third.line); +}); + +Deno.test('A10.2 MANDATORY: two generated `this.count++;` event handlers map to two distinct arrows', () => { + const { code, map } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + + // The two generated handler lines are byte-identical; each must map back to + // ITS OWN authored arrow function (line AND column), not first-match-wins. + const first = resolve(trace, code, '__compiledEvent0(): void { this.count++; }', 1); + const second = resolve(trace, code, '__compiledEvent1(): void { this.count++; }', 1); + const firstArrow = positionOf(SOURCE, '() => this.count++', 1); + const secondArrow = positionOf(SOURCE, '() => this.count++', 2); + assertEquals({ source: first.source, line: first.line, column: first.column }, { + source: FILE, + line: firstArrow.line, + column: firstArrow.column, + }); + assertEquals({ source: second.source, line: second.line, column: second.column }, { + source: FILE, + line: secondArrow.line, + column: secondArrow.column, + }); + assertNotEquals( + first.line, + second.line, + 'duplicate handler bodies must not collapse to one line', + ); +}); + +Deno.test('A10.2 segments carry original identifier names where the compiler knows them', () => { + const { code, map } = compileElementProgram(SOURCE, FILE); + const trace = traceOf(map); + assert(map.names.includes('count'), 'names table must carry authored identifiers'); + assert(map.names.includes('bump'), 'names table must carry authored method names'); + const resolved = resolve(trace, code, ' count = 0;', 1); + assertEquals(resolved.name, 'count'); +}); + +Deno.test('A10.2 diagnostics keep pointing at authored positions', () => { + const nested = `import { element, OpenElement, property } from '@openelement/element'; +@element('oe-nested-region') +export class NestedRegion extends OpenElement { + @property({ reflect: true }) count = 0; + render() { + return ( +
+ {this.count > 0 ?

{this.count > 1 ? many : one}

:

zero

} +
+ ); + } +} +`; + const file = '/project/app/components/nested-region.tsx'; + let caught: unknown; + try { + compileElementProgram(nested, file); + } catch (error) { + caught = error; + } + assert(caught instanceof CompiledElementError, 'nested regions must fail closed'); + const diagnostic = caught.diagnostics[0]; + assertEquals(diagnostic.code, 'OEC9012'); + const expected = positionOf(nested, '{this.count > 1 ?'); + assertEquals(diagnostic.file, file); + assertEquals(diagnostic.line, expected.line); + assertEquals(diagnostic.character, expected.column + 1); +}); + +Deno.test('A10.2 Vite boundary: open:core hands the real map to Vite without a double map story', () => { + // compileElementModule (the Vite-bound entrypoint) returns the real map. + const result = compileElementModule(SOURCE, FILE); + assert(result, 'fixture must be admitted by the compiler gate'); + assertNotEquals(result.map.mappings, ''); + assertEquals(result.map.x_openElement, result.program.sourceMap); + const trace = traceOf(result.map); + const resolved = resolve(trace, result.code, '__compiledEvent1(): void { this.count++; }', 1); + const secondArrow = positionOf(SOURCE, '() => this.count++', 2); + assertEquals({ source: resolved.source, line: resolved.line, column: resolved.column }, { + source: FILE, + line: secondArrow.line, + column: secondArrow.column, + }); + + // The open:core transform returns the real map object as its `map` output + // (Vite composes downstream) and strips the inline comment from the served + // code so there is exactly one map story at the plugin boundary. + const core = createOpenPlugin().find((plugin: Plugin) => plugin.name === 'open:core'); + assert(core, 'open:core plugin must be registered'); + const transform = core.transform as unknown as ( + this: { error(message: string): never }, + code: string, + id: string, + ) => { code: string; map?: { mappings: string } } | string | null; + const transformed = transform.call( + { + error(message: string): never { + throw new Error(message); + }, + }, + SOURCE, + FILE, + ); + assert(transformed !== null && typeof transformed === 'object', 'open:core must return code+map'); + assertEquals( + transformed.code.includes('sourceMappingURL'), + false, + 'no inline map comment may survive the boundary', + ); + assert(transformed.map, 'open:core must return the real map object'); + assertNotEquals(transformed.map!.mappings, ''); + const boundaryTrace = traceOf(transformed.map); + const viaPlugin = resolve(boundaryTrace, transformed.code, ' count = 0;', 1); + assertEquals({ source: viaPlugin.source, line: viaPlugin.line, column: viaPlugin.column }, { + source: FILE, + line: positionOf(SOURCE, 'count = 0;').line, + column: positionOf(SOURCE, 'count = 0;').column, + }); +}); diff --git a/packages/adapter-vite/__tests__/v044-delivery/island-delivery.test.ts b/packages/adapter-vite/__tests__/v044-delivery/island-delivery.test.ts index 49de050cc..f522f7ed8 100644 --- a/packages/adapter-vite/__tests__/v044-delivery/island-delivery.test.ts +++ b/packages/adapter-vite/__tests__/v044-delivery/island-delivery.test.ts @@ -1,10 +1,7 @@ import { assertEquals, assertStringIncludes, assertThrows } from '@std/assert'; import { join } from 'node:path'; import { buildCriticalHeadExtras } from '../../src/internal/ssg/critical-assets.ts'; -import { - compiledElementPlugin, - createCompiledElementSourceMap, -} from '../../src/internal/compiler/plugin.ts'; +import { compiledElementPlugin, compileElementModule } from '../../src/internal/compiler/plugin.ts'; import { generateClientEntry } from '../../src/internal/ssg/entry-client-codegen.ts'; import { createIslandScheduler } from '../../src/internal/ssg/island-scheduler.ts'; import { readIslandConfig } from '../../src/internal/ssg/island-scanner.ts'; @@ -204,22 +201,21 @@ Deno.test('v0.44 SSR admission expands one capability declaration per delivered }); Deno.test('v0.44 compiler source records pass through the Vite source map', () => { - const records = { - version: 1, - file: '/src/clock.tsx', - records: [{ - id: 'root', - kind: 'root', - source: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 1, line: 1, column: 2 } }, - }], - }; - const map = createCompiledElementSourceMap( - 'const source = true;', - 'const generated = true;', - '/src/clock.tsx', - { sourceMap: records }, - ); - assertEquals(map.x_openElement, records); + // A10.2 (#1210): the map returned for Vite composition is the core's real + // Source Map v3; the program's v1 provenance records ride along only as + // supplementary x_openElement metadata. + const source = [ + "import { element, OpenElement, property } from '@openelement/element';", + "@element('oe-clock')", + 'export class Clock extends OpenElement {', + ' @property({ reflect: true }) count = 0;', + ' render() { return
{this.count}
; }', + '}', + ].join('\n'); + const result = compileElementModule(source, '/src/clock.tsx'); + assertEquals(result?.map.x_openElement, result?.program.sourceMap); + assertEquals(result?.map.sources, ['/src/clock.tsx']); + assertEquals(result?.map.sourcesContent, [source]); }); Deno.test('v0.44 compiler hook transforms once and classifies HMR shape changes', async () => { diff --git a/packages/adapter-vite/src/internal/compiler/plugin.ts b/packages/adapter-vite/src/internal/compiler/plugin.ts index 669896d72..ae32d804a 100644 --- a/packages/adapter-vite/src/internal/compiler/plugin.ts +++ b/packages/adapter-vite/src/internal/compiler/plugin.ts @@ -31,17 +31,6 @@ import { analyzeModuleSemantics } from './semantic-core/module-analysis.ts'; export const COMPILED_ELEMENT_MARKER = '@element('; -interface CompiledElementSourceMap { - version: 3; - file: string; - sources: string[]; - sourcesContent: string[]; - names: string[]; - mappings: string; - /** Compiler-owned Part Program source records carried through Vite. */ - x_openElement?: unknown; -} - /** * Cheap first stage only — NOT a recognizer. The substring match exists to * keep plain modules off the AST path and may false-positive (string literals @@ -84,56 +73,15 @@ export function compileElementModule(code: string, id: string): CompileElementRe return compileElementProgram(code, id); } -function encodeVlq(value: number): string { - const base64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - let current = value < 0 ? ((-value) << 1) | 1 : value << 1; - let encoded = ''; - do { - let digit = current & 31; - current >>>= 5; - if (current > 0) digit |= 32; - encoded += base64[digit]; - } while (current > 0); - return encoded; -} - /** - * Emit a small source map for generated compiler modules. Generated program - * data has no source position of its own, while copied fields/methods can be - * mapped back to their source line. Unmapped scaffolding points at line 1 so - * Vite and stack consumers still have a valid, named source rather than an - * absent map. + * Strip the inline map comment from a compiled module at the Vite boundary. + * The core artifact embeds its real Source Map v3 inline for standalone + * consumers; the Vite transform returns that same map as its `map` output so + * Vite composes it with the rest of the pipeline — leaving the comment in the + * served code would create a second, conflicting map story (#1210). */ -export function createCompiledElementSourceMap( - source: string, - generated: string, - id: string, - program?: unknown, -): CompiledElementSourceMap { - const sourceLines = source.split(/\r?\n/); - let previousOriginalLine = 0; - const mappings = generated.split('\n').map((line) => { - const text = line.trim(); - const match = text.length > 0 - ? sourceLines.findIndex((sourceLine) => sourceLine.trim() === text) - : -1; - const originalLine = match >= 0 ? match : 0; - const mapping = `AA${encodeVlq(originalLine - previousOriginalLine)}A`; - previousOriginalLine = originalLine; - return mapping; - }).join(';'); - const artifactSourceMap = program && typeof program === 'object' - ? (program as { sourceMap?: unknown }).sourceMap - : undefined; - return { - version: 3, - file: id, - sources: [id], - sourcesContent: [source], - names: [], - mappings, - ...(artifactSourceMap === undefined ? {} : { x_openElement: artifactSourceMap }), - }; +export function stripInlineSourceMapComment(code: string): string { + return code.replace(/\n\/\/# sourceMappingURL=data:application\/json;base64,[^\n]*(?=\n?$)/, ''); } export function compiledElementPlugin(): Plugin { diff --git a/packages/adapter-vite/src/internal/compiler/semantic-core/compile.ts b/packages/adapter-vite/src/internal/compiler/semantic-core/compile.ts index 3c143b41c..284fcaff8 100644 --- a/packages/adapter-vite/src/internal/compiler/semantic-core/compile.ts +++ b/packages/adapter-vite/src/internal/compiler/semantic-core/compile.ts @@ -20,6 +20,7 @@ import { isCompileTimeOnlyImport, type ModuleIntrinsicBindings, } from './module-analysis.ts'; +import { type CompiledElementSourceMap, SourceMapSegmentBuilder } from './source-map.ts'; import { type CompiledElementMetadata, type PartProgramV1, @@ -49,6 +50,13 @@ export class CompiledElementError extends CompilerDiagnosticError { export interface CompileElementResult { code: string; + /** + * Real Source Map v3 for the emitted module (VLQ line+column segments + * derived from the compiler's span records and emission provenance). The + * same map is embedded as the module's inline map; the Vite shell returns it + * as its `map` output for downstream composition (#1210). + */ + map: CompiledElementSourceMap; program: PartProgramV1; } @@ -71,7 +79,7 @@ interface CompiledField { * source-signal dependencies and emits a `__computedFields` factory over the * instance's signal record; no field initializer runs on the generated class. */ - computed?: { deps: string[]; factoryText: string }; + computed?: { deps: string[]; factoryText: string; body: ts.Expression }; } interface GeneratedHandler { @@ -318,7 +326,7 @@ function parseComputedInitializer( plainFieldNames: ReadonlySet, computedFieldNames: ReadonlySet, fail: (node: ts.Node, code: string, message: string) => never, -): { deps: string[]; factoryText: string } | null { +): { deps: string[]; factoryText: string; body: ts.Expression } | null { const value = unwrapExpression(initializer); if (!ts.isCallExpression(value)) return null; const resolution = intrinsics.resolveIntrinsic(value.expression, 'computed'); @@ -394,7 +402,7 @@ function parseComputedInitializer( `__s.${replacement.name}.value` + bodyText.slice(replacement.end - offset); } - return { deps, factoryText: `(__s) => ${resolution.localName}(() => ${bodyText})` }; + return { deps, factoryText: `(__s) => ${resolution.localName}(() => ${bodyText})`, body }; } function inferPropertyType( @@ -1268,11 +1276,13 @@ function propertyFields( methods: ts.MethodDeclaration[]; render: ts.MethodDeclaration; stylesText?: string; + stylesNode?: ts.Expression; } { const fields: CompiledField[] = []; const methods: ts.MethodDeclaration[] = []; let render: ts.MethodDeclaration | null = null; let stylesText: string | undefined; + let stylesNode: ts.Expression | undefined; const names = new Set(); const propertyAttributeNames = new Set(); for (const member of classNode.members) { @@ -1297,6 +1307,7 @@ function propertyFields( fail(member, 'OEC9005', 'compiled classes may declare static styles only once'); } stylesText = member.initializer.getText(sf); + stylesNode = member.initializer; continue; } const accessibilityModifiers = modifiers.filter((modifier) => @@ -1531,7 +1542,7 @@ function propertyFields( ); } if (!render) fail(classNode, 'OEC9007', 'compiled classes must declare render()'); - return { fields, methods, render, stylesText }; + return { fields, methods, render, stylesText, stylesNode }; } function isDeclareStatement(statement: ts.Statement): boolean { @@ -1659,25 +1670,6 @@ function encodeBase64(value: string): string { return btoa(binary); } -function runtimePropsText(fields: CompiledField[]): string[] { - const lines = ['const __compiledProps = {']; - for (const field of fields) { - if (field.computed) { - lines.push( - ` ${field.name}: { type: Object, default: undefined, reflect: false, attribute: false },`, - ); - continue; - } - const attribute = field.attribute === null ? 'false' : JSON.stringify(field.attribute); - lines.push( - ` ${field.name}: { type: ${field.typeConstructor}, default: ${field.initializerText}, ` + - `reflect: ${field.reflect}, attribute: ${attribute} },`, - ); - } - lines.push('};'); - return lines; -} - function generatedHandlerText(handler: GeneratedHandler): string { const action = handler.action; if (action.kind === 'method') return ` ${handler.name}(): void { this.${action.name}(); }`; @@ -1716,7 +1708,7 @@ export function compileElementProgram(source: string, fileName: string): Compile // analysis — no spelling-based recognizer survives in the compiler. const intrinsics = createModuleIntrinsicBindings(sf); - const passthroughStatements: string[] = []; + const passthroughStatements: ts.Statement[] = []; for (const statement of sf.statements) { if ( ts.isImportDeclaration(statement) || ts.isClassDeclaration(statement) || @@ -1726,7 +1718,7 @@ export function compileElementProgram(source: string, fileName: string): Compile // alpha.8: the island delivery policy is the one runtime statement a // compiled module may carry; it is validated and copied verbatim below. if (isIslandConfigStatement(statement, intrinsics)) { - passthroughStatements.push(statement.getText(sf)); + passthroughStatements.push(statement); continue; } fail( @@ -1898,7 +1890,12 @@ export function compileElementProgram(source: string, fileName: string): Compile const openElementLocalName = heritageResolution.localName!; if (!classNode.name) fail(classNode, 'OEC9003', 'compiled classes must be named'); const className = classNode.name.text; - const { fields, methods, render, stylesText } = propertyFields(sf, classNode, intrinsics, fail); + const { fields, methods, render, stylesText, stylesNode } = propertyFields( + sf, + classNode, + intrinsics, + fail, + ); const methodNames = methods.map((method) => (method.name as ts.Identifier).text); const lowering = new Lowering(sf, fields, methodNames); const renderStatements = render.body?.statements ?? []; @@ -1977,31 +1974,210 @@ export function compileElementProgram(source: string, fileName: string): Compile const propertiesJson = JSON.stringify(metadata.properties, null, 2); const metadataJson = JSON.stringify(metadata, null, 2); const observedJson = JSON.stringify(metadata.observedAttributes, null, 2); - const memberLines: string[] = [ - ' static __partProgram = __partProgram;', - ' static __compiledProperties = __compiledProperties;', - ' static __elementMetadata = __elementMetadata;', - ' static props = __compiledProps;', - ' static observedAttributes = __observedAttributes;', - ]; - if (delegatesFocus) memberLines.push(' static delegatesFocus = true;'); - if (formAssociated) memberLines.push(' static formAssociated = true;'); + + // Emission provenance (#1210, ADR-0148): the semantic core owns both the + // original source spans and where each copied/derived construct lands in the + // generated module. Every such line records a real Source Map v3 segment + // (VLQ line+column, names where known); pure scaffolding stays unmapped so + // consumers fall through to the nearest real construct. The emitted module + // text is unchanged by this bookkeeping. + const segments = new SourceMapSegmentBuilder(); + const codeLines: string[] = []; + const nodePosition = (node: ts.Node): { line: number; column: number } => { + const position = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + return { line: position.line + 1, column: position.character }; + }; + const mapLineAt = ( + generatedLine: number, + generatedColumn: number, + node: ts.Node, + name?: string, + ): void => { + const position = nodePosition(node); + segments.add({ + generatedLine, + generatedColumn, + sourceLine: position.line, + sourceColumn: position.column, + ...(name === undefined ? {} : { name }), + }); + }; + /** + * Continuation lines of a verbatim-copied block: text line i is authored + * line (start + i) verbatim, so generated column (prefix + whitespace) maps + * to authored column (whitespace) — the first non-whitespace character. + */ + const mapContinuationLines = ( + text: string, + firstGeneratedLine: number, + node: ts.Node, + prefix: number, + ): void => { + const position = nodePosition(node); + const lines = text.split('\n'); + for (let index = 1; index < lines.length; index++) { + const whitespace = /^\s*/.exec(lines[index])![0].length; + segments.add({ + generatedLine: firstGeneratedLine + index, + generatedColumn: prefix + whitespace, + sourceLine: position.line + index, + sourceColumn: whitespace, + }); + } + }; + /** Push a line or block: multiline text always splits so codeLines.length tracks emitted lines. */ + const push = (text: string): void => { + for (const line of text.split('\n')) codeLines.push(line); + }; + /** Push a verbatim copy of a node's text (optionally line-prefixed). */ + const pushVerbatim = (text: string, node: ts.Node, prefix = '', name?: string): void => { + const firstGeneratedLine = codeLines.length + 1; + push(text.split('\n').map((line) => prefix + line).join('\n')); + mapLineAt(firstGeneratedLine, prefix.length, node, name); + mapContinuationLines(text, firstGeneratedLine, node, prefix.length); + }; + /** Push one synthesized line embedding a verbatim value, mapping both. */ + const pushDerivedLine = ( + head: string, + valueText: string, + tail: string, + nameNode: ts.Node, + nameColumn: number, + name: string, + valueNode: ts.Node | undefined, + ): void => { + const generatedLine = codeLines.length + 1; + push(`${head}${valueText}${tail}`); + mapLineAt(generatedLine, nameColumn, nameNode, name); + if (valueNode !== undefined) { + mapLineAt(generatedLine, head.length, valueNode); + mapContinuationLines(valueText, generatedLine, valueNode, 0); + } + }; + /** Push a synthesized block whose first line traces to `node`. */ + const pushDerivedBlock = (text: string, node: ts.Node, name?: string): void => { + const firstGeneratedLine = codeLines.length + 1; + push(text); + mapLineAt(firstGeneratedLine, 0, node, name); + }; + + push('// '); + for (const statement of sf.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const rewritten = rewriteImportForGeneratedModule(sf, statement); + // No OpenElement import injection (#1209): heritage provenance is required, + // so the canonical binding (possibly aliased) always exists in the source + // and is carried by the copied imports above. + if (rewritten !== null) pushVerbatim(rewritten, statement); + } + push(''); + for (const statement of passthroughStatements) pushVerbatim(statement.getText(sf), statement); + if (passthroughStatements.length > 0) push(''); + + // The serialized program payload derives from the render() JSX; each source + // record's own serialized entry maps to its authored span below. + const programStartLine = codeLines.length + 1; + pushDerivedBlock(`const __partProgram = ${programJson};`, render); + const programJsonPosition = (offset: number): { line: number; column: number } => { + const before = programJson.slice(0, offset); + return { + line: programStartLine + before.split('\n').length - 1, + column: offset - (before.lastIndexOf('\n') + 1), + }; + }; + const tagOffset = programJson.indexOf(`"tag": ${JSON.stringify(tag)}`); + if (tagOffset >= 0) { + // The compiled tag payload traces to the @element decorator application. + const at = programJsonPosition(tagOffset); + mapLineAt(at.line, at.column, decorator); + } + let recordsCursor = programJson.indexOf('"records": ['); + if (recordsCursor >= 0) { + for (const record of program.sourceMap.records) { + const needle = `"id": ${JSON.stringify(record.id)}`; + const offset = programJson.indexOf(needle, recordsCursor); + if (offset < 0) continue; // records always serialize in order; defensive + recordsCursor = offset + needle.length; + const at = programJsonPosition(offset); + segments.add({ + generatedLine: at.line, + generatedColumn: at.column, + sourceLine: record.source.start.line, + sourceColumn: record.source.start.column - 1, + }); + } + } + push(''); + pushDerivedBlock(`const __compiledProperties = ${propertiesJson};`, classNode.name!); + push(''); + pushDerivedBlock(`const __elementMetadata = ${metadataJson};`, classNode.name!); + push(''); + pushDerivedBlock(`const __observedAttributes = ${observedJson};`, classNode.name!); + push(''); + + pushDerivedBlock('const __compiledProps = {', classNode.name!); + for (const field of fields) { + if (field.computed) { + const computedLine = codeLines.length + 1; + push( + ` ${field.name}: { type: Object, default: undefined, reflect: false, attribute: false },`, + ); + mapLineAt(computedLine, 2, field.node.name, field.name); + continue; + } + const attribute = field.attribute === null ? 'false' : JSON.stringify(field.attribute); + pushDerivedLine( + ` ${field.name}: { type: ${field.typeConstructor}, default: `, + field.initializerText, + `, reflect: ${field.reflect}, attribute: ${attribute} },`, + field.node.name, + 2, + field.name, + field.node.initializer, + ); + } + push('};'); + push(''); + + const classLine = `export ${ + isDefaultExport ? 'default ' : '' + }class ${className} extends ${openElementLocalName} {`; + push(classLine); + mapLineAt(codeLines.length, classLine.indexOf(className), classNode.name!, className); + push( + [ + ' static __partProgram = __partProgram;', + ' static __compiledProperties = __compiledProperties;', + ' static __elementMetadata = __elementMetadata;', + ' static props = __compiledProps;', + ' static observedAttributes = __observedAttributes;', + ].join('\n'), + ); + if (delegatesFocus) push(' static delegatesFocus = true;'); + if (formAssociated) push(' static formAssociated = true;'); const computedFields = fields.filter((field) => field.computed); if (computedFields.length > 0) { // Derived-signal factories: each builds the field's read-only computed // over the instance's plain property signals (facade + renderDsd run the // same factories, so server output and client claim read one value set). - memberLines.push(' static __computedFields = {'); + push(' static __computedFields = {'); for (const field of computedFields) { - memberLines.push(` ${field.name}: ${field.computed!.factoryText},`); + const factoryLine = codeLines.length + 1; + push(` ${field.name}: ${field.computed!.factoryText},`); + mapLineAt(factoryLine, 4, field.node.name, field.name); + mapLineAt(factoryLine, 4 + field.name.length + 2, field.node.initializer!); + mapContinuationLines(field.computed!.factoryText, factoryLine, field.computed!.body, 0); } - memberLines.push(' };'); + push(' };'); } - if (stylesText !== undefined) { + if (stylesText !== undefined && stylesNode !== undefined) { // Copied verbatim: the facade reads static styles into the compiled style // scope (adoptedStyleSheets on shadow roots, a document-head sink on light // roots); the serializer inlines them as the marked DSD