From af805c7dfd9d9968f1b3eccb92302a108f5bc9c0 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 14:50:14 -0400 Subject: [PATCH 1/8] Avoid empty generated macro expansion rounds --- src/frontend/project_macro_support.ts | 284 +++++++++++++++++++++++++- 1 file changed, 279 insertions(+), 5 deletions(-) diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index 37f25f2..333e9da 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -1347,6 +1347,264 @@ export function createProjectMacroEnvironment( return siteKindsBySpecifier; } + function sourceTextHasGeneratedAnnotationSyntax(sourceText: string): boolean { + return /(^|\n)\s*(?:\/\/|\/\*)\s*#\[/u.test(sourceText); + } + + function escapeRegExp(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); + } + + function sourceTextHasGeneratedAnnotationForName(sourceText: string, name: string): boolean { + return new RegExp( + `(^|\\n)\\s*(?://|/\\*)\\s*#\\[\\s*${escapeRegExp(name)}(?:[.\\](]|\\s)`, + 'u', + ).test(sourceText); + } + + function collectGeneratedStdlibMacroSiteNames(sourceFile: ts.SourceFile): { + readonly annotationNames: Set; + readonly callNames: Set; + readonly tagNames: Set; + } { + const annotationNames = new Set(); + const callNames = new Set(); + const tagNames = new Set(); + + for (const [macroName, siteKind] of alwaysAvailableMacroSiteKinds.entries()) { + if (siteKind === 'annotation') { + annotationNames.add(macroName); + } else if (siteKind === 'call') { + callNames.add(macroName); + } else { + tagNames.add(macroName); + } + } + + const generatedSiteKindsBySpecifier = builtinMacroSiteKindsForGeneratedExpansion(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !statement.importClause || + !ts.isStringLiteral(statement.moduleSpecifier) + ) { + continue; + } + + const explicitKinds = generatedSiteKindsBySpecifier.get(statement.moduleSpecifier.text); + if (!explicitKinds) { + continue; + } + + if (statement.importClause.name) { + const localName = statement.importClause.name.text; + const explicitKind = explicitKinds.get('default'); + if (explicitKind === 'annotation') { + annotationNames.add(localName); + } else if (explicitKind === 'call') { + callNames.add(localName); + } else if (explicitKind === 'tag') { + tagNames.add(localName); + } + } + + const namedBindings = statement.importClause.namedBindings; + if (!namedBindings || !ts.isNamedImports(namedBindings)) { + continue; + } + + for (const element of namedBindings.elements) { + const localName = element.name.text; + const exportName = element.propertyName?.text ?? localName; + const explicitKind = explicitKinds.get(exportName); + if (explicitKind === 'annotation') { + annotationNames.add(localName); + } else if (explicitKind === 'call') { + callNames.add(localName); + } else if (explicitKind === 'tag') { + tagNames.add(localName); + } + } + } + + return { annotationNames, callNames, tagNames }; + } + + function sourceFileMayContainGeneratedStdlibMacro( + sourceFile: ts.SourceFile, + sourceText: string, + ): boolean { + const { annotationNames, callNames, tagNames } = collectGeneratedStdlibMacroSiteNames( + sourceFile, + ); + + if ( + annotationNames.size > 0 && + sourceTextHasGeneratedAnnotationSyntax(sourceText) + ) { + return true; + } + + let found = false; + const visit = (node: ts.Node): void => { + if (found) { + return; + } + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + callNames.has(node.expression.text) + ) { + found = true; + return; + } + if ( + ts.isTaggedTemplateExpression(node) && + ts.isIdentifier(node.tag) && + tagNames.has(node.tag.text) + ) { + found = true; + return; + } + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + return found; + } + + function sourceFileMayContainGeneratedUserMacroImport( + sourceFile: ts.SourceFile, + sourceText: string, + ): boolean { + const importedBindings: Array<{ + readonly exportName: string; + readonly localName: string; + readonly specifier: string; + }> = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const specifier = statement.moduleSpecifier.text; + if ( + specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier) + ) { + continue; + } + + if (statement.importClause?.isTypeOnly === true) { + continue; + } + + if (statement.importClause?.name) { + importedBindings.push({ + exportName: 'default', + localName: statement.importClause.name.text, + specifier, + }); + } + + const namedBindings = statement.importClause?.namedBindings; + if (!namedBindings || !ts.isNamedImports(namedBindings)) { + continue; + } + + for (const element of namedBindings.elements) { + if (!element.isTypeOnly) { + importedBindings.push({ + exportName: element.propertyName?.text ?? element.name.text, + localName: element.name.text, + specifier, + }); + } + } + } + + if (importedBindings.length === 0) { + return false; + } + + const importedLocalNames = new Set(importedBindings.map((binding) => binding.localName)); + const usedCandidateNames = new Set(); + for (const localName of importedLocalNames) { + if (sourceTextHasGeneratedAnnotationForName(sourceText, localName)) { + usedCandidateNames.add(localName); + } + } + + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + importedLocalNames.has(node.expression.text) + ) { + usedCandidateNames.add(node.expression.text); + return; + } + if ( + ts.isTaggedTemplateExpression(node) && + ts.isIdentifier(node.tag) && + importedLocalNames.has(node.tag.text) + ) { + usedCandidateNames.add(node.tag.text); + return; + } + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + if (usedCandidateNames.size === 0) { + return false; + } + + for (const binding of importedBindings) { + if (!usedCandidateNames.has(binding.localName)) { + continue; + } + + const resolved = resolvePreferredSoundscriptMacroModule( + binding.specifier, + sourceFile.fileName, + ) ?? + ts.resolveModuleName( + binding.specifier, + sourceFile.fileName, + preparedProgram.options, + resolutionHost, + moduleResolutionCache, + ).resolvedModule; + const resolvedRuntimeFileName = resolved?.resolvedFileName; + if (!resolvedRuntimeFileName) { + continue; + } + + const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( + binding.specifier, + resolvedRuntimeFileName, + resolutionHost, + )?.sourceEntryPath; + const resolvedFileName = packageMacroSourceEntry + ? toSourceFileName(packageMacroSourceEntry) + : toSourceFileName(resolvedRuntimeFileName); + if (!isSoundscriptSourceFile(resolvedFileName)) { + continue; + } + + const scanned = isLoadableMacroModuleFile(resolvedFileName) || + isPureMacroReexportBridgeModule(resolvedFileName) || + isLikelyMacroModule(resolvedFileName) + ? scannedFactoriesForMacroModule(resolvedFileName) + : new Map(); + if (scanned.has(binding.exportName)) { + return true; + } + } + + return false; + } + function createGeneratedMacroError( sourceText: string, invocation: ParsedMacroInvocation, @@ -1683,17 +1941,32 @@ export function createProjectMacroEnvironment( sourceFileName, printer.printFile(currentSourceFile), ); + const generatedSourceFile = ts.createSourceFile( + sourceFileName, + sourceText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(sourceFileName), + ); + if ( + !sourceFileMayContainGeneratedStdlibMacro(generatedSourceFile, sourceText) && + !sourceFileMayContainGeneratedUserMacroImport(generatedSourceFile, sourceText) + ) { + return currentSourceFile; + } const generatedPreparedProgram = createGeneratedExpansionPreparedProgram( sourceFileName, sourceText, ); try { const programFileName = generatedPreparedProgram.toProgramFileName(sourceFileName); - const generatedSourceFile = generatedPreparedProgram.program.getSourceFile(programFileName); + const generatedProgramSourceFile = generatedPreparedProgram.program.getSourceFile( + programFileName, + ); const generatedPreparedSource = generatedPreparedProgram.preparedHost .getPreparedSourceFile(sourceFileName); const invocations = [...(generatedPreparedSource?.rewriteResult.macrosById.values() ?? [])]; - if (invocations.length === 0 || !generatedSourceFile) { + if (invocations.length === 0 || !generatedProgramSourceFile) { return currentSourceFile; } if (remainingGeneratedRounds <= 0) { @@ -1715,7 +1988,7 @@ export function createProjectMacroEnvironment( const expandedGeneratedFiles = expandPreparedProgramWithFileRegistries( generatedPreparedProgram, new Map([[ - generatedSourceFile.fileName, + generatedProgramSourceFile.fileName, { advancedRegistry: bindings.advancedRegistry, registry: bindings.registry, @@ -1724,10 +1997,11 @@ export function createProjectMacroEnvironment( ]]), preserveMissingExpanders, annotateExpansions, - [generatedSourceFile], + [generatedProgramSourceFile], ); currentSourceFile = stripCompileTimeOnlyImportedBindings( - expandedGeneratedFiles.get(generatedSourceFile.fileName) ?? generatedSourceFile, + expandedGeneratedFiles.get(generatedProgramSourceFile.fileName) ?? + generatedProgramSourceFile, bindings.importedBindingUsage, preserveRemovedImportStatements, ); From 230dc4248899dcdcea841906d3c20cf3ac63b0fd Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 15:23:22 -0400 Subject: [PATCH 2/8] Optimize expand stage reuse --- .gitignore | 1 + deno.json | 2 + scripts/perf/expand_benchmark.ts | 507 +++++++++++++++++++++ src/frontend/builtin_macro_support.ts | 63 ++- src/frontend/expand_project.ts | 3 +- src/frontend/project_frontend_test.ts | 65 +++ src/frontend/project_macro_support.ts | 77 ++-- src/frontend/project_macro_support_test.ts | 76 +++ 8 files changed, 754 insertions(+), 40 deletions(-) create mode 100644 scripts/perf/expand_benchmark.ts diff --git a/.gitignore b/.gitignore index 72c9614..879c706 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .deno/ bin/ coverage/ +.bench/ dist/ node_modules/ !src/bundled/typescript/types/node_modules/ diff --git a/deno.json b/deno.json index 1e0eef6..7448a19 100644 --- a/deno.json +++ b/deno.json @@ -59,6 +59,8 @@ "tasks": { "build": "deno compile --allow-env --allow-read --allow-run --allow-write --output ./bin/soundscript src/main.ts && deno run -A scripts/release/stage_cli_runtime_support.ts ./bin", "dev": "deno run src/main.ts", + "bench:expand": "deno run -A scripts/perf/expand_benchmark.ts", + "bench:expand:quick": "deno run -A scripts/perf/expand_benchmark.ts --iterations 1 --warmups 0", "check": "deno check src/main.ts src/cli/cli.ts src/lsp_main.ts src/stdlib/*.ts", "fmt": "deno fmt", "fmt:check": "deno fmt --check", diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts new file mode 100644 index 0000000..c12a8f1 --- /dev/null +++ b/scripts/perf/expand_benchmark.ts @@ -0,0 +1,507 @@ +import { dirname, join } from '@std/path'; + +import { expandProject } from '../../src/frontend/expand_project.ts'; +interface BenchmarkOptions { + iterations: number; + outDir: string; + warmups: number; +} + +interface FixtureFile { + readonly path: string; + readonly contents: string; +} + +interface TimingEntry { + readonly durationMs: number; + readonly metadata: Record; + readonly stage: string; +} + +interface IterationResult { + readonly diagnostics: number; + readonly exitCode: number; + readonly timings: readonly TimingEntry[]; + readonly wallMs: number; +} + +interface ScenarioResult { + readonly iterations: readonly IterationResult[]; + readonly name: string; + readonly projectPath: string; +} + +const DEFAULT_OPTIONS: BenchmarkOptions = { + iterations: 5, + outDir: join(Deno.cwd(), '.bench', 'expand'), + warmups: 1, +}; + +function parseArgs(args: readonly string[]): BenchmarkOptions { + const options: BenchmarkOptions = { ...DEFAULT_OPTIONS }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--iterations') { + options.iterations = Number(args[index + 1] ?? ''); + index += 1; + continue; + } + if (arg === '--warmups') { + options.warmups = Number(args[index + 1] ?? ''); + index += 1; + continue; + } + if (arg === '--out-dir') { + options.outDir = args[index + 1] ?? ''; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!Number.isInteger(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive integer.'); + } + if (!Number.isInteger(options.warmups) || options.warmups < 0) { + throw new Error('--warmups must be a non-negative integer.'); + } + if (!options.outDir) { + throw new Error('--out-dir must not be empty.'); + } + return options; +} + +function baseProjectFiles(extraFiles: readonly FixtureFile[]): readonly FixtureFile[] { + return [ + { + path: 'package.json', + contents: JSON.stringify( + { + name: 'soundscript-expand-benchmark-fixture', + version: '1.0.0', + type: 'module', + }, + null, + 2, + ), + }, + { + path: 'tsconfig.json', + contents: JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'bundler', + }, + include: ['src/**/*.sts'], + }, + null, + 2, + ), + }, + ...extraFiles, + ]; +} + +function noMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/main.sts', + contents: [ + 'export interface Item {', + ' readonly id: string;', + ' readonly count: number;', + '}', + '', + 'export function total(items: readonly Item[]): number {', + ' return items.reduce((sum, item) => sum + item.count, 0);', + '}', + '', + ].join('\n'), + }, + ]); +} + +function stdlibMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/main.sts', + contents: [ + "type Ok = { tag: 'ok'; value: number };", + "type Err = { tag: 'err'; error: string };", + '', + 'export function score(value: Ok | Err | undefined): number {', + ' return Match(value, [', + ' ({ value }: Ok) => value,', + ' ({ error }: Err) => error.length,', + ' (_: undefined) => 0,', + ' ]);', + '}', + '', + ].join('\n'), + }, + ]); +} + +function userMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/macros.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Two() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`2`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { Two } from './macros.macro';", + '', + 'export const value = Two();', + '', + ].join('\n'), + }, + ]); +} + +function packageMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'node_modules/bench-macro-pkg/package.json', + contents: JSON.stringify( + { + name: 'bench-macro-pkg', + version: '1.0.0', + type: 'module', + types: './dist/index.d.ts', + soundscript: { + source: './src/index.sts', + }, + }, + null, + 2, + ), + }, + { + path: 'node_modules/bench-macro-pkg/dist/index.d.ts', + contents: 'export declare function Three(): number;\n', + }, + { + path: 'node_modules/bench-macro-pkg/src/index.sts', + contents: 'export { Three } from "./macros/three.macro";\n', + }, + { + path: 'node_modules/bench-macro-pkg/src/macros/three.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Three() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`3`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { Three } from 'bench-macro-pkg';", + '', + 'export const value = Three();', + '', + ].join('\n'), + }, + ]); +} + +function generatedStdlibMacroFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/model.macro.sts', + contents: [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitTodo() {', + ' return {', + ' expand(ctx: any) {', + ' return ctx.output.expr(ctx.quote.expr`todo("generated recursive macro")`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/main.sts', + contents: [ + "import { EmitTodo } from './model.macro';", + '', + 'export function fail(): never {', + ' return EmitTodo();', + '}', + '', + ].join('\n'), + }, + ]); +} + +function soundstageLikeFixture(): readonly FixtureFile[] { + return baseProjectFiles([ + { + path: 'src/view.macro.sts', + contents: [ + "import { macroSignature } from 'sts:macros';", + '', + "const DECL = macroSignature.of(macroSignature.decl('target'));", + '', + '// #[macro(decl)]', + 'export function view() {', + ' return {', + " declarationKinds: ['class'] as const,", + " expansionMode: 'augment' as const,", + ' signature: DECL,', + ' expand(ctx: any) {', + ' const name = ctx.syntax.declaration().name ?? ctx.error("expected class name");', + ' return ctx.output.stmt(ctx.quote.stmt`', + ' export function ${`mount${name}`}(target: unknown): void {', + ' void target;', + ' }', + ' `);', + ' },', + ' };', + '}', + '', + ].join('\n'), + }, + { + path: 'src/app.sts', + contents: [ + "import { view } from './view.macro';", + '', + '// #[view]', + 'export class App {', + ' title = "Bench";', + '}', + '', + ].join('\n'), + }, + { + path: 'src/browser.sts', + contents: [ + "import { mountApp } from './app';", + '', + 'export function mount(target: unknown): void {', + ' mountApp(target);', + '}', + '', + ].join('\n'), + }, + ]); +} + +const FIXTURES: ReadonlyArray<{ + readonly name: string; + readonly files: () => readonly FixtureFile[]; +}> = [ + { name: 'no-macros', files: noMacroFixture }, + { name: 'stdlib-macros', files: stdlibMacroFixture }, + { name: 'user-macros', files: userMacroFixture }, + { name: 'package-macros', files: packageMacroFixture }, + { name: 'generated-stdlib-macros', files: generatedStdlibMacroFixture }, + { name: 'soundstage-like', files: soundstageLikeFixture }, +]; + +async function writeFixture(root: string, files: readonly FixtureFile[]): Promise { + for (const file of files) { + const path = join(root, file.path); + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, file.contents); + } +} + +function parseTiming(line: string): TimingEntry | null { + const match = /^\[soundscript:checker\]\s+(\S+)\s+([\d.]+)ms(?:\s+(.*))?$/u.exec(line); + if (!match) { + return null; + } + + const metadata: Record = {}; + for (const part of (match[3] ?? '').split(/\s+/u)) { + if (!part) { + continue; + } + const separatorIndex = part.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + metadata[part.slice(0, separatorIndex)] = part.slice(separatorIndex + 1); + } + + return { + durationMs: Number(match[2]), + metadata, + stage: match[1], + }; +} + +async function runExpand( + projectPath: string, + outDir: string, +): Promise { + const originalTiming = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + + const start = performance.now(); + try { + const result = await expandProject({ + outDir, + projectPath, + workingDirectory: dirname(projectPath), + }); + return { + diagnostics: result.diagnostics.length, + exitCode: result.exitCode, + timings: logs.map(parseTiming).filter((entry): entry is TimingEntry => entry !== null), + wallMs: performance.now() - start, + }; + } finally { + if (originalTiming === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTiming); + } + console.error = originalError; + } +} + +function median(values: readonly number[]): number { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const midpoint = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[midpoint - 1]! + sorted[midpoint]!) / 2 + : sorted[midpoint]!; +} + +function stageMedian(result: ScenarioResult, stage: string): number { + return median( + result.iterations.map((iteration) => + iteration.timings + .filter((entry) => entry.stage === stage) + .reduce((total, entry) => total + entry.durationMs, 0) + ), + ); +} + +function semanticBuildMedian(result: ScenarioResult): number { + return median( + result.iterations.map((iteration) => + iteration.timings.filter((entry) => + entry.stage === 'project.prepare.semanticBuilderHostReuse' + ).length + ), + ); +} + +function markdownReport(results: readonly ScenarioResult[]): string { + const lines = [ + '# Soundscript Expand Benchmark', + '', + '| scenario | wall median ms | initial ms | expand macros ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ]; + for (const result of results) { + lines.push( + `| ${result.name} | ${ + median(result.iterations.map((iteration) => iteration.wallMs)).toFixed(1) + } | ${stageMedian(result, 'project.prepare.builtin.initialProgram').toFixed(1)} | ${ + stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) + } | ${stageMedian(result, 'project.prepare.builtin.annotatedProgram').toFixed(1)} | ${ + stageMedian(result, 'project.prepare.builtin.finalProgram').toFixed(1) + } | ${semanticBuildMedian(result).toFixed(1)} |`, + ); + } + lines.push(''); + return lines.join('\n'); +} + +async function runScenario( + name: string, + files: readonly FixtureFile[], + options: BenchmarkOptions, +): Promise { + const root = await Deno.makeTempDir({ prefix: `soundscript-expand-${name}-` }); + await writeFixture(root, files); + const projectPath = join(root, 'tsconfig.json'); + const iterations: IterationResult[] = []; + + for (let index = 0; index < options.warmups; index += 1) { + await runExpand(projectPath, join(root, `.warmup-${index}`)); + } + + for (let index = 0; index < options.iterations; index += 1) { + iterations.push(await runExpand(projectPath, join(root, `.out-${index}`))); + } + + return { iterations, name, projectPath }; +} + +async function main(): Promise { + const options = parseArgs(Deno.args); + await Deno.mkdir(options.outDir, { recursive: true }); + const results: ScenarioResult[] = []; + for (const fixture of FIXTURES) { + results.push(await runScenario(fixture.name, fixture.files(), options)); + } + + const report = { + createdAt: new Date().toISOString(), + options, + results, + }; + await Deno.writeTextFile( + join(options.outDir, 'expand-benchmark.json'), + `${JSON.stringify(report, null, 2)}\n`, + ); + const markdown = markdownReport(results); + await Deno.writeTextFile(join(options.outDir, 'expand-benchmark.md'), markdown); + console.log(markdown); + + const failed = results.flatMap((result) => + result.iterations + .filter((iteration) => iteration.exitCode !== 0) + .map((iteration) => `${result.name}: exit ${iteration.exitCode} (${iteration.diagnostics})`) + ); + if (failed.length > 0) { + console.error(failed.join('\n')); + Deno.exit(1); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/src/frontend/builtin_macro_support.ts b/src/frontend/builtin_macro_support.ts index efe6d37..9021884 100644 --- a/src/frontend/builtin_macro_support.ts +++ b/src/frontend/builtin_macro_support.ts @@ -68,6 +68,7 @@ import { lowerJsxSyntaxToRuntimeCalls, mapProgramRangeToSource, persistPreparedProgramBuildInfo, + type PreparedCompilerHostReuseState, type PreparedProgram, type PreparedSourceFile, toSourceFileName, @@ -97,6 +98,7 @@ interface BuiltinProgramBase { dispose(): void; frontendDiagnostics(): readonly MergedDiagnostic[]; macroEnvironment: ProjectMacroEnvironment; + outputTextBySourceFileName: ReadonlyMap; preparedProgram: PreparedProgram; program: ts.Program; tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[]; @@ -1051,6 +1053,37 @@ function createBuiltinMacroDiagnostic(error: MacroError): MergedDiagnostic { }; } +function primePreparedCompilerHostReuseState( + target: PreparedCompilerHostReuseState, + sourceProgram: PreparedProgram, +): void { + const source = sourceProgram.preparedHost.reuseState; + if (target.programSourceFiles.size > 0 || target.rewrittenSourceFiles.size > 0) { + return; + } + + target.macroModuleArtifactCache = source.macroModuleArtifactCache; + target.moduleResolutionCache = source.moduleResolutionCache; + target.moduleResolutionCacheSignature = source.moduleResolutionCacheSignature; + target.previousProgramSourceFiles = new Set(source.programSourceFiles); + target.preparedSourceFiles = new Map(source.preparedSourceFiles); + target.programSourceFiles = new Set(source.programSourceFiles); + target.projectedDeclarationBuilderProgram = source.projectedDeclarationBuilderProgram; + target.projectedDeclarationOutputs = source.projectedDeclarationOutputs; + target.projectedDeclarationOptionSignature = source.projectedDeclarationOptionSignature; + target.projectedDeclarationProgram = source.projectedDeclarationProgram; + target.projectedDeclarationRootNamesSignature = source.projectedDeclarationRootNamesSignature; + target.projectedDeclarationSourceFiles = new Map(source.projectedDeclarationSourceFiles); + target.resolvedModulesByKey = new Map(source.resolvedModulesByKey); + target.rewrittenSourceFiles = new Map(source.rewrittenSourceFiles); + target.semanticDiagnosticsBuildInfoPath = source.semanticDiagnosticsBuildInfoPath; + target.semanticDiagnosticsBuilderProgram = source.semanticDiagnosticsBuilderProgram; + target.semanticDiagnosticsOptionSignature = source.semanticDiagnosticsOptionSignature; + target.semanticDiagnosticsProjectReferencesSignature = + source.semanticDiagnosticsProjectReferencesSignature; + target.semanticDiagnosticsRootNamesSignature = source.semanticDiagnosticsRootNamesSignature; +} + export function withBuiltinMacroSupport( options: CreatePreparedProgramOptions, ): CreatePreparedProgramOptions { @@ -1114,7 +1147,10 @@ export function expandPreparedProgramWithBuiltinsForDiagnostics( } interface BuiltinProgramStageContext { - createStagePreparedProgramOptions(stage: 'annotated' | 'final'): CreatePreparedProgramOptions; + createStagePreparedProgramOptions( + stage: 'annotated' | 'final', + seedProgram: PreparedProgram, + ): CreatePreparedProgramOptions; diagnosticPreparedFiles: Map; frontendDiagnostics: MergedDiagnostic[]; macroEnvironment: ProjectMacroEnvironment; @@ -1154,6 +1190,7 @@ function createBuiltinProgramBaseResult( ownedPreparedPrograms: readonly PreparedProgram[], preparedProgram: PreparedProgram, program: ts.Program, + outputTextBySourceFileName: ReadonlyMap = new Map(), tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[] = [{ program }], ): BuiltinProgramBase { return { @@ -1179,6 +1216,7 @@ function createBuiltinProgramBaseResult( }, frontendDiagnostics: () => frontendDiagnostics, macroEnvironment, + outputTextBySourceFileName, preparedProgram, program, tsDiagnosticPrograms, @@ -1193,6 +1231,7 @@ function createBuiltinDiagnosticProgramResult( ownedPreparedPrograms: readonly PreparedProgram[], preparedProgram: PreparedProgram, program: ts.Program, + outputTextBySourceFileName: ReadonlyMap = new Map(), tsDiagnosticPrograms: readonly BuiltinExpandedTsDiagnosticProgram[] = [{ program }], ): BuiltinDiagnosticProgram { return createBuiltinProgramBaseResult( @@ -1203,6 +1242,7 @@ function createBuiltinDiagnosticProgramResult( ownedPreparedPrograms, preparedProgram, program, + outputTextBySourceFileName, tsDiagnosticPrograms, ); } @@ -1232,24 +1272,30 @@ function createBuiltinProgramStageContext( { always: true }, ); persistPreparedProgramBuildInfo(preparedProgram); - const getBuiltinStageReuseState = (stage: 'annotated' | 'final') => { + const getBuiltinStageReuseState = ( + stage: 'annotated' | 'final', + seedProgram: PreparedProgram, + ) => { const primaryReuseState = preparedProgram.preparedHost.reuseState; const stageReuseStates = primaryReuseState.builtinStageReuseStates ??= {}; const existingStageReuseState = stageReuseStates[stage]; if (existingStageReuseState) { + primePreparedCompilerHostReuseState(existingStageReuseState, seedProgram); return existingStageReuseState; } const stageReuseState = createPreparedCompilerHostReuseState( supportedOptions.baseHost.getCurrentDirectory?.() ?? ts.sys.getCurrentDirectory(), ); + primePreparedCompilerHostReuseState(stageReuseState, seedProgram); stageReuseStates[stage] = stageReuseState; return stageReuseState; }; const createStagePreparedProgramOptions = ( stage: 'annotated' | 'final', + seedProgram: PreparedProgram, ): CreatePreparedProgramOptions => ({ ...withPersistentSemanticBuildInfoStage(supportedOptions, stage), - reusableCompilerHostState: getBuiltinStageReuseState(stage), + reusableCompilerHostState: getBuiltinStageReuseState(stage, seedProgram), }); const frontendDiagnostics: MergedDiagnostic[] = [...preparedProgram.frontendDiagnostics()]; const macroEnvironment = measureCheckerTiming( @@ -1397,7 +1443,7 @@ function createBuiltinAnnotatedStage( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('annotated'), + ...context.createStagePreparedProgramOptions('annotated', context.preparedProgram), fileOverrides: annotatedOverrides, invalidateModuleResolutions: false, oldProgram: context.preparedProgram.program, @@ -1449,7 +1495,7 @@ function createBuiltinEmitStagePreparation( } const nextNumericsProgram = context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', numericsProgram), fileOverrides: numericOverrides, invalidateModuleResolutions: false, oldProgram: numericsProgram.program, @@ -1754,7 +1800,7 @@ function createBuiltinFinalProgram( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', emitStage.numericsProgram), fileOverrides: emitStage.finalOverrides, invalidateModuleResolutions: false, oldProgram: emitStage.numericsProgram.program, @@ -1784,7 +1830,7 @@ function createSupplementalTsDiagnosticPrograms( }, () => context.trackPreparedProgram(createPreparedProgram({ - ...context.createStagePreparedProgramOptions('final'), + ...context.createStagePreparedProgramOptions('final', emitStage.numericsProgram), fileOverrides: emitStage.finalOverrides, invalidateModuleResolutions: false, oldProgram: emitStage.numericsProgram.program, @@ -1858,6 +1904,7 @@ function createBuiltinEmitProgramInternal( [...context.ownedPreparedPrograms], context.preparedProgram, analysisPreparedProgram.program, + emitStage.finalOverrides, ); } @@ -1921,6 +1968,7 @@ export function createBuiltinDiagnosticProgram( [...context.ownedPreparedPrograms], context.preparedProgram, analysisPreparedProgram.program, + emitStage.finalOverrides, supplementalTsDiagnosticPrograms, ); } @@ -1935,6 +1983,7 @@ export function createBuiltinDiagnosticProgram( [...context.ownedPreparedPrograms], context.preparedProgram, expandedProgram.program, + emitStage.finalOverrides, ); } diff --git a/src/frontend/expand_project.ts b/src/frontend/expand_project.ts index ab5681f..bad4b65 100644 --- a/src/frontend/expand_project.ts +++ b/src/frontend/expand_project.ts @@ -253,7 +253,8 @@ export async function expandProject(options: ExpandProjectOptions): Promise { + const fileName = '/virtual/index.sts'; + const originalTimingEnv = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + + const builtinExpanded = createBuiltinExpandedProgram({ + baseHost: createBaseHost( + new Map([ + [ + fileName, + [ + "type Ok = { tag: 'ok'; value: number };", + "type Err = { tag: 'err'; error: string };", + 'declare const value: Ok | Err | undefined;', + 'export const matched = Match(value, [', + ' ({ value }: Ok) => value,', + ' ({ error }: Err) => error.length,', + ' (_: undefined) => 0,', + ']);', + 'try {', + ' throw new Error("boom");', + '} catch (error) {', + ' console.log(error.message);', + '}', + '', + ].join('\n'), + ], + ]), + ), + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + strict: true, + }, + rootNames: [fileName], + }); + + assertEquals(ts.getPreEmitDiagnostics(builtinExpanded.program), []); + const semanticReuseLogs = logs.filter((line) => + line.includes('[soundscript:checker] project.prepare.semanticBuilderHostReuse ') + ); + assertEquals(semanticReuseLogs.length >= 3, true); + assertStringIncludes(semanticReuseLogs[1]!, 'changedProgramFiles=1'); + assertStringIncludes(semanticReuseLogs[1]!, 'rewrittenSourceFileCacheHits='); + assertStringIncludes(semanticReuseLogs[2]!, 'changedProgramFiles=1'); + assertStringIncludes(semanticReuseLogs[2]!, 'rewrittenSourceFileCacheHits='); + } finally { + if (originalTimingEnv === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTimingEnv); + } + console.error = originalError; + } +}); + Deno.test('createBuiltinExpandedProgram reuses unchanged builtin rewrite artifacts across .sts edits', () => { const changedFileName = '/virtual/changed.sts'; const stableFileName = '/virtual/stable.sts'; diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index 333e9da..86c5ca6 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -2680,6 +2680,7 @@ export function createProjectMacroEnvironment( macroCacheStats.moduleCacheMisses += 1; const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); + const graphRootNames = [...dependencySourceTexts.keys()]; const macroTargetProgram = createPreparedProgram({ alwaysAvailableMacroSiteKinds, baseHost: createMacroTargetBaseHost(), @@ -2694,7 +2695,7 @@ export function createProjectMacroEnvironment( }, preserveMacroAuthoring: true, reusableCompilerHostState: macroTargetReuseState, - rootNames: [fileName], + rootNames: graphRootNames, runtime: preparedProgram.runtime, }); const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => @@ -2728,44 +2729,56 @@ export function createProjectMacroEnvironment( ); } - const sourceFile = macroTargetProgram.program.getSourceFile( - macroTargetProgram.toProgramFileName(fileName), - ); - if (!sourceFile) { - throw createMacroModuleError( - fileName, - sourceTextForMacroModule(fileName), - `Failed to compile macro module "${fileName}".`, - 'SOUNDSCRIPT_MACRO_EXPANSION', + for (const graphFileName of graphRootNames) { + const sourceFile = macroTargetProgram.program.getSourceFile( + macroTargetProgram.toProgramFileName(graphFileName), ); + if (!sourceFile) { + throw createMacroModuleError( + graphFileName, + sourceTextForMacroModule(graphFileName), + `Failed to compile macro module "${graphFileName}".`, + 'SOUNDSCRIPT_MACRO_EXPANSION', + ); + } + + const tsDiagnostics = [ + ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), + ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), + ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); + if (tsDiagnostics.length > 0) { + throw createMacroModuleErrorFromDiagnostic( + tsDiagnostics[0]!, + graphFileName, + `Failed to compile macro module "${graphFileName}".`, + ); + } + + const javaScriptText = emitCommonJsMacroArtifactWithFallback( + macroTargetProgram, + sourceFile, + graphFileName, + macroTargetProgram.options, + ); + + const artifact: CachedMacroModuleArtifactEntry = { + dependencySourceTexts, + javaScriptText, + }; + compiledArtifactCache.set(graphFileName, artifact); + stableCompiledArtifactCache.set(graphFileName, artifact); } - const tsDiagnostics = [ - ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), - ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), - ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); - if (tsDiagnostics.length > 0) { - throw createMacroModuleErrorFromDiagnostic( - tsDiagnostics[0]!, + const compiled = compiledArtifactCache.get(fileName); + if (!compiled) { + throw createMacroModuleError( fileName, + sourceTextForMacroModule(fileName), `Failed to compile macro module "${fileName}".`, + 'SOUNDSCRIPT_MACRO_EXPANSION', ); } - - const javaScriptText = emitCommonJsMacroArtifactWithFallback( - macroTargetProgram, - sourceFile, - fileName, - macroTargetProgram.options, - ); - - const artifact: CachedMacroModuleArtifactEntry = { - dependencySourceTexts, - javaScriptText, - }; - compiledArtifactCache.set(fileName, artifact); - stableCompiledArtifactCache.set(fileName, artifact); - return artifact; + return compiled; } function loadResolvedModuleValue(fileName: string): Record { diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index d3b3b29..7848243 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -369,6 +369,82 @@ Deno.test('createProjectMacroEnvironment handles duplicate generated stdlib macr assert(!printed.includes('__sts_macro_stmt')); }); +Deno.test('createProjectMacroEnvironment ignores unused generated user macro imports', () => { + const fileName = '/virtual/index.sts'; + const preparedProgram = createGeneratedMacroTestProgram( + [ + "import { EmitPlainRuntimeCode } from './macros/defs.macro';", + '', + 'EmitPlainRuntimeCode();', + '', + ].join('\n'), + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitPlainRuntimeCode() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.stmts(ctx.quote.stmts`', + " import { RuntimeOnlyMacro } from './macros/defs.macro';", + ' export const value = 1;', + ' export const message = "RuntimeOnlyMacro is not invoked";', + ' `);', + ' },', + ' };', + '}', + '', + '// #[macro(call)]', + 'export function RuntimeOnlyMacro() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`2`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + const printed = printExpandedFileWithBuiltins(preparedProgram, fileName); + assertStringIncludes(printed, 'export const value = 1;'); + assertStringIncludes(printed, '"RuntimeOnlyMacro is not invoked"'); + assert(!printed.includes('__sts_macro_stmt')); +}); + +Deno.test('createProjectMacroEnvironment ignores macro-looking generated string literals', () => { + const fileName = '/virtual/index.sts'; + const preparedProgram = createGeneratedMacroTestProgram( + [ + "import { EmitMacroLookingText } from './macros/defs.macro';", + '', + 'EmitMacroLookingText();', + '', + ].join('\n'), + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function EmitMacroLookingText() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.stmts(ctx.quote.stmts`', + ' export const message = "// #[value] is documentation text";', + ' export const multiline = "\\\\n// #[codec]\\\\nnot an annotation";', + ' `);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + const printed = printExpandedFileWithBuiltins(preparedProgram, fileName); + assertStringIncludes(printed, '"// #[value] is documentation text"'); + assertStringIncludes(printed, 'not an annotation'); + assert(!printed.includes('__sts_macro_stmt')); +}); + Deno.test('createProjectMacroEnvironment honors macroExpansionRecursionLimit 0 for generated stdlib macros', () => { const fileName = '/virtual/index.sts'; const preparedProgram = createGeneratedMacroTestProgram( From 50a34e55ef0e0b92015ccd56828cbf932d355991 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 17:21:10 -0400 Subject: [PATCH 3/8] Profile expand macro costs --- scripts/perf/expand_benchmark.ts | 26 +- src/frontend/macro_expander.ts | 25 +- src/frontend/project_macro_support.ts | 1126 ++++++++++++-------- src/frontend/project_macro_support_test.ts | 47 + 4 files changed, 760 insertions(+), 464 deletions(-) diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts index c12a8f1..c8831d8 100644 --- a/scripts/perf/expand_benchmark.ts +++ b/scripts/perf/expand_benchmark.ts @@ -427,12 +427,22 @@ function semanticBuildMedian(result: ScenarioResult): number { ); } +function macroDetailMedian(result: ScenarioResult, metadataKey: string): number { + return median( + result.iterations.map((iteration) => + iteration.timings + .filter((entry) => entry.stage === 'project.prepare.macro.expandDetails') + .reduce((total, entry) => total + Number(entry.metadata[metadataKey] ?? 0), 0) + ), + ); +} + function markdownReport(results: readonly ScenarioResult[]): string { const lines = [ '# Soundscript Expand Benchmark', '', - '| scenario | wall median ms | initial ms | expand macros ms | annotated ms | final ms | semantic builds |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + '| scenario | wall median ms | initial ms | expand macros ms | graph compile ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const result of results) { lines.push( @@ -440,9 +450,15 @@ function markdownReport(results: readonly ScenarioResult[]): string { median(result.iterations.map((iteration) => iteration.wallMs)).toFixed(1) } | ${stageMedian(result, 'project.prepare.builtin.initialProgram').toFixed(1)} | ${ stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) - } | ${stageMedian(result, 'project.prepare.builtin.annotatedProgram').toFixed(1)} | ${ - stageMedian(result, 'project.prepare.builtin.finalProgram').toFixed(1) - } | ${semanticBuildMedian(result).toFixed(1)} |`, + } | ${macroDetailMedian(result, 'graphCompileMs').toFixed(1)} | ${ + macroDetailMedian(result, 'moduleEvalMs').toFixed(1) + } | ${macroDetailMedian(result, 'bindingPlanMs').toFixed(1)} | ${ + macroDetailMedian(result, 'macroExecutionMs').toFixed(1) + } | ${macroDetailMedian(result, 'sourceExpansionMs').toFixed(1)} | ${ + stageMedian(result, 'project.prepare.builtin.annotatedProgram').toFixed(1) + } | ${stageMedian(result, 'project.prepare.builtin.finalProgram').toFixed(1)} | ${ + semanticBuildMedian(result).toFixed(1) + } |`, ); } lines.push(''); diff --git a/src/frontend/macro_expander.ts b/src/frontend/macro_expander.ts index 90184a9..2994929 100644 --- a/src/frontend/macro_expander.ts +++ b/src/frontend/macro_expander.ts @@ -32,6 +32,10 @@ export type MacroExpanderRegistry = ReadonlyMap; export const MACRO_EXPANSION_START_MARKER_PREFIX = '__SS_MACRO_EXPANSION_START_'; export const MACRO_EXPANSION_END_MARKER_PREFIX = '__SS_MACRO_EXPANSION_END_'; +export interface MacroExpansionTimingObserver { + recordMacroExecution(kind: 'advanced' | 'rewrite', durationMs: number): void; +} + export interface MacroModule { expanders: Readonly>; moduleName: string; @@ -275,6 +279,7 @@ function expandAdvancedMacroPlaceholdersInSourceFile( rewriteRegistry: MacroExpanderRegistry = new Map(), siteKindsBySpecifier: ReadonlyMap> = new Map(), annotateExpansions = false, + timingObserver?: MacroExpansionTimingObserver, ): ts.SourceFile { if (registry.size === 0) { return sourceFile; @@ -300,7 +305,9 @@ function expandAdvancedMacroPlaceholdersInSourceFile( if (!expander) { continue; } + const start = performance.now(); const expansion = expander(site.resolved, nestedRegistries); + timingObserver?.recordMacroExecution('advanced', performance.now() - start); if (expansion) { expansions.set(site.resolved.placeholder.id, expansion); runtimeImports.push(...(expansion.runtimeImports ?? [])); @@ -1504,6 +1511,7 @@ export function expandMacroPlaceholdersWithRegistry( registry: MacroExpanderRegistry, preserveMissingExpanders = false, annotateExpansions = false, + timingObserver?: MacroExpansionTimingObserver, ): ts.SourceFile { return expandMacroPlaceholdersInSourceFile(sourceFile, collected, (resolved) => { const expander = registry.get(resolved.placeholder.invocation.nameText); @@ -1516,7 +1524,12 @@ export function expandMacroPlaceholdersWithRegistry( ); } - return expander(resolved); + const start = performance.now(); + try { + return expander(resolved); + } finally { + timingObserver?.recordMacroExecution('rewrite', performance.now() - start); + } }, annotateExpansions); } @@ -1539,6 +1552,9 @@ export function expandPreparedProgramWithRegistry( collected, advancedRegistry, registry, + new Map(), + false, + undefined, ); expandedFiles.set( sourceFile.fileName, @@ -1573,8 +1589,11 @@ export function expandPreparedProgramWithFileRegistries( preserveMissingExpanders = false, annotateExpansions = false, sourceFiles = preparedProgram.program.getSourceFiles(), + timingObserver?: MacroExpansionTimingObserver, ): ReadonlyMap { - const nonDeclarationSourceFiles = sourceFiles.filter((sourceFile) => !sourceFile.isDeclarationFile); + const nonDeclarationSourceFiles = sourceFiles.filter((sourceFile) => + !sourceFile.isDeclarationFile + ); const collected = collectResolvedMacroPlaceholders(preparedProgram, nonDeclarationSourceFiles); const expandedFiles = new Map(); @@ -1592,6 +1611,7 @@ export function expandPreparedProgramWithFileRegistries( registry, siteKindsBySpecifier, annotateExpansions, + timingObserver, ); expandedFiles.set( sourceFile.fileName, @@ -1601,6 +1621,7 @@ export function expandPreparedProgramWithFileRegistries( registry, preserveMissingExpanders, annotateExpansions, + timingObserver, ), ); } diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index 86c5ca6..a194205 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -1,5 +1,6 @@ import ts from 'typescript'; +import { logCheckerTiming } from '../checker/timing.ts'; import { createAnnotationLookup } from '../language/annotation_syntax.ts'; import { dirname, join } from '../platform/path.ts'; import { SOUND_DIAGNOSTIC_CODES } from '../checker/engine/diagnostic_codes.ts'; @@ -30,7 +31,10 @@ import { usesLegacyDefineMacroAuthoring, } from './macro_factory_support.ts'; import { collectImportedNamedBindings } from './macro_site_kind_support.ts'; -import { expandPreparedProgramWithFileRegistries } from './macro_expander.ts'; +import { + expandPreparedProgramWithFileRegistries, + type MacroExpansionTimingObserver, +} from './macro_expander.ts'; import { classifyImportedBindingUsage, type ImportedBindingUsage, @@ -269,6 +273,58 @@ export interface MacroModuleCacheStats { moduleCacheMisses: number; } +interface MacroExpansionTimingStats { + advancedMacroExecutionCount: number; + authorityResolutionMs: number; + bindingCacheWriteMs: number; + bindingPlanFiles: number; + bindingPlanMs: number; + definitionLoadMs: number; + dependencySignatureMs: number; + exportLoadMs: number; + generatedStdlibFiles: number; + generatedStdlibMs: number; + graphCompileFiles: number; + graphCompileGraphs: number; + graphCompileMs: number; + importUsageMs: number; + likelyMacroModuleMs: number; + macroExecutionMs: number; + moduleEvalRoots: number; + moduleEvalMs: number; + resolveImportMs: number; + rewriteMacroExecutionCount: number; + sourceExpansionFiles: number; + sourceExpansionMs: number; +} + +function createMacroExpansionTimingStats(): MacroExpansionTimingStats { + return { + advancedMacroExecutionCount: 0, + authorityResolutionMs: 0, + bindingCacheWriteMs: 0, + bindingPlanFiles: 0, + bindingPlanMs: 0, + definitionLoadMs: 0, + dependencySignatureMs: 0, + exportLoadMs: 0, + generatedStdlibFiles: 0, + generatedStdlibMs: 0, + graphCompileFiles: 0, + graphCompileGraphs: 0, + graphCompileMs: 0, + importUsageMs: 0, + likelyMacroModuleMs: 0, + macroExecutionMs: 0, + moduleEvalRoots: 0, + moduleEvalMs: 0, + resolveImportMs: 0, + rewriteMacroExecutionCount: 0, + sourceExpansionFiles: 0, + sourceExpansionMs: 0, + }; +} + export interface ProjectMacroEnvironment { cacheStats(): MacroModuleCacheStats; definitionsForFile(sourceFile: ts.SourceFile): ReadonlyMap; @@ -964,7 +1020,12 @@ export function createProjectMacroEnvironment( const macroModuleCandidateCache = new Map(); const macroReexportBridgeCache = new Map(); const macroModuleScanCache = new Map>(); + const macroModuleSourceFileCache = new Map(); const macroModuleSourceTextCache = new Map(); + const macroModuleCompilationDependencySourceTextsCache = new Map< + string, + ReadonlyMap + >(); const validatedMacroModuleFiles = new Set(); const definitionsByResolvedFile = new Map>(); const exportsByResolvedFile = new Map(); @@ -1002,6 +1063,30 @@ export function createProjectMacroEnvironment( const stableReuseState = getStableProjectMacroEnvironmentReuseState( preparedProgram.preparedHost.reuseState, ); + let activeMacroTimingStats: MacroExpansionTimingStats | null = null; + let macroModuleEvaluationDepth = 0; + + function recordActiveMacroTiming( + key: keyof MacroExpansionTimingStats, + value: number, + ): void { + if (!activeMacroTimingStats) { + return; + } + activeMacroTimingStats[key] += value; + } + + function measureActiveMacroTiming( + key: keyof MacroExpansionTimingStats, + fn: () => T, + ): T { + const start = performance.now(); + try { + return fn(); + } finally { + recordActiveMacroTiming(key, performance.now() - start); + } + } function macroNamesForFile(sourceFile: ts.SourceFile): ReadonlySet { const cached = macroNamesByFile.get(sourceFile.fileName); @@ -1021,12 +1106,23 @@ export function createProjectMacroEnvironment( return names; } + function fnv1aHash(text: string, seed = 0x811c9dc5): number { + let hash = seed; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; + } + function serializeDependencySourceTexts( dependencySourceTexts: ReadonlyMap, ): string { return [...dependencySourceTexts.entries()] .sort(([left], [right]) => left.localeCompare(right)) - .map(([fileName, text]) => `${fileName}\u0001${text.length}\u0001${text}`) + .map(([fileName, text]) => + `${fileName}\u0001${text.length}\u0001${fnv1aHash(text).toString(16)}` + ) .join('\u0002'); } @@ -1103,7 +1199,7 @@ export function createProjectMacroEnvironment( continue; } for ( - const [dependencyFileName, sourceText] of collectDependencySourceTextsForCompilation( + const [dependencyFileName, sourceText] of dependencySourceTextsForCompilation( authorityBinding.resolvedFileName, ) ) { @@ -1260,8 +1356,13 @@ export function createProjectMacroEnvironment( return cached; } + const dependencySignatureStart = performance.now(); const signature = serializeDependencySourceTexts( - collectDependencySourceTextsForCompilation(fileName), + dependencySourceTextsForCompilation(fileName), + ); + recordActiveMacroTiming( + 'dependencySignatureMs', + performance.now() - dependencySignatureStart, ); macroModuleExpansionDependencySignatureCache.set(fileName, signature); return signature; @@ -2017,101 +2118,108 @@ export function createProjectMacroEnvironment( specifier: string, options: { readonly fromMacroGraph?: boolean } = {}, ): string | null { - const normalizedContainingFileName = toSourceFileName(containingFileName); - if (options.fromMacroGraph && isLoadableMacroModuleFile(normalizedContainingFileName)) { - validateMacroModuleSourcePolicy(normalizedContainingFileName); - } - if (specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier)) { - return specifier; - } + const resolveImportStart = performance.now(); + try { + const normalizedContainingFileName = toSourceFileName(containingFileName); + if (options.fromMacroGraph && isLoadableMacroModuleFile(normalizedContainingFileName)) { + validateMacroModuleSourcePolicy(normalizedContainingFileName); + } + if ( + specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier) + ) { + return specifier; + } - const cacheKey = `${normalizedContainingFileName}\u0000${specifier}`; - const cached = resolvedImportCache.get(cacheKey); - if (cached !== undefined) { - return cached; - } + const cacheKey = `${normalizedContainingFileName}\u0000${specifier}`; + const cached = resolvedImportCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } - const resolved = - resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName) ?? - ts.resolveModuleName( - specifier, + const resolved = + resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName) ?? + ts.resolveModuleName( + specifier, + normalizedContainingFileName, + preparedProgram.options, + resolutionHost, + moduleResolutionCache, + ).resolvedModule; + const resolvedRuntimeFileName = resolved?.resolvedFileName; + if (!resolvedRuntimeFileName) { + resolvedImportCache.set(cacheKey, null); + return null; + } + + if (options.fromMacroGraph) { + const interopImportRange = findMacroGraphInteropImportRange( normalizedContainingFileName, - preparedProgram.options, - resolutionHost, - moduleResolutionCache, - ).resolvedModule; - const resolvedRuntimeFileName = resolved?.resolvedFileName; - if (!resolvedRuntimeFileName) { - resolvedImportCache.set(cacheKey, null); - return null; - } + specifier, + ); + if (interopImportRange) { + throw createMacroModuleError( + normalizedContainingFileName, + sourceTextForMacroModule(normalizedContainingFileName), + `Macro module "${normalizedContainingFileName}" cannot use #[interop] anywhere in its dependency graph. Macro graphs must stay entirely inside soundscript source.`, + MACRO_GRAPH_ERROR_CODES.forbiddenInterop, + interopImportRange.start, + interopImportRange.end, + ); + } + } - if (options.fromMacroGraph) { - const interopImportRange = findMacroGraphInteropImportRange( - normalizedContainingFileName, - specifier, - ); - if (interopImportRange) { + if (isProjectedSoundscriptDeclarationFile(resolvedRuntimeFileName)) { throw createMacroModuleError( normalizedContainingFileName, sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot use #[interop] anywhere in its dependency graph. Macro graphs must stay entirely inside soundscript source.`, + `Macro module "${normalizedContainingFileName}" cannot import "${specifier}" because macro graphs cannot cross projected declaration boundaries or #[interop] edges.`, MACRO_GRAPH_ERROR_CODES.forbiddenInterop, - interopImportRange.start, - interopImportRange.end, ); } - } - if (isProjectedSoundscriptDeclarationFile(resolvedRuntimeFileName)) { - throw createMacroModuleError( - normalizedContainingFileName, - sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot import "${specifier}" because macro graphs cannot cross projected declaration boundaries or #[interop] edges.`, - MACRO_GRAPH_ERROR_CODES.forbiddenInterop, - ); - } + const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( + specifier, + resolvedRuntimeFileName, + resolutionHost, + )?.sourceEntryPath; + const resolvedFileName = packageMacroSourceEntry + ? toSourceFileName(packageMacroSourceEntry) + : toSourceFileName(resolvedRuntimeFileName); + if (!isLoadableMacroModuleFile(resolvedFileName)) { + if (isPureMacroReexportBridgeModule(resolvedFileName)) { + validateMacroModuleSourcePolicy(resolvedFileName); + resolvedImportCache.set(cacheKey, resolvedFileName); + return resolvedFileName; + } - const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( - specifier, - resolvedRuntimeFileName, - resolutionHost, - )?.sourceEntryPath; - const resolvedFileName = packageMacroSourceEntry - ? toSourceFileName(packageMacroSourceEntry) - : toSourceFileName(resolvedRuntimeFileName); - if (!isLoadableMacroModuleFile(resolvedFileName)) { - if (isPureMacroReexportBridgeModule(resolvedFileName)) { - validateMacroModuleSourcePolicy(resolvedFileName); - resolvedImportCache.set(cacheKey, resolvedFileName); - return resolvedFileName; - } - - const resolvedSourceText = - preparedProgram.preparedHost.getPreparedSourceFile(resolvedFileName)?.originalText ?? - resolutionHost.readFile(resolvedFileName) ?? - ''; - const looksLikeMacroModule = sourceTextLooksLikeMacroModule(resolvedSourceText) || - usesLegacyDefineMacroAuthoring(resolvedSourceText); - if (looksLikeMacroModule) { + const resolvedSourceText = + preparedProgram.preparedHost.getPreparedSourceFile(resolvedFileName)?.originalText ?? + resolutionHost.readFile(resolvedFileName) ?? + ''; + const looksLikeMacroModule = sourceTextLooksLikeMacroModule(resolvedSourceText) || + usesLegacyDefineMacroAuthoring(resolvedSourceText); + if (looksLikeMacroModule) { + throw createMacroModuleError( + normalizedContainingFileName, + sourceTextForMacroModule(normalizedContainingFileName), + `Macro import "${specifier}" resolved to "${resolvedFileName}", but user-authored macro modules must come from a soundscript .macro.sts module.`, + MACRO_GRAPH_ERROR_CODES.unsupportedSourceKind, + ); + } throw createMacroModuleError( normalizedContainingFileName, sourceTextForMacroModule(normalizedContainingFileName), - `Macro import "${specifier}" resolved to "${resolvedFileName}", but user-authored macro modules must come from a soundscript .macro.sts module.`, - MACRO_GRAPH_ERROR_CODES.unsupportedSourceKind, + `Macro module "${normalizedContainingFileName}" cannot import non-macro source "${specifier}". Macro graphs may only depend on .macro.sts modules.`, + MACRO_GRAPH_ERROR_CODES.nonSoundscriptDependency, ); } - throw createMacroModuleError( - normalizedContainingFileName, - sourceTextForMacroModule(normalizedContainingFileName), - `Macro module "${normalizedContainingFileName}" cannot import non-macro source "${specifier}". Macro graphs may only depend on .macro.sts modules.`, - MACRO_GRAPH_ERROR_CODES.nonSoundscriptDependency, - ); - } - validateMacroModuleSourcePolicy(resolvedFileName); - resolvedImportCache.set(cacheKey, resolvedFileName); - return resolvedFileName; + validateMacroModuleSourcePolicy(resolvedFileName); + resolvedImportCache.set(cacheKey, resolvedFileName); + return resolvedFileName; + } finally { + recordActiveMacroTiming('resolveImportMs', performance.now() - resolveImportStart); + } } function resolvePreferredSoundscriptMacroModule( @@ -2151,17 +2259,25 @@ export function createProjectMacroEnvironment( } function isLikelyMacroModule(fileName: string): boolean { - const cached = macroModuleCandidateCache.get(fileName); - if (cached !== undefined) { - return cached; - } + const likelyMacroModuleStart = performance.now(); + try { + const cached = macroModuleCandidateCache.get(fileName); + if (cached !== undefined) { + return cached; + } - const sourceText = sourceTextForMacroModule(fileName); - const result = sourceTextLooksLikeMacroModule(sourceText) || - usesLegacyDefineMacroAuthoring(sourceText) || - isPureMacroReexportBridgeModule(fileName); - macroModuleCandidateCache.set(fileName, result); - return result; + const sourceText = sourceTextForMacroModule(fileName); + const result = sourceTextLooksLikeMacroModule(sourceText) || + usesLegacyDefineMacroAuthoring(sourceText) || + isPureMacroReexportBridgeModule(fileName); + macroModuleCandidateCache.set(fileName, result); + return result; + } finally { + recordActiveMacroTiming( + 'likelyMacroModuleMs', + performance.now() - likelyMacroModuleStart, + ); + } } function sourceTextForMacroModule(fileName: string): string { @@ -2180,6 +2296,22 @@ export function createProjectMacroEnvironment( return sourceText; } + function sourceFileForMacroModule(fileName: string): ts.SourceFile { + const cached = macroModuleSourceFileCache.get(fileName); + if (cached) { + return cached; + } + const sourceFile = ts.createSourceFile( + fileName, + sourceTextForMacroModule(fileName), + ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(fileName), + ); + macroModuleSourceFileCache.set(fileName, sourceFile); + return sourceFile; + } + function isPureMacroReexportBridgeModule(fileName: string): boolean { const cached = macroReexportBridgeCache.get(fileName); if (cached !== undefined) { @@ -2191,14 +2323,7 @@ export function createProjectMacroEnvironment( return false; } - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); let sawReexport = false; for (const statement of sourceFile.statements) { @@ -2231,13 +2356,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const interopIndex = sourceText.indexOf('#[interop]'); if (interopIndex >= 0) { throw createMacroModuleError( @@ -2394,14 +2513,7 @@ export function createProjectMacroEnvironment( fileName: string, specifier: string, ): { readonly start: number; readonly end: number } | null { - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const annotationLookup = createAnnotationLookup(sourceFile); for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { @@ -2440,14 +2552,7 @@ export function createProjectMacroEnvironment( } function collectImportedSpecifiersForMacroModule(fileName: string): string[] { - const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const specifiers: string[] = []; for (const statement of sourceFile.statements) { if ( @@ -2504,6 +2609,18 @@ export function createProjectMacroEnvironment( return dependencySourceTexts; } + function dependencySourceTextsForCompilation( + fileName: string, + ): ReadonlyMap { + const cached = macroModuleCompilationDependencySourceTextsCache.get(fileName); + if (cached) { + return cached; + } + const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); + macroModuleCompilationDependencySourceTextsCache.set(fileName, dependencySourceTexts); + return dependencySourceTexts; + } + function collectDependencySourceTextsForModule( fileName: string, visited = new Set(), @@ -2679,94 +2796,101 @@ export function createProjectMacroEnvironment( stableCompiledArtifactCache.delete(fileName); macroCacheStats.moduleCacheMisses += 1; + const graphCompileStart = performance.now(); const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); const graphRootNames = [...dependencySourceTexts.keys()]; - const macroTargetProgram = createPreparedProgram({ - alwaysAvailableMacroSiteKinds, - baseHost: createMacroTargetBaseHost(), - configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, - expansionEnabled: false, - options: { - ...preparedProgram.options, - module: ts.ModuleKind.CommonJS, - moduleResolution: ts.ModuleResolutionKind.Node10, - noEmit: false, - target: ts.ScriptTarget.ES2022, - }, - preserveMacroAuthoring: true, - reusableCompilerHostState: macroTargetReuseState, - rootNames: graphRootNames, - runtime: preparedProgram.runtime, - }); - const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => - diagnostic.category === 'error' - ); - if (frontendDiagnostics.length > 0) { - const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => - diagnostic.code === 'SOUNDSCRIPT_EXPANSION_DISABLED' + try { + const macroTargetProgram = createPreparedProgram({ + alwaysAvailableMacroSiteKinds, + baseHost: createMacroTargetBaseHost(), + configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, + expansionEnabled: false, + options: { + ...preparedProgram.options, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + noEmit: false, + target: ts.ScriptTarget.ES2022, + }, + preserveMacroAuthoring: true, + reusableCompilerHostState: macroTargetReuseState, + rootNames: graphRootNames, + runtime: preparedProgram.runtime, + }); + const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => + diagnostic.category === 'error' ); - if (expansionDisabledDiagnostic) { - const diagnosticFilePath = expansionDisabledDiagnostic.filePath ?? fileName; + if (frontendDiagnostics.length > 0) { + const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => + diagnostic.code === 'SOUNDSCRIPT_EXPANSION_DISABLED' + ); + if (expansionDisabledDiagnostic) { + const diagnosticFilePath = expansionDisabledDiagnostic.filePath ?? fileName; + throw createMacroModuleError( + diagnosticFilePath, + sourceTextForMacroModule(diagnosticFilePath), + `Macro module "${diagnosticFilePath}" cannot contain macro invocations. Macro authoring modules compile as soundscript, but macro syntax is disabled inside the macro target.`, + MACRO_GRAPH_ERROR_CODES.forbiddenInvocation, + 0, + 0, + ); + } + + const diagnostic = frontendDiagnostics[0]!; + const diagnosticFilePath = diagnostic.filePath ?? fileName; throw createMacroModuleError( diagnosticFilePath, sourceTextForMacroModule(diagnosticFilePath), - `Macro module "${diagnosticFilePath}" cannot contain macro invocations. Macro authoring modules compile as soundscript, but macro syntax is disabled inside the macro target.`, - MACRO_GRAPH_ERROR_CODES.forbiddenInvocation, + diagnostic.message, + 'SOUNDSCRIPT_MACRO_EXPANSION', 0, 0, ); } - const diagnostic = frontendDiagnostics[0]!; - const diagnosticFilePath = diagnostic.filePath ?? fileName; - throw createMacroModuleError( - diagnosticFilePath, - sourceTextForMacroModule(diagnosticFilePath), - diagnostic.message, - 'SOUNDSCRIPT_MACRO_EXPANSION', - 0, - 0, - ); - } - - for (const graphFileName of graphRootNames) { - const sourceFile = macroTargetProgram.program.getSourceFile( - macroTargetProgram.toProgramFileName(graphFileName), - ); - if (!sourceFile) { - throw createMacroModuleError( - graphFileName, - sourceTextForMacroModule(graphFileName), - `Failed to compile macro module "${graphFileName}".`, - 'SOUNDSCRIPT_MACRO_EXPANSION', + for (const graphFileName of graphRootNames) { + const sourceFile = macroTargetProgram.program.getSourceFile( + macroTargetProgram.toProgramFileName(graphFileName), ); - } + if (!sourceFile) { + throw createMacroModuleError( + graphFileName, + sourceTextForMacroModule(graphFileName), + `Failed to compile macro module "${graphFileName}".`, + 'SOUNDSCRIPT_MACRO_EXPANSION', + ); + } + + const tsDiagnostics = [ + ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), + ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), + ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); + if (tsDiagnostics.length > 0) { + throw createMacroModuleErrorFromDiagnostic( + tsDiagnostics[0]!, + graphFileName, + `Failed to compile macro module "${graphFileName}".`, + ); + } - const tsDiagnostics = [ - ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), - ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), - ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); - if (tsDiagnostics.length > 0) { - throw createMacroModuleErrorFromDiagnostic( - tsDiagnostics[0]!, + const javaScriptText = emitCommonJsMacroArtifactWithFallback( + macroTargetProgram, + sourceFile, graphFileName, - `Failed to compile macro module "${graphFileName}".`, + macroTargetProgram.options, ); - } - - const javaScriptText = emitCommonJsMacroArtifactWithFallback( - macroTargetProgram, - sourceFile, - graphFileName, - macroTargetProgram.options, - ); - const artifact: CachedMacroModuleArtifactEntry = { - dependencySourceTexts, - javaScriptText, - }; - compiledArtifactCache.set(graphFileName, artifact); - stableCompiledArtifactCache.set(graphFileName, artifact); + const artifact: CachedMacroModuleArtifactEntry = { + dependencySourceTexts, + javaScriptText, + }; + compiledArtifactCache.set(graphFileName, artifact); + stableCompiledArtifactCache.set(graphFileName, artifact); + } + } finally { + recordActiveMacroTiming('graphCompileMs', performance.now() - graphCompileStart); + recordActiveMacroTiming('graphCompileGraphs', 1); + recordActiveMacroTiming('graphCompileFiles', graphRootNames.length); } const compiled = compiledArtifactCache.get(fileName); @@ -2828,17 +2952,28 @@ export function createProjectMacroEnvironment( moduleRecord.directDependencies.add(resolved); return loadResolvedModuleValue(resolved); }; - moduleRecord.exports = macroModuleEvaluator.evaluateCommonJsModule( - compiledArtifact.javaScriptText, - { - crypto: portableGlobalThis.crypto, - exports: moduleRecord.exports, - fileName, - globalThis: portableGlobalThis, - math: portableGlobalThis.Math, - require, - }, - ); + const shouldRecordModuleEval = macroModuleEvaluationDepth === 0; + const moduleEvalStart = performance.now(); + macroModuleEvaluationDepth += 1; + try { + moduleRecord.exports = macroModuleEvaluator.evaluateCommonJsModule( + compiledArtifact.javaScriptText, + { + crypto: portableGlobalThis.crypto, + exports: moduleRecord.exports, + fileName, + globalThis: portableGlobalThis, + math: portableGlobalThis.Math, + require, + }, + ); + } finally { + macroModuleEvaluationDepth -= 1; + if (shouldRecordModuleEval) { + recordActiveMacroTiming('moduleEvalMs', performance.now() - moduleEvalStart); + recordActiveMacroTiming('moduleEvalRoots', 1); + } + } } catch (error) { if (error instanceof MacroError) { throw error; @@ -2865,6 +3000,7 @@ export function createProjectMacroEnvironment( } let definitions: ReadonlyMap; + const definitionLoadStart = performance.now(); try { definitions = collectNamedMacroDefinitions( fileName, @@ -2886,6 +3022,7 @@ export function createProjectMacroEnvironment( 'SOUNDSCRIPT_MACRO_EXPANSION', ); } + recordActiveMacroTiming('definitionLoadMs', performance.now() - definitionLoadStart); definitionsByResolvedFile.set(fileName, definitions); return definitions; } @@ -2897,6 +3034,7 @@ export function createProjectMacroEnvironment( } let exports: LoadedNamedMacroExports; + const exportLoadStart = performance.now(); try { exports = collectNamedMacroExports( fileName, @@ -2920,6 +3058,7 @@ export function createProjectMacroEnvironment( 'SOUNDSCRIPT_MACRO_EXPANSION', ); } + recordActiveMacroTiming('exportLoadMs', performance.now() - exportLoadStart); exportsByResolvedFile.set(fileName, exports); return exports; } @@ -2960,13 +3099,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ); + const sourceFile = sourceFileForMacroModule(fileName); const importedBindingsByLocalName = new Map( collectImportedNamedBindings(fileName, sourceText) .map((binding) => [binding.localName, binding] as const), @@ -3115,6 +3248,7 @@ export function createProjectMacroEnvironment( macroCacheStats.bindingPlanCacheMisses += 1; } + const bindingPlanStart = performance.now(); const definitions = new Map(); const registry = new Map(); const advancedRegistry = new Map(); @@ -3188,9 +3322,11 @@ export function createProjectMacroEnvironment( } for (const { localName, exportName } of candidateBindings) { + const authorityStart = performance.now(); const authority = builtinDefinitions ? { dependencyFiles: new Set(), exportName, resolvedFileName: specifier } : resolveMacroBindingAuthority(resolved, exportName); + recordActiveMacroTiming('authorityResolutionMs', performance.now() - authorityStart); if (!authority) { continue; } @@ -3253,6 +3389,7 @@ export function createProjectMacroEnvironment( const originalFileName = preparedProgram.toSourceFileName(sourceFile.fileName); const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(originalFileName); + const importUsageStart = performance.now(); const classificationSourceFile = ts.createSourceFile( originalFileName, preparedSource?.originalText ?? sourceFile.text, @@ -3272,6 +3409,7 @@ export function createProjectMacroEnvironment( importedBindingUsage.set(localName, 'runtimeOnly'); } } + recordActiveMacroTiming('importUsageMs', performance.now() - importUsageStart); const loaded = { advancedRegistry, @@ -3281,6 +3419,7 @@ export function createProjectMacroEnvironment( registry, siteKindsBySpecifier, }; + const bindingCacheWriteStart = performance.now(); storeCachedBindingPlan( sourceFile.fileName, createCachedPerFileMacroBindingPlanEntry( @@ -3291,7 +3430,10 @@ export function createProjectMacroEnvironment( resolutionDependencyFiles, ), ); + recordActiveMacroTiming('bindingCacheWriteMs', performance.now() - bindingCacheWriteStart); bindingsByFile.set(sourceFile.fileName, loaded); + recordActiveMacroTiming('bindingPlanMs', performance.now() - bindingPlanStart); + recordActiveMacroTiming('bindingPlanFiles', 1); return loaded; } @@ -3326,109 +3468,67 @@ export function createProjectMacroEnvironment( preserveMissingExpanders = false, annotateExpansions = false, ): ReadonlyMap { - const expansionModeKey = createExpandedFilesModeKey( - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); - const cachedExpandedFiles = stableReuseState.expandedFilesByMode.get(expansionModeKey); - const hadReusableMacroState = cachedExpandedFiles !== undefined || - stableReuseState.bindingPlansByFile.size > 0 || - stableReuseState.dependencySourceTextsByFile.size > 0 || - stableReuseState.expandedFilesByMode.size > 0; - const expandedFiles = cachedExpandedFiles ?? new Map(); - stableReuseState.expandedFilesByMode.set(expansionModeKey, expandedFiles); - const registriesByFile = new Map< - string, - { - registry: ReadonlyMap; - advancedRegistry: ReadonlyMap; - siteKindsBySpecifier: ReadonlyMap>; - } - >(); - const bindingUsageByFile = new Map>(); - const expansionCacheKeyByFile = new Map(); - const hasBindingsByFile = new Map(); - const expansionCache = preparedProgram.preparedHost.reuseState.expandedMacroSourceFiles; - const macroSourceFiles: ts.SourceFile[] = []; - const currentProgramSourceFiles = new Set( - [...preparedProgram.preparedHost.reuseState.programSourceFiles].filter( - isExpandableProgramSourceFile, - ), - ); - const removedProgramSourceFiles = [ - ...preparedProgram.preparedHost.reuseState - .removedProgramSourceFiles, - ].filter(isExpandableProgramSourceFile); - const affectedSourceFiles = new Set(); - - for (const removedFileName of removedProgramSourceFiles) { - clearCachedBindingPlan(removedFileName); - expansionCache.delete(removedFileName); - for (const modeExpandedFiles of stableReuseState.expandedFilesByMode.values()) { - modeExpandedFiles.delete(removedFileName); - } - const removedSourcePath = preparedProgram.toSourceFileName(removedFileName); - for ( - const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - removedSourcePath, - ) ?? [] - ) { - if (currentProgramSourceFiles.has(dependentFileName)) { - affectedSourceFiles.add(dependentFileName); - } - } - } - - if (!processedPreparedProgramChangedMacroFiles) { - processedPreparedProgramChangedMacroFiles = true; - const changedMacroModuleFiles = hadReusableMacroState - ? [ - ...preparedProgram.preparedHost.reuseState.changedProgramSourceFiles, - ].filter((changedFileName) => { - const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); - if (isSoundscriptMacroSourceFile(changedSourcePath)) { - return true; - } - try { - return isLikelyMacroModule(changedSourcePath); - } catch { - return false; - } - }) - : []; - if (changedMacroModuleFiles.length > 0) { - stableCompiledArtifactCache.clear(); - compiledArtifactCache.clear(); - preparedProgram.preparedHost.reuseState.builtinAnnotatedSourceFiles.clear(); - preparedProgram.preparedHost.reuseState.builtinFinalSourceFiles.clear(); - for (const currentFileName of currentProgramSourceFiles) { - affectedSourceFiles.add(currentFileName); - } - } - } + const expansionTimingStats = createMacroExpansionTimingStats(); + const previousActiveMacroTimingStats = activeMacroTimingStats; + const expandDetailsStart = performance.now(); + const timingObserver: MacroExpansionTimingObserver = { + recordMacroExecution(kind, durationMs): void { + recordActiveMacroTiming('macroExecutionMs', durationMs); + recordActiveMacroTiming( + kind === 'advanced' ? 'advancedMacroExecutionCount' : 'rewriteMacroExecutionCount', + 1, + ); + }, + }; + activeMacroTimingStats = expansionTimingStats; - if (!cachedExpandedFiles) { - for (const fileName of currentProgramSourceFiles) { - affectedSourceFiles.add(fileName); - } - } else { - for ( - const [dependencyFileName, cachedSourceText] of stableReuseState - .dependencySourceTextsByFile - ) { - let currentSourceText: string | undefined; - try { - currentSourceText = sourceTextForMacroModule(dependencyFileName); - } catch { - currentSourceText = undefined; + const runExpansion = (): ReadonlyMap => { + const expansionModeKey = createExpandedFilesModeKey( + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + ); + const cachedExpandedFiles = stableReuseState.expandedFilesByMode.get(expansionModeKey); + const hadReusableMacroState = cachedExpandedFiles !== undefined || + stableReuseState.bindingPlansByFile.size > 0 || + stableReuseState.dependencySourceTextsByFile.size > 0 || + stableReuseState.expandedFilesByMode.size > 0; + const expandedFiles = cachedExpandedFiles ?? new Map(); + stableReuseState.expandedFilesByMode.set(expansionModeKey, expandedFiles); + const registriesByFile = new Map< + string, + { + registry: ReadonlyMap; + advancedRegistry: ReadonlyMap; + siteKindsBySpecifier: ReadonlyMap>; } - if (currentSourceText === cachedSourceText) { - continue; + >(); + const bindingUsageByFile = new Map>(); + const expansionCacheKeyByFile = new Map(); + const hasBindingsByFile = new Map(); + const expansionCache = preparedProgram.preparedHost.reuseState.expandedMacroSourceFiles; + const macroSourceFiles: ts.SourceFile[] = []; + const currentProgramSourceFiles = new Set( + [...preparedProgram.preparedHost.reuseState.programSourceFiles].filter( + isExpandableProgramSourceFile, + ), + ); + const removedProgramSourceFiles = [ + ...preparedProgram.preparedHost.reuseState + .removedProgramSourceFiles, + ].filter(isExpandableProgramSourceFile); + const affectedSourceFiles = new Set(); + + for (const removedFileName of removedProgramSourceFiles) { + clearCachedBindingPlan(removedFileName); + expansionCache.delete(removedFileName); + for (const modeExpandedFiles of stableReuseState.expandedFilesByMode.values()) { + modeExpandedFiles.delete(removedFileName); } + const removedSourcePath = preparedProgram.toSourceFileName(removedFileName); for ( const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - dependencyFileName, + removedSourcePath, ) ?? [] ) { if (currentProgramSourceFiles.has(dependentFileName)) { @@ -3436,181 +3536,293 @@ export function createProjectMacroEnvironment( } } } - for ( - const changedFileName of preparedProgram.preparedHost.reuseState.changedProgramSourceFiles - ) { - if (!isExpandableProgramSourceFile(changedFileName)) { - continue; + + if (!processedPreparedProgramChangedMacroFiles) { + processedPreparedProgramChangedMacroFiles = true; + const changedMacroModuleFiles = hadReusableMacroState + ? [ + ...preparedProgram.preparedHost.reuseState.changedProgramSourceFiles, + ].filter((changedFileName) => { + const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + if (isSoundscriptMacroSourceFile(changedSourcePath)) { + return true; + } + try { + return isLikelyMacroModule(changedSourcePath); + } catch { + return false; + } + }) + : []; + if (changedMacroModuleFiles.length > 0) { + stableCompiledArtifactCache.clear(); + compiledArtifactCache.clear(); + preparedProgram.preparedHost.reuseState.builtinAnnotatedSourceFiles.clear(); + preparedProgram.preparedHost.reuseState.builtinFinalSourceFiles.clear(); + for (const currentFileName of currentProgramSourceFiles) { + affectedSourceFiles.add(currentFileName); + } + } + } + + if (!cachedExpandedFiles) { + for (const fileName of currentProgramSourceFiles) { + affectedSourceFiles.add(fileName); } - affectedSourceFiles.add(changedFileName); - const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + } else { for ( - const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( - changedSourcePath, - ) ?? [] + const [dependencyFileName, cachedSourceText] of stableReuseState + .dependencySourceTextsByFile ) { - if (currentProgramSourceFiles.has(dependentFileName)) { - affectedSourceFiles.add(dependentFileName); + let currentSourceText: string | undefined; + try { + currentSourceText = sourceTextForMacroModule(dependencyFileName); + } catch { + currentSourceText = undefined; + } + if (currentSourceText === cachedSourceText) { + continue; + } + for ( + const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( + dependencyFileName, + ) ?? [] + ) { + if (currentProgramSourceFiles.has(dependentFileName)) { + affectedSourceFiles.add(dependentFileName); + } } } - } - for (const fileName of currentProgramSourceFiles) { - if (!expandedFiles.has(fileName)) { - affectedSourceFiles.add(fileName); + for ( + const changedFileName of preparedProgram.preparedHost.reuseState + .changedProgramSourceFiles + ) { + if (!isExpandableProgramSourceFile(changedFileName)) { + continue; + } + affectedSourceFiles.add(changedFileName); + const changedSourcePath = preparedProgram.toSourceFileName(changedFileName); + for ( + const dependentFileName of stableReuseState.dependentFilesByDependencyFile.get( + changedSourcePath, + ) ?? [] + ) { + if (currentProgramSourceFiles.has(dependentFileName)) { + affectedSourceFiles.add(dependentFileName); + } + } } - } - for (const fileName of currentProgramSourceFiles) { - if (!affectedSourceFiles.has(fileName) && expandedFiles.has(fileName)) { - macroCacheStats.expandedFileCacheHits += 1; + for (const fileName of currentProgramSourceFiles) { + if (!expandedFiles.has(fileName)) { + affectedSourceFiles.add(fileName); + } + } + for (const fileName of currentProgramSourceFiles) { + if (!affectedSourceFiles.has(fileName) && expandedFiles.has(fileName)) { + macroCacheStats.expandedFileCacheHits += 1; + } + } + if (affectedSourceFiles.size === 0) { + return expandedFiles; } } - if (affectedSourceFiles.size === 0) { - return expandedFiles; - } - } - for (const fileName of affectedSourceFiles) { - const sourceFile = preparedProgram.program.getSourceFile(fileName); - if (!sourceFile || sourceFile.isDeclarationFile) { - expandedFiles.delete(fileName); - continue; - } - const cachedExpandedSourceFile = expansionCache.get(sourceFile.fileName); - const macroNames = macroNamesForFile(sourceFile); - if (macroNames.size === 0) { - clearCachedBindingPlan(sourceFile.fileName); - const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - const nonMacroExpansionCacheKey = createNonMacroExpansionCacheKey( - sourceFile, - preparedSource, - ); - if (cachedExpandedSourceFile?.cacheKey === nonMacroExpansionCacheKey) { - macroCacheStats.expandedFileCacheHits += 1; - expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + for (const fileName of affectedSourceFiles) { + const sourceFile = preparedProgram.program.getSourceFile(fileName); + if (!sourceFile || sourceFile.isDeclarationFile) { + expandedFiles.delete(fileName); continue; } - if (cachedExpandedSourceFile) { - macroCacheStats.expandedFileCacheInvalidations += 1; - } else { - macroCacheStats.expandedFileCacheMisses += 1; + const cachedExpandedSourceFile = expansionCache.get(sourceFile.fileName); + const macroNames = macroNamesForFile(sourceFile); + if (macroNames.size === 0) { + clearCachedBindingPlan(sourceFile.fileName); + const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + const nonMacroExpansionCacheKey = createNonMacroExpansionCacheKey( + sourceFile, + preparedSource, + ); + if (cachedExpandedSourceFile?.cacheKey === nonMacroExpansionCacheKey) { + macroCacheStats.expandedFileCacheHits += 1; + expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + continue; + } + if (cachedExpandedSourceFile) { + macroCacheStats.expandedFileCacheInvalidations += 1; + } else { + macroCacheStats.expandedFileCacheMisses += 1; + } + const expandedSourceFile = preparedSource + ? ts.createSourceFile( + sourceFile.fileName, + preparedSource.originalText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(sourceFile.fileName), + ) + : sourceFile; + expandedFiles.set(sourceFile.fileName, expandedSourceFile); + expansionCache.set(sourceFile.fileName, { + cacheKey: nonMacroExpansionCacheKey, + sourceFile: expandedSourceFile, + }); + continue; } - const expandedSourceFile = preparedSource - ? ts.createSourceFile( - sourceFile.fileName, - preparedSource.originalText, - preparedProgram.options.target ?? ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(sourceFile.fileName), - ) - : sourceFile; - expandedFiles.set(sourceFile.fileName, expandedSourceFile); - expansionCache.set(sourceFile.fileName, { - cacheKey: nonMacroExpansionCacheKey, - sourceFile: expandedSourceFile, - }); - continue; - } - const cachedBindingPlan = stableReuseState.bindingPlansByFile.get(sourceFile.fileName); - if ( - cachedExpandedSourceFile && cachedBindingPlan && - isCachedMacroBindingPlanValid(sourceFile, cachedBindingPlan) - ) { - const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - const cachedExpansionCacheKey = createExpansionCacheKeyFromPreparedState( + const cachedBindingPlan = stableReuseState.bindingPlansByFile.get(sourceFile.fileName); + if ( + cachedExpandedSourceFile && cachedBindingPlan && + isCachedMacroBindingPlanValid(sourceFile, cachedBindingPlan) + ) { + const sourceFileName = preparedProgram.toSourceFileName(sourceFile.fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + const cachedExpansionCacheKey = createExpansionCacheKeyFromPreparedState( + sourceFile, + cachedBindingPlan.expansionDependencySignature, + cachedBindingPlan.importedBindingUsage, + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + preparedSource, + ); + if (cachedExpandedSourceFile.cacheKey === cachedExpansionCacheKey) { + macroCacheStats.expandedFileCacheHits += 1; + expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); + continue; + } + } + + const bindings = bindingsForSourceFile(sourceFile); + const expansionCacheKey = createExpansionCacheKey( sourceFile, - cachedBindingPlan.expansionDependencySignature, - cachedBindingPlan.importedBindingUsage, + bindings, preserveRemovedImportStatements, preserveMissingExpanders, annotateExpansions, - preparedSource, ); - if (cachedExpandedSourceFile.cacheKey === cachedExpansionCacheKey) { + if (cachedExpandedSourceFile?.cacheKey === expansionCacheKey) { macroCacheStats.expandedFileCacheHits += 1; expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); continue; } + if (cachedExpandedSourceFile) { + macroCacheStats.expandedFileCacheInvalidations += 1; + } else { + macroCacheStats.expandedFileCacheMisses += 1; + } + registriesByFile.set(sourceFile.fileName, { + registry: bindings.registry, + advancedRegistry: bindings.advancedRegistry, + siteKindsBySpecifier: bindings.siteKindsBySpecifier, + }); + expansionCacheKeyByFile.set(sourceFile.fileName, expansionCacheKey); + bindingUsageByFile.set(sourceFile.fileName, bindings.importedBindingUsage); + hasBindingsByFile.set(sourceFile.fileName, hasResolvedMacroBindings(bindings)); + macroSourceFiles.push(sourceFile); } - const bindings = bindingsForSourceFile(sourceFile); - const expansionCacheKey = createExpansionCacheKey( - sourceFile, - bindings, - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); - if (cachedExpandedSourceFile?.cacheKey === expansionCacheKey) { - macroCacheStats.expandedFileCacheHits += 1; - expandedFiles.set(sourceFile.fileName, cachedExpandedSourceFile.sourceFile); - continue; - } - if (cachedExpandedSourceFile) { - macroCacheStats.expandedFileCacheInvalidations += 1; - } else { - macroCacheStats.expandedFileCacheMisses += 1; - } - registriesByFile.set(sourceFile.fileName, { - registry: bindings.registry, - advancedRegistry: bindings.advancedRegistry, - siteKindsBySpecifier: bindings.siteKindsBySpecifier, - }); - expansionCacheKeyByFile.set(sourceFile.fileName, expansionCacheKey); - bindingUsageByFile.set(sourceFile.fileName, bindings.importedBindingUsage); - hasBindingsByFile.set(sourceFile.fileName, hasResolvedMacroBindings(bindings)); - macroSourceFiles.push(sourceFile); - } + recordActiveMacroTiming('sourceExpansionFiles', macroSourceFiles.length); + const expanded = macroSourceFiles.length > 0 + ? measureActiveMacroTiming( + 'sourceExpansionMs', + () => + expandPreparedProgramWithFileRegistries( + preparedProgram, + registriesByFile, + preserveMissingExpanders, + annotateExpansions, + macroSourceFiles, + timingObserver, + ), + ) + : new Map(); + for (const [fileName, sourceFile] of expanded.entries()) { + let finalExpandedSourceFile: ts.SourceFile; + if (!hasBindingsByFile.get(fileName)) { + const sourceFileName = preparedProgram.toSourceFileName(fileName); + const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile( + sourceFileName, + ); + finalExpandedSourceFile = preparedSource + ? ts.createSourceFile( + fileName, + preparedSource.originalText, + preparedProgram.options.target ?? ts.ScriptTarget.Latest, + true, + scriptKindForHostFile(fileName), + ) + : sourceFile; + } else { + finalExpandedSourceFile = stripCompileTimeOnlyImportedBindings( + sourceFile, + bindingUsageByFile.get(fileName) ?? new Map(), + preserveRemovedImportStatements, + ); + const generatedStdlibStart = performance.now(); + finalExpandedSourceFile = expandGeneratedStdlibMacros( + finalExpandedSourceFile, + preserveRemovedImportStatements, + preserveMissingExpanders, + annotateExpansions, + ); + recordActiveMacroTiming('generatedStdlibMs', performance.now() - generatedStdlibStart); + recordActiveMacroTiming('generatedStdlibFiles', 1); + } - const expanded = macroSourceFiles.length > 0 - ? expandPreparedProgramWithFileRegistries( - preparedProgram, - registriesByFile, - preserveMissingExpanders, - annotateExpansions, - macroSourceFiles, - ) - : new Map(); - for (const [fileName, sourceFile] of expanded.entries()) { - let finalExpandedSourceFile: ts.SourceFile; - if (!hasBindingsByFile.get(fileName)) { - const sourceFileName = preparedProgram.toSourceFileName(fileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - finalExpandedSourceFile = preparedSource - ? ts.createSourceFile( - fileName, - preparedSource.originalText, - preparedProgram.options.target ?? ts.ScriptTarget.Latest, - true, - scriptKindForHostFile(fileName), - ) - : sourceFile; - } else { - finalExpandedSourceFile = stripCompileTimeOnlyImportedBindings( - sourceFile, - bindingUsageByFile.get(fileName) ?? new Map(), - preserveRemovedImportStatements, - ); - finalExpandedSourceFile = expandGeneratedStdlibMacros( - finalExpandedSourceFile, - preserveRemovedImportStatements, - preserveMissingExpanders, - annotateExpansions, - ); + expandedFiles.set(fileName, finalExpandedSourceFile); + const expansionCacheKey = expansionCacheKeyByFile.get(fileName); + if (expansionCacheKey !== undefined) { + expansionCache.set(fileName, { + cacheKey: expansionCacheKey, + sourceFile: finalExpandedSourceFile, + }); + } } + return expandedFiles; + }; - expandedFiles.set(fileName, finalExpandedSourceFile); - const expansionCacheKey = expansionCacheKeyByFile.get(fileName); - if (expansionCacheKey !== undefined) { - expansionCache.set(fileName, { - cacheKey: expansionCacheKey, - sourceFile: finalExpandedSourceFile, - }); - } + try { + return runExpansion(); + } finally { + const durationMs = performance.now() - expandDetailsStart; + logCheckerTiming( + 'project.prepare.macro.expandDetails', + durationMs, + { + advancedMacroExecutionCount: expansionTimingStats.advancedMacroExecutionCount, + authorityResolutionMs: Number(expansionTimingStats.authorityResolutionMs.toFixed(1)), + bindingCacheWriteMs: Number(expansionTimingStats.bindingCacheWriteMs.toFixed(1)), + bindingPlanFiles: expansionTimingStats.bindingPlanFiles, + bindingPlanMs: Number(expansionTimingStats.bindingPlanMs.toFixed(1)), + definitionLoadMs: Number(expansionTimingStats.definitionLoadMs.toFixed(1)), + dependencySignatureMs: Number(expansionTimingStats.dependencySignatureMs.toFixed(1)), + exportLoadMs: Number(expansionTimingStats.exportLoadMs.toFixed(1)), + generatedStdlibFiles: expansionTimingStats.generatedStdlibFiles, + generatedStdlibMs: Number(expansionTimingStats.generatedStdlibMs.toFixed(1)), + graphCompileFiles: expansionTimingStats.graphCompileFiles, + graphCompileGraphs: expansionTimingStats.graphCompileGraphs, + graphCompileMs: Number(expansionTimingStats.graphCompileMs.toFixed(1)), + importUsageMs: Number(expansionTimingStats.importUsageMs.toFixed(1)), + likelyMacroModuleMs: Number(expansionTimingStats.likelyMacroModuleMs.toFixed(1)), + macroExecutionCount: expansionTimingStats.advancedMacroExecutionCount + + expansionTimingStats.rewriteMacroExecutionCount, + macroExecutionMs: Number(expansionTimingStats.macroExecutionMs.toFixed(1)), + moduleEvalMs: Number(expansionTimingStats.moduleEvalMs.toFixed(1)), + moduleEvalRoots: expansionTimingStats.moduleEvalRoots, + resolveImportMs: Number(expansionTimingStats.resolveImportMs.toFixed(1)), + rewriteMacroExecutionCount: expansionTimingStats.rewriteMacroExecutionCount, + sourceExpansionFiles: expansionTimingStats.sourceExpansionFiles, + sourceExpansionMs: Number(expansionTimingStats.sourceExpansionMs.toFixed(1)), + }, + { always: true }, + ); + activeMacroTimingStats = previousActiveMacroTimingStats; } - return expandedFiles; }, trackedDependencyFilesForFile(sourceFile: ts.SourceFile): readonly string[] { try { diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index 7848243..1f5f574 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -445,6 +445,53 @@ Deno.test('createProjectMacroEnvironment ignores macro-looking generated string assert(!printed.includes('__sts_macro_stmt')); }); +Deno.test('createProjectMacroEnvironment reports macro expansion detail timing', () => { + const originalTimingEnv = Deno.env.get('SOUNDSCRIPT_CHECKER_TIMING'); + const originalError = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', '1'); + const preparedProgram = createMacroPreparedProgram( + [ + "import 'sts:macros';", + '', + '// #[macro(call)]', + 'export function Foo() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`1`);', + ' },', + ' };', + '}', + '', + ].join('\n'), + ); + + printExpandedFile(preparedProgram, '/virtual/index.sts'); + } finally { + if (originalTimingEnv === undefined) { + Deno.env.delete('SOUNDSCRIPT_CHECKER_TIMING'); + } else { + Deno.env.set('SOUNDSCRIPT_CHECKER_TIMING', originalTimingEnv); + } + console.error = originalError; + } + + const detailLog = logs.find((line) => + line.includes('[soundscript:checker] project.prepare.macro.expandDetails ') + ); + assert(detailLog); + assertStringIncludes(detailLog, 'graphCompileGraphs=1'); + assertStringIncludes(detailLog, 'bindingPlanFiles=1'); + assertStringIncludes(detailLog, 'moduleEvalRoots=1'); + assertStringIncludes(detailLog, 'macroExecutionCount=2'); + assertStringIncludes(detailLog, 'sourceExpansionFiles=1'); +}); + Deno.test('createProjectMacroEnvironment honors macroExpansionRecursionLimit 0 for generated stdlib macros', () => { const fileName = '/virtual/index.sts'; const preparedProgram = createGeneratedMacroTestProgram( From db8c62f92e15c8d91e9986aafdbea025501b7d92 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 19:44:26 -0400 Subject: [PATCH 4/8] Reduce macro package expand overhead --- scripts/perf/expand_benchmark.ts | 8 +- src/frontend/project_frontend.ts | 6 + src/frontend/project_macro_support.ts | 162 +++++++++++++++++----- src/project/soundscript_packages.ts | 164 +++++++++++++++++------ src/project/soundscript_packages_test.ts | 83 ++++++++++++ 5 files changed, 340 insertions(+), 83 deletions(-) create mode 100644 src/project/soundscript_packages_test.ts diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts index c8831d8..303ec51 100644 --- a/scripts/perf/expand_benchmark.ts +++ b/scripts/perf/expand_benchmark.ts @@ -441,8 +441,8 @@ function markdownReport(results: readonly ScenarioResult[]): string { const lines = [ '# Soundscript Expand Benchmark', '', - '| scenario | wall median ms | initial ms | expand macros ms | graph compile ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const result of results) { lines.push( @@ -450,7 +450,9 @@ function markdownReport(results: readonly ScenarioResult[]): string { median(result.iterations.map((iteration) => iteration.wallMs)).toFixed(1) } | ${stageMedian(result, 'project.prepare.builtin.initialProgram').toFixed(1)} | ${ stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) - } | ${macroDetailMedian(result, 'graphCompileMs').toFixed(1)} | ${ + } | ${macroDetailMedian(result, 'packageExportInfoMs').toFixed(1)} | ${ + macroDetailMedian(result, 'graphCompileMs').toFixed(1) + } | ${macroDetailMedian(result, 'generatedStdlibMs').toFixed(1)} | ${ macroDetailMedian(result, 'moduleEvalMs').toFixed(1) } | ${macroDetailMedian(result, 'bindingPlanMs').toFixed(1)} | ${ macroDetailMedian(result, 'macroExecutionMs').toFixed(1) diff --git a/src/frontend/project_frontend.ts b/src/frontend/project_frontend.ts index f082a03..f596419 100644 --- a/src/frontend/project_frontend.ts +++ b/src/frontend/project_frontend.ts @@ -801,6 +801,7 @@ export interface PreparedCompilerHost { getCachedPreparedSourceFiles(): readonly PreparedSourceFile[]; getMacroPlaceholderIndex(): MacroPlaceholderIndex; host: ts.CompilerHost; + readOriginalFile(fileName: string): string | undefined; reuseState: PreparedCompilerHostReuseState; sourceFileCacheStats(): { projectedDeclarationHits: number; @@ -4268,6 +4269,7 @@ export function createPreparedCompilerHost( specifier, resolved.resolvedFileName, moduleResolutionHost, + { trustMacroAuthoringSourcePath: true }, )?.sourceEntryPath : undefined; const resolvedFileName = resolved?.resolvedFileName @@ -5023,6 +5025,10 @@ export function createPreparedCompilerHost( })); }, }, + readOriginalFile(fileName: string): string | undefined { + const sourceFileName = toSourceFileName(fileName); + return fileOverrides.get(sourceFileName) ?? baseHost.readFile(sourceFileName); + }, reuseState: reusableState, sourceFileCacheStats(): { projectedDeclarationHits: number; diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index a194205..f371df0 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -6,7 +6,10 @@ import { dirname, join } from '../platform/path.ts'; import { SOUND_DIAGNOSTIC_CODES } from '../checker/engine/diagnostic_codes.ts'; import { describeUnsupportedFeature } from '../checker/unsupported_feature_messages.ts'; import * as publicMacroApi from '../macros.ts'; -import { getSoundScriptPackageExportInfoForResolvedModule } from '../project/soundscript_packages.ts'; +import { + getSoundScriptPackageExportInfoForResolvedModule, + type SoundScriptPackageExportInfo, +} from '../project/soundscript_packages.ts'; import type { MacroDefinition } from './macro_api.ts'; import { getLoadedMacroDefinitionMetadata } from './macro_api_internal.ts'; @@ -288,11 +291,16 @@ interface MacroExpansionTimingStats { graphCompileGraphs: number; graphCompileMs: number; importUsageMs: number; + interopImportCheckMs: number; likelyMacroModuleMs: number; macroExecutionMs: number; moduleEvalRoots: number; moduleEvalMs: number; + packageExportInfoMs: number; + policyValidationMs: number; + preferredMacroResolutionMs: number; resolveImportMs: number; + tsModuleResolutionMs: number; rewriteMacroExecutionCount: number; sourceExpansionFiles: number; sourceExpansionMs: number; @@ -314,11 +322,16 @@ function createMacroExpansionTimingStats(): MacroExpansionTimingStats { graphCompileGraphs: 0, graphCompileMs: 0, importUsageMs: 0, + interopImportCheckMs: 0, likelyMacroModuleMs: 0, macroExecutionMs: 0, moduleEvalRoots: 0, moduleEvalMs: 0, + packageExportInfoMs: 0, + policyValidationMs: 0, + preferredMacroResolutionMs: 0, resolveImportMs: 0, + tsModuleResolutionMs: 0, rewriteMacroExecutionCount: 0, sourceExpansionFiles: 0, sourceExpansionMs: 0, @@ -626,20 +639,35 @@ function inferMacroMutableContainerKind( function createModuleResolutionHost(preparedProgram: PreparedProgram): ts.ModuleResolutionHost { const baseHost = preparedProgram.preparedHost.host; + const fileExistsCache = new Map(); + const readFileCache = new Map(); return { directoryExists: baseHost.directoryExists?.bind(baseHost), fileExists(fileName: string): boolean { const sourceFileName = toSourceFileName(fileName); - return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined || - baseHost.fileExists(sourceFileName); + const cached = fileExistsCache.get(sourceFileName); + if (cached !== undefined) { + return cached; + } + const exists = baseHost.fileExists(sourceFileName) || + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined; + fileExistsCache.set(sourceFileName, exists); + return exists; }, getCurrentDirectory: baseHost.getCurrentDirectory?.bind(baseHost) ?? (() => ts.sys.getCurrentDirectory()), getDirectories: baseHost.getDirectories?.bind(baseHost), readFile(fileName: string): string | undefined { const sourceFileName = toSourceFileName(fileName); - return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText ?? - baseHost.readFile(sourceFileName); + if (readFileCache.has(sourceFileName)) { + return readFileCache.get(sourceFileName); + } + const text = isSoundscriptSourceFile(sourceFileName) + ? preparedProgram.preparedHost.readOriginalFile(sourceFileName) ?? + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText + : baseHost.readFile(sourceFileName); + readFileCache.set(sourceFileName, text); + return text; }, realpath: baseHost.realpath?.bind(baseHost), useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, @@ -1012,6 +1040,7 @@ export function createProjectMacroEnvironment( const resolutionHost = createModuleResolutionHost(preparedProgram); const moduleResolutionCache = preparedProgram.preparedHost.reuseState.moduleResolutionCache ?? createPreparedCompilerHostReuseState().moduleResolutionCache; + const packageExportInfoCache = new Map(); const resolvedImportCache = new Map(); const evaluatedModuleCache = new Map(); const compiledArtifactCache = new Map(); @@ -1022,6 +1051,10 @@ export function createProjectMacroEnvironment( const macroModuleScanCache = new Map>(); const macroModuleSourceFileCache = new Map(); const macroModuleSourceTextCache = new Map(); + const macroGraphInteropImportRangeCache = new Map< + string, + { readonly start: number; readonly end: number } | null + >(); const macroModuleCompilationDependencySourceTextsCache = new Map< string, ReadonlyMap @@ -1685,6 +1718,7 @@ export function createProjectMacroEnvironment( binding.specifier, resolvedRuntimeFileName, resolutionHost, + { trustMacroAuthoringSourcePath: true }, )?.sourceEntryPath; const resolvedFileName = packageMacroSourceEntry ? toSourceFileName(packageMacroSourceEntry) @@ -1735,11 +1769,9 @@ export function createProjectMacroEnvironment( if (override !== undefined) { return override; } - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - if (preparedSource) { - return preparedSource.originalText; - } - return baseHost.readFile(sourceFileName) ?? baseHost.readFile(fileName); + return preparedProgram.preparedHost.readOriginalFile(sourceFileName) ?? + baseHost.readFile(sourceFileName) ?? + baseHost.readFile(fileName); }; return { @@ -1747,6 +1779,7 @@ export function createProjectMacroEnvironment( fileExists(fileName: string): boolean { const sourceFileName = preparedProgram.toSourceFileName(fileName); return fileOverrides.has(sourceFileName) || + preparedProgram.preparedHost.readOriginalFile(sourceFileName) !== undefined || preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) !== undefined || baseHost.fileExists(sourceFileName) || baseHost.fileExists(fileName); @@ -2122,7 +2155,7 @@ export function createProjectMacroEnvironment( try { const normalizedContainingFileName = toSourceFileName(containingFileName); if (options.fromMacroGraph && isLoadableMacroModuleFile(normalizedContainingFileName)) { - validateMacroModuleSourcePolicy(normalizedContainingFileName); + validateMacroModuleSourcePolicyMeasured(normalizedContainingFileName); } if ( specifier === MACRO_API_MODULE_SPECIFIER || builtinDefinitionsBySpecifier.has(specifier) @@ -2136,15 +2169,22 @@ export function createProjectMacroEnvironment( return cached; } - const resolved = - resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName) ?? - ts.resolveModuleName( - specifier, - normalizedContainingFileName, - preparedProgram.options, - resolutionHost, - moduleResolutionCache, - ).resolvedModule; + const preferredResolved = measureActiveMacroTiming( + 'preferredMacroResolutionMs', + () => resolvePreferredSoundscriptMacroModule(specifier, normalizedContainingFileName), + ); + const resolved = preferredResolved ?? + measureActiveMacroTiming( + 'tsModuleResolutionMs', + () => + ts.resolveModuleName( + specifier, + normalizedContainingFileName, + preparedProgram.options, + resolutionHost, + moduleResolutionCache, + ).resolvedModule, + ); const resolvedRuntimeFileName = resolved?.resolvedFileName; if (!resolvedRuntimeFileName) { resolvedImportCache.set(cacheKey, null); @@ -2152,9 +2192,9 @@ export function createProjectMacroEnvironment( } if (options.fromMacroGraph) { - const interopImportRange = findMacroGraphInteropImportRange( - normalizedContainingFileName, - specifier, + const interopImportRange = measureActiveMacroTiming( + 'interopImportCheckMs', + () => findMacroGraphInteropImportRange(normalizedContainingFileName, specifier), ); if (interopImportRange) { throw createMacroModuleError( @@ -2177,17 +2217,16 @@ export function createProjectMacroEnvironment( ); } - const packageMacroSourceEntry = getSoundScriptPackageExportInfoForResolvedModule( + const packageMacroSourceEntry = packageExportInfoForResolvedModule( specifier, resolvedRuntimeFileName, - resolutionHost, )?.sourceEntryPath; const resolvedFileName = packageMacroSourceEntry ? toSourceFileName(packageMacroSourceEntry) : toSourceFileName(resolvedRuntimeFileName); if (!isLoadableMacroModuleFile(resolvedFileName)) { if (isPureMacroReexportBridgeModule(resolvedFileName)) { - validateMacroModuleSourcePolicy(resolvedFileName); + validateMacroModuleSourcePolicyMeasured(resolvedFileName); resolvedImportCache.set(cacheKey, resolvedFileName); return resolvedFileName; } @@ -2214,7 +2253,7 @@ export function createProjectMacroEnvironment( ); } - validateMacroModuleSourcePolicy(resolvedFileName); + validateMacroModuleSourcePolicyMeasured(resolvedFileName); resolvedImportCache.set(cacheKey, resolvedFileName); return resolvedFileName; } finally { @@ -2286,8 +2325,8 @@ export function createProjectMacroEnvironment( return cached; } - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(fileName); - const sourceText = preparedSource?.originalText; + const sourceText = preparedProgram.preparedHost.readOriginalFile(fileName) ?? + preparedProgram.preparedHost.getPreparedSourceFile(fileName)?.originalText; if (sourceText === undefined) { throw new Error(`Could not read macro module "${fileName}".`); } @@ -2312,6 +2351,33 @@ export function createProjectMacroEnvironment( return sourceFile; } + function validateMacroModuleSourcePolicyMeasured(fileName: string): void { + measureActiveMacroTiming('policyValidationMs', () => validateMacroModuleSourcePolicy(fileName)); + } + + function packageExportInfoForResolvedModule( + specifier: string, + resolvedRuntimeFileName: string, + ): SoundScriptPackageExportInfo | undefined { + const cacheKey = `${specifier}\u0000${resolvedRuntimeFileName}`; + const cached = packageExportInfoCache.get(cacheKey); + if (cached !== undefined) { + return cached ?? undefined; + } + const result = measureActiveMacroTiming( + 'packageExportInfoMs', + () => + getSoundScriptPackageExportInfoForResolvedModule( + specifier, + resolvedRuntimeFileName, + resolutionHost, + { trustMacroAuthoringSourcePath: true }, + ), + ); + packageExportInfoCache.set(cacheKey, result ?? null); + return result; + } + function isPureMacroReexportBridgeModule(fileName: string): boolean { const cached = macroReexportBridgeCache.get(fileName); if (cached !== undefined) { @@ -2513,6 +2579,16 @@ export function createProjectMacroEnvironment( fileName: string, specifier: string, ): { readonly start: number; readonly end: number } | null { + const cacheKey = `${fileName}\u0000${specifier}`; + const cached = macroGraphInteropImportRangeCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + if (!sourceTextForMacroModule(fileName).includes('#[interop]')) { + macroGraphInteropImportRangeCache.set(cacheKey, null); + return null; + } + const sourceFile = sourceFileForMacroModule(fileName); const annotationLookup = createAnnotationLookup(sourceFile); for (const statement of sourceFile.statements) { @@ -2529,8 +2605,11 @@ export function createProjectMacroEnvironment( if (!interopAnnotation || !block) { continue; } - return { start: block.range.start, end: block.range.end }; + const result = { start: block.range.start, end: block.range.end }; + macroGraphInteropImportRangeCache.set(cacheKey, result); + return result; } + macroGraphInteropImportRangeCache.set(cacheKey, null); return null; } @@ -2583,7 +2662,7 @@ export function createProjectMacroEnvironment( } visited.add(fileName); - validateMacroModuleSourcePolicy(fileName); + validateMacroModuleSourcePolicyMeasured(fileName); const dependencySourceTexts = new Map([[ fileName, sourceTextForMacroModule(fileName), @@ -2759,7 +2838,10 @@ export function createProjectMacroEnvironment( fileExists(candidateFileName: string): boolean { if (isSoundscriptSourceFile(toSourceFileName(candidateFileName))) { const sourceFileName = toSourceFileName(candidateFileName); - if (preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)) { + if ( + preparedProgram.preparedHost.readOriginalFile(sourceFileName) !== undefined || + preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName) + ) { return true; } } @@ -2768,10 +2850,11 @@ export function createProjectMacroEnvironment( readFile(candidateFileName: string): string | undefined { if (isSoundscriptSourceFile(toSourceFileName(candidateFileName))) { const sourceFileName = toSourceFileName(candidateFileName); - const preparedSource = preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName); - if (preparedSource) { - return preparedSource.originalText; + const originalSource = preparedProgram.preparedHost.readOriginalFile(sourceFileName); + if (originalSource !== undefined) { + return originalSource; } + return preparedProgram.preparedHost.getPreparedSourceFile(sourceFileName)?.originalText; } return baseHost.readFile(candidateFileName); }, @@ -2912,7 +2995,7 @@ export function createProjectMacroEnvironment( } const sourceText = sourceTextForMacroModule(fileName); - validateMacroModuleSourcePolicy(fileName); + validateMacroModuleSourcePolicyMeasured(fileName); const compiledArtifact = compileResolvedMacroModuleArtifact(fileName); const moduleRecord: MutableEvaluatedModule = { @@ -3808,16 +3891,23 @@ export function createProjectMacroEnvironment( graphCompileGraphs: expansionTimingStats.graphCompileGraphs, graphCompileMs: Number(expansionTimingStats.graphCompileMs.toFixed(1)), importUsageMs: Number(expansionTimingStats.importUsageMs.toFixed(1)), + interopImportCheckMs: Number(expansionTimingStats.interopImportCheckMs.toFixed(1)), likelyMacroModuleMs: Number(expansionTimingStats.likelyMacroModuleMs.toFixed(1)), macroExecutionCount: expansionTimingStats.advancedMacroExecutionCount + expansionTimingStats.rewriteMacroExecutionCount, macroExecutionMs: Number(expansionTimingStats.macroExecutionMs.toFixed(1)), moduleEvalMs: Number(expansionTimingStats.moduleEvalMs.toFixed(1)), moduleEvalRoots: expansionTimingStats.moduleEvalRoots, + packageExportInfoMs: Number(expansionTimingStats.packageExportInfoMs.toFixed(1)), + policyValidationMs: Number(expansionTimingStats.policyValidationMs.toFixed(1)), + preferredMacroResolutionMs: Number( + expansionTimingStats.preferredMacroResolutionMs.toFixed(1), + ), resolveImportMs: Number(expansionTimingStats.resolveImportMs.toFixed(1)), rewriteMacroExecutionCount: expansionTimingStats.rewriteMacroExecutionCount, sourceExpansionFiles: expansionTimingStats.sourceExpansionFiles, sourceExpansionMs: Number(expansionTimingStats.sourceExpansionMs.toFixed(1)), + tsModuleResolutionMs: Number(expansionTimingStats.tsModuleResolutionMs.toFixed(1)), }, { always: true }, ); diff --git a/src/project/soundscript_packages.ts b/src/project/soundscript_packages.ts index 74286de..28d00e2 100644 --- a/src/project/soundscript_packages.ts +++ b/src/project/soundscript_packages.ts @@ -23,6 +23,10 @@ export interface SoundScriptPackageInfo { version?: number; } +export interface SoundScriptPackageExportInfoOptions { + trustMacroAuthoringSourcePath?: boolean; +} + interface ModuleResolutionHostLike { directoryExists?(directoryName: string): boolean; fileExists(fileName: string): boolean; @@ -346,39 +350,6 @@ function collectPackageLocalImportSpecifiers( const moduleSpecifiers = new Set( ts.preProcessFile(sourceText, true, true).importedFiles.map((entry) => entry.fileName), ); - const sourceFile = ts.createSourceFile( - fileName, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - - const visit = (node: ts.Node): void => { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier && - ts.isStringLiteralLike(node.moduleSpecifier) - ) { - moduleSpecifiers.add(node.moduleSpecifier.text); - } else if ( - ts.isImportEqualsDeclaration(node) && - ts.isExternalModuleReference(node.moduleReference) && - node.moduleReference.expression && - ts.isStringLiteralLike(node.moduleReference.expression) - ) { - moduleSpecifiers.add(node.moduleReference.expression.text); - } else if ( - ts.isImportTypeNode(node) && - ts.isLiteralTypeNode(node.argument) && - ts.isStringLiteralLike(node.argument.literal) - ) { - moduleSpecifiers.add(node.argument.literal.text); - } - - ts.forEachChild(node, visit); - }; - visit(sourceFile); return [...moduleSpecifiers].sort(); } @@ -559,6 +530,111 @@ function parsePackageJson( }; } +function parsePackageJsonForExport( + packageJsonPath: string, + exportKey: string, + host: ModuleResolutionHostLike, + options: SoundScriptPackageExportInfoOptions = {}, +): SoundScriptPackageExportInfo | undefined { + const packageJsonText = host.readFile(packageJsonPath); + if (!packageJsonText) { + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(packageJsonText); + } catch { + return undefined; + } + + if (!parsed || typeof parsed !== 'object') { + return undefined; + } + + const packageRecord = parsed as Record; + const packageName = typeof packageRecord.name === 'string' ? packageRecord.name : undefined; + const soundscript = packageRecord.soundscript; + if (!packageName || !soundscript || typeof soundscript !== 'object') { + return undefined; + } + + const soundscriptRecord = soundscript as Record; + const packageRoot = dirname(packageJsonPath); + const rawExportsMap = new Map(); + const rawExports = soundscriptRecord.exports; + + if (rawExports && typeof rawExports === 'object') { + for (const [rawKey, rawValue] of Object.entries(rawExports as Record)) { + const normalizedExportKey = normalizeSoundscriptExportKey(rawKey); + if (!normalizedExportKey || !rawValue || typeof rawValue !== 'object') { + continue; + } + + const source = (rawValue as Record).source; + if (typeof source !== 'string' || source.length === 0) { + continue; + } + + const resolved = resolvePackageLocalSoundScriptSourcePath( + packageName, + packageRoot, + source, + host, + ); + if (resolved) { + rawExportsMap.set(normalizedExportKey, resolved); + } + } + } + + const legacySource = soundscriptRecord.source; + const rawLegacySourceEntryPath = typeof legacySource === 'string' && legacySource.length > 0 + ? resolvePackageLocalSoundScriptSourcePath(packageName, packageRoot, legacySource, host) + : undefined; + const sourceEntryPath = rawExportsMap.get(exportKey) ?? + (exportKey === '.' ? rawLegacySourceEntryPath : undefined); + if (!sourceEntryPath) { + return undefined; + } + + const draftPackageInfo: SoundScriptPackageInfo = { + exports: rawExportsMap, + legacySourceEntryPath: rawLegacySourceEntryPath, + name: packageName, + packageJsonPath, + packageRoot, + toolchain: typeof soundscriptRecord.toolchain === 'string' + ? soundscriptRecord.toolchain + : undefined, + version: typeof soundscriptRecord.version === 'number' ? soundscriptRecord.version : undefined, + }; + if ( + !(options.trustMacroAuthoringSourcePath && isMacroAuthoringSourcePath(sourceEntryPath)) && + !isTrustedPublishedPackageSourceClosure(sourceEntryPath, draftPackageInfo, host) + ) { + return undefined; + } + + return { + exportKey, + packageInfo: { + exports: new Map([[exportKey, sourceEntryPath]]), + legacySourceEntryPath: exportKey === '.' ? rawLegacySourceEntryPath : undefined, + name: packageName, + packageJsonPath, + packageRoot, + toolchain: typeof soundscriptRecord.toolchain === 'string' + ? soundscriptRecord.toolchain + : undefined, + version: typeof soundscriptRecord.version === 'number' + ? soundscriptRecord.version + : undefined, + }, + sourceEntryPath, + }; +} + export function loadSoundScriptPackageInfo( packageJsonPath: string, host: ModuleResolutionHostLike, @@ -582,28 +658,28 @@ export function getSoundScriptPackageExportInfoForResolvedModule( moduleSpecifier: string, resolvedFileName: string, host: ModuleResolutionHostLike, + options: SoundScriptPackageExportInfoOptions = {}, ): SoundScriptPackageExportInfo | undefined { const parsedSpecifier = parsePackageSpecifier(moduleSpecifier); if (!parsedSpecifier) { return undefined; } - const packageInfo = getSoundScriptPackageInfoForResolvedModule(resolvedFileName, host); - if (!packageInfo || packageInfo.name !== parsedSpecifier.packageName) { + const packageJsonPath = findNearestPackageJson(resolvedFileName, host); + if (!packageJsonPath) { return undefined; } - const sourceEntryPath = packageInfo.exports.get(parsedSpecifier.exportKey) ?? - (parsedSpecifier.exportKey === '.' ? packageInfo.legacySourceEntryPath : undefined); - if (!sourceEntryPath) { + const packageExport = parsePackageJsonForExport( + packageJsonPath, + parsedSpecifier.exportKey, + host, + options, + ); + if (!packageExport || packageExport.packageInfo.name !== parsedSpecifier.packageName) { return undefined; } - - return { - exportKey: parsedSpecifier.exportKey, - packageInfo, - sourceEntryPath, - }; + return packageExport; } function resolveBareSoundScriptPackageSourceModule( diff --git a/src/project/soundscript_packages_test.ts b/src/project/soundscript_packages_test.ts new file mode 100644 index 0000000..3c53c6e --- /dev/null +++ b/src/project/soundscript_packages_test.ts @@ -0,0 +1,83 @@ +import { assert, assertEquals } from '@std/assert'; + +import { dirname, join } from '../platform/path.ts'; + +import { getSoundScriptPackageExportInfoForResolvedModule } from './soundscript_packages.ts'; + +function createHost(files: ReadonlyMap) { + const knownDirectories = new Set(); + for (const fileName of files.keys()) { + let current = dirname(fileName); + while (current !== dirname(current)) { + knownDirectories.add(current); + current = dirname(current); + } + knownDirectories.add(current); + } + + return { + directoryExists(directoryName: string): boolean { + return knownDirectories.has(directoryName); + }, + fileExists(fileName: string): boolean { + return files.has(fileName); + }, + getCurrentDirectory(): string { + return '/workspace'; + }, + getDirectories(path: string): string[] { + const entries = new Set(); + for (const directory of knownDirectories) { + if (dirname(directory) === path) { + entries.add(directory.slice(path.endsWith('/') ? path.length : path.length + 1)); + } + } + return [...entries]; + }, + readFile(fileName: string): string | undefined { + return files.get(fileName); + }, + }; +} + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule can trust macro source entrypoints for macro graph validation', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/index.d.ts'); + const macroSourcePath = join(packageRoot, 'src/index.macro.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.macro.sts', + }, + }, + }, + }), + ], + [resolvedRuntimeFileName, 'export declare function m(): void;\n'], + [macroSourcePath, "import { helper } from './helper.ts';\nexport { helper };\n"], + [join(packageRoot, 'src/helper.ts'), 'export const helper = 1;\n'], + ]), + ); + + assertEquals( + getSoundScriptPackageExportInfoForResolvedModule('pkg', resolvedRuntimeFileName, host), + undefined, + ); + + const trusted = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg', + resolvedRuntimeFileName, + host, + { trustMacroAuthoringSourcePath: true }, + ); + assert(trusted); + assertEquals(trusted.sourceEntryPath, macroSourcePath); +}); From 1c1ce32b16f3a3d709f9b3547371107c6cf62416 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 20:05:22 -0400 Subject: [PATCH 5/8] Profile and trim macro graph emit --- scripts/perf/expand_benchmark.ts | 6 +- src/frontend/project_macro_support.ts | 145 +++++++++++++-------- src/frontend/project_macro_support_test.ts | 2 + 3 files changed, 95 insertions(+), 58 deletions(-) diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts index 303ec51..b6a60d2 100644 --- a/scripts/perf/expand_benchmark.ts +++ b/scripts/perf/expand_benchmark.ts @@ -441,8 +441,8 @@ function markdownReport(results: readonly ScenarioResult[]): string { const lines = [ '# Soundscript Expand Benchmark', '', - '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | graph prepare ms | graph emit ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const result of results) { lines.push( @@ -452,6 +452,8 @@ function markdownReport(results: readonly ScenarioResult[]): string { stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) } | ${macroDetailMedian(result, 'packageExportInfoMs').toFixed(1)} | ${ macroDetailMedian(result, 'graphCompileMs').toFixed(1) + } | ${macroDetailMedian(result, 'graphPrepareProgramMs').toFixed(1)} | ${ + macroDetailMedian(result, 'graphEmitMs').toFixed(1) } | ${macroDetailMedian(result, 'generatedStdlibMs').toFixed(1)} | ${ macroDetailMedian(result, 'moduleEvalMs').toFixed(1) } | ${macroDetailMedian(result, 'bindingPlanMs').toFixed(1)} | ${ diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index f371df0..4dbac68 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -287,9 +287,13 @@ interface MacroExpansionTimingStats { exportLoadMs: number; generatedStdlibFiles: number; generatedStdlibMs: number; + graphCollectDepsMs: number; graphCompileFiles: number; graphCompileGraphs: number; + graphDiagnosticsMs: number; + graphEmitMs: number; graphCompileMs: number; + graphPrepareProgramMs: number; importUsageMs: number; interopImportCheckMs: number; likelyMacroModuleMs: number; @@ -318,9 +322,13 @@ function createMacroExpansionTimingStats(): MacroExpansionTimingStats { exportLoadMs: 0, generatedStdlibFiles: 0, generatedStdlibMs: 0, + graphCollectDepsMs: 0, graphCompileFiles: 0, graphCompileGraphs: 0, + graphDiagnosticsMs: 0, + graphEmitMs: 0, graphCompileMs: 0, + graphPrepareProgramMs: 0, importUsageMs: 0, interopImportCheckMs: 0, likelyMacroModuleMs: 0, @@ -2777,35 +2785,11 @@ export function createProjectMacroEnvironment( ); } - function emitCommonJsMacroArtifactWithFallback( - macroTargetProgram: PreparedProgram, + function transpileCommonJsMacroArtifact( sourceFile: ts.SourceFile, fileName: string, compilerOptions: ts.CompilerOptions, ): string { - let javaScriptText: string | undefined; - const emitResult = macroTargetProgram.program.emit( - sourceFile, - (_outputFileName: string, text: string) => { - javaScriptText = text; - }, - undefined, - false, - ); - const emitDiagnostics = emitResult.diagnostics.filter((diagnostic: ts.Diagnostic) => - diagnostic.category === ts.DiagnosticCategory.Error - ); - if (emitDiagnostics.length > 0) { - throw createMacroModuleErrorFromDiagnostic( - emitDiagnostics[0]!, - fileName, - `Failed to emit macro module "${fileName}".`, - ); - } - if (javaScriptText !== undefined && !/^\s*(?:import|export)\b/mu.test(javaScriptText)) { - return javaScriptText; - } - const transpiled = ts.transpileModule(sourceFile.text, { compilerOptions: { ...compilerOptions, @@ -2831,6 +2815,21 @@ export function createProjectMacroEnvironment( return transpiled.outputText; } + function transpileCommonJsMacroArtifacts( + graphSourceFiles: ReadonlyMap, + compilerOptions: ts.CompilerOptions, + ): Map { + const javaScriptTexts = new Map(); + for (const [graphFileName, sourceFile] of graphSourceFiles) { + javaScriptTexts.set( + graphFileName, + transpileCommonJsMacroArtifact(sourceFile, graphFileName, compilerOptions), + ); + } + + return javaScriptTexts; + } + function createMacroTargetBaseHost(): ts.CompilerHost { const baseHost = preparedProgram.preparedHost.host; return withMacroApiModuleResolution({ @@ -2880,28 +2879,39 @@ export function createProjectMacroEnvironment( macroCacheStats.moduleCacheMisses += 1; const graphCompileStart = performance.now(); - const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); + const dependencySourceTexts = measureActiveMacroTiming( + 'graphCollectDepsMs', + () => collectDependencySourceTextsForCompilation(fileName), + ); const graphRootNames = [...dependencySourceTexts.keys()]; try { - const macroTargetProgram = createPreparedProgram({ - alwaysAvailableMacroSiteKinds, - baseHost: createMacroTargetBaseHost(), - configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, - expansionEnabled: false, - options: { - ...preparedProgram.options, - module: ts.ModuleKind.CommonJS, - moduleResolution: ts.ModuleResolutionKind.Node10, - noEmit: false, - target: ts.ScriptTarget.ES2022, - }, - preserveMacroAuthoring: true, - reusableCompilerHostState: macroTargetReuseState, - rootNames: graphRootNames, - runtime: preparedProgram.runtime, - }); - const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => - diagnostic.category === 'error' + const macroTargetProgram = measureActiveMacroTiming( + 'graphPrepareProgramMs', + () => + createPreparedProgram({ + alwaysAvailableMacroSiteKinds, + baseHost: createMacroTargetBaseHost(), + configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, + expansionEnabled: false, + options: { + ...preparedProgram.options, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + noEmit: false, + target: ts.ScriptTarget.ES2022, + }, + preserveMacroAuthoring: true, + reusableCompilerHostState: macroTargetReuseState, + rootNames: graphRootNames, + runtime: preparedProgram.runtime, + }), + ); + const frontendDiagnostics = measureActiveMacroTiming( + 'graphDiagnosticsMs', + () => + macroTargetProgram.frontendDiagnostics().filter((diagnostic) => + diagnostic.category === 'error' + ), ); if (frontendDiagnostics.length > 0) { const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => @@ -2931,6 +2941,7 @@ export function createProjectMacroEnvironment( ); } + const graphSourceFiles = new Map(); for (const graphFileName of graphRootNames) { const sourceFile = macroTargetProgram.program.getSourceFile( macroTargetProgram.toProgramFileName(graphFileName), @@ -2944,10 +2955,16 @@ export function createProjectMacroEnvironment( ); } - const tsDiagnostics = [ - ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), - ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), - ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); + graphSourceFiles.set(graphFileName, sourceFile); + + const tsDiagnostics = measureActiveMacroTiming( + 'graphDiagnosticsMs', + () => + [ + ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), + ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), + ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error), + ); if (tsDiagnostics.length > 0) { throw createMacroModuleErrorFromDiagnostic( tsDiagnostics[0]!, @@ -2955,14 +2972,26 @@ export function createProjectMacroEnvironment( `Failed to compile macro module "${graphFileName}".`, ); } + } - const javaScriptText = emitCommonJsMacroArtifactWithFallback( - macroTargetProgram, - sourceFile, - graphFileName, - macroTargetProgram.options, - ); - + const emittedArtifacts = measureActiveMacroTiming( + 'graphEmitMs', + () => + transpileCommonJsMacroArtifacts( + graphSourceFiles, + macroTargetProgram.options, + ), + ); + for (const graphFileName of graphRootNames) { + const javaScriptText = emittedArtifacts.get(graphFileName); + if (javaScriptText === undefined) { + throw createMacroModuleError( + graphFileName, + sourceTextForMacroModule(graphFileName), + `Failed to emit macro module "${graphFileName}".`, + 'SOUNDSCRIPT_MACRO_EXPANSION', + ); + } const artifact: CachedMacroModuleArtifactEntry = { dependencySourceTexts, javaScriptText, @@ -3887,9 +3916,13 @@ export function createProjectMacroEnvironment( exportLoadMs: Number(expansionTimingStats.exportLoadMs.toFixed(1)), generatedStdlibFiles: expansionTimingStats.generatedStdlibFiles, generatedStdlibMs: Number(expansionTimingStats.generatedStdlibMs.toFixed(1)), + graphCollectDepsMs: Number(expansionTimingStats.graphCollectDepsMs.toFixed(1)), graphCompileFiles: expansionTimingStats.graphCompileFiles, graphCompileGraphs: expansionTimingStats.graphCompileGraphs, + graphDiagnosticsMs: Number(expansionTimingStats.graphDiagnosticsMs.toFixed(1)), + graphEmitMs: Number(expansionTimingStats.graphEmitMs.toFixed(1)), graphCompileMs: Number(expansionTimingStats.graphCompileMs.toFixed(1)), + graphPrepareProgramMs: Number(expansionTimingStats.graphPrepareProgramMs.toFixed(1)), importUsageMs: Number(expansionTimingStats.importUsageMs.toFixed(1)), interopImportCheckMs: Number(expansionTimingStats.interopImportCheckMs.toFixed(1)), likelyMacroModuleMs: Number(expansionTimingStats.likelyMacroModuleMs.toFixed(1)), diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index 1f5f574..99232d3 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -486,6 +486,8 @@ Deno.test('createProjectMacroEnvironment reports macro expansion detail timing', ); assert(detailLog); assertStringIncludes(detailLog, 'graphCompileGraphs=1'); + assertStringIncludes(detailLog, 'graphPrepareProgramMs='); + assertStringIncludes(detailLog, 'graphEmitMs='); assertStringIncludes(detailLog, 'bindingPlanFiles=1'); assertStringIncludes(detailLog, 'moduleEvalRoots=1'); assertStringIncludes(detailLog, 'macroExecutionCount=2'); From e527ef4c231153f68235527eae781f1f32d690d9 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Mon, 27 Apr 2026 20:08:55 -0400 Subject: [PATCH 6/8] Revert "Profile and trim macro graph emit" This reverts commit 1c1ce32b16f3a3d709f9b3547371107c6cf62416. --- scripts/perf/expand_benchmark.ts | 6 +- src/frontend/project_macro_support.ts | 145 ++++++++------------- src/frontend/project_macro_support_test.ts | 2 - 3 files changed, 58 insertions(+), 95 deletions(-) diff --git a/scripts/perf/expand_benchmark.ts b/scripts/perf/expand_benchmark.ts index b6a60d2..303ec51 100644 --- a/scripts/perf/expand_benchmark.ts +++ b/scripts/perf/expand_benchmark.ts @@ -441,8 +441,8 @@ function markdownReport(results: readonly ScenarioResult[]): string { const lines = [ '# Soundscript Expand Benchmark', '', - '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | graph prepare ms | graph emit ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', - '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + '| scenario | wall median ms | initial ms | expand macros ms | package export ms | graph compile ms | generated stdlib ms | module eval ms | binding plan ms | macro exec ms | source expansion ms | annotated ms | final ms | semantic builds |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ]; for (const result of results) { lines.push( @@ -452,8 +452,6 @@ function markdownReport(results: readonly ScenarioResult[]): string { stageMedian(result, 'project.prepare.builtin.expandMacros').toFixed(1) } | ${macroDetailMedian(result, 'packageExportInfoMs').toFixed(1)} | ${ macroDetailMedian(result, 'graphCompileMs').toFixed(1) - } | ${macroDetailMedian(result, 'graphPrepareProgramMs').toFixed(1)} | ${ - macroDetailMedian(result, 'graphEmitMs').toFixed(1) } | ${macroDetailMedian(result, 'generatedStdlibMs').toFixed(1)} | ${ macroDetailMedian(result, 'moduleEvalMs').toFixed(1) } | ${macroDetailMedian(result, 'bindingPlanMs').toFixed(1)} | ${ diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index 4dbac68..f371df0 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -287,13 +287,9 @@ interface MacroExpansionTimingStats { exportLoadMs: number; generatedStdlibFiles: number; generatedStdlibMs: number; - graphCollectDepsMs: number; graphCompileFiles: number; graphCompileGraphs: number; - graphDiagnosticsMs: number; - graphEmitMs: number; graphCompileMs: number; - graphPrepareProgramMs: number; importUsageMs: number; interopImportCheckMs: number; likelyMacroModuleMs: number; @@ -322,13 +318,9 @@ function createMacroExpansionTimingStats(): MacroExpansionTimingStats { exportLoadMs: 0, generatedStdlibFiles: 0, generatedStdlibMs: 0, - graphCollectDepsMs: 0, graphCompileFiles: 0, graphCompileGraphs: 0, - graphDiagnosticsMs: 0, - graphEmitMs: 0, graphCompileMs: 0, - graphPrepareProgramMs: 0, importUsageMs: 0, interopImportCheckMs: 0, likelyMacroModuleMs: 0, @@ -2785,11 +2777,35 @@ export function createProjectMacroEnvironment( ); } - function transpileCommonJsMacroArtifact( + function emitCommonJsMacroArtifactWithFallback( + macroTargetProgram: PreparedProgram, sourceFile: ts.SourceFile, fileName: string, compilerOptions: ts.CompilerOptions, ): string { + let javaScriptText: string | undefined; + const emitResult = macroTargetProgram.program.emit( + sourceFile, + (_outputFileName: string, text: string) => { + javaScriptText = text; + }, + undefined, + false, + ); + const emitDiagnostics = emitResult.diagnostics.filter((diagnostic: ts.Diagnostic) => + diagnostic.category === ts.DiagnosticCategory.Error + ); + if (emitDiagnostics.length > 0) { + throw createMacroModuleErrorFromDiagnostic( + emitDiagnostics[0]!, + fileName, + `Failed to emit macro module "${fileName}".`, + ); + } + if (javaScriptText !== undefined && !/^\s*(?:import|export)\b/mu.test(javaScriptText)) { + return javaScriptText; + } + const transpiled = ts.transpileModule(sourceFile.text, { compilerOptions: { ...compilerOptions, @@ -2815,21 +2831,6 @@ export function createProjectMacroEnvironment( return transpiled.outputText; } - function transpileCommonJsMacroArtifacts( - graphSourceFiles: ReadonlyMap, - compilerOptions: ts.CompilerOptions, - ): Map { - const javaScriptTexts = new Map(); - for (const [graphFileName, sourceFile] of graphSourceFiles) { - javaScriptTexts.set( - graphFileName, - transpileCommonJsMacroArtifact(sourceFile, graphFileName, compilerOptions), - ); - } - - return javaScriptTexts; - } - function createMacroTargetBaseHost(): ts.CompilerHost { const baseHost = preparedProgram.preparedHost.host; return withMacroApiModuleResolution({ @@ -2879,39 +2880,28 @@ export function createProjectMacroEnvironment( macroCacheStats.moduleCacheMisses += 1; const graphCompileStart = performance.now(); - const dependencySourceTexts = measureActiveMacroTiming( - 'graphCollectDepsMs', - () => collectDependencySourceTextsForCompilation(fileName), - ); + const dependencySourceTexts = collectDependencySourceTextsForCompilation(fileName); const graphRootNames = [...dependencySourceTexts.keys()]; try { - const macroTargetProgram = measureActiveMacroTiming( - 'graphPrepareProgramMs', - () => - createPreparedProgram({ - alwaysAvailableMacroSiteKinds, - baseHost: createMacroTargetBaseHost(), - configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, - expansionEnabled: false, - options: { - ...preparedProgram.options, - module: ts.ModuleKind.CommonJS, - moduleResolution: ts.ModuleResolutionKind.Node10, - noEmit: false, - target: ts.ScriptTarget.ES2022, - }, - preserveMacroAuthoring: true, - reusableCompilerHostState: macroTargetReuseState, - rootNames: graphRootNames, - runtime: preparedProgram.runtime, - }), - ); - const frontendDiagnostics = measureActiveMacroTiming( - 'graphDiagnosticsMs', - () => - macroTargetProgram.frontendDiagnostics().filter((diagnostic) => - diagnostic.category === 'error' - ), + const macroTargetProgram = createPreparedProgram({ + alwaysAvailableMacroSiteKinds, + baseHost: createMacroTargetBaseHost(), + configuredSoundscriptFileNames: preparedProgram.configuredSoundscriptFileNames, + expansionEnabled: false, + options: { + ...preparedProgram.options, + module: ts.ModuleKind.CommonJS, + moduleResolution: ts.ModuleResolutionKind.Node10, + noEmit: false, + target: ts.ScriptTarget.ES2022, + }, + preserveMacroAuthoring: true, + reusableCompilerHostState: macroTargetReuseState, + rootNames: graphRootNames, + runtime: preparedProgram.runtime, + }); + const frontendDiagnostics = macroTargetProgram.frontendDiagnostics().filter((diagnostic) => + diagnostic.category === 'error' ); if (frontendDiagnostics.length > 0) { const expansionDisabledDiagnostic = frontendDiagnostics.find((diagnostic) => @@ -2941,7 +2931,6 @@ export function createProjectMacroEnvironment( ); } - const graphSourceFiles = new Map(); for (const graphFileName of graphRootNames) { const sourceFile = macroTargetProgram.program.getSourceFile( macroTargetProgram.toProgramFileName(graphFileName), @@ -2955,16 +2944,10 @@ export function createProjectMacroEnvironment( ); } - graphSourceFiles.set(graphFileName, sourceFile); - - const tsDiagnostics = measureActiveMacroTiming( - 'graphDiagnosticsMs', - () => - [ - ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), - ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), - ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error), - ); + const tsDiagnostics = [ + ...macroTargetProgram.program.getSyntacticDiagnostics(sourceFile), + ...macroTargetProgram.program.getSemanticDiagnostics(sourceFile), + ].filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error); if (tsDiagnostics.length > 0) { throw createMacroModuleErrorFromDiagnostic( tsDiagnostics[0]!, @@ -2972,26 +2955,14 @@ export function createProjectMacroEnvironment( `Failed to compile macro module "${graphFileName}".`, ); } - } - const emittedArtifacts = measureActiveMacroTiming( - 'graphEmitMs', - () => - transpileCommonJsMacroArtifacts( - graphSourceFiles, - macroTargetProgram.options, - ), - ); - for (const graphFileName of graphRootNames) { - const javaScriptText = emittedArtifacts.get(graphFileName); - if (javaScriptText === undefined) { - throw createMacroModuleError( - graphFileName, - sourceTextForMacroModule(graphFileName), - `Failed to emit macro module "${graphFileName}".`, - 'SOUNDSCRIPT_MACRO_EXPANSION', - ); - } + const javaScriptText = emitCommonJsMacroArtifactWithFallback( + macroTargetProgram, + sourceFile, + graphFileName, + macroTargetProgram.options, + ); + const artifact: CachedMacroModuleArtifactEntry = { dependencySourceTexts, javaScriptText, @@ -3916,13 +3887,9 @@ export function createProjectMacroEnvironment( exportLoadMs: Number(expansionTimingStats.exportLoadMs.toFixed(1)), generatedStdlibFiles: expansionTimingStats.generatedStdlibFiles, generatedStdlibMs: Number(expansionTimingStats.generatedStdlibMs.toFixed(1)), - graphCollectDepsMs: Number(expansionTimingStats.graphCollectDepsMs.toFixed(1)), graphCompileFiles: expansionTimingStats.graphCompileFiles, graphCompileGraphs: expansionTimingStats.graphCompileGraphs, - graphDiagnosticsMs: Number(expansionTimingStats.graphDiagnosticsMs.toFixed(1)), - graphEmitMs: Number(expansionTimingStats.graphEmitMs.toFixed(1)), graphCompileMs: Number(expansionTimingStats.graphCompileMs.toFixed(1)), - graphPrepareProgramMs: Number(expansionTimingStats.graphPrepareProgramMs.toFixed(1)), importUsageMs: Number(expansionTimingStats.importUsageMs.toFixed(1)), interopImportCheckMs: Number(expansionTimingStats.interopImportCheckMs.toFixed(1)), likelyMacroModuleMs: Number(expansionTimingStats.likelyMacroModuleMs.toFixed(1)), diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index 99232d3..1f5f574 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -486,8 +486,6 @@ Deno.test('createProjectMacroEnvironment reports macro expansion detail timing', ); assert(detailLog); assertStringIncludes(detailLog, 'graphCompileGraphs=1'); - assertStringIncludes(detailLog, 'graphPrepareProgramMs='); - assertStringIncludes(detailLog, 'graphEmitMs='); assertStringIncludes(detailLog, 'bindingPlanFiles=1'); assertStringIncludes(detailLog, 'moduleEvalRoots=1'); assertStringIncludes(detailLog, 'macroExecutionCount=2'); From 09e6ba35b546fbb6176d1127110017b9ccb7eb1d Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Fri, 1 May 2026 20:42:38 -0400 Subject: [PATCH 7/8] Fix macro cache and package export safety --- src/frontend/project_macro_support.ts | 13 +---- src/frontend/project_macro_support_test.ts | 55 ++++++++++++++++++++++ src/project/soundscript_packages.ts | 26 +++++++++- src/project/soundscript_packages_test.ts | 46 ++++++++++++++++++ 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/frontend/project_macro_support.ts b/src/frontend/project_macro_support.ts index f371df0..b35415d 100644 --- a/src/frontend/project_macro_support.ts +++ b/src/frontend/project_macro_support.ts @@ -1139,23 +1139,12 @@ export function createProjectMacroEnvironment( return names; } - function fnv1aHash(text: string, seed = 0x811c9dc5): number { - let hash = seed; - for (let index = 0; index < text.length; index += 1) { - hash ^= text.charCodeAt(index); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash >>> 0; - } - function serializeDependencySourceTexts( dependencySourceTexts: ReadonlyMap, ): string { return [...dependencySourceTexts.entries()] .sort(([left], [right]) => left.localeCompare(right)) - .map(([fileName, text]) => - `${fileName}\u0001${text.length}\u0001${fnv1aHash(text).toString(16)}` - ) + .map(([fileName, text]) => `${fileName}\u0001${text.length}\u0001${text}`) .join('\u0002'); } diff --git a/src/frontend/project_macro_support_test.ts b/src/frontend/project_macro_support_test.ts index 1f5f574..b58e001 100644 --- a/src/frontend/project_macro_support_test.ts +++ b/src/frontend/project_macro_support_test.ts @@ -1089,6 +1089,61 @@ Deno.test( }, ); +Deno.test( + 'createProjectMacroEnvironment invalidates expanded files for exact macro dependency source changes', + () => { + const fileName = '/virtual/index.sts'; + const macroFile = '/virtual/macros/defs.macro.sts'; + const helperFile = '/virtual/macros/helper.macro.sts'; + const reuseState = createPreparedCompilerHostReuseState('/virtual'); + const macroSource = [ + "import 'sts:macros';", + "import { helperValue } from './helper.macro';", + '', + '// #[macro(call)]', + 'export function Foo() {', + ' return {', + ' expand(ctx) {', + ' return ctx.output.expr(ctx.quote.expr`${JSON.stringify(helperValue)}`);', + ' },', + ' };', + '}', + '', + ].join('\n'); + const createProgram = (helperValue: string, oldProgram?: ts.Program) => + createPreparedProgram({ + baseHost: createBaseHost( + new Map([ + [fileName, "import { Foo } from './macros/defs.macro';\nexport const value = Foo();\n"], + [macroFile, macroSource], + [helperFile, `export const helperValue = "${helperValue}";\n`], + ]), + ), + oldProgram, + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + }, + reusableCompilerHostState: reuseState, + rootNames: [fileName], + }); + + // These two helper sources have the same length and collide under the previous + // 32-bit FNV dependency signature shortcut. Cache validity must depend on the + // exact source text instead. + const firstProgram = createProgram('htdi5qip'); + const firstExpanded = printExpandedFile(firstProgram, fileName); + + const secondProgram = createProgram('ofw3agyf', firstProgram.program); + const secondExpanded = printExpandedFile(secondProgram, fileName); + + assertStringIncludes(firstExpanded, '"htdi5qip"'); + assertStringIncludes(secondExpanded, '"ofw3agyf"'); + }, +); + Deno.test('createProjectMacroEnvironment reparses remote statement expansions using the caller source file script kind', () => { const fileName = '/virtual/index.sts'; const macroFile = '/virtual/macros/defs.macro.sts'; diff --git a/src/project/soundscript_packages.ts b/src/project/soundscript_packages.ts index 28d00e2..da868a3 100644 --- a/src/project/soundscript_packages.ts +++ b/src/project/soundscript_packages.ts @@ -616,11 +616,33 @@ function parsePackageJsonForExport( return undefined; } + const exportsMap = new Map(); + for (const [candidateExportKey, candidateSourceEntryPath] of rawExportsMap) { + if ( + options.trustMacroAuthoringSourcePath && + candidateExportKey === exportKey && + isMacroAuthoringSourcePath(candidateSourceEntryPath) + ) { + exportsMap.set(candidateExportKey, candidateSourceEntryPath); + } else if ( + isTrustedPublishedPackageSourceClosure(candidateSourceEntryPath, draftPackageInfo, host) + ) { + exportsMap.set(candidateExportKey, candidateSourceEntryPath); + } + } + const legacySourceEntryPath = rawLegacySourceEntryPath && + (options.trustMacroAuthoringSourcePath && + exportKey === '.' && + isMacroAuthoringSourcePath(rawLegacySourceEntryPath) || + isTrustedPublishedPackageSourceClosure(rawLegacySourceEntryPath, draftPackageInfo, host)) + ? rawLegacySourceEntryPath + : undefined; + return { exportKey, packageInfo: { - exports: new Map([[exportKey, sourceEntryPath]]), - legacySourceEntryPath: exportKey === '.' ? rawLegacySourceEntryPath : undefined, + exports: exportsMap, + legacySourceEntryPath, name: packageName, packageJsonPath, packageRoot, diff --git a/src/project/soundscript_packages_test.ts b/src/project/soundscript_packages_test.ts index 3c53c6e..4416efd 100644 --- a/src/project/soundscript_packages_test.ts +++ b/src/project/soundscript_packages_test.ts @@ -81,3 +81,49 @@ Deno.test('getSoundScriptPackageExportInfoForResolvedModule can trust macro sour assert(trusted); assertEquals(trusted.sourceEntryPath, macroSourcePath); }); + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule returns full package export info', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/extra.d.ts'); + const indexSourcePath = join(packageRoot, 'src/index.sts'); + const extraSourcePath = join(packageRoot, 'src/extra.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.sts', + }, + './extra': { + source: './src/extra.sts', + }, + }, + }, + }), + ], + [join(packageRoot, 'dist/index.d.ts'), 'export declare const index: number;\n'], + [resolvedRuntimeFileName, 'export declare const extra: number;\n'], + [indexSourcePath, 'export const index = 1;\n'], + [extraSourcePath, 'export const extra = 2;\n'], + ]), + ); + + const packageExport = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg/extra', + resolvedRuntimeFileName, + host, + ); + + assert(packageExport); + assertEquals(packageExport.sourceEntryPath, extraSourcePath); + assertEquals([...packageExport.packageInfo.exports.entries()], [ + ['.', indexSourcePath], + ['./extra', extraSourcePath], + ]); +}); From f6caa30447f6a9b3a3a4cbc0302be2bcd2ba3bb4 Mon Sep 17 00:00:00 2001 From: Jake McCloskey Date: Sat, 2 May 2026 09:53:08 -0400 Subject: [PATCH 8/8] Fix package export metadata for macro verification --- src/project/soundscript_packages.ts | 36 +--------------- src/project/soundscript_packages_test.ts | 54 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/project/soundscript_packages.ts b/src/project/soundscript_packages.ts index da868a3..4c0d57a 100644 --- a/src/project/soundscript_packages.ts +++ b/src/project/soundscript_packages.ts @@ -616,43 +616,9 @@ function parsePackageJsonForExport( return undefined; } - const exportsMap = new Map(); - for (const [candidateExportKey, candidateSourceEntryPath] of rawExportsMap) { - if ( - options.trustMacroAuthoringSourcePath && - candidateExportKey === exportKey && - isMacroAuthoringSourcePath(candidateSourceEntryPath) - ) { - exportsMap.set(candidateExportKey, candidateSourceEntryPath); - } else if ( - isTrustedPublishedPackageSourceClosure(candidateSourceEntryPath, draftPackageInfo, host) - ) { - exportsMap.set(candidateExportKey, candidateSourceEntryPath); - } - } - const legacySourceEntryPath = rawLegacySourceEntryPath && - (options.trustMacroAuthoringSourcePath && - exportKey === '.' && - isMacroAuthoringSourcePath(rawLegacySourceEntryPath) || - isTrustedPublishedPackageSourceClosure(rawLegacySourceEntryPath, draftPackageInfo, host)) - ? rawLegacySourceEntryPath - : undefined; - return { exportKey, - packageInfo: { - exports: exportsMap, - legacySourceEntryPath, - name: packageName, - packageJsonPath, - packageRoot, - toolchain: typeof soundscriptRecord.toolchain === 'string' - ? soundscriptRecord.toolchain - : undefined, - version: typeof soundscriptRecord.version === 'number' - ? soundscriptRecord.version - : undefined, - }, + packageInfo: draftPackageInfo, sourceEntryPath, }; } diff --git a/src/project/soundscript_packages_test.ts b/src/project/soundscript_packages_test.ts index 4416efd..ed1bacd 100644 --- a/src/project/soundscript_packages_test.ts +++ b/src/project/soundscript_packages_test.ts @@ -127,3 +127,57 @@ Deno.test('getSoundScriptPackageExportInfoForResolvedModule returns full package ['./extra', extraSourcePath], ]); }); + +Deno.test('getSoundScriptPackageExportInfoForResolvedModule keeps full package info when trusting one macro export', () => { + const packageRoot = '/workspace/node_modules/pkg'; + const packageJsonPath = join(packageRoot, 'package.json'); + const resolvedRuntimeFileName = join(packageRoot, 'dist/index.d.ts'); + const indexSourcePath = join(packageRoot, 'src/index.macro.sts'); + const extraSourcePath = join(packageRoot, 'src/extra.macro.sts'); + const host = createHost( + new Map([ + [ + packageJsonPath, + JSON.stringify({ + name: 'pkg', + soundscript: { + version: 1, + exports: { + '.': { + source: './src/index.macro.sts', + }, + './extra': { + source: './src/extra.macro.sts', + }, + }, + }, + }), + ], + [resolvedRuntimeFileName, 'export declare const index: number;\n'], + [join(packageRoot, 'dist/extra.d.ts'), 'export declare const extra: number;\n'], + [indexSourcePath, "import { helper } from './helper.ts';\nexport { helper };\n"], + [extraSourcePath, "import { extra } from './extra-helper.ts';\nexport { extra };\n"], + [join(packageRoot, 'src/helper.ts'), 'export const helper = 1;\n'], + [join(packageRoot, 'src/extra-helper.ts'), 'export const extra = 2;\n'], + ]), + ); + + assertEquals( + getSoundScriptPackageExportInfoForResolvedModule('pkg', resolvedRuntimeFileName, host), + undefined, + ); + + const packageExport = getSoundScriptPackageExportInfoForResolvedModule( + 'pkg', + resolvedRuntimeFileName, + host, + { trustMacroAuthoringSourcePath: true }, + ); + + assert(packageExport); + assertEquals(packageExport.sourceEntryPath, indexSourcePath); + assertEquals([...packageExport.packageInfo.exports.entries()], [ + ['.', indexSourcePath], + ['./extra', extraSourcePath], + ]); +});