diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c99046c50..bf9d063a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: with: # Only write to the cache for builds on the specific branches. (Default is 'main' only.) # Builds on other branches will only read existing entries from the cache. - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'ref/heads/neo' }} + cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/neo' }} - name: Build and run tests run: ./gradlew --scan build -x :jacodb-ets:build @@ -108,9 +108,31 @@ jobs: with: # Only write to the cache for builds on the specific branches. (Default is 'main' only.) # Builds on other branches will only read existing entries from the cache. - cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'ref/heads/neo' }} + cache-read-only: ${{ github.ref != 'refs/heads/develop' && github.ref != 'refs/heads/neo' }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: jacodb-ets/ts-frontend/package-lock.json - - name: Set up ArkAnalyzer + - name: Build and test ts-frontend + working-directory: jacodb-ets/ts-frontend + run: | + npm ci + npm run build + npm test + + # The legacy ArkAnalyzer provider stays supported (selectable via + # ETS_IR_PROVIDER=arkanalyzer); setting ARKANALYZER_DIR here enables + # the provider-parity test in EtsTsFrontendTest. + # + # Best-effort: the default provider is the bundled ts-frontend, so a + # broken upstream ArkAnalyzer must not fail the job — without + # ARKANALYZER_DIR the parity test skips itself. + - name: Set up ArkAnalyzer (legacy provider, best-effort) + continue-on-error: true run: | REPO_URL="https://gitcode.com/Lipen/arkanalyzer" DEST_DIR="arkanalyzer" @@ -131,7 +153,6 @@ jobs: echo "Repository cloned successfully." fi - echo "ARKANALYZER_DIR=$(realpath $DEST_DIR)" >> $GITHUB_ENV cd $DEST_DIR # ArkAnalyzer's bundled TypeScript (ohos-typescript 4.9.5) cannot parse @@ -145,6 +166,9 @@ jobs: npm install npm run build + # Export only after a successful build so a broken checkout is never used. + echo "ARKANALYZER_DIR=$(realpath .)" >> $GITHUB_ENV + - name: Run ETS tests run: ./gradlew --scan :jacodb-ets:generateTestResources :jacodb-ets:test diff --git a/jacodb-ets/ARKANALYZER.md b/jacodb-ets/ARKANALYZER.md index 1fb123d41..71b5c598e 100644 --- a/jacodb-ets/ARKANALYZER.md +++ b/jacodb-ets/ARKANALYZER.md @@ -1,5 +1,11 @@ # ArkAnalyzer +> **Note:** ArkAnalyzer is now the LEGACY EtsIR provider. The default provider +> is the native TypeScript frontend in [`ts-frontend`](ts-frontend/README.md), +> which requires no external checkout. ArkAnalyzer remains fully supported: +> select it with `ETS_IR_PROVIDER=arkanalyzer` (plus `ARKANALYZER_DIR`) or by +> passing `EtsIrProvider.ARKANALYZER` to the `loadEts*AutoConvert` functions. + ## Installation Clone and install the ArkAnalyzer via NPM: diff --git a/jacodb-ets/build.gradle.kts b/jacodb-ets/build.gradle.kts index 3349239e9..e4b6daa1f 100644 --- a/jacodb-ets/build.gradle.kts +++ b/jacodb-ets/build.gradle.kts @@ -1,3 +1,4 @@ +import org.apache.tools.ant.taskdefs.condition.Os import java.io.FileNotFoundException plugins { @@ -21,44 +22,117 @@ dependencies { testFixturesImplementation(Libs.junit_jupiter_api) } +// ---------------------------------------------------------------------------- +// Native TypeScript frontend (ts-frontend) +// ---------------------------------------------------------------------------- + +val tsFrontendDir: File = projectDir.resolve("ts-frontend") +val npmExecutable: String = if (Os.isFamily(Os.FAMILY_WINDOWS)) "npm.cmd" else "npm" + +fun isNpmAvailable(): Boolean = try { + ProcessBuilder(npmExecutable, "--version").start().waitFor() == 0 +} catch (_: Exception) { + false +} + +val installTsFrontend = tasks.register("installTsFrontend") { + group = "build" + description = "Installs npm dependencies of the ts-frontend." + workingDir = tsFrontendDir + commandLine(npmExecutable, "ci") + inputs.files(tsFrontendDir.resolve("package.json"), tsFrontendDir.resolve("package-lock.json")) + outputs.dir(tsFrontendDir.resolve("node_modules")) + onlyIf { + isNpmAvailable().also { available -> + if (!available) logger.warn("npm is not available; skipping ts-frontend install") + } + } +} + +val buildTsFrontend = tasks.register("buildTsFrontend") { + group = "build" + description = "Compiles the ts-frontend (TypeScript -> dist)." + dependsOn(installTsFrontend) + workingDir = tsFrontendDir + commandLine(npmExecutable, "run", "build") + inputs.dir(tsFrontendDir.resolve("src")) + inputs.files(tsFrontendDir.resolve("package.json"), tsFrontendDir.resolve("tsconfig.json")) + outputs.dir(tsFrontendDir.resolve("dist")) + onlyIf { + isNpmAvailable().also { available -> + if (!available) logger.warn("npm is not available; skipping ts-frontend build") + } + } +} + +tasks.register("testTsFrontend") { + group = "verification" + description = "Runs the ts-frontend unit tests (vitest)." + dependsOn(installTsFrontend) + workingDir = tsFrontendDir + commandLine(npmExecutable, "test") + onlyIf { + isNpmAvailable().also { available -> + if (!available) logger.warn("npm is not available; skipping ts-frontend tests") + } + } +} + +tasks.test { + dependsOn(buildTsFrontend) + systemProperty("ets.frontend.dir", tsFrontendDir.absolutePath) +} + +// ---------------------------------------------------------------------------- +// Test resource generation +// ---------------------------------------------------------------------------- + // Example usage: // ``` -// export ARKANALYZER_DIR=~/dev/arkanalyzer // ./gradlew generateTestResources +// # or with the legacy ArkAnalyzer provider: +// export ARKANALYZER_DIR=~/dev/arkanalyzer +// ETS_IR_PROVIDER=arkanalyzer ./gradlew generateTestResources // ``` tasks.register("generateTestResources") { group = "build" - description = "Generates test resources from TypeScript files using ArkAnalyzer." + description = "Generates test resources (EtsIR JSON) from TypeScript sample files." + dependsOn(buildTsFrontend) doLast { - println("Generating test resources using ArkAnalyzer...") + val provider = (System.getenv("ETS_IR_PROVIDER") ?: "ts-frontend").lowercase() + println("Generating test resources using provider: $provider") val startTime = System.currentTimeMillis() - val envVarName = "ARKANALYZER_DIR" - val defaultArkAnalyzerDir = "arkanalyzer" - - val arkAnalyzerDir = rootDir.resolve(System.getenv(envVarName) ?: run { - println("Please, set $envVarName environment variable. Using default value: '$defaultArkAnalyzerDir'") - defaultArkAnalyzerDir - }) - if (!arkAnalyzerDir.exists()) { - throw FileNotFoundException( - "ArkAnalyzer directory does not exist: '${arkAnalyzerDir.absolutePath}'. " + - "Did you forget to set the '$envVarName' environment variable? " + - "Current value is '${System.getenv(envVarName)}', " + - "current dir is '${File("").absolutePath}'." - ) - } - println("Using ArkAnalyzer directory: '${arkAnalyzerDir.relativeTo(rootDir)}'") - - val scriptSubPath = "src/save/serializeArkIR" - val script = arkAnalyzerDir.resolve("out").resolve("$scriptSubPath.js") - if (!script.exists()) { - throw FileNotFoundException( - "Script file not found: '$script'. " + - "Did you forget to execute 'npm run build' in the arkanalyzer project?" - ) + val script: File = when (provider) { + "arkanalyzer" -> { + val envVarName = "ARKANALYZER_DIR" + val arkAnalyzerDir = rootDir.resolve(System.getenv(envVarName) ?: "arkanalyzer") + if (!arkAnalyzerDir.exists()) { + throw FileNotFoundException( + "ArkAnalyzer directory does not exist: '${arkAnalyzerDir.absolutePath}'. " + + "Did you forget to set the '$envVarName' environment variable?" + ) + } + arkAnalyzerDir.resolve("out/src/save/serializeArkIR.js").also { + if (!it.exists()) { + throw FileNotFoundException( + "Script file not found: '$it'. " + + "Did you forget to execute 'npm run build' in the arkanalyzer project?" + ) + } + } + } + + else -> tsFrontendDir.resolve("dist/index.js").also { + if (!it.exists()) { + throw FileNotFoundException( + "Script file not found: '$it'. " + + "Did you forget to execute 'npm run build' in ts-frontend?" + ) + } + } } - println("Using script: '${script.relativeTo(arkAnalyzerDir)}'") + println("Using script: '$script'") val resources = projectDir.resolve("src/test/resources") val inputDir = resources.resolve("samples/source") @@ -87,8 +161,11 @@ tasks.register("generateTestResources") { } if (!ok) { - println("Timeout!") process.destroy() + throw GradleException("Test resource generation timed out") + } + if (process.exitValue() != 0) { + throw GradleException("Test resource generation failed with exit code ${process.exitValue()}") } println("Done generating test resources in %.1fs".format((System.currentTimeMillis() - startTime) / 1000.0)) diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt index eb2402dea..0e6e97ca5 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/LoadEtsFile.kt @@ -39,41 +39,113 @@ import kotlin.time.Duration.Companion.seconds private val logger = KotlinLogging.logger {} +/** + * Which frontend generates the EtsIR JSON. + * + * - [TS_FRONTEND] — the native TypeScript frontend bundled with jacodb + * (`jacodb-ets/ts-frontend`, build with `npm run build`). Default. + * - [ARKANALYZER] — the external ArkAnalyzer `serializeArkIR` script + * (requires the `ARKANALYZER_DIR` environment variable). + * + * The default can be overridden with the `ETS_IR_PROVIDER` environment variable + * (`ts-frontend` or `arkanalyzer`). + */ +enum class EtsIrProvider { + TS_FRONTEND, + ARKANALYZER; + + companion object { + private const val ENV_VAR_ETS_IR_PROVIDER = "ETS_IR_PROVIDER" + + fun default(): EtsIrProvider = + when (System.getenv(ENV_VAR_ETS_IR_PROVIDER)?.trim()?.lowercase()) { + null, "", "ts-frontend", "ts_frontend", "tsfrontend" -> TS_FRONTEND + "arkanalyzer", "ark-analyzer", "ark_analyzer" -> ARKANALYZER + else -> { + logger.warn { "Unknown $ENV_VAR_ETS_IR_PROVIDER value, falling back to TS_FRONTEND" } + TS_FRONTEND + } + } + } +} + +// ArkAnalyzer provider configuration: private const val ENV_VAR_ARK_ANALYZER_DIR = "ARKANALYZER_DIR" private const val DEFAULT_ARK_ANALYZER_DIR = "arkanalyzer" private const val ENV_VAR_SERIALIZE_SCRIPT_PATH = "SERIALIZE_SCRIPT_PATH" private const val DEFAULT_SERIALIZE_SCRIPT_PATH = "out/src/save/serializeArkIR.js" +// TS frontend provider configuration: +private const val ENV_VAR_ETS_FRONTEND_DIR = "ETS_FRONTEND_DIR" +private const val PROPERTY_ETS_FRONTEND_DIR = "ets.frontend.dir" +private const val DEFAULT_ETS_FRONTEND_DIR = "ts-frontend" + +private const val ENV_VAR_ETS_FRONTEND_SCRIPT = "ETS_FRONTEND_SCRIPT" +private const val DEFAULT_ETS_FRONTEND_SCRIPT = "dist/index.js" + private const val ENV_VAR_NODE_EXECUTABLE = "NODE_EXECUTABLE" private const val DEFAULT_NODE_EXECUTABLE = "node" +/** Location of the serializer script for the chosen [provider]. */ +fun etsIrSerializerScript(provider: EtsIrProvider = EtsIrProvider.default()): Path = + when (provider) { + EtsIrProvider.ARKANALYZER -> { + val arkAnalyzerDir = Path(System.getenv(ENV_VAR_ARK_ANALYZER_DIR) ?: DEFAULT_ARK_ANALYZER_DIR) + if (!arkAnalyzerDir.exists()) { + throw FileNotFoundException( + "ArkAnalyzer directory does not exist: '${arkAnalyzerDir.absolute()}'. " + + "Did you forget to set the '$ENV_VAR_ARK_ANALYZER_DIR' environment variable? " + + "Current value is '${System.getenv(ENV_VAR_ARK_ANALYZER_DIR)}', " + + "current dir is '${Path("").toAbsolutePath()}'." + ) + } + val scriptPath = System.getenv(ENV_VAR_SERIALIZE_SCRIPT_PATH) ?: DEFAULT_SERIALIZE_SCRIPT_PATH + val script = arkAnalyzerDir.resolve(scriptPath) + if (!script.exists()) { + throw FileNotFoundException( + "Script file not found: '$script'. " + + "Did you forget to execute 'npm run build' in the arkanalyzer project?" + ) + } + script + } + + EtsIrProvider.TS_FRONTEND -> { + val frontendDir = Path( + System.getenv(ENV_VAR_ETS_FRONTEND_DIR) + ?: System.getProperty(PROPERTY_ETS_FRONTEND_DIR) + ?: DEFAULT_ETS_FRONTEND_DIR + ) + if (!frontendDir.exists()) { + throw FileNotFoundException( + "ts-frontend directory does not exist: '${frontendDir.absolute()}'. " + + "Set the '$ENV_VAR_ETS_FRONTEND_DIR' environment variable " + + "(or the '$PROPERTY_ETS_FRONTEND_DIR' system property) " + + "to the location of jacodb-ets/ts-frontend. " + + "Current dir is '${Path("").toAbsolutePath()}'." + ) + } + val script = frontendDir.resolve(System.getenv(ENV_VAR_ETS_FRONTEND_SCRIPT) ?: DEFAULT_ETS_FRONTEND_SCRIPT) + if (!script.exists()) { + throw FileNotFoundException( + "Script file not found: '$script'. " + + "Did you forget to execute 'npm run build' in ts-frontend?" + ) + } + script + } + } + fun generateEtsIR( projectPath: Path, isProject: Boolean = false, loadEntrypoints: Boolean = true, useArkAnalyzerTypeInference: Int? = null, timeout: Duration? = 10.seconds, + provider: EtsIrProvider = EtsIrProvider.default(), ): Path { - val arkAnalyzerDir = Path(System.getenv(ENV_VAR_ARK_ANALYZER_DIR) ?: DEFAULT_ARK_ANALYZER_DIR) - if (!arkAnalyzerDir.exists()) { - throw FileNotFoundException( - "ArkAnalyzer directory does not exist: '${arkAnalyzerDir.absolute()}'. " + - "Did you forget to set the '$ENV_VAR_ARK_ANALYZER_DIR' environment variable? " + - "Current value is '${System.getenv(ENV_VAR_ARK_ANALYZER_DIR)}', " + - "current dir is '${Path("").toAbsolutePath()}'." - ) - } - - val scriptPath = System.getenv(ENV_VAR_SERIALIZE_SCRIPT_PATH) ?: DEFAULT_SERIALIZE_SCRIPT_PATH - val script = arkAnalyzerDir.resolve(scriptPath) - if (!script.exists()) { - throw FileNotFoundException( - "Script file not found: '$script'. " + - "Did you forget to execute 'npm run build' in the arkanalyzer project?" - ) - } - + val script = etsIrSerializerScript(provider) val node = System.getenv(ENV_VAR_NODE_EXECUTABLE) ?: DEFAULT_NODE_EXECUTABLE val output = if (isProject) { createTempDirectory(projectPath.nameWithoutExtension) @@ -81,23 +153,26 @@ fun generateEtsIR( createTempFile(projectPath.nameWithoutExtension, suffix = ".json") } - val cmd = listOfNotNull( - node, - script.pathString, - if (isProject) "-p" else null, - if (loadEntrypoints) "-e" else null, - useArkAnalyzerTypeInference?.let { "-t $it" }, - projectPath.pathString, - output.pathString, - "-v", - ) + val cmd: List = buildList { + add(node) + add(script.pathString) + if (isProject) add("-p") + if (loadEntrypoints) add("-e") + if (useArkAnalyzerTypeInference != null) { + add("-t") + add(useArkAnalyzerTypeInference.toString()) + } + add(projectPath.pathString) + add(output.pathString) + add("-v") + } val res = ProcessUtil.run(cmd, timeout = timeout) if (res.exitCode != 0) { - logger.error { "ARKANALYZER failed with exit code ${res.exitCode}" } + logger.error { "EtsIR generation ($provider) failed with exit code ${res.exitCode}" } logger.error { "STDOUT:\n${res.stdout}" } logger.error { "STDERR:\n${res.stderr}" } } else if (res.isTimeout) { - logger.error { "ARKANALYZER timed out after $timeout" } + logger.error { "EtsIR generation ($provider) timed out after $timeout" } logger.error { "STDOUT:\n${res.stdout}" } logger.error { "STDERR:\n${res.stderr}" } } @@ -114,11 +189,13 @@ fun generateSdkIR(sdkPath: Path): Path = generateEtsIR( fun loadEtsFileAutoConvert( path: Path, useArkAnalyzerTypeInference: Int? = 1, + provider: EtsIrProvider = EtsIrProvider.default(), ): EtsFile { val irFilePath = generateEtsIR( path, isProject = false, useArkAnalyzerTypeInference = useArkAnalyzerTypeInference, + provider = provider, ) irFilePath.inputStream().use { stream -> val etsFileDto = EtsFileDto.loadFromJson(stream) @@ -131,12 +208,14 @@ fun loadEtsProjectAutoConvert( sdkIRPath: Path? = null, loadEntrypoints: Boolean = false, useArkAnalyzerTypeInference: Int? = 1, + provider: EtsIrProvider = EtsIrProvider.default(), ): EtsScene { val irFolderPath = generateEtsIR( projectPath, isProject = true, loadEntrypoints = loadEntrypoints, useArkAnalyzerTypeInference = useArkAnalyzerTypeInference, + provider = provider, ) return loadEtsProjectFromIR(irFolderPath, sdkIRPath) diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt new file mode 100644 index 000000000..eb041f579 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -0,0 +1,343 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCaughtExceptionRef +import org.jacodb.ets.model.EtsScene +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.etsIrSerializerScript +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import kotlin.io.path.createTempDirectory +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Tests for the native TypeScript frontend (`jacodb-ets/ts-frontend`). + * + * These tests spawn `node ts-frontend/dist/index.js` on TS/JS sources and verify + * that the produced JSON deserializes into [EtsFileDto] and converts to a valid model. + * + * Tests are skipped if the frontend is not built (`npm run build` in `ts-frontend`) + * or `node` is not available. + */ +class EtsTsFrontendTest { + + companion object { + private fun tsFrontendAvailable(): Boolean = + try { + etsIrSerializerScript(EtsIrProvider.TS_FRONTEND).exists() + } catch (_: Exception) { + false + } + + /** Run the PRODUCTION integration path: generateEtsIR + EtsFileDto.loadFromJson. */ + private fun runFrontend(source: String, fileName: String = "test.ts"): EtsFileDto { + assumeTrue(tsFrontendAvailable(), "ts-frontend is not built (run 'npm run build' in ts-frontend)") + + val dir = createTempDirectory("ts-frontend-test") + val inputPath = dir.resolve(fileName) + inputPath.writeText(source) + + val outputPath = generateEtsIR( + inputPath, + isProject = false, + timeout = 60.seconds, + provider = EtsIrProvider.TS_FRONTEND, + ) + assertTrue(outputPath.exists(), "ts-frontend did not produce output: $outputPath") + + return EtsFileDto.loadFromJson(outputPath.readText()) + } + } + + @Test + fun `straight-line program lowers, converts and linearizes`() { + val etsFileDto = runFrontend( + """ + function add(a: number, b: number): number { + return a + b; + } + class C {} + let x = add(1, 2); + let arr = [1, 2, 3]; + arr[0] = x + 1; + let s = "value: " + x; + console.log(s); + """.trimIndent() + ) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + val defaultClass = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + + val addMethod = defaultClass.methods.single { it.name == "add" } + assertEquals(2, addMethod.parameters.size) + assertTrue(addMethod.cfg.stmts.isNotEmpty(), "'add' must have a non-empty body") + + val defaultMethod = defaultClass.methods.single { it.name == DEFAULT_ARK_METHOD_NAME } + assertTrue(defaultMethod.cfg.stmts.size >= 8, "top-level code must be lowered into the default method") + assertTrue(defaultMethod.locals.any { it.name == "x" }, "local 'x' must be declared") + assertTrue(defaultMethod.locals.any { it.name == "arr" }, "local 'arr' must be declared") + } + + @Test + fun `control flow program converts and linearizes`() { + val etsFileDto = runFrontend( + """ + function classify(n: number): string { + if (n < 0) { + return "negative"; + } + let result = ""; + for (let i = 0; i < n; i++) { + if (i % 2 === 0) { + continue; + } + result += i; + } + switch (n) { + case 0: return "zero"; + case 1: return "one"; + default: break; + } + let arr = [1, 2, 3]; + for (const v of arr) { + result = n > 5 ? result + v : result; + } + while (n > 0) { + n--; + } + return result; + } + """.trimIndent() + ) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + val defaultClass = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + val method = defaultClass.methods.single { it.name == "classify" } + + // Linearization walks the whole block CFG — this validates successor structure. + val stmts = method.cfg.stmts + assertTrue(stmts.size > 20, "expected a rich linearized body, got ${stmts.size} stmts") + assertTrue(method.cfg.blocks.size > 10, "expected multiple basic blocks, got ${method.cfg.blocks.size}") + } + + @Test + fun `classes, enums and namespaces convert to the model`() { + val etsFileDto = runFrontend( + """ + export interface Shape { + area(): number; + } + + export class Circle implements Shape { + static count: number = 0; + radius: number = 1; + + constructor(radius: number) { + this.radius = radius; + Circle.count++; + } + + area(): number { + return 3.14 * this.radius * this.radius; + } + } + + enum Color { Red, Green = 5, Blue } + + namespace Geometry { + export class Point { + x: number = 0; + } + } + + let c = new Circle(2); + console.log(c.area(), Color.Green); + """.trimIndent() + ) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + + val circle = scene.projectClasses.single { it.name == "Circle" } + assertTrue(circle.fields.any { it.name == "radius" }) + assertTrue(circle.fields.any { it.name == "count" }) + + // ArkAnalyzer conventions: ctor + %instInit + %statInit present and linearizable. + val ctor = circle.methods.single { it.name == "constructor" } + assertTrue(ctor.cfg.stmts.isNotEmpty()) + val instInit = circle.methods.single { it.name == "%instInit" } + assertTrue(instInit.cfg.stmts.isNotEmpty()) + val statInit = circle.methods.single { it.name == "%statInit" } + assertTrue(statInit.cfg.stmts.isNotEmpty()) + + val shape = scene.projectClasses.single { it.name == "Shape" } + assertTrue(shape.methods.single { it.name == "area" }.cfg.stmts.isEmpty(), "interface methods have no body") + + val color = scene.projectClasses.single { it.name == "Color" } + assertTrue(color.fields.any { it.name == "Green" }) + + val point = etsFile.namespaces.single().classes.single { it.name == "Point" } + assertTrue(point.methods.any { it.name == "%instInit" }) + } + + @Test + fun `try-catch and imports-exports convert to the model`() { + val etsFileDto = runFrontend( + """ + import { helper as h } from "./helper"; + import * as fs from "fs"; + + export function risky(x: number): number { + try { + if (x < 0) { + throw new Error("negative"); + } + return x; + } catch (e) { + console.log(e); + return -1; + } finally { + console.log("done"); + } + } + + export { risky as saferRisky }; + """.trimIndent() + ) + + assertEquals(2, etsFileDto.importInfos.size) + assertEquals("h", etsFileDto.importInfos[0].importName) + assertEquals("helper", etsFileDto.importInfos[0].nameBeforeAs) + assertEquals("NamespaceImport", etsFileDto.importInfos[1].importType) + assertTrue(etsFileDto.exportInfos.any { it.exportName == "risky" }) + assertTrue(etsFileDto.exportInfos.any { it.exportName == "saferRisky" && it.nameBeforeAs == "risky" }) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + val method = scene.projectClasses + .single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "risky" } + + // The catch handler must survive lowering (ArkAnalyzer used to drop it). + val stmts = method.cfg.stmts + assertTrue( + stmts.filterIsInstance().any { it.rhv is EtsCaughtExceptionRef }, + "expected a caught-exception binding in:\n${stmts.joinToString("\n")}" + ) + } + + @Test + fun `closures, destructuring and object literals convert to the model`() { + val etsFileDto = runFrontend( + """ + const config = { host: "localhost", port: 8080, describe(): string { return this.host; } }; + const { host, port: p = 80 } = config; + + const handlers = [1, 2, 3].map((x: number) => x * 2); + + function safeFirst(arr?: number[]): number | undefined { + return arr?.[0]; + } + + function* naturals(): Generator { + let i = 0; + while (true) { + yield i++; + } + } + """.trimIndent() + ) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + val defaultClass = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + + // Closure lifted into an anonymous method. + val anonymousMethod = defaultClass.methods.single { it.name.startsWith("%AM0") } + assertTrue(anonymousMethod.cfg.stmts.isNotEmpty(), "closure body must be lowered") + + // Object literal became an anonymous class with fields and a method. + val anonymousClass = scene.projectClasses.single { it.name.startsWith("%AC0") } + assertTrue(anonymousClass.fields.any { it.name == "host" }) + assertTrue(anonymousClass.methods.any { it.name == "describe" }) + + // Destructured locals exist in the default method. + val defaultMethod = defaultClass.methods.single { it.name == DEFAULT_ARK_METHOD_NAME } + assertTrue(defaultMethod.locals.any { it.name == "host" }) + assertTrue(defaultMethod.locals.any { it.name == "p" }) + + // Optional chaining and generators linearize fine. + assertTrue(defaultClass.methods.single { it.name == "safeFirst" }.cfg.stmts.isNotEmpty()) + assertTrue(defaultClass.methods.single { it.name == "naturals" }.cfg.stmts.isNotEmpty()) + } + + @Test + fun `arkanalyzer provider stays selectable`() { + val arkAnalyzerAvailable = try { + etsIrSerializerScript(EtsIrProvider.ARKANALYZER).exists() + } catch (_: Exception) { + false + } + assumeTrue(arkAnalyzerAvailable, "ArkAnalyzer is not available (set ARKANALYZER_DIR to enable)") + + val dir = createTempDirectory("arkanalyzer-provider-test") + val inputPath = dir.resolve("simple.ts") + inputPath.writeText( + """ + function twice(x: number): number { + return x * 2; + } + let y = twice(21); + """.trimIndent() + ) + + val etsFile = loadEtsFileAutoConvert(inputPath, provider = EtsIrProvider.ARKANALYZER) + val scene = EtsScene(listOf(etsFile)) + val defaultClass = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + assertTrue(defaultClass.methods.any { it.name == "twice" }) + } + + @Test + fun `smoke - produced JSON deserializes and converts to a valid EtsFile`() { + val etsFileDto = runFrontend("") + + val defaultClass = etsFileDto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + val defaultMethod = defaultClass.methods.single { it.signature.name == DEFAULT_ARK_METHOD_NAME } + checkNotNull(defaultMethod.body) + + val etsFile = etsFileDto.toEtsFile() + val scene = EtsScene(listOf(etsFile)) + val clazz = scene.projectClasses.single { it.name == DEFAULT_ARK_CLASS_NAME } + val method = clazz.methods.single { it.name == DEFAULT_ARK_METHOD_NAME } + assertTrue(method.cfg.stmts.isNotEmpty(), "default method must have a non-empty body") + } +} diff --git a/jacodb-ets/ts-frontend/.gitignore b/jacodb-ets/ts-frontend/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/jacodb-ets/ts-frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md new file mode 100644 index 000000000..03303d018 --- /dev/null +++ b/jacodb-ets/ts-frontend/README.md @@ -0,0 +1,264 @@ +# ts-frontend — native TypeScript frontend for jacodb-ets + +`ts-frontend` parses TypeScript/JavaScript with the real TypeScript compiler +and lowers it directly into **EtsIR** — three-address code organized into a +basic-block CFG — serialized as JSON that the Kotlin side of `jacodb-ets` +deserializes with `EtsFileDto.loadFromJson` and converts into `EtsFile` / +`EtsScene` model objects. + +It is a drop-in replacement for the external [ArkAnalyzer](../ARKANALYZER.md) +dependency: wire-compatible output, same CLI shape, no external checkout +required. Both frontends remain supported and selectable +(see [Choosing a provider](#choosing-a-provider)). + +``` + .ts / .js sources JVM (jacodb-ets) + │ ▲ + ▼ │ EtsFileDto.loadFromJson + ts.createProgram ──► lowering ──► EtsIR JSON ─┘ + Convert.kt + (parse + types) (3-addr code, ──► EtsFile / EtsScene + block CFG) +``` + +## Prerequisites + +- **Node.js ≥ 18** and **npm** on `PATH` (that is the only requirement — + the frontend has a single runtime dependency, the `typescript` package). + +## Quick start + +### 1. Build + +```shell +cd jacodb-ets/ts-frontend +npm ci +npm run build # compiles src/ -> dist/ +``` + +Or let Gradle do it (also happens automatically before `:jacodb-ets:test`): + +```shell +./gradlew :jacodb-ets:buildTsFrontend +``` + +### 2. Run on a single file + +```shell +node dist/index.js path/to/app.ts out/app.ts.json +``` + +`out/app.ts.json` now contains a complete `EtsFileDto`: the `%dflt` class with +lowered top-level code, all declared classes/interfaces/enums/namespaces with +method bodies as basic-block CFGs, import/export infos. + +### 3. Run on a directory (project mode) + +```shell +node dist/index.js -p path/to/project out/ir/ +# or, equivalently for a flat directory of samples: +node dist/index.js --multi path/to/sources out/ir/ +``` + +One shared compiler program is built over all sources (so cross-file +references resolve to proper signatures), and a `.json` is +written per source file, mirroring the input tree under the output directory. + +## Using from Kotlin + +The main entry points live in `org.jacodb.ets.utils` (`LoadEtsFile.kt`). +With the frontend built, no configuration is needed when running from the +`jacodb-ets` module directory (Gradle sets `ets.frontend.dir` for tests +automatically): + +```kotlin +import org.jacodb.ets.utils.loadEtsFileAutoConvert +import org.jacodb.ets.utils.loadEtsProjectAutoConvert +import org.jacodb.ets.model.EtsScene + +// Single file -> EtsFile +val etsFile = loadEtsFileAutoConvert(Path("src/app.ts")) +val scene = EtsScene(listOf(etsFile)) + +// Whole project directory -> EtsScene +val projectScene = loadEtsProjectAutoConvert(Path("path/to/project")) + +// Work with the IR +for (cls in scene.projectClasses) { + for (method in cls.methods) { + println("${cls.name}::${method.name}") + method.cfg.stmts.forEach(::println) // linearized three-address code + } +} +``` + +Lower-level pieces, if you need the raw DTO or the JSON file: + +```kotlin +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR + +val jsonPath = generateEtsIR(Path("src/app.ts"), provider = EtsIrProvider.TS_FRONTEND) +val dto = EtsFileDto.loadFromJson(jsonPath.readText()) +val file = dto.toEtsFile() +``` + +### Locating the frontend + +When your working directory is not `jacodb-ets`, tell the loader where the +frontend lives (any one of these): + +| Mechanism | Example | +|---|---| +| env var | `ETS_FRONTEND_DIR=/path/to/jacodb/jacodb-ets/ts-frontend` | +| system property | `-Dets.frontend.dir=/path/to/jacodb/jacodb-ets/ts-frontend` | +| default | `ts-frontend` relative to the current working directory | + +Additional knobs: `ETS_FRONTEND_SCRIPT` (default `dist/index.js`), +`NODE_EXECUTABLE` (default `node`). + +### Choosing a provider + +`EtsIrProvider` selects which frontend generates the IR. The default is +`TS_FRONTEND`; the legacy ArkAnalyzer path is fully preserved: + +```kotlin +// explicitly per call: +loadEtsFileAutoConvert(path, provider = EtsIrProvider.ARKANALYZER) +loadEtsProjectAutoConvert(path, provider = EtsIrProvider.TS_FRONTEND) +``` + +```shell +# or globally via the environment: +export ETS_IR_PROVIDER=arkanalyzer # requires ARKANALYZER_DIR (see ../ARKANALYZER.md) +export ETS_IR_PROVIDER=ts-frontend # the default +``` + +The same switch drives the Gradle task that (re)generates test resources: + +```shell +./gradlew :jacodb-ets:generateTestResources # ts-frontend +ETS_IR_PROVIDER=arkanalyzer ./gradlew :jacodb-ets:generateTestResources +``` + +## CLI reference + +``` +node dist/index.js [options] + + source file, or a directory with -p/--multi + output JSON file, or a directory with -p/--multi + + -p, --project treat as a project directory + -m, --multi same as -p (kept for serializeArkIR compatibility) + -e accepted for compatibility (entrypoints), no-op + -t N accepted for compatibility (type inference level), no-op — + checker-based type inference is always on + -v, --verbose log progress and degradation diagnostics to stderr +``` + +Exit codes: `0` — success, `1` — I/O or invariant failure, `2` — bad usage. +Stdout is never used for data; diagnostics go to stderr. + +**Robustness guarantees.** The frontend never fails on exotic-but-parseable +input: any construct it cannot model degrades to a `RawStmt`/`RawValue` +fallback (`"_": "Unsupported*"`, deserialized by Kotlin into +`RawStmtDto`/`RawValueDto`) plus a `-v` diagnostic. Every produced file is +checked against the Kotlin-side structural invariants (`src/validate.ts`) +before being written — invalid IR is never emitted. + +## Supported language features + +Fully lowered to IR: literals and template strings; all `Ops.kt` operators; +variables and compound assignments; free/method/static/pointer calls; `new` + +constructor calls; arrays and element access; fields (instance and static, +including `this.f` in static methods); `if`/ternary; `while`/`do`/`for`/ +`for-of` (iterator protocol)/`for-in`; `switch` with fallthrough; +`break`/`continue` (incl. labeled); `return`/`throw`; `try/catch/finally` +(catch handlers stay reachable and bind `CaughtExceptionRef`); classes with +`%instInit`/`%statInit`/constructors, inheritance, parameter properties, +static blocks; interfaces (bodyless methods); enums (numeric auto-increment +and string); namespaces (nested); closures (`%AM` methods); object literals +(`%AC` classes); object/array destructuring with defaults and nesting; +optional chaining; `super()`/`super.m()`; `typeof`/`await`/`yield`/`delete`/ +`void`/`instanceof`/`as`-casts; imports/exports of every flavor. + +Degrades to `Raw*` fallbacks (for now): regex literals, tagged templates, +spread arguments/elements, rest patterns in destructuring, accessors/spread +in object literals, variable capture via `LexicalEnvType` (captured outer +variables become same-named locals instead). + +## IR conventions + +The output follows the ArkAnalyzer conventions expected by `Convert.kt` +(see `../src/main/kotlin/org/jacodb/ets/dto/`): + +- every file gets a `%dflt` class; loose top-level statements form its `%dflt` + method; free functions become its methods; +- method prologue: `param_i := ParameterRef(i)` per parameter, then + `this := ThisRef`; +- classes get synthesized `%instInit`/`%statInit` initializers; constructors + call `%instInit` and `return this` (a default constructor is synthesized + when absent); +- basic blocks are listed with `id == index`, entry block is `0`; a block + ending with `IfStmt` has successors `[falseBranch, trueBranch]` (the Kotlin + converter reverses every successor list), any other block has ≤ 1 successor; +- call instances and field-ref instances are always `Local`s; expression + operands, call arguments and array indices are immediates (`Local` / + `Constant`), hoisted into `%0, %1, …` temps (never `_tmp*` — that prefix is + reserved by the Kotlin converter); +- truthiness in conditions is normalized as `v != false` (booleans) / + `v != 0` (everything else). + +## Development + +```shell +npm run typecheck # tsc --noEmit +npm test # vitest: unit specs + corpus +./gradlew :jacodb-ets:testTsFrontend # the same via Gradle +``` + +Layout: + +``` +src/ + dto/ TS mirrors of the Kotlin DTOs (wire format, discriminator "_") + types/convert.ts ts.TypeNode / checker.Type -> TypeDto + lowering/ + fileBuilder.ts file/namespace scopes, %dflt classes, imports/exports + classBuilder.ts classes (ctor + %instInit/%statInit), interfaces, enums + methodBuilder.ts locals table, %N temps, prologue emission + cfg.ts label/terminator CFG builder; DTO successor conventions + exprLowering.ts expressions -> three-address values + stmtLowering.ts control flow, try/catch, destructuring + diagnostics.ts Raw* fallbacks + warnings + serialize.ts DTO -> JSON (undefined-stripping) + validate.ts invariant checker (runs before every write) +test/ + *.spec.ts per-feature specs (types, lowering, cfg, classes, try, + imports, closures, serialize, validate, cli) + corpus.spec.ts realistic TS/JS fixtures must lower with zero invariant + violations and zero raw fallbacks + fixtures/ the corpus itself (plain TS/JS programs) +``` + +When adding support for a new construct: + +1. check the expected JSON shape against the ArkAnalyzer ground truth in + `../src/test/resources/repos//etsir/` (schema compatibility is + the contract, not byte identity); +2. add the lowering + a per-feature spec, extend `validate.ts` if the + construct introduces a new invariant; +3. run the Kotlin round-trip: `./gradlew :jacodb-ets:test` (the + `EtsTsFrontendTest` suite drives the production `generateEtsIR` path). + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `Script file not found: '.../dist/index.js'` | run `npm run build` (or `./gradlew :jacodb-ets:buildTsFrontend`) | +| `ts-frontend directory does not exist` | set `ETS_FRONTEND_DIR` / `-Dets.frontend.dir` (see above) | +| Kotlin tests are skipped with "ts-frontend is not built" | same as above — the tests skip instead of failing when the frontend is missing | +| `npm is not available; skipping ts-frontend build` in Gradle | install Node.js ≥ 18 and ensure `npm` is on `PATH` | +| Constructs missing from the IR | run the CLI with `-v` — every degraded construct is reported to stderr | diff --git a/jacodb-ets/ts-frontend/package-lock.json b/jacodb-ets/ts-frontend/package-lock.json new file mode 100644 index 000000000..5643e7c1f --- /dev/null +++ b/jacodb-ets/ts-frontend/package-lock.json @@ -0,0 +1,1452 @@ +{ + "name": "@jacodb/ets-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@jacodb/ets-frontend", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "typescript": "^5.4.5" + }, + "devDependencies": { + "@types/node": "^18.19.0", + "vitest": "^2.1.9" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.127", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/jacodb-ets/ts-frontend/package.json b/jacodb-ets/ts-frontend/package.json new file mode 100644 index 000000000..598bd32dc --- /dev/null +++ b/jacodb-ets/ts-frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "@jacodb/ets-frontend", + "version": "0.1.0", + "private": true, + "description": "Native TypeScript frontend producing EtsIR JSON for jacodb-ets", + "license": "Apache-2.0", + "main": "dist/index.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" + }, + "dependencies": { + "typescript": "^5.4.5" + }, + "devDependencies": { + "@types/node": "^18.19.0", + "vitest": "^2.1.9" + } +} diff --git a/jacodb-ets/ts-frontend/src/dto/constants.ts b/jacodb-ets/ts-frontend/src/dto/constants.ts new file mode 100644 index 000000000..8e06052df --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/constants.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Name conventions shared with the Kotlin side. + * Mirrors `org.jacodb.ets.utils.Constants` (jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Constants.kt). + */ + +export const CONSTRUCTOR_NAME = "constructor"; +export const DEFAULT_ARK_CLASS_NAME = "%dflt"; +export const DEFAULT_ARK_METHOD_NAME = "%dflt"; +export const UNKNOWN_PROJECT_NAME = "%unk"; +export const UNKNOWN_FILE_NAME = "%unk"; +export const UNKNOWN_NAMESPACE_NAME = "%unk"; +export const UNKNOWN_CLASS_NAME = ""; +export const UNKNOWN_FIELD_NAME = ""; +export const UNKNOWN_METHOD_NAME = ""; +export const INSTANCE_INIT_METHOD_NAME = "%instInit"; +export const STATIC_INIT_METHOD_NAME = "%statInit"; +export const ANONYMOUS_CLASS_PREFIX = "%AC"; +export const ANONYMOUS_METHOD_PREFIX = "%AM"; +export const TEMP_LOCAL_PREFIX = "%"; + +/** + * Local names with this prefix are reserved by the Kotlin `Convert.kt` + * (its own three-address lowering introduces `_tmpN` locals), + * so the frontend must never emit them. + */ +export const FORBIDDEN_LOCAL_PREFIX = "_tmp"; + +/** + * Modifier bitmask. Mirrors `org.jacodb.ets.model.EtsModifiers` + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Modifiers.kt). + */ +export const Modifier = { + PRIVATE: 1 << 0, + PROTECTED: 1 << 1, + PUBLIC: 1 << 2, + EXPORT: 1 << 3, + STATIC: 1 << 4, + ABSTRACT: 1 << 5, + ASYNC: 1 << 6, + CONST: 1 << 7, + ACCESSOR: 1 << 8, + DEFAULT: 1 << 9, + IN: 1 << 10, + READONLY: 1 << 11, + OUT: 1 << 12, + OVERRIDE: 1 << 13, + DECLARE: 1 << 14, +} as const; + +export type ModifierName = keyof typeof Modifier; + +/** + * Class category. Mirrors `org.jacodb.ets.model.EtsClassCategory` + * (values are the serialized ints expected by `ClassDto.category`). + */ +export const ClassCategory = { + CLASS: 0, + STRUCT: 1, + INTERFACE: 2, + ENUM: 3, + TYPE_LITERAL: 4, + OBJECT: 5, +} as const; + +export type ClassCategoryValue = (typeof ClassCategory)[keyof typeof ClassCategory]; + +/** + * Export type ints expected by `ExportInfoDto.exportType` (see Kotlin `Convert.kt`). + */ +export const ExportType = { + NAMESPACE: 0, + CLASS: 1, + METHOD: 2, + LOCAL: 3, + TYPE: 4, + UNKNOWN: 9, +} as const; + +export type ExportTypeValue = (typeof ExportType)[keyof typeof ExportType]; + +/** + * Import type strings expected by `ImportInfoDto.importType` (see Kotlin `Convert.kt`): + * "Identifier" (default import), "NamedImports", "NamespaceImport", "" (side-effect import). + */ +export type ImportType = "Identifier" | "NamedImports" | "NamespaceImport" | ""; diff --git a/jacodb-ets/ts-frontend/src/dto/model.ts b/jacodb-ets/ts-frontend/src/dto/model.ts new file mode 100644 index 000000000..4b5df394d --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/model.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Top-level DTOs. Mirror `org.jacodb.ets.dto` model + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Model.kt and Cfg.kt). + */ + +import { ClassCategoryValue, ExportTypeValue, ImportType } from "./constants"; +import { + ClassSignatureDto, + FieldSignatureDto, + MethodParameterDto, + MethodSignatureDto, + NamespaceSignatureDto, + FileSignatureDto, +} from "./signatures"; +import { StmtDto } from "./stmts"; +import { TypeDto } from "./types"; + +export interface EtsFileDto { + signature: FileSignatureDto; + namespaces: NamespaceDto[]; + classes: ClassDto[]; + importInfos: ImportInfoDto[]; + exportInfos: ExportInfoDto[]; +} + +export interface NamespaceDto { + signature: NamespaceSignatureDto; + classes?: ClassDto[]; // Kotlin default: empty list + namespaces?: NamespaceDto[]; // Kotlin default: empty list +} + +export interface ClassDto { + signature: ClassSignatureDto; + modifiers: number; + decorators: DecoratorDto[]; + category?: ClassCategoryValue; // Kotlin default: 0 (CLASS) + typeParameters?: TypeDto[]; // Kotlin default: null + superClassName: string | null; // required by Kotlin (nullable, no default); "" means "no superclass" + implementedInterfaceNames: string[]; + fields: FieldDto[]; + methods: MethodDto[]; +} + +export interface FieldDto { + signature: FieldSignatureDto; + modifiers: number; + decorators: DecoratorDto[]; + questionToken: boolean; // '?' + exclamationToken: boolean; // '!' +} + +export interface MethodDto { + signature: MethodSignatureDto; + modifiers: number; + decorators: DecoratorDto[]; + typeParameters?: TypeDto[]; // Kotlin default: null + body?: BodyDto; // omitted for bodyless methods (interfaces, ambient declarations) +} + +export interface BodyDto { + locals: LocalDeclDto[]; + cfg: CfgDto; +} + +/** + * An entry of `body.locals`. Same shape as a Local value, but deserialized + * NON-polymorphically on the Kotlin side (the list is typed `List`), + * so a "_" discriminator would be rejected as an unknown key by the strict Json. + * Emit ONLY `name` and `type` here (matches ArkAnalyzer output). + */ +export interface LocalDeclDto { + name: string; + type: TypeDto; +} + +export interface CfgDto { + blocks: BasicBlockDto[]; +} + +export interface BasicBlockDto { + id: number; // must equal its index in `blocks`; entry block has id 0 + successors: number[]; // for a block ending with IfStmt: [falseBranch, trueBranch] + predecessors?: number[]; // Kotlin default: null (unused by Convert); we emit it for parity with ArkAnalyzer + stmts: StmtDto[]; +} + +export interface ImportInfoDto { + importName: string; + importType: ImportType; + importFrom: string; + nameBeforeAs?: string; // Kotlin default: null + modifiers: number; +} + +export interface ExportInfoDto { + exportName: string; + exportType: ExportTypeValue; + exportFrom?: string; // Kotlin default: null + nameBeforeAs?: string; // Kotlin default: null + modifiers: number; +} + +export interface DecoratorDto { + kind: string; +} diff --git a/jacodb-ets/ts-frontend/src/dto/ops.ts b/jacodb-ets/ts-frontend/src/dto/ops.ts new file mode 100644 index 000000000..2c416d31e --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/ops.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Operator strings accepted by the Kotlin side. + * Mirrors `org.jacodb.ets.dto.Ops` (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Ops.kt). + * Any other operator string makes Kotlin `Convert.kt` fail hard. + */ + +export const UNARY_OPS = ["+", "-", "!", "~", "++", "--"] as const; +export type UnaryOp = (typeof UNARY_OPS)[number]; + +export const BINARY_OPS = [ + "+", "-", "*", "/", "%", "**", + "<<", ">>", ">>>", + "&", "|", "^", + "&&", "||", "??", +] as const; +export type BinaryOp = (typeof BINARY_OPS)[number]; + +export const RELATION_OPS = ["==", "!=", "===", "!==", "<", "<=", ">", ">=", "in"] as const; +export type RelationOp = (typeof RELATION_OPS)[number]; + +export function isUnaryOp(op: string): op is UnaryOp { + return (UNARY_OPS as readonly string[]).includes(op); +} + +export function isBinaryOp(op: string): op is BinaryOp { + return (BINARY_OPS as readonly string[]).includes(op); +} + +export function isRelationOp(op: string): op is RelationOp { + return (RELATION_OPS as readonly string[]).includes(op); +} diff --git a/jacodb-ets/ts-frontend/src/dto/signatures.ts b/jacodb-ets/ts-frontend/src/dto/signatures.ts new file mode 100644 index 000000000..010187649 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/signatures.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Signature DTOs. Mirror `org.jacodb.ets.dto` signatures + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Signatures.kt). + * + * NOTE: signatures are serialized NON-polymorphically on the Kotlin side, + * so they carry no "_" discriminator. + */ + +import { TypeDto } from "./types"; + +export interface FileSignatureDto { + projectName: string; + fileName: string; +} + +export interface NamespaceSignatureDto { + name: string; + declaringFile: FileSignatureDto; + declaringNamespace?: NamespaceSignatureDto; +} + +export interface ClassSignatureDto { + name: string; + declaringFile: FileSignatureDto; + declaringNamespace?: NamespaceSignatureDto; +} + +export interface FieldSignatureDto { + declaringClass: ClassSignatureDto; + name: string; + type: TypeDto; +} + +export interface MethodParameterDto { + name: string; + type: TypeDto; + isOptional?: boolean; // Kotlin default: false + isRest?: boolean; // Kotlin default: false +} + +export interface MethodSignatureDto { + declaringClass: ClassSignatureDto; + name: string; + parameters: MethodParameterDto[]; + returnType: TypeDto; +} + +export interface LocalSignatureDto { + name: string; + method: MethodSignatureDto; +} + +/** Signature placeholders mirroring `EtsFileSignature.UNKNOWN` / `EtsClassSignature.UNKNOWN` semantics. */ +export const UNKNOWN_FILE_SIGNATURE: FileSignatureDto = { + projectName: "%unk", + fileName: "%unk", +}; + +export const UNKNOWN_CLASS_SIGNATURE: ClassSignatureDto = { + name: "", + declaringFile: UNKNOWN_FILE_SIGNATURE, +}; diff --git a/jacodb-ets/ts-frontend/src/dto/stmts.ts b/jacodb-ets/ts-frontend/src/dto/stmts.ts new file mode 100644 index 000000000..18aced38b --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/stmts.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Statement DTOs. Mirror `org.jacodb.ets.dto` statements + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Stmts.kt). + * + * Serialized polymorphically with the "_" discriminator. + * Unknown discriminators fall back to RawStmtDto on the Kotlin side. + */ + +import { CallExprDto, ConditionExprDto, ValueDto } from "./values"; + +/** + * Closed union of the known statement kinds. + * Raw fallback statements (RawStmtDto) are intentionally NOT part of this union + * (index signature breaks narrowing); emission sites cast them explicitly. + */ +export type StmtDto = + | NopStmtDto + | AssignStmtDto + | CallStmtDto + | ReturnVoidStmtDto + | ReturnStmtDto + | ThrowStmtDto + | IfStmtDto; + +/** Fallback for constructs we cannot model. */ +export interface RawStmtDto { + readonly _: string; // any kind not listed below + [extra: string]: unknown; +} + +export interface NopStmtDto { + readonly _: "NopStmt"; +} + +export interface AssignStmtDto { + readonly _: "AssignStmt"; + left: ValueDto; // must convert to Local / FieldRef / ArrayRef on the Kotlin side + right: ValueDto; +} + +export interface CallStmtDto { + readonly _: "CallStmt"; + expr: CallExprDto; +} + +export interface ReturnVoidStmtDto { + readonly _: "ReturnVoidStmt"; +} + +export interface ReturnStmtDto { + readonly _: "ReturnStmt"; + arg: ValueDto; +} + +export interface ThrowStmtDto { + readonly _: "ThrowStmt"; + arg: ValueDto; +} + +export interface IfStmtDto { + readonly _: "IfStmt"; + condition: ConditionExprDto; +} + +export const NOP_STMT: NopStmtDto = { _: "NopStmt" }; +export const RETURN_VOID_STMT: ReturnVoidStmtDto = { _: "ReturnVoidStmt" }; + +/** Statements that must terminate a basic block (no stmts may follow them). */ +export function isTerminatorStmt(stmt: StmtDto): boolean { + return ( + stmt._ === "ReturnVoidStmt" || + stmt._ === "ReturnStmt" || + stmt._ === "ThrowStmt" || + stmt._ === "IfStmt" + ); +} diff --git a/jacodb-ets/ts-frontend/src/dto/types.ts b/jacodb-ets/ts-frontend/src/dto/types.ts new file mode 100644 index 000000000..56a8316c1 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/types.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Type DTOs. Mirror `org.jacodb.ets.dto` types + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Types.kt). + * + * Serialized polymorphically with the "_" discriminator. + * Unknown discriminators fall back to RawTypeDto on the Kotlin side. + * + * IMPORTANT: the Kotlin Json is strict — no extra keys are allowed on known kinds. + * Optional fields here correspond to Kotlin constructor parameters with defaults. + */ + +import { ClassSignatureDto, LocalSignatureDto, MethodSignatureDto } from "./signatures"; +import { LocalDto } from "./values"; + +export type TypeDto = + | AnyTypeDto + | UnknownTypeDto + | GenericTypeDto + | AliasTypeDto + | LexicalEnvTypeDto + | EnumValueTypeDto + | VoidTypeDto + | NeverTypeDto + | UnionTypeDto + | IntersectionTypeDto + | BooleanTypeDto + | NumberTypeDto + | StringTypeDto + | NullTypeDto + | UndefinedTypeDto + | LiteralTypeDto + | ClassTypeDto + | UnclearReferenceTypeDto + | ArrayTypeDto + | TupleTypeDto + | FunctionTypeDto; + +export interface AnyTypeDto { + readonly _: "AnyType"; +} + +export interface UnknownTypeDto { + readonly _: "UnknownType"; +} + +export interface GenericTypeDto { + readonly _: "GenericType"; + name: string; + constraint?: TypeDto; + defaultType?: TypeDto; +} + +export interface AliasTypeDto { + readonly _: "AliasType"; + name: string; + originalType: TypeDto; + signature: LocalSignatureDto; +} + +export interface LexicalEnvTypeDto { + readonly _: "LexicalEnvType"; + method: MethodSignatureDto; + closures: LocalDto[]; +} + +export interface EnumValueTypeDto { + readonly _: "EnumValueType"; + signature: ClassSignatureDto; + name?: string; +} + +export interface VoidTypeDto { + readonly _: "VoidType"; +} + +export interface NeverTypeDto { + readonly _: "NeverType"; +} + +export interface UnionTypeDto { + readonly _: "UnionType"; + types: TypeDto[]; +} + +export interface IntersectionTypeDto { + readonly _: "IntersectionType"; + types: TypeDto[]; +} + +export interface BooleanTypeDto { + readonly _: "BooleanType"; +} + +export interface NumberTypeDto { + readonly _: "NumberType"; +} + +export interface StringTypeDto { + readonly _: "StringType"; +} + +export interface NullTypeDto { + readonly _: "NullType"; +} + +export interface UndefinedTypeDto { + readonly _: "UndefinedType"; +} + +/** The literal is a BARE JSON primitive (string | number | boolean), not an object. */ +export interface LiteralTypeDto { + readonly _: "LiteralType"; + literal: string | number | boolean; +} + +export interface ClassTypeDto { + readonly _: "ClassType"; + signature: ClassSignatureDto; + typeParameters?: TypeDto[]; // Kotlin default: empty list +} + +export interface UnclearReferenceTypeDto { + readonly _: "UnclearReferenceType"; + name: string; + typeParameters?: TypeDto[]; // Kotlin default: empty list +} + +export interface ArrayTypeDto { + readonly _: "ArrayType"; + elementType: TypeDto; + dimensions: number; +} + +export interface TupleTypeDto { + readonly _: "TupleType"; + types: TypeDto[]; +} + +export interface FunctionTypeDto { + readonly _: "FunctionType"; + signature: MethodSignatureDto; + typeParameters?: TypeDto[]; // Kotlin default: empty list +} + +// Singletons for field-less types. +export const ANY_TYPE: AnyTypeDto = { _: "AnyType" }; +export const UNKNOWN_TYPE: UnknownTypeDto = { _: "UnknownType" }; +export const VOID_TYPE: VoidTypeDto = { _: "VoidType" }; +export const NEVER_TYPE: NeverTypeDto = { _: "NeverType" }; +export const BOOLEAN_TYPE: BooleanTypeDto = { _: "BooleanType" }; +export const NUMBER_TYPE: NumberTypeDto = { _: "NumberType" }; +export const STRING_TYPE: StringTypeDto = { _: "StringType" }; +export const NULL_TYPE: NullTypeDto = { _: "NullType" }; +export const UNDEFINED_TYPE: UndefinedTypeDto = { _: "UndefinedType" }; diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts new file mode 100644 index 000000000..35db4f7c6 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -0,0 +1,225 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Value DTOs. Mirror `org.jacodb.ets.dto` values + * (jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt). + * + * Serialized polymorphically with the "_" discriminator. + * Unknown discriminators fall back to RawValueDto on the Kotlin side + * (RawValue REQUIRES a "type" key). + * + * IMPORTANT: only fields that are Kotlin constructor parameters are serialized. + * Computed `type` getters (e.g. on NewExpr, UnopExpr, call exprs) must NOT be emitted — + * the strict Kotlin Json would reject the extra key. + */ + +import { BinaryOp, RelationOp, UnaryOp } from "./ops"; +import { FieldSignatureDto, MethodSignatureDto } from "./signatures"; +import { TypeDto } from "./types"; + +/** + * Closed union of the known value kinds. + * Raw fallback values (RawValueDto) are intentionally NOT part of this union: + * they carry an index signature that would break discriminated-union narrowing. + * Emission sites must cast them explicitly (see lowering/fallback.ts). + */ +export type ValueDto = ImmediateDto | ExprDto | RefDto; + +export type ImmediateDto = LocalDto | ConstantDto; + +export type ExprDto = + | NewExprDto + | NewArrayExprDto + | DeleteExprDto + | AwaitExprDto + | YieldExprDto + | TypeOfExprDto + | InstanceOfExprDto + | CastExprDto + | UnopExprDto + | BinopExprDto + | ConditionExprDto + | InstanceCallExprDto + | StaticCallExprDto + | PtrCallExprDto; + +export type CallExprDto = InstanceCallExprDto | StaticCallExprDto | PtrCallExprDto; + +export type RefDto = + | ThisRefDto + | ParameterRefDto + | CaughtExceptionRefDto + | GlobalRefDto + | ClosureFieldRefDto + | ArrayRefDto + | InstanceFieldRefDto + | StaticFieldRefDto; + +/** LValue kinds accepted by Kotlin Convert as the LHS of AssignStmt. */ +export type LValueDto = LocalDto | ArrayRefDto | InstanceFieldRefDto | StaticFieldRefDto; + +/** Fallback for constructs we cannot model; Kotlin deserializes any unknown kind into RawValueDto. */ +export interface RawValueDto { + readonly _: string; // any kind not listed above + type: TypeDto; // REQUIRED by RawValueSerializer + [extra: string]: unknown; +} + +export interface LocalDto { + readonly _: "Local"; + name: string; + type: TypeDto; +} + +/** All constants carry a string-encoded value (e.g. "42", "true", "null"). */ +export interface ConstantDto { + readonly _: "Constant"; + value: string; + type: TypeDto; +} + +export interface NewExprDto { + readonly _: "NewExpr"; + classType: TypeDto; // ClassType +} + +export interface NewArrayExprDto { + readonly _: "NewArrayExpr"; + elementType: TypeDto; + size: ValueDto; +} + +export interface DeleteExprDto { + readonly _: "DeleteExpr"; + arg: ValueDto; +} + +export interface AwaitExprDto { + readonly _: "AwaitExpr"; + arg: ValueDto; +} + +export interface YieldExprDto { + readonly _: "YieldExpr"; + arg: ValueDto; +} + +export interface TypeOfExprDto { + readonly _: "TypeOfExpr"; + arg: ValueDto; +} + +export interface InstanceOfExprDto { + readonly _: "InstanceOfExpr"; + arg: ValueDto; + checkType: TypeDto; +} + +export interface CastExprDto { + readonly _: "CastExpr"; + arg: ValueDto; + type: TypeDto; +} + +export interface UnopExprDto { + readonly _: "UnopExpr"; + op: UnaryOp; + arg: ValueDto; +} + +export interface BinopExprDto { + readonly _: "BinopExpr"; + op: BinaryOp; + left: ValueDto; + right: ValueDto; + type?: TypeDto; // Kotlin default: UnknownType +} + +export interface ConditionExprDto { + readonly _: "ConditionExpr"; + op: RelationOp; + left: ValueDto; + right: ValueDto; + type?: TypeDto; // Kotlin default: UnknownType +} + +export interface InstanceCallExprDto { + readonly _: "InstanceCallExpr"; + instance: LocalDto; // Kotlin Convert casts this to LocalDto — MUST be a Local + method: MethodSignatureDto; + args: ValueDto[]; +} + +export interface StaticCallExprDto { + readonly _: "StaticCallExpr"; + method: MethodSignatureDto; + args: ValueDto[]; +} + +export interface PtrCallExprDto { + readonly _: "PtrCallExpr"; + ptr: ValueDto; // Local or FieldRef (must be a value, not an expr) + method: MethodSignatureDto; + args: ValueDto[]; +} + +export interface ThisRefDto { + readonly _: "ThisRef"; + type: TypeDto; // ClassType +} + +export interface ParameterRefDto { + readonly _: "ParameterRef"; + index: number; + type: TypeDto; +} + +export interface CaughtExceptionRefDto { + readonly _: "CaughtExceptionRef"; + type: TypeDto; +} + +export interface GlobalRefDto { + readonly _: "GlobalRef"; + name: string; + ref: ValueDto | null; // nullable WITHOUT default on the Kotlin side — must be present +} + +export interface ClosureFieldRefDto { + readonly _: "ClosureFieldRef"; + base: LocalDto; + fieldName: string; + type: TypeDto; +} + +export interface ArrayRefDto { + readonly _: "ArrayRef"; + array: ValueDto; + index: ValueDto; // must be a value (immediate/ref), not an expr — Kotlin casts to EtsValue + type: TypeDto; +} + +export interface InstanceFieldRefDto { + readonly _: "InstanceFieldRef"; + instance: LocalDto; // Kotlin Convert casts this to LocalDto — MUST be a Local + field: FieldSignatureDto; +} + +export interface StaticFieldRefDto { + readonly _: "StaticFieldRef"; + field: FieldSignatureDto; +} diff --git a/jacodb-ets/ts-frontend/src/index.ts b/jacodb-ets/ts-frontend/src/index.ts new file mode 100644 index 000000000..cccd13fe9 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/index.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * CLI entry point. + * + * Usage: `node dist/index.js [-p] [-e] [-t N] [--multi] [-v] ` + * + * Parses TS/JS with the real TypeScript compiler and emits EtsIR JSON + * consumable by the Kotlin side (`EtsFileDto.loadFromJson`). + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as ts from "typescript"; +import { Diagnostics } from "./lowering/diagnostics"; +import { buildEtsFile } from "./lowering/fileBuilder"; +import { serializeEtsFile } from "./serialize"; +import { validateEtsFile } from "./validate"; + +export const COMPILER_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + strict: false, + allowJs: true, + checkJs: false, + noEmit: true, + skipLibCheck: true, +}; + +interface CliArgs { + input: string; + output: string; + project: boolean; + multi: boolean; + entrypoints: boolean; + typeInference: number | undefined; + verbose: boolean; +} + +/** + * Hand-rolled arg parsing. + * NOTE: the Kotlin side historically passes `-t N` as a SINGLE argv token `"-t N"`, + * so tokens containing spaces are pre-split here. + */ +export function parseArgs(argv: string[]): CliArgs | string { + const tokens = argv.flatMap((a) => (a.startsWith("-") && a.includes(" ") ? a.split(/\s+/) : [a])); + const positional: string[] = []; + const args: CliArgs = { + input: "", + output: "", + project: false, + multi: false, + entrypoints: false, + typeInference: undefined, + verbose: false, + }; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + switch (token) { + case "-p": + case "--project": + args.project = true; + break; + case "-m": + case "--multi": + args.multi = true; + break; + case "-e": + case "--entrypoints": + args.entrypoints = true; + break; + case "-v": + case "--verbose": + args.verbose = true; + break; + case "-t": + case "--type-inference": { + const next = tokens[i + 1]; + if (next !== undefined && /^\d+$/.test(next)) { + args.typeInference = parseInt(next, 10); + i++; + } else { + args.typeInference = 1; + } + break; + } + default: + if (token.startsWith("-")) { + return `unknown option: ${token}`; + } + positional.push(token); + } + } + if (positional.length !== 2) { + return `expected exactly 2 positional arguments , got ${positional.length}`; + } + args.input = positional[0]; + args.output = positional[1]; + return args; +} + +const SOURCE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs", ".ets"]; + +function isSourceFilePath(filePath: string): boolean { + if (filePath.endsWith(".d.ts")) return false; + return SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); +} + +/** Recursively collect source files under a directory. */ +function collectSourceFiles(dir: string): string[] { + const result: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + result.push(...collectSourceFiles(full)); + } else if (entry.isFile() && isSourceFilePath(entry.name)) { + result.push(full); + } + } + return result.sort(); +} + +/** Compiler host that parses unknown extensions (.ets) as TypeScript. */ +function createHost(): ts.CompilerHost { + const host = ts.createCompilerHost(COMPILER_OPTIONS); + const originalGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => { + if (name.endsWith(".ets")) { + const text = host.readFile(name); + if (text === undefined) return undefined; + return ts.createSourceFile(name, text, languageVersion, true, ts.ScriptKind.TS); + } + return originalGetSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + }; + return host; +} + +interface EmitResult { + violations: string[]; + warnings: string[]; +} + +/** Lower one source file of `program` and write its JSON next to `outputPath`. */ +function emitOne( + program: ts.Program, + sourceFile: ts.SourceFile, + projectName: string, + fileName: string, + outputPath: string, + fileSignatureFor: ((sf: ts.SourceFile) => { projectName: string; fileName: string }) | undefined, +): EmitResult { + const diagnostics = new Diagnostics(); + const file = buildEtsFile(program, sourceFile, { projectName, fileName, fileSignatureFor }, diagnostics); + const violations = validateEtsFile(file); + if (violations.length === 0) { + fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true }); + fs.writeFileSync(outputPath, serializeEtsFile(file)); + } + return { violations, warnings: diagnostics.messages }; +} + +function main(argv: string[]): number { + const parsed = parseArgs(argv); + if (typeof parsed === "string") { + process.stderr.write(`error: ${parsed}\n`); + process.stderr.write("usage: node index.js [-p] [-e] [-t N] [--multi] [-v] \n"); + return 2; + } + const log = (msg: string) => { + if (parsed.verbose) { + process.stderr.write(`[ets-frontend] ${msg}\n`); + } + }; + log(`input: ${parsed.input}`); + log(`output: ${parsed.output}`); + + const inputPath = path.resolve(parsed.input); + if (!fs.existsSync(inputPath)) { + process.stderr.write(`error: input does not exist: ${inputPath}\n`); + return 1; + } + + // Directory mode: -p (project) and --multi behave the same here — one shared + // program over all files, one JSON per source file mirroring the input tree. + if (parsed.project || parsed.multi) { + if (!fs.statSync(inputPath).isDirectory()) { + process.stderr.write(`error: directory expected in project/multi mode: ${inputPath}\n`); + return 1; + } + const projectName = path.basename(inputPath); + const sources = collectSourceFiles(inputPath); + log(`found ${sources.length} source files`); + if (sources.length === 0) { + return 0; + } + + const program = ts.createProgram(sources, COMPILER_OPTIONS, createHost()); + const relativeOf = (sf: ts.SourceFile): string => + path.relative(inputPath, path.resolve(sf.fileName)).split(path.sep).join("/"); + const fileSignatureFor = (sf: ts.SourceFile) => { + if (!sf.isDeclarationFile && !path.relative(inputPath, path.resolve(sf.fileName)).startsWith("..")) { + return { projectName, fileName: relativeOf(sf) }; + } + return { projectName: "%unk", fileName: "%unk" }; + }; + + let hadErrors = false; + for (const source of sources) { + const sourceFile = program.getSourceFile(source); + if (sourceFile === undefined) { + process.stderr.write(`error: could not load: ${source}\n`); + hadErrors = true; + continue; + } + const relative = path.relative(inputPath, source).split(path.sep).join("/"); + const outputFile = path.join(parsed.output, `${relative}.json`); + const result = emitOne(program, sourceFile, projectName, relative, outputFile, fileSignatureFor); + for (const warning of result.warnings) { + log(`warning: ${warning}`); + } + for (const violation of result.violations) { + process.stderr.write(`invariant violation in ${relative}: ${violation}\n`); + hadErrors = true; + } + log(`emitted: ${outputFile}`); + } + return hadErrors ? 1 : 0; + } + + // Single-file mode. + const program = ts.createProgram([inputPath], COMPILER_OPTIONS, createHost()); + const sourceFile = program.getSourceFile(inputPath); + if (sourceFile === undefined) { + process.stderr.write(`error: could not load source file: ${inputPath}\n`); + return 1; + } + const result = emitOne(program, sourceFile, "", path.basename(inputPath), parsed.output, undefined); + for (const warning of result.warnings) { + log(`warning: ${warning}`); + } + if (result.violations.length > 0) { + for (const violation of result.violations) { + process.stderr.write(`invariant violation: ${violation}\n`); + } + return 1; + } + log("done"); + return 0; +} + +if (require.main === module) { + process.exit(main(process.argv.slice(2))); +} diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts new file mode 100644 index 000000000..5a0dc5e63 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** Shared AST helpers used across the lowering modules. */ + +import * as ts from "typescript"; +import { Modifier } from "../dto/constants"; +import { DecoratorDto } from "../dto/model"; +import { MethodParameterDto } from "../dto/signatures"; +import { TypeDto, UNKNOWN_TYPE } from "../dto/types"; +import type { LoweringContext } from "./methodBuilder"; + +export function memberName(name: ts.PropertyName | ts.BindingName | ts.EntityName): string { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) || ts.isPrivateIdentifier(name)) { + return name.text; + } + return "%computed"; +} + +export function modifiersOf(node: ts.Node): number { + const flags = ts.getCombinedModifierFlags(node as ts.Declaration); + let result = 0; + if (flags & ts.ModifierFlags.Private) result |= Modifier.PRIVATE; + if (flags & ts.ModifierFlags.Protected) result |= Modifier.PROTECTED; + if (flags & ts.ModifierFlags.Public) result |= Modifier.PUBLIC; + if (flags & ts.ModifierFlags.Export) result |= Modifier.EXPORT; + if (flags & ts.ModifierFlags.Static) result |= Modifier.STATIC; + if (flags & ts.ModifierFlags.Abstract) result |= Modifier.ABSTRACT; + if (flags & ts.ModifierFlags.Async) result |= Modifier.ASYNC; + if (flags & ts.ModifierFlags.Const) result |= Modifier.CONST; + if (flags & ts.ModifierFlags.Accessor) result |= Modifier.ACCESSOR; + if (flags & ts.ModifierFlags.Default) result |= Modifier.DEFAULT; + if (flags & ts.ModifierFlags.In) result |= Modifier.IN; + if (flags & ts.ModifierFlags.Readonly) result |= Modifier.READONLY; + if (flags & ts.ModifierFlags.Out) result |= Modifier.OUT; + if (flags & ts.ModifierFlags.Override) result |= Modifier.OVERRIDE; + if (flags & ts.ModifierFlags.Ambient) result |= Modifier.DECLARE; + return result; +} + +export function decoratorsOf(node: ts.Node): DecoratorDto[] { + const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined; + if (decorators === undefined) { + return []; + } + return decorators.map((d) => { + const expr = d.expression; + if (ts.isIdentifier(expr)) { + return { kind: expr.text }; + } + if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) { + return { kind: expr.expression.text }; + } + return { kind: expr.getText().slice(0, 50) }; + }); +} + +export function parameterType(ctx: LoweringContext, p: ts.ParameterDeclaration): TypeDto { + return p.type !== undefined ? ctx.converter.convertTypeNode(p.type) : ctx.converter.typeOfNode(p.name); +} + +export interface BuiltParameters { + parameters: MethodParameterDto[]; + prologueParams: { name: string; type: TypeDto }[]; +} + +export function buildParameters(ctx: LoweringContext, decl: ts.SignatureDeclarationBase): BuiltParameters { + const parameters: MethodParameterDto[] = []; + const prologueParams: { name: string; type: TypeDto }[] = []; + for (const p of decl.parameters) { + // A TS fake `this` parameter is a type annotation, not a real parameter: + // it must not shift ParameterRef indices or shadow the `this` local. + if (ts.isIdentifier(p.name) && p.name.text === "this") { + continue; + } + const name = ts.isIdentifier(p.name) ? p.name.text : "%pat"; + const type = parameterType(ctx, p); + const param: MethodParameterDto = { name, type }; + if (p.questionToken !== undefined) param.isOptional = true; + if (p.dotDotDotToken !== undefined) param.isRest = true; + parameters.push(param); + prologueParams.push({ name, type }); + } + return { parameters, prologueParams }; +} + +export function returnTypeOf(ctx: LoweringContext, decl: ts.SignatureDeclarationBase): TypeDto { + if (decl.type !== undefined) { + return ctx.converter.convertTypeNode(decl.type); + } + try { + const signature = ctx.checker.getSignatureFromDeclaration(decl as ts.SignatureDeclaration); + if (signature !== undefined) { + return ctx.converter.convertType(ctx.checker.getReturnTypeOfSignature(signature)); + } + } catch { + // fall through + } + return UNKNOWN_TYPE; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/cfg.ts b/jacodb-ets/ts-frontend/src/lowering/cfg.ts new file mode 100644 index 000000000..94b79d06d --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/cfg.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Basic-block CFG builder. + * + * Lowering code works with symbolic labels and explicit terminators + * (goto / if / return / throw); `finalize()` then renumbers reachable blocks + * (entry = 0, id == index), materializes IfStmt terminators, and computes + * successor lists in the DTO convention: + * - a block ending with IfStmt has successors [falseBranch, trueBranch] + * (the Kotlin Convert reverses every successor list); + * - any other block has at most one successor. + */ + +import { BasicBlockDto, CfgDto } from "../dto/model"; +import { RETURN_VOID_STMT, StmtDto } from "../dto/stmts"; +import { ConditionExprDto, ValueDto } from "../dto/values"; + +export type Label = number; + +type Terminator = + | { kind: "goto"; target: Label } + | { kind: "if"; condition: ConditionExprDto; trueTarget: Label; falseTarget: Label } + | { kind: "return"; arg?: ValueDto } + | { kind: "throw"; arg: ValueDto } + | { kind: "open" }; // fall-through end; finalize() turns it into `return void` + +interface BuilderBlock { + label: Label; + stmts: StmtDto[]; + terminator: Terminator; +} + +export class CfgBuilder { + private readonly blocks: (BuilderBlock | undefined)[] = []; + private current: BuilderBlock; + private nextLabel: Label = 0; + private readonly entry: Label; + + constructor() { + this.entry = this.newLabel(); + this.current = this.place(this.entry); + } + + /** Allocate a fresh label; the block must later be placed with `placeLabel`. */ + newLabel(): Label { + const label = this.nextLabel++; + this.blocks.push(undefined); + return label; + } + + private place(label: Label): BuilderBlock { + if (this.blocks[label] !== undefined) { + throw new Error(`label ${label} is already placed`); + } + const block: BuilderBlock = { label, stmts: [], terminator: { kind: "open" } }; + this.blocks[label] = block; + return block; + } + + /** + * Start emitting into the block for `label`. + * If the current block is still open, it falls through (goto) to `label`. + */ + placeLabel(label: Label): void { + if (this.current.terminator.kind === "open") { + this.current.terminator = { kind: "goto", target: label }; + } + this.current = this.place(label); + } + + /** Whether the current block is unterminated (still accepts statements). */ + isOpen(): boolean { + return this.current.terminator.kind === "open"; + } + + emit(stmt: StmtDto): void { + if (!this.isOpen()) { + // Unreachable code after return/throw/etc: emit into a detached block + // so lowering can proceed; it is dropped by reachability in finalize(). + this.current = this.place(this.newLabel()); + } + this.current.stmts.push(stmt); + } + + goto(target: Label): void { + if (!this.isOpen()) { + this.current = this.place(this.newLabel()); + } + this.current.terminator = { kind: "goto", target }; + } + + branch(condition: ConditionExprDto, trueTarget: Label, falseTarget: Label): void { + if (!this.isOpen()) { + this.current = this.place(this.newLabel()); + } + this.current.terminator = { kind: "if", condition, trueTarget, falseTarget }; + } + + ret(arg?: ValueDto): void { + if (!this.isOpen()) { + this.current = this.place(this.newLabel()); + } + this.current.terminator = { kind: "return", arg }; + } + + throwValue(arg: ValueDto): void { + if (!this.isOpen()) { + this.current = this.place(this.newLabel()); + } + this.current.terminator = { kind: "throw", arg }; + } + + /** + * Produce the final CfgDto: reachable blocks only, renumbered from the entry + * in DFS order (true branch first), successors in the DTO convention, + * predecessors computed. + */ + finalize(): CfgDto { + for (const block of this.blocks) { + if (block === undefined) { + throw new Error("finalize() with unplaced labels"); + } + } + const placed = this.blocks as BuilderBlock[]; + + // Reachability + stable renumbering (DFS from entry, successor order). + const order: BuilderBlock[] = []; + const idOf = new Map(); + const visit = (label: Label): void => { + if (idOf.has(label)) return; + const block = placed[label]; + idOf.set(label, order.length); + order.push(block); + for (const succ of terminatorTargets(block.terminator)) { + visit(succ); + } + }; + visit(this.entry); + + const result: BasicBlockDto[] = order.map((block, id) => { + const stmts = [...block.stmts]; + let successors: number[]; + const t = block.terminator; + switch (t.kind) { + case "goto": + successors = [idOf.get(t.target)!]; + break; + case "if": + stmts.push({ _: "IfStmt", condition: t.condition }); + // DTO convention: [false, true]. + successors = [idOf.get(t.falseTarget)!, idOf.get(t.trueTarget)!]; + break; + case "return": + stmts.push(t.arg === undefined ? RETURN_VOID_STMT : { _: "ReturnStmt", arg: t.arg }); + successors = []; + break; + case "throw": + stmts.push({ _: "ThrowStmt", arg: t.arg }); + successors = []; + break; + case "open": + // Fall-through method end. + stmts.push(RETURN_VOID_STMT); + successors = []; + break; + } + return { id, successors, predecessors: [], stmts }; + }); + + // Predecessors. + for (const block of result) { + for (const succ of block.successors) { + result[succ].predecessors!.push(block.id); + } + } + + return { blocks: result }; + } +} + +function terminatorTargets(t: Terminator): Label[] { + switch (t.kind) { + case "goto": + return [t.target]; + case "if": + // Visit the TRUE branch first so it gets the smaller id + // (matches natural source order for `if (c) {then} else {else}`). + return [t.trueTarget, t.falseTarget]; + default: + return []; + } +} diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts new file mode 100644 index 000000000..cdf00bce2 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -0,0 +1,526 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Class-like declarations -> ClassDto. + * + * Conventions (verified against ArkAnalyzer ground truth): + * - classes: synthesized `%instInit` / `%statInit` initializer methods + * (prologue `this := ThisRef`, field-init assignments, return void), + * constructor always present (synthesized if absent) and shaped as + * `this := ThisRef; this.%instInit(); ...body...; return this`; + * - enums: category 3, members as STATIC fields typed EnumValueType, + * values assigned in `%statInit`; + * - interfaces: category 2, bodyless methods, no initializers. + */ + +import * as ts from "typescript"; +import { CONSTRUCTOR_NAME, INSTANCE_INIT_METHOD_NAME, Modifier, STATIC_INIT_METHOD_NAME } from "../dto/constants"; +import { ClassDto, DecoratorDto, FieldDto, MethodDto } from "../dto/model"; +import { ClassSignatureDto, MethodParameterDto } from "../dto/signatures"; +import { ClassTypeDto, TypeDto, BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, UNKNOWN_TYPE, VOID_TYPE } from "../dto/types"; +import { buildParameters, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf } from "./astUtils"; +import { constant } from "./exprLowering"; +import { LoweringContext, MethodContext } from "./methodBuilder"; +import { StmtLowerer } from "./stmtLowering"; + +type ClassMemberDecl = ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.SetAccessorDeclaration; + +export class ClassBuilder { + constructor(private readonly ctx: LoweringContext) {} + + // ------------------------------------------------------------------ + // Classes + // ------------------------------------------------------------------ + + buildClass(decl: ts.ClassDeclaration): ClassDto { + const signature = this.ctx.converter.classSignatureOf(decl); + + const instanceFields: ts.PropertyDeclaration[] = []; + const staticFields: ts.PropertyDeclaration[] = []; + const fields: FieldDto[] = []; + const methods: MethodDto[] = []; + let ctorDecl: ts.ConstructorDeclaration | undefined; + + for (const member of decl.members) { + if (ts.isPropertyDeclaration(member)) { + fields.push(this.buildField(signature, member)); + if (member.initializer !== undefined) { + if (isStatic(member)) { + staticFields.push(member); + } else { + instanceFields.push(member); + } + } + } else if (ts.isConstructorDeclaration(member)) { + if (member.body !== undefined) { + ctorDecl = member; + } + // Parameter properties (constructor(private x: number)) become fields. + for (const p of member.parameters) { + if (hasParameterPropertyModifier(p) && ts.isIdentifier(p.name)) { + fields.push(this.buildParameterPropertyField(signature, p)); + } + } + } else if ( + ts.isMethodDeclaration(member) || + ts.isGetAccessorDeclaration(member) || + ts.isSetAccessorDeclaration(member) + ) { + methods.push(this.buildMethodFromDecl(signature, member)); + } else if (ts.isSemicolonClassElement(member) || ts.isIndexSignatureDeclaration(member)) { + // ignore + } else if (ts.isClassStaticBlockDeclaration(member)) { + this.ctx.diagnostics.warn(member, "static blocks are folded into %statInit"); + // handled below via buildStatInit extension: keep simple — lowered into %statInit + } else { + this.ctx.diagnostics.warn(member, `unsupported class member: ${ts.SyntaxKind[member.kind]}`); + } + } + + const staticBlocks = decl.members.filter(ts.isClassStaticBlockDeclaration); + + methods.push(this.buildInstInit(signature, instanceFields)); + methods.push(this.buildStatInit(signature, staticFields, staticBlocks)); + methods.push( + ctorDecl !== undefined + ? this.buildConstructor(signature, ctorDecl) + : this.synthesizeDefaultConstructor(signature), + ); + + const result: ClassDto = { + signature, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + category: 0, + superClassName: superClassNameOf(decl) ?? "", + implementedInterfaceNames: implementedInterfacesOf(decl), + fields, + methods, + }; + const typeParameters = this.ctx.converter.convertTypeParameters(decl.typeParameters); + if (typeParameters !== undefined) { + result.typeParameters = typeParameters; + } + return result; + } + + // ------------------------------------------------------------------ + // Interfaces + // ------------------------------------------------------------------ + + buildInterface(decl: ts.InterfaceDeclaration): ClassDto { + const signature = this.ctx.converter.classSignatureOf(decl); + const fields: FieldDto[] = []; + const methods: MethodDto[] = []; + + for (const member of decl.members) { + if (ts.isPropertySignature(member)) { + const field: FieldDto = { + signature: { + declaringClass: signature, + name: memberName(member.name), + type: + member.type !== undefined + ? this.ctx.converter.convertTypeNode(member.type) + : UNKNOWN_TYPE, + }, + modifiers: modifiersOf(member), + decorators: [], + questionToken: member.questionToken !== undefined, + exclamationToken: false, + }; + fields.push(field); + } else if (ts.isMethodSignature(member)) { + methods.push(this.buildBodylessMethod(signature, member)); + } else { + this.ctx.diagnostics.warn(member, `unsupported interface member: ${ts.SyntaxKind[member.kind]}`); + } + } + + const result: ClassDto = { + signature, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + category: 2, + superClassName: "", + implementedInterfaceNames: extendedInterfacesOf(decl), + fields, + methods, + }; + const typeParameters = this.ctx.converter.convertTypeParameters(decl.typeParameters); + if (typeParameters !== undefined) { + result.typeParameters = typeParameters; + } + return result; + } + + // ------------------------------------------------------------------ + // Enums + // ------------------------------------------------------------------ + + buildEnum(decl: ts.EnumDeclaration): ClassDto { + const signature = this.ctx.converter.classSignatureOf(decl); + + const fields: FieldDto[] = decl.members.map((member) => ({ + signature: { + declaringClass: signature, + name: memberName(member.name), + type: { _: "EnumValueType", signature, name: memberName(member.name) }, + }, + modifiers: Modifier.STATIC, + decorators: [], + questionToken: false, + exclamationToken: false, + })); + + // %statInit assigns member values. + const m = new MethodContext(this.ctx, signature, STATIC_INIT_METHOD_NAME); + m.emitPrologue([]); + const lowerer = new StmtLowerer(m); + let autoValue = 0; + for (const member of decl.members) { + const name = memberName(member.name); + let value; + const constValue = this.constantValueOf(member); + if (constValue !== undefined) { + value = + typeof constValue === "number" + ? constant(String(constValue), NUMBER_TYPE) + : constant(constValue, STRING_TYPE); + if (typeof constValue === "number") { + autoValue = constValue + 1; + } + } else if (member.initializer !== undefined) { + value = lowerer.expr.lowerToImmediate(member.initializer); + } else { + value = constant(String(autoValue++), NUMBER_TYPE); + } + m.cfg.emit({ + _: "AssignStmt", + left: { + _: "StaticFieldRef", + field: { + declaringClass: signature, + name, + type: { _: "EnumValueType", signature, name }, + }, + }, + right: value, + }); + } + m.cfg.ret(); + + return { + signature, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + category: 3, + superClassName: "", + implementedInterfaceNames: [], + fields, + methods: [ + { + signature: { declaringClass: signature, name: STATIC_INIT_METHOD_NAME, parameters: [], returnType: VOID_TYPE }, + modifiers: Modifier.STATIC, + decorators: [], + body: m.build(), + }, + ], + }; + } + + private constantValueOf(member: ts.EnumMember): string | number | undefined { + try { + return this.ctx.checker.getConstantValue(member); + } catch { + return undefined; + } + } + + // ------------------------------------------------------------------ + // Methods + // ------------------------------------------------------------------ + + buildMethodFromDecl(declaringClass: ClassSignatureDto, decl: ClassMemberDecl | ts.FunctionDeclaration): MethodDto { + const name = decl.name !== undefined ? memberName(decl.name) : ""; + const { parameters, prologueParams } = buildParameters(this.ctx, decl); + const returnType = returnTypeOf(this.ctx, decl); + + const method: MethodDto = { + signature: { declaringClass, name, parameters, returnType }, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + }; + const typeParameters = this.ctx.converter.convertTypeParameters(decl.typeParameters); + if (typeParameters !== undefined) { + method.typeParameters = typeParameters; + } + + if (decl.body !== undefined) { + const isStaticMethod = (modifiersOf(decl) & Modifier.STATIC) !== 0; + const m = new MethodContext(this.ctx, declaringClass, name, isStaticMethod); + m.emitPrologue(prologueParams); + new StmtLowerer(m).lowerStatements(decl.body.statements); + method.body = m.build(); + } + return method; + } + + buildBodylessMethod(declaringClass: ClassSignatureDto, decl: ts.MethodSignature): MethodDto { + const { parameters } = buildParameters(this.ctx, decl); + return { + signature: { + declaringClass, + name: memberName(decl.name), + parameters, + returnType: returnTypeOf(this.ctx, decl), + }, + modifiers: modifiersOf(decl), + decorators: [], + }; + } + + private buildConstructor(declaringClass: ClassSignatureDto, decl: ts.ConstructorDeclaration): MethodDto { + const { parameters, prologueParams } = buildParameters(this.ctx, decl); + const classType: ClassTypeDto = { _: "ClassType", signature: declaringClass }; + + const m = new MethodContext(this.ctx, declaringClass, CONSTRUCTOR_NAME); + m.emitPrologue(prologueParams); + const thisLocal = m.getOrCreateLocal("this", classType); + this.emitInstInitCall(m, declaringClass); + // Parameter properties: this.x := x + for (const p of decl.parameters) { + if (hasParameterPropertyModifier(p) && ts.isIdentifier(p.name)) { + const paramType = parameterType(this.ctx, p); + m.cfg.emit({ + _: "AssignStmt", + left: { + _: "InstanceFieldRef", + instance: thisLocal, + field: { declaringClass, name: p.name.text, type: paramType }, + }, + right: m.getOrCreateLocal(p.name.text, paramType), + }); + } + } + if (decl.body !== undefined) { + new StmtLowerer(m).lowerStatements(decl.body.statements); + } + if (m.cfg.isOpen()) { + m.cfg.ret(thisLocal); + } + return { + signature: { declaringClass, name: CONSTRUCTOR_NAME, parameters, returnType: classType }, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + body: m.build(), + }; + } + + private synthesizeDefaultConstructor(declaringClass: ClassSignatureDto): MethodDto { + const classType: ClassTypeDto = { _: "ClassType", signature: declaringClass }; + const m = new MethodContext(this.ctx, declaringClass, CONSTRUCTOR_NAME); + m.emitPrologue([]); + this.emitInstInitCall(m, declaringClass); + m.cfg.ret(m.getOrCreateLocal("this", classType)); + return { + signature: { declaringClass, name: CONSTRUCTOR_NAME, parameters: [], returnType: classType }, + modifiers: 0, + decorators: [], + body: m.build(), + }; + } + + private emitInstInitCall(m: MethodContext, declaringClass: ClassSignatureDto): void { + m.cfg.emit({ + _: "CallStmt", + expr: { + _: "InstanceCallExpr", + instance: m.getOrCreateLocal("this", m.thisType()), + method: { + declaringClass, + name: INSTANCE_INIT_METHOD_NAME, + parameters: [], + returnType: VOID_TYPE, + }, + args: [], + }, + }); + } + + // ------------------------------------------------------------------ + // Initializers + // ------------------------------------------------------------------ + + private buildInstInit(declaringClass: ClassSignatureDto, fields: ts.PropertyDeclaration[]): MethodDto { + const m = new MethodContext(this.ctx, declaringClass, INSTANCE_INIT_METHOD_NAME); + m.emitPrologue([]); + const thisLocal = m.getOrCreateLocal("this", m.thisType()); + const lowerer = new StmtLowerer(m); + for (const field of fields) { + const fieldType = this.fieldType(field); + m.cfg.emit({ + _: "AssignStmt", + left: { + _: "InstanceFieldRef", + instance: thisLocal, + field: { declaringClass, name: memberName(field.name), type: fieldType }, + }, + right: lowerer.expr.lowerToImmediate(field.initializer!), + }); + } + m.cfg.ret(); + return { + signature: { declaringClass, name: INSTANCE_INIT_METHOD_NAME, parameters: [], returnType: VOID_TYPE }, + modifiers: 0, + decorators: [], + body: m.build(), + }; + } + + private buildStatInit( + declaringClass: ClassSignatureDto, + fields: ts.PropertyDeclaration[], + staticBlocks: readonly ts.ClassStaticBlockDeclaration[] = [], + ): MethodDto { + const m = new MethodContext(this.ctx, declaringClass, STATIC_INIT_METHOD_NAME); + m.emitPrologue([]); + const lowerer = new StmtLowerer(m); + for (const field of fields) { + m.cfg.emit({ + _: "AssignStmt", + left: { + _: "StaticFieldRef", + field: { declaringClass, name: memberName(field.name), type: this.fieldType(field) }, + }, + right: lowerer.expr.lowerToImmediate(field.initializer!), + }); + } + for (const block of staticBlocks) { + lowerer.lowerStatements(block.body.statements); + } + m.cfg.ret(); + return { + signature: { declaringClass, name: STATIC_INIT_METHOD_NAME, parameters: [], returnType: VOID_TYPE }, + modifiers: Modifier.STATIC, + decorators: [], + body: m.build(), + }; + } + + // ------------------------------------------------------------------ + // Fields / parameters / types + // ------------------------------------------------------------------ + + private buildField(declaringClass: ClassSignatureDto, decl: ts.PropertyDeclaration): FieldDto { + return { + signature: { + declaringClass, + name: memberName(decl.name), + type: this.fieldType(decl), + }, + modifiers: modifiersOf(decl), + decorators: decoratorsOf(decl), + questionToken: decl.questionToken !== undefined, + exclamationToken: decl.exclamationToken !== undefined, + }; + } + + private buildParameterPropertyField(declaringClass: ClassSignatureDto, p: ts.ParameterDeclaration): FieldDto { + return { + signature: { + declaringClass, + name: ts.isIdentifier(p.name) ? p.name.text : "%pat", + type: parameterType(this.ctx, p), + }, + modifiers: modifiersOf(p), + decorators: [], + questionToken: p.questionToken !== undefined, + exclamationToken: false, + }; + } + + private fieldType(decl: ts.PropertyDeclaration): TypeDto { + if (decl.type !== undefined) { + return this.ctx.converter.convertTypeNode(decl.type); + } + if (decl.initializer !== undefined) { + const inferred = this.ctx.converter.typeOfNode(decl.name); + return widenLiteral(inferred); + } + return UNKNOWN_TYPE; + } + +} + +// ---------------------------------------------------------------------- +// Shared helpers +// ---------------------------------------------------------------------- + +function isStatic(member: ts.ClassElement): boolean { + return (ts.getCombinedModifierFlags(member as ts.Declaration) & ts.ModifierFlags.Static) !== 0; +} + +function hasParameterPropertyModifier(p: ts.ParameterDeclaration): boolean { + const flags = ts.getCombinedModifierFlags(p); + return ( + (flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Public | ts.ModifierFlags.Readonly)) !== + 0 + ); +} + +function superClassNameOf(decl: ts.ClassDeclaration): string | undefined { + for (const clause of decl.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length > 0) { + return clause.types[0].expression.getText(); + } + } + return undefined; +} + +function implementedInterfacesOf(decl: ts.ClassDeclaration): string[] { + for (const clause of decl.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ImplementsKeyword) { + return clause.types.map((t) => t.expression.getText()); + } + } + return []; +} + +function extendedInterfacesOf(decl: ts.InterfaceDeclaration): string[] { + for (const clause of decl.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + return clause.types.map((t) => t.expression.getText()); + } + } + return []; +} + +/** Widen literal types inferred from initializers (fields are mutable). */ +function widenLiteral(type: TypeDto): TypeDto { + if (type._ === "LiteralType") { + switch (typeof type.literal) { + case "number": + return NUMBER_TYPE; + case "string": + return STRING_TYPE; + case "boolean": + return BOOLEAN_TYPE; + } + } + return type; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts b/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts new file mode 100644 index 000000000..cc27a8c0f --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/diagnostics.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Diagnostics collection + Raw* fallback emission. + * + * The frontend must never fail on exotic-but-parseable input: anything we cannot + * lower degrades to a raw statement/value with an "Unsupported*" discriminator, + * which the Kotlin side deserializes into RawStmtDto / RawValueDto automatically. + */ + +import * as ts from "typescript"; +import { StmtDto } from "../dto/stmts"; +import { TypeDto, UNKNOWN_TYPE } from "../dto/types"; +import { ValueDto } from "../dto/values"; + +export class Diagnostics { + readonly messages: string[] = []; + + warn(node: ts.Node | undefined, message: string): void { + let location = ""; + if (node !== undefined) { + try { + const sf = node.getSourceFile(); + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart()); + location = `${sf.fileName}:${line + 1}:${character + 1}: `; + } catch { + // synthetic node without position + } + } + this.messages.push(`${location}${message}`); + } +} + +function snippet(node: ts.Node): string { + try { + return node.getText().slice(0, 200); + } catch { + return ""; + } +} + +/** Fallback statement: deserialized by Kotlin as RawStmt(kind="UnsupportedStmt"). */ +export function unsupportedStmt(node: ts.Node): StmtDto { + return { + _: "UnsupportedStmt", + kindName: ts.SyntaxKind[node.kind], + text: snippet(node), + } as unknown as StmtDto; +} + +/** Fallback value: deserialized by Kotlin as RawValue(kind="UnsupportedValue"); `type` is required. */ +export function unsupportedValue(node: ts.Node, type: TypeDto = UNKNOWN_TYPE): ValueDto { + return { + _: "UnsupportedValue", + kindName: ts.SyntaxKind[node.kind], + text: snippet(node), + type, + } as unknown as ValueDto; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts new file mode 100644 index 000000000..7a693ce31 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -0,0 +1,1109 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Expression lowering: ts.Expression -> ValueDto, emitting three-address + * statements into the current basic block as needed. + * + * Value discipline (verified against ArkAnalyzer ground truth): + * - operands of exprs, call arguments and array indices are IMMEDIATES + * (Local | Constant), hoisted into `%N` temps; + * - call instances are strictly Locals; + * - full exprs appear only as AssignStmt.right / CallStmt.expr; + * - `new C(args)` becomes `%t := NewExpr(C); %t := %t.constructor(args)`; + * - unresolved identifiers become Locals with UnknownType (e.g. `console`); + * - unresolved callees get a method signature with the UNKNOWN class. + */ + +import * as ts from "typescript"; +import { + ANONYMOUS_CLASS_PREFIX, + ANONYMOUS_METHOD_PREFIX, + CONSTRUCTOR_NAME, + DEFAULT_ARK_CLASS_NAME, +} from "../dto/constants"; +import { FieldDto, MethodDto } from "../dto/model"; +import { buildParameters, memberName, modifiersOf, returnTypeOf } from "./astUtils"; +import { BinaryOp, RelationOp, UnaryOp } from "../dto/ops"; +import { + ClassSignatureDto, + MethodParameterDto, + MethodSignatureDto, + UNKNOWN_CLASS_SIGNATURE, + UNKNOWN_FILE_SIGNATURE, +} from "../dto/signatures"; +import { + BOOLEAN_TYPE, + ClassTypeDto, + NUMBER_TYPE, + NULL_TYPE, + STRING_TYPE, + TypeDto, + UNDEFINED_TYPE, + UNKNOWN_TYPE, +} from "../dto/types"; +import { + CallExprDto, + ConditionExprDto, + ConstantDto, + ImmediateDto, + LValueDto, + LocalDto, + ValueDto, +} from "../dto/values"; +import { unsupportedValue } from "./diagnostics"; +import { MethodContext } from "./methodBuilder"; + +const RELATION_BY_SYNTAX: Partial> = { + [ts.SyntaxKind.EqualsEqualsToken]: "==", + [ts.SyntaxKind.ExclamationEqualsToken]: "!=", + [ts.SyntaxKind.EqualsEqualsEqualsToken]: "===", + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: "!==", + [ts.SyntaxKind.LessThanToken]: "<", + [ts.SyntaxKind.LessThanEqualsToken]: "<=", + [ts.SyntaxKind.GreaterThanToken]: ">", + [ts.SyntaxKind.GreaterThanEqualsToken]: ">=", + [ts.SyntaxKind.InKeyword]: "in", +}; + +const BINARY_BY_SYNTAX: Partial> = { + [ts.SyntaxKind.PlusToken]: "+", + [ts.SyntaxKind.MinusToken]: "-", + [ts.SyntaxKind.AsteriskToken]: "*", + [ts.SyntaxKind.SlashToken]: "/", + [ts.SyntaxKind.PercentToken]: "%", + [ts.SyntaxKind.AsteriskAsteriskToken]: "**", + [ts.SyntaxKind.LessThanLessThanToken]: "<<", + [ts.SyntaxKind.GreaterThanGreaterThanToken]: ">>", + [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: ">>>", + [ts.SyntaxKind.AmpersandToken]: "&", + [ts.SyntaxKind.BarToken]: "|", + [ts.SyntaxKind.CaretToken]: "^", + [ts.SyntaxKind.AmpersandAmpersandToken]: "&&", + [ts.SyntaxKind.BarBarToken]: "||", + [ts.SyntaxKind.QuestionQuestionToken]: "??", +}; + +/** Compound assignment operator -> the underlying binary op. */ +const COMPOUND_ASSIGN_BY_SYNTAX: Partial> = { + [ts.SyntaxKind.PlusEqualsToken]: "+", + [ts.SyntaxKind.MinusEqualsToken]: "-", + [ts.SyntaxKind.AsteriskEqualsToken]: "*", + [ts.SyntaxKind.SlashEqualsToken]: "/", + [ts.SyntaxKind.PercentEqualsToken]: "%", + [ts.SyntaxKind.AsteriskAsteriskEqualsToken]: "**", + [ts.SyntaxKind.LessThanLessThanEqualsToken]: "<<", + [ts.SyntaxKind.GreaterThanGreaterThanEqualsToken]: ">>", + [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken]: ">>>", + [ts.SyntaxKind.AmpersandEqualsToken]: "&", + [ts.SyntaxKind.BarEqualsToken]: "|", + [ts.SyntaxKind.CaretEqualsToken]: "^", + [ts.SyntaxKind.AmpersandAmpersandEqualsToken]: "&&", + [ts.SyntaxKind.BarBarEqualsToken]: "||", + [ts.SyntaxKind.QuestionQuestionEqualsToken]: "??", +}; + +/** Thrown internally for constructs the current milestone cannot lower; callers degrade to Raw*. */ +export class LoweringError extends Error {} + +/** Lowers the body of a nested function (closure / object-literal method) into a fresh MethodContext. */ +export type FunctionBodyLowerer = (m: MethodContext, body: ts.ConciseBody) => void; + +export class ExprLowerer { + constructor( + private readonly m: MethodContext, + private readonly lowerFunctionBody?: FunctionBodyLowerer, + ) {} + + // ------------------------------------------------------------------ + // Entry points + // ------------------------------------------------------------------ + + /** Lower to any value (full exprs allowed). Use only for AssignStmt.right / CallStmt. */ + lowerExpr(node: ts.Expression): ValueDto { + try { + return this.lowerExprImpl(node); + } catch (e) { + if (e instanceof LoweringError) { + this.m.diagnostics.warn(node, `unsupported expression: ${e.message}`); + // Raw fallback values are only legal as the RHS of a Local + // assignment (Kotlin's ensureOneAddress rejects EtsRawEntity in + // every other position), so hoist into a temp right away. + const type = this.safeTypeOf(node); + return this.materialize(unsupportedValue(node, type), type); + } + throw e; + } + } + + /** Lower to an immediate (Local | Constant), hoisting into a temp if needed. */ + lowerToImmediate(node: ts.Expression): ImmediateDto { + const value = this.lowerExpr(node); + if (value._ === "Local" || value._ === "Constant") { + return value; + } + return this.materialize(value, this.safeTypeOf(node)); + } + + /** Lower to a Local (call instances must be Locals). */ + lowerToLocal(node: ts.Expression): LocalDto { + const value = this.lowerExpr(node); + if (value._ === "Local") { + return value; + } + return this.materialize(value, this.safeTypeOf(node)); + } + + /** Hoist a value into a fresh temp: `%t := value`. */ + materialize(value: ValueDto, type: TypeDto = UNKNOWN_TYPE): LocalDto { + const temp = this.m.newTemp(type); + this.m.cfg.emit({ _: "AssignStmt", left: temp, right: value }); + return temp; + } + + // ------------------------------------------------------------------ + // Dispatch + // ------------------------------------------------------------------ + + private lowerExprImpl(node: ts.Expression): ValueDto { + if (ts.isNumericLiteral(node)) { + return constant(node.text, NUMBER_TYPE); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return constant(node.text, STRING_TYPE); + } + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return constant("true", BOOLEAN_TYPE); + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return constant("false", BOOLEAN_TYPE); + } + if (node.kind === ts.SyntaxKind.NullKeyword) { + return constant("null", NULL_TYPE); + } + if (node.kind === ts.SyntaxKind.ThisKeyword) { + return this.m.getOrCreateLocal("this", this.m.thisType()); + } + if (node.kind === ts.SyntaxKind.SuperKeyword) { + // `super` is the same object as `this`. + return this.m.getOrCreateLocal("this", this.m.thisType()); + } + if (ts.isIdentifier(node)) { + return this.lowerIdentifier(node); + } + if (ts.isParenthesizedExpression(node)) { + return this.lowerExprImpl(node.expression); + } + if (ts.isAsExpression(node)) { + return { _: "CastExpr", arg: this.lowerToImmediate(node.expression), type: this.m.converter.convertTypeNode(node.type) }; + } + if (ts.isTypeAssertionExpression(node)) { + return { _: "CastExpr", arg: this.lowerToImmediate(node.expression), type: this.m.converter.convertTypeNode(node.type) }; + } + if (ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) { + return this.lowerExprImpl(node.expression); + } + if (ts.isPropertyAccessExpression(node)) { + return this.lowerPropertyAccess(node); + } + if (ts.isElementAccessExpression(node)) { + return this.lowerElementAccess(node); + } + if (ts.isBinaryExpression(node)) { + return this.lowerBinary(node); + } + if (ts.isPrefixUnaryExpression(node)) { + return this.lowerPrefixUnary(node); + } + if (ts.isPostfixUnaryExpression(node)) { + return this.lowerPostfixUnary(node); + } + if (ts.isCallExpression(node)) { + return this.lowerCall(node); + } + if (ts.isNewExpression(node)) { + return this.lowerNew(node); + } + if (ts.isArrayLiteralExpression(node)) { + return this.lowerArrayLiteral(node); + } + if (ts.isTemplateExpression(node)) { + return this.lowerTemplate(node); + } + if (ts.isTypeOfExpression(node)) { + return { _: "TypeOfExpr", arg: this.lowerToImmediate(node.expression) }; + } + if (ts.isAwaitExpression(node)) { + return { _: "AwaitExpr", arg: this.lowerToImmediate(node.expression) }; + } + if (ts.isDeleteExpression(node)) { + return { _: "DeleteExpr", arg: this.lowerExpr(node.expression) }; + } + if (ts.isVoidExpression(node)) { + this.lowerDiscarded(node.expression); + return constant("undefined", UNDEFINED_TYPE); + } + if (ts.isConditionalExpression(node)) { + return this.lowerTernary(node); + } + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return this.lowerClosure(node); + } + if (ts.isObjectLiteralExpression(node)) { + return this.lowerObjectLiteral(node); + } + if (ts.isYieldExpression(node)) { + return { + _: "YieldExpr", + arg: node.expression !== undefined + ? this.lowerToImmediate(node.expression) + : constant("undefined", UNDEFINED_TYPE), + }; + } + throw new LoweringError(ts.SyntaxKind[node.kind]); + } + + // ------------------------------------------------------------------ + // Leaves + // ------------------------------------------------------------------ + + private lowerIdentifier(node: ts.Identifier): ValueDto { + if (node.text === "undefined") { + return constant("undefined", UNDEFINED_TYPE); + } + // Every named reference in a method body is a Local; unresolved globals + // (e.g. `console`) become Locals with UnknownType, same as ArkAnalyzer. + return this.m.getOrCreateLocal(node.text, this.safeTypeOf(node)); + } + + // ------------------------------------------------------------------ + // Field / array access + // ------------------------------------------------------------------ + + private lowerPropertyAccess(node: ts.PropertyAccessExpression): ValueDto { + const fieldName = node.name.text; + const fieldType = this.safeTypeOf(node); + + if (node.questionDotToken !== undefined) { + // a?.b + return this.optionalDiamond(node.expression, fieldType, (obj) => ({ + _: "InstanceFieldRef", + instance: obj, + field: { + declaringClass: this.classSignatureFromType(obj.type), + name: fieldName, + type: fieldType, + }, + })); + } + + // `this.f` inside a STATIC method addresses a static field of the class. + if (node.expression.kind === ts.SyntaxKind.ThisKeyword && this.m.isStaticMethod) { + return { + _: "StaticFieldRef", + field: { declaringClass: this.m.declaringClass, name: fieldName, type: fieldType }, + }; + } + + const staticTarget = this.classLikeSignatureOf(node.expression); + if (staticTarget !== undefined) { + return { + _: "StaticFieldRef", + field: { declaringClass: staticTarget, name: fieldName, type: fieldType }, + }; + } + + const instance = this.lowerToLocal(node.expression); + return { + _: "InstanceFieldRef", + instance, + field: { + declaringClass: this.classSignatureFromType(instance.type), + name: fieldName, + type: fieldType, + }, + }; + } + + private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto { + if (node.questionDotToken !== undefined) { + // a?.[i] + return this.optionalDiamond(node.expression, this.safeTypeOf(node), (obj) => ({ + _: "ArrayRef", + array: obj, + index: this.lowerToImmediate(node.argumentExpression), + type: this.safeTypeOf(node), + })); + } + return { + _: "ArrayRef", + array: this.lowerToImmediate(node.expression), + index: this.lowerToImmediate(node.argumentExpression), + type: this.safeTypeOf(node), + }; + } + + /** Assignment target. */ + lowerLValue(node: ts.Expression): LValueDto { + if (ts.isParenthesizedExpression(node)) { + return this.lowerLValue(node.expression); + } + if (ts.isIdentifier(node)) { + return this.m.getOrCreateLocal(node.text, this.safeTypeOf(node)); + } + if (ts.isPropertyAccessExpression(node)) { + const ref = this.lowerPropertyAccess(node); + if (ref._ === "InstanceFieldRef" || ref._ === "StaticFieldRef") { + return ref; + } + throw new LoweringError("property access did not produce a field ref"); + } + if (ts.isElementAccessExpression(node)) { + const ref = this.lowerElementAccess(node); + if (ref._ === "ArrayRef") { + return ref; + } + throw new LoweringError("element access did not produce an array ref"); + } + throw new LoweringError(`unsupported assignment target: ${ts.SyntaxKind[node.kind]}`); + } + + // ------------------------------------------------------------------ + // Binary / unary + // ------------------------------------------------------------------ + + private lowerBinary(node: ts.BinaryExpression): ValueDto { + const opKind = node.operatorToken.kind; + + if (opKind === ts.SyntaxKind.EqualsToken || COMPOUND_ASSIGN_BY_SYNTAX[opKind] !== undefined) { + return this.lowerAssignment(node); + } + if (opKind === ts.SyntaxKind.CommaToken) { + this.lowerDiscarded(node.left); + return this.lowerExprImpl(node.right); + } + if (opKind === ts.SyntaxKind.InstanceOfKeyword) { + return { + _: "InstanceOfExpr", + arg: this.lowerToImmediate(node.left), + checkType: this.checkTypeOf(node.right), + }; + } + + const relationOp = RELATION_BY_SYNTAX[opKind]; + if (relationOp !== undefined) { + return this.relation(relationOp, this.lowerToImmediate(node.left), this.lowerToImmediate(node.right)); + } + + const binaryOp = BINARY_BY_SYNTAX[opKind]; + if (binaryOp !== undefined) { + return { + _: "BinopExpr", + op: binaryOp, + left: this.lowerToImmediate(node.left), + right: this.lowerToImmediate(node.right), + type: this.safeTypeOf(node), + }; + } + + throw new LoweringError(`binary operator ${ts.SyntaxKind[opKind]}`); + } + + relation(op: RelationOp, left: ValueDto, right: ValueDto): ConditionExprDto { + return { _: "ConditionExpr", op, left, right, type: BOOLEAN_TYPE }; + } + + // ------------------------------------------------------------------ + // Conditions / branching + // ------------------------------------------------------------------ + + /** + * Lower a boolean context expression into a conditional branch. + * Direct comparisons branch on the ConditionExpr itself; anything else is + * normalized the ArkAnalyzer way: booleans as `v != false`, others as `v != 0`. + * `!x` swaps the branch targets. + */ + lowerCondition(node: ts.Expression, trueTarget: number, falseTarget: number): void { + if (ts.isParenthesizedExpression(node)) { + this.lowerCondition(node.expression, trueTarget, falseTarget); + return; + } + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { + this.lowerCondition(node.operand, falseTarget, trueTarget); + return; + } + if (ts.isBinaryExpression(node)) { + const relationOp = RELATION_BY_SYNTAX[node.operatorToken.kind]; + if (relationOp !== undefined) { + const condition = this.relation( + relationOp, + this.lowerToImmediate(node.left), + this.lowerToImmediate(node.right), + ); + this.m.cfg.branch(condition, trueTarget, falseTarget); + return; + } + } + const value = this.lowerToImmediate(node); + this.m.cfg.branch(this.truthyCondition(value), trueTarget, falseTarget); + } + + /** ArkAnalyzer truthiness normalization: `v != false` for booleans, `v != 0` otherwise. */ + truthyCondition(value: ValueDto): ConditionExprDto { + const valueType = value._ === "Local" || value._ === "Constant" ? value.type : UNKNOWN_TYPE; + if (valueType._ === "BooleanType") { + return this.relation("!=", value, constant("false", BOOLEAN_TYPE)); + } + return this.relation("!=", value, constant("0", NUMBER_TYPE)); + } + + /** `c ? a : b` -> branch diamond writing a shared temp. */ + private lowerTernary(node: ts.ConditionalExpression): ValueDto { + const cfg = this.m.cfg; + const result = this.m.newTemp(this.safeTypeOf(node)); + const trueLabel = cfg.newLabel(); + const falseLabel = cfg.newLabel(); + const joinLabel = cfg.newLabel(); + + this.lowerCondition(node.condition, trueLabel, falseLabel); + cfg.placeLabel(trueLabel); + cfg.emit({ _: "AssignStmt", left: result, right: this.lowerExpr(node.whenTrue) }); + cfg.goto(joinLabel); + cfg.placeLabel(falseLabel); + cfg.emit({ _: "AssignStmt", left: result, right: this.lowerExpr(node.whenFalse) }); + cfg.goto(joinLabel); + cfg.placeLabel(joinLabel); + return result; + } + + /** `x = e`, `x += e`, obj.f = e, arr[i] = e; returns the assigned value. */ + lowerAssignment(node: ts.BinaryExpression): ValueDto { + const opKind = node.operatorToken.kind; + const target = this.lowerLValue(node.left); + + let rhs: ValueDto; + const compoundOp = COMPOUND_ASSIGN_BY_SYNTAX[opKind]; + if (compoundOp !== undefined) { + // load-op-store (note: no short-circuit for &&= / ||= / ??= — approximation) + const oldValue = target._ === "Local" ? target : this.materialize(target, lvalueType(target)); + rhs = { + _: "BinopExpr", + op: compoundOp, + left: oldValue, + right: this.lowerToImmediate(node.right), + type: this.safeTypeOf(node), + }; + } else { + rhs = this.lowerExpr(node.right); + } + + // Only `local := ` may carry a full expr on the right; + // stores into refs take immediates (ArkAnalyzer discipline). + if (target._ !== "Local" && rhs._ !== "Local" && rhs._ !== "Constant") { + rhs = this.materialize(rhs, lvalueType(target)); + } + this.m.cfg.emit({ _: "AssignStmt", left: target, right: rhs }); + return target._ === "Local" ? target : (rhs as ImmediateDto); + } + + private lowerPrefixUnary(node: ts.PrefixUnaryExpression): ValueDto { + switch (node.operator) { + case ts.SyntaxKind.PlusToken: + return { _: "UnopExpr", op: "+", arg: this.lowerToImmediate(node.operand) }; + case ts.SyntaxKind.MinusToken: { + // Constant-fold negative literals: -5 => Constant("-5"). + if (ts.isNumericLiteral(node.operand)) { + return constant(`-${node.operand.text}`, NUMBER_TYPE); + } + return { _: "UnopExpr", op: "-", arg: this.lowerToImmediate(node.operand) }; + } + case ts.SyntaxKind.ExclamationToken: + return { _: "UnopExpr", op: "!", arg: this.lowerToImmediate(node.operand) }; + case ts.SyntaxKind.TildeToken: + return { _: "UnopExpr", op: "~", arg: this.lowerToImmediate(node.operand) }; + case ts.SyntaxKind.PlusPlusToken: + case ts.SyntaxKind.MinusMinusToken: + return this.lowerIncDec(node.operand, node.operator, /* returnOld */ false); + default: + throw new LoweringError(`prefix operator ${ts.SyntaxKind[node.operator]}`); + } + } + + private lowerPostfixUnary(node: ts.PostfixUnaryExpression): ValueDto { + return this.lowerIncDec(node.operand, node.operator, /* returnOld */ true); + } + + private lowerIncDec( + operand: ts.Expression, + operator: ts.SyntaxKind.PlusPlusToken | ts.SyntaxKind.MinusMinusToken, + returnOld: boolean, + ): ValueDto { + const op: UnaryOp = operator === ts.SyntaxKind.PlusPlusToken ? "++" : "--"; + const target = this.lowerLValue(operand); + + if (target._ === "Local") { + // Postfix needs a copy of the old value BEFORE the update. + const saved = returnOld ? this.materialize(target, target.type) : undefined; + this.m.cfg.emit({ _: "AssignStmt", left: target, right: { _: "UnopExpr", op, arg: target } }); + return saved ?? target; + } + + // Field/array target: load old, compute updated, store back. + // %old := ref; %new := %old ++; ref := %new + const oldValue = this.materialize(target, lvalueType(target)); + const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, lvalueType(target)); + this.m.cfg.emit({ _: "AssignStmt", left: target, right: updated }); + return returnOld ? oldValue : updated; + } + + // ------------------------------------------------------------------ + // Calls / new / literals + // ------------------------------------------------------------------ + + lowerCall(node: ts.CallExpression): ValueDto { + const callee = node.expression; + + // a?.b(...) / f?.(...) — wrap the whole call in a null-check diamond. + if (ts.isPropertyAccessExpression(callee) && callee.questionDotToken !== undefined) { + return this.optionalDiamond(callee.expression, this.safeTypeOf(node), (obj) => ({ + _: "InstanceCallExpr", + instance: obj, + method: this.methodSignatureForCall(node, callee.name.text, this.classSignatureFromType(obj.type)), + args: node.arguments.map((a) => + ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerToImmediate(a), + ), + })); + } + if (node.questionDotToken !== undefined) { + return this.optionalDiamond(callee, this.safeTypeOf(node), (obj) => ({ + _: "PtrCallExpr", + ptr: obj, + method: this.methodSignatureForCall(node, "%call", UNKNOWN_CLASS_SIGNATURE), + args: node.arguments.map((a) => + ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerToImmediate(a), + ), + })); + } + + const args = node.arguments.map((a) => + ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerToImmediate(a), + ); + + // `super(...)` — call the superclass constructor on `this`. + if (callee.kind === ts.SyntaxKind.SuperKeyword) { + const thisLocal = this.m.getOrCreateLocal("this", this.m.thisType()); + const superSignature = this.classSignatureFromType(this.safeTypeOf(callee)); + return { + _: "InstanceCallExpr", + instance: thisLocal, + method: { + declaringClass: superSignature, + name: CONSTRUCTOR_NAME, + parameters: [], + returnType: { _: "ClassType", signature: superSignature }, + }, + args, + }; + } + + // `super.m(...)` — instance call on `this` with the superclass as declaring class. + if (ts.isPropertyAccessExpression(callee) && callee.expression.kind === ts.SyntaxKind.SuperKeyword) { + const thisLocal = this.m.getOrCreateLocal("this", this.m.thisType()); + const superSignature = this.classSignatureFromType(this.safeTypeOf(callee.expression)); + return { + _: "InstanceCallExpr", + instance: thisLocal, + method: this.methodSignatureForCall(node, callee.name.text, superSignature), + args, + }; + } + + if (ts.isPropertyAccessExpression(callee)) { + const methodName = callee.name.text; + const staticTarget = this.classLikeSignatureOf(callee.expression); + if (staticTarget !== undefined) { + return { + _: "StaticCallExpr", + method: this.methodSignatureForCall(node, methodName, staticTarget), + args, + }; + } + const instance = this.lowerToLocal(callee.expression); + return { + _: "InstanceCallExpr", + instance, + method: this.methodSignatureForCall(node, methodName, this.classSignatureFromType(instance.type)), + args, + }; + } + + if (ts.isIdentifier(callee)) { + const resolved = this.resolveCalleeDeclaration(node); + if (resolved !== undefined && ts.isFunctionDeclaration(resolved) && isProjectFile(resolved)) { + // Free function declared in a project file: method of that file's %dflt class. + const declaringClass: ClassSignatureDto = { + name: DEFAULT_ARK_CLASS_NAME, + declaringFile: this.m.ctx.fileSignatureFor(resolved.getSourceFile()), + }; + return { _: "StaticCallExpr", method: this.methodSignatureForCall(node, callee.text, declaringClass), args }; + } + if (resolved === undefined && !isDeclaredLocalValue(this.m, callee)) { + // Fully unresolved global callee — static call with the UNKNOWN class. + return { + _: "StaticCallExpr", + method: this.methodSignatureForCall(node, callee.text, UNKNOWN_CLASS_SIGNATURE), + args, + }; + } + // Function value in a variable -> pointer call. + const localCallee = this.m.getOrCreateLocal(callee.text, this.safeTypeOf(callee)); + return { + _: "PtrCallExpr", + ptr: localCallee, + method: this.methodSignatureForCall(node, callee.text, UNKNOWN_CLASS_SIGNATURE), + args, + }; + } + + // Computed callee: evaluate to a local, pointer call. + const ptr = this.lowerToLocal(callee); + return { + _: "PtrCallExpr", + ptr, + method: this.methodSignatureForCall(node, "%call", UNKNOWN_CLASS_SIGNATURE), + args, + }; + } + + private lowerNew(node: ts.NewExpression): ValueDto { + const classType = this.newTargetClassType(node.expression); + const temp = this.m.newTemp(classType); + this.m.cfg.emit({ _: "AssignStmt", left: temp, right: { _: "NewExpr", classType } }); + + const args = (node.arguments ?? []).map((a) => + ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerToImmediate(a), + ); + const ctorSig: MethodSignatureDto = { + declaringClass: classType._ === "ClassType" ? classType.signature : UNKNOWN_CLASS_SIGNATURE, + name: CONSTRUCTOR_NAME, + parameters: this.constructorParameters(node), + returnType: classType, + }; + this.m.cfg.emit({ + _: "AssignStmt", + left: temp, + right: { _: "InstanceCallExpr", instance: temp, method: ctorSig, args }, + }); + return temp; + } + + private lowerArrayLiteral(node: ts.ArrayLiteralExpression): ValueDto { + const arrayType = this.safeTypeOf(node); + const elementType: TypeDto = arrayType._ === "ArrayType" ? arrayType.elementType : UNKNOWN_TYPE; + const temp = this.m.newTemp( + arrayType._ === "ArrayType" ? arrayType : { _: "ArrayType", elementType, dimensions: 1 }, + ); + this.m.cfg.emit({ + _: "AssignStmt", + left: temp, + right: { + _: "NewArrayExpr", + elementType, + size: constant(String(node.elements.length), NUMBER_TYPE), + }, + }); + node.elements.forEach((element, index) => { + const value = ts.isSpreadElement(element) ? this.spreadFallback(element) : this.lowerToImmediate(element); + this.m.cfg.emit({ + _: "AssignStmt", + left: { + _: "ArrayRef", + array: temp, + index: constant(String(index), NUMBER_TYPE), + type: elementType, + }, + right: value, + }); + }); + return temp; + } + + /** `a${x}b` -> chain of string `+` binops. */ + private lowerTemplate(node: ts.TemplateExpression): ValueDto { + let acc: ImmediateDto = constant(node.head.text, STRING_TYPE); + for (const span of node.templateSpans) { + const exprValue = this.lowerToImmediate(span.expression); + acc = this.materialize( + { _: "BinopExpr", op: "+", left: acc, right: exprValue, type: STRING_TYPE }, + STRING_TYPE, + ); + if (span.literal.text.length > 0) { + acc = this.materialize( + { _: "BinopExpr", op: "+", left: acc, right: constant(span.literal.text, STRING_TYPE), type: STRING_TYPE }, + STRING_TYPE, + ); + } + } + return acc; + } + + /** Evaluate an expression for side effects; discard the value. */ + lowerDiscarded(node: ts.Expression): void { + const value = this.lowerExpr(node); + if (value._ === "InstanceCallExpr" || value._ === "StaticCallExpr" || value._ === "PtrCallExpr") { + this.m.cfg.emit({ _: "CallStmt", expr: value }); + } else if (value._ !== "Local" && value._ !== "Constant") { + this.materialize(value); + } + } + + // ------------------------------------------------------------------ + // Closures / object literals + // ------------------------------------------------------------------ + + /** + * Arrow function / function expression -> anonymous `%AM$` method + * on the file's %dflt class; the use-site value is a Local of that name typed + * FunctionType (the ArkAnalyzer shape). Captured outer variables degrade to + * same-named locals inside the anonymous method (no LexicalEnvType yet). + */ + private lowerClosure(node: ts.ArrowFunction | ts.FunctionExpression): ValueDto { + if (this.lowerFunctionBody === undefined) { + throw new LoweringError("closure in a context without a body lowerer"); + } + const registry = this.m.ctx.anonymous; + const name = `${ANONYMOUS_METHOD_PREFIX}${registry.nextMethodId++}$${this.m.methodName}`; + const declaringClass = registry.defaultClassSignature; + + const { parameters, prologueParams } = buildParameters(this.m.ctx, node); + const returnType = returnTypeOf(this.m.ctx, node); + const signature: MethodSignatureDto = { declaringClass, name, parameters, returnType }; + + const closureContext = new MethodContext(this.m.ctx, declaringClass, name); + closureContext.emitPrologue(prologueParams); + this.lowerFunctionBody(closureContext, node.body); + registry.methods.push({ + signature, + modifiers: modifiersOf(node), + decorators: [], + body: closureContext.build(), + }); + + return this.m.getOrCreateLocal(name, { _: "FunctionType", signature }); + } + + /** + * Object literal -> anonymous `%AC$` class (category OBJECT) plus + * `new` + per-property stores at the use site. Literal methods become methods + * of the anonymous class. + */ + private lowerObjectLiteral(node: ts.ObjectLiteralExpression): ValueDto { + if (this.lowerFunctionBody === undefined) { + throw new LoweringError("object literal in a context without a body lowerer"); + } + const registry = this.m.ctx.anonymous; + const name = `${ANONYMOUS_CLASS_PREFIX}${registry.nextClassId++}$${this.m.methodName}`; + const signature: ClassSignatureDto = { + name, + declaringFile: registry.defaultClassSignature.declaringFile, + }; + const classType: ClassTypeDto = { _: "ClassType", signature }; + + // Evaluate property values BEFORE instantiation (source evaluation order). + const stores: { name: string; type: TypeDto; value: ValueDto }[] = []; + const fields: FieldDto[] = []; + const methods: MethodDto[] = []; + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const propName = memberName(property.name); + const propType = this.safeTypeOf(property.initializer); + fields.push(objectField(signature, propName, propType)); + stores.push({ name: propName, type: propType, value: this.lowerToImmediate(property.initializer) }); + } else if (ts.isShorthandPropertyAssignment(property)) { + const propName = property.name.text; + const local = this.m.getOrCreateLocal(propName, this.safeTypeOf(property.name)); + fields.push(objectField(signature, propName, local.type)); + stores.push({ name: propName, type: local.type, value: local }); + } else if (ts.isMethodDeclaration(property)) { + const methodName = memberName(property.name); + const { parameters, prologueParams } = buildParameters(this.m.ctx, property); + const methodSignature: MethodSignatureDto = { + declaringClass: signature, + name: methodName, + parameters, + returnType: returnTypeOf(this.m.ctx, property), + }; + const methodContext = new MethodContext(this.m.ctx, signature, methodName); + methodContext.emitPrologue(prologueParams); + if (property.body !== undefined) { + this.lowerFunctionBody(methodContext, property.body); + } + methods.push({ + signature: methodSignature, + modifiers: modifiersOf(property), + decorators: [], + body: methodContext.build(), + }); + } else { + // spread / accessors / computed names degrade the whole literal + throw new LoweringError(`object literal member: ${ts.SyntaxKind[property.kind]}`); + } + } + + registry.classes.push({ + signature, + modifiers: 0, + decorators: [], + category: 5, // OBJECT + superClassName: "", + implementedInterfaceNames: [], + fields, + methods, + }); + + const temp = this.m.newTemp(classType); + this.m.cfg.emit({ _: "AssignStmt", left: temp, right: { _: "NewExpr", classType } }); + for (const store of stores) { + this.m.cfg.emit({ + _: "AssignStmt", + left: { + _: "InstanceFieldRef", + instance: temp, + field: { declaringClass: signature, name: store.name, type: store.type }, + }, + right: store.value, + }); + } + return temp; + } + + // ------------------------------------------------------------------ + // Optional chaining + // ------------------------------------------------------------------ + + /** + * `obj?.access` -> null-check diamond: + * if (obj != null) %t := ; else %t := undefined + * (loose `!= null` also covers undefined). + */ + private optionalDiamond( + objectNode: ts.Expression, + resultType: TypeDto, + access: (obj: LocalDto) => ValueDto, + ): LocalDto { + const cfg = this.m.cfg; + const obj = this.lowerToLocal(objectNode); + const result = this.m.newTemp(resultType); + const accessLabel = cfg.newLabel(); + const elseLabel = cfg.newLabel(); + const joinLabel = cfg.newLabel(); + + cfg.branch(this.relation("!=", obj, constant("null", NULL_TYPE)), accessLabel, elseLabel); + cfg.placeLabel(accessLabel); + cfg.emit({ _: "AssignStmt", left: result, right: access(obj) }); + cfg.goto(joinLabel); + cfg.placeLabel(elseLabel); + cfg.emit({ _: "AssignStmt", left: result, right: constant("undefined", UNDEFINED_TYPE) }); + cfg.goto(joinLabel); + cfg.placeLabel(joinLabel); + return result; + } + + // ------------------------------------------------------------------ + // Resolution helpers + // ------------------------------------------------------------------ + + private safeTypeOf(node: ts.Node): TypeDto { + return this.m.converter.typeOfNode(node); + } + + /** + * If the expression statically refers to a class-like declaration + * (class / enum — e.g. `Math`, `E` in `E.A`, `Foo` in `Foo.bar()`), + * return its class signature; project classes get real signatures, + * ambient ones get the %unk file. + */ + private classLikeSignatureOf(node: ts.Expression): ClassSignatureDto | undefined { + if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) { + return undefined; + } + try { + let symbol = this.m.checker.getSymbolAtLocation(ts.isIdentifier(node) ? node : node.name); + if (symbol === undefined) return undefined; + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = this.m.checker.getAliasedSymbol(symbol); + } + const decl = symbol.declarations?.find( + (d): d is ts.ClassDeclaration | ts.EnumDeclaration => + ts.isClassDeclaration(d) || ts.isEnumDeclaration(d), + ); + if (decl === undefined) return undefined; + if (isProjectFile(decl)) { + return this.m.converter.classSignatureOf(decl); + } + const name = decl.name !== undefined && ts.isIdentifier(decl.name) ? decl.name.text : ""; + return { name, declaringFile: UNKNOWN_FILE_SIGNATURE }; + } catch { + return undefined; + } + } + + private classSignatureFromType(type: TypeDto): ClassSignatureDto { + if (type._ === "ClassType") { + return type.signature; + } + return UNKNOWN_CLASS_SIGNATURE; + } + + /** ClassType for `new X(...)`; ambient classes get the %unk file (ArkAnalyzer convention). */ + private newTargetClassType(callee: ts.Expression): ClassTypeDto { + const signature = this.classLikeSignatureOf(callee); + if (signature !== undefined) { + return { _: "ClassType", signature }; + } + const name = ts.isIdentifier(callee) + ? callee.text + : ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ""; + return { _: "ClassType", signature: { name, declaringFile: UNKNOWN_FILE_SIGNATURE } }; + } + + private resolveCalleeDeclaration(node: ts.CallExpression): ts.Declaration | undefined { + try { + const signature = this.m.checker.getResolvedSignature(node); + return signature?.getDeclaration(); + } catch { + return undefined; + } + } + + /** Method signature for a call site, resolved through the checker when possible. */ + private methodSignatureForCall( + node: ts.CallExpression, + name: string, + declaringClass: ClassSignatureDto, + ): MethodSignatureDto { + let parameters: MethodParameterDto[] = []; + let returnType: TypeDto = UNKNOWN_TYPE; + try { + const signature = this.m.checker.getResolvedSignature(node); + if (signature !== undefined) { + const decl = signature.getDeclaration(); + if (decl !== undefined && isProjectFile(decl)) { + parameters = this.parametersOfDeclaration(decl); + } + returnType = this.m.converter.convertType(this.m.checker.getReturnTypeOfSignature(signature)); + } + } catch { + // keep unknowns + } + return { declaringClass, name, parameters, returnType }; + } + + parametersOfDeclaration(decl: ts.SignatureDeclaration): MethodParameterDto[] { + return decl.parameters + .filter((p) => p.name.kind !== ts.SyntaxKind.Identifier || (p.name as ts.Identifier).text !== "this") + .map((p) => { + const param: MethodParameterDto = { + name: ts.isIdentifier(p.name) ? p.name.text : "%pat", + type: + p.type !== undefined + ? this.m.converter.convertTypeNode(p.type) + : this.m.converter.typeOfNode(p), + }; + if (p.questionToken !== undefined) param.isOptional = true; + if (p.dotDotDotToken !== undefined) param.isRest = true; + return param; + }); + } + + private constructorParameters(node: ts.NewExpression): MethodParameterDto[] { + try { + const signature = this.m.checker.getResolvedSignature(node); + const decl = signature?.getDeclaration(); + if (decl !== undefined && ts.isConstructorDeclaration(decl) && isProjectFile(decl)) { + return this.parametersOfDeclaration(decl); + } + } catch { + // fall through + } + return []; + } + + private checkTypeOf(node: ts.Expression): TypeDto { + const signature = this.classLikeSignatureOf(node); + if (signature !== undefined) { + return { _: "ClassType", signature }; + } + if (ts.isIdentifier(node)) { + return { _: "UnclearReferenceType", name: node.text }; + } + return UNKNOWN_TYPE; + } + + private spreadFallback(node: ts.SpreadElement): ValueDto { + this.m.diagnostics.warn(node, "spread arguments are not supported yet"); + // Hoisted for the same reason as in lowerExpr: raw values are only + // legal as the RHS of a Local assignment. + const type = this.safeTypeOf(node.expression); + return this.materialize(unsupportedValue(node, type), type); + } +} + +// ---------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------- + +export function constant(value: string, type: TypeDto): ConstantDto { + return { _: "Constant", value, type }; +} + +function objectField(declaringClass: ClassSignatureDto, name: string, type: TypeDto): FieldDto { + return { + signature: { declaringClass, name, type }, + modifiers: 0, + decorators: [], + questionToken: false, + exclamationToken: false, + }; +} + +function lvalueType(target: LValueDto): TypeDto { + switch (target._) { + case "Local": + case "ArrayRef": + return target.type; + case "InstanceFieldRef": + case "StaticFieldRef": + return target.field.type; + } +} + +function isProjectFile(decl: ts.Node): boolean { + return !decl.getSourceFile().isDeclarationFile; +} + +/** Whether the identifier refers to a value declared somewhere in project code. */ +function isDeclaredLocalValue(m: MethodContext, node: ts.Identifier): boolean { + try { + const symbol = m.checker.getSymbolAtLocation(node); + const decl = symbol?.declarations?.[0]; + return decl !== undefined && !decl.getSourceFile().isDeclarationFile; + } catch { + return false; + } +} diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts new file mode 100644 index 000000000..9853e5f0c --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -0,0 +1,413 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * File-level lowering: ts.SourceFile -> EtsFileDto. + * + * Structure conventions (ArkAnalyzer-compatible): + * - every file (and every namespace) gets a default class `%dflt`; + * - loose top-level statements form its default method `%dflt`; + * - function declarations become methods of the enclosing `%dflt`; + * - classes/interfaces/enums become ClassDto entries; + * - namespaces become NamespaceDto entries (recursively); + * - imports/exports become Import/ExportInfoDto entries (M6). + */ + +import * as ts from "typescript"; +import { + DEFAULT_ARK_CLASS_NAME, + DEFAULT_ARK_METHOD_NAME, + ExportType, + ExportTypeValue, + ImportType, + Modifier, +} from "../dto/constants"; +import { ClassDto, EtsFileDto, ExportInfoDto, ImportInfoDto, MethodDto, NamespaceDto } from "../dto/model"; +import { ClassSignatureDto, FileSignatureDto, NamespaceSignatureDto } from "../dto/signatures"; +import { VOID_TYPE } from "../dto/types"; +import { TypeConverter } from "../types/convert"; +import { modifiersOf } from "./astUtils"; +import { ClassBuilder } from "./classBuilder"; +import { Diagnostics } from "./diagnostics"; +import { AnonymousRegistry, LoweringContext, MethodContext } from "./methodBuilder"; +import { StmtLowerer } from "./stmtLowering"; + +export interface BuildFileOptions { + projectName: string; + /** Project-relative file name, e.g. `src/foo.ts`. */ + fileName: string; + /** Resolve signatures for OTHER project files (project mode); defaults to %unk. */ + fileSignatureFor?: (sf: ts.SourceFile) => FileSignatureDto; +} + +export function buildEtsFile( + program: ts.Program, + sourceFile: ts.SourceFile, + options: BuildFileOptions, + diagnostics: Diagnostics = new Diagnostics(), +): EtsFileDto { + const fileSignature: FileSignatureDto = { + projectName: options.projectName, + fileName: options.fileName, + }; + const fileSignatureFor = (sf: ts.SourceFile): FileSignatureDto => { + if (sf === sourceFile) { + return fileSignature; + } + if (options.fileSignatureFor !== undefined) { + return options.fileSignatureFor(sf); + } + return { projectName: "%unk", fileName: "%unk" }; + }; + + const checker = program.getTypeChecker(); + const converter = new TypeConverter(checker, fileSignatureFor); + const anonymous: AnonymousRegistry = { + defaultClassSignature: { name: DEFAULT_ARK_CLASS_NAME, declaringFile: fileSignature }, + methods: [], + classes: [], + nextMethodId: 0, + nextClassId: 0, + }; + const ctx: LoweringContext = { checker, converter, fileSignatureFor, diagnostics, anonymous }; + + const builder = new FileBuilder(ctx, fileSignature); + return builder.build(sourceFile); +} + +/** Lowered contents of a statement scope (source file or namespace body). */ +interface ScopeContents { + classes: ClassDto[]; + namespaces: NamespaceDto[]; +} + +class FileBuilder { + private readonly classBuilder: ClassBuilder; + + constructor( + private readonly ctx: LoweringContext, + private readonly fileSignature: FileSignatureDto, + ) { + this.classBuilder = new ClassBuilder(ctx); + } + + build(sourceFile: ts.SourceFile): EtsFileDto { + const contents = this.buildScope(sourceFile.statements, undefined); + + // Anonymous methods (closures) and classes (object literals) registered + // during body lowering are attached to the file's %dflt class / class list. + const defaultClass = contents.classes.find( + (c) => c.signature.name === DEFAULT_ARK_CLASS_NAME && c.signature.declaringNamespace === undefined, + ); + defaultClass?.methods.push(...this.ctx.anonymous.methods); + contents.classes.push(...this.ctx.anonymous.classes); + + return { + signature: this.fileSignature, + namespaces: contents.namespaces, + classes: contents.classes, + importInfos: this.buildImportInfos(sourceFile), + exportInfos: this.buildExportInfos(sourceFile), + }; + } + + // ------------------------------------------------------------------ + // Imports / exports + // ------------------------------------------------------------------ + + private buildImportInfos(sourceFile: ts.SourceFile): ImportInfoDto[] { + const infos: ImportInfoDto[] = []; + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + const importFrom = ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : ""; + const clause = statement.importClause; + if (clause === undefined) { + // Side-effect import: `import "module"`. + infos.push(this.importInfo("", "", importFrom)); + continue; + } + if (clause.name !== undefined) { + // Default import: `import d from "module"`. + infos.push(this.importInfo(clause.name.text, "Identifier", importFrom)); + } + const bindings = clause.namedBindings; + if (bindings !== undefined) { + if (ts.isNamespaceImport(bindings)) { + // `import * as ns from "module"`. + infos.push(this.importInfo(bindings.name.text, "NamespaceImport", importFrom)); + } else { + // `import { a, b as c } from "module"`. + for (const element of bindings.elements) { + const info = this.importInfo(element.name.text, "NamedImports", importFrom); + if (element.propertyName !== undefined) { + info.nameBeforeAs = element.propertyName.text; + } + infos.push(info); + } + } + } + } + return infos; + } + + private importInfo(importName: string, importType: ImportType, importFrom: string): ImportInfoDto { + return { importName, importType, importFrom, modifiers: 0 }; + } + + private buildExportInfos(sourceFile: ts.SourceFile): ExportInfoDto[] { + const infos: ExportInfoDto[] = []; + for (const statement of sourceFile.statements) { + // Exported declarations: `export class C {}`, `export function f() {}`, ... + const modifiers = modifiersOf(statement); + if ((modifiers & Modifier.EXPORT) !== 0) { + const name = declarationName(statement); + if (name !== undefined) { + infos.push({ + exportName: name, + exportType: exportTypeOfDeclaration(statement), + modifiers, + }); + } else if (ts.isVariableStatement(statement)) { + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name)) { + infos.push({ exportName: decl.name.text, exportType: ExportType.LOCAL, modifiers }); + } + } + } + continue; + } + // Re-exports: `export { a, b as c } [from "module"]`, `export * from "module"`. + if (ts.isExportDeclaration(statement)) { + const exportFrom = + statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier) + ? statement.moduleSpecifier.text + : undefined; + if (statement.exportClause === undefined) { + // `export * from "module"`. + const info: ExportInfoDto = { exportName: "*", exportType: ExportType.UNKNOWN, modifiers: 0 }; + if (exportFrom !== undefined) info.exportFrom = exportFrom; + infos.push(info); + } else if (ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + const info: ExportInfoDto = { + exportName: element.name.text, + exportType: this.exportTypeOfSymbol(element.name), + modifiers: 0, + }; + if (element.propertyName !== undefined) info.nameBeforeAs = element.propertyName.text; + if (exportFrom !== undefined) info.exportFrom = exportFrom; + infos.push(info); + } + } else if (ts.isNamespaceExport(statement.exportClause)) { + // `export * as ns from "module"` — star re-export with an alias + // (nameBeforeAs "*" drives the model's isStarReExport). + const info: ExportInfoDto = { + exportName: statement.exportClause.name.text, + exportType: ExportType.NAMESPACE, + nameBeforeAs: "*", + modifiers: 0, + }; + if (exportFrom !== undefined) info.exportFrom = exportFrom; + infos.push(info); + } + continue; + } + // `export default ;` / `export = ;` + if (ts.isExportAssignment(statement)) { + const name = ts.isIdentifier(statement.expression) ? statement.expression.text : "default"; + infos.push({ + exportName: name, + exportType: this.exportTypeOfSymbol(statement.expression), + modifiers: Modifier.DEFAULT, + }); + } + } + return infos; + } + + /** Export type of a re-exported name, resolved through the checker. */ + private exportTypeOfSymbol(node: ts.Node): ExportTypeValue { + try { + let symbol = this.ctx.checker.getSymbolAtLocation(node); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = this.ctx.checker.getAliasedSymbol(symbol); + } + const decl = symbol?.declarations?.[0]; + if (decl !== undefined) { + return exportTypeOfDeclaration(decl); + } + } catch { + // fall through + } + return ExportType.UNKNOWN; + } + + /** + * Lower the statements of a scope (file or namespace body): + * a `%dflt` class collecting loose statements + functions, plus + * declared classes/interfaces/enums and nested namespaces. + */ + private buildScope( + statements: readonly ts.Statement[], + declaringNamespace: NamespaceSignatureDto | undefined, + ): ScopeContents { + const classes: ClassDto[] = []; + const namespaces: NamespaceDto[] = []; + + const defaultClassSignature: ClassSignatureDto = { + name: DEFAULT_ARK_CLASS_NAME, + declaringFile: this.fileSignature, + }; + if (declaringNamespace !== undefined) { + defaultClassSignature.declaringNamespace = declaringNamespace; + } + + const methods: MethodDto[] = [this.buildDefaultMethod(defaultClassSignature, statements)]; + + for (const statement of statements) { + if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) { + methods.push(this.classBuilder.buildMethodFromDecl(defaultClassSignature, statement)); + } else if (ts.isClassDeclaration(statement)) { + classes.push(this.classBuilder.buildClass(statement)); + } else if (ts.isInterfaceDeclaration(statement)) { + classes.push(this.classBuilder.buildInterface(statement)); + } else if (ts.isEnumDeclaration(statement)) { + classes.push(this.classBuilder.buildEnum(statement)); + } else if (ts.isModuleDeclaration(statement)) { + const namespace = this.buildNamespace(statement); + if (namespace !== undefined) { + namespaces.push(namespace); + } + } + } + + classes.unshift({ + signature: defaultClassSignature, + modifiers: 0, + decorators: [], + category: 0, + superClassName: "", + implementedInterfaceNames: [], + fields: [], + methods, + }); + + return { classes, namespaces }; + } + + private buildNamespace(decl: ts.ModuleDeclaration): NamespaceDto | undefined { + if (!ts.isIdentifier(decl.name)) { + this.ctx.diagnostics.warn(decl, "string-named modules are not supported"); + return undefined; + } + // `namespace A.B {}` nests; resolve the innermost body. + let body = decl.body; + let signature: NamespaceSignatureDto = { + name: decl.name.text, + declaringFile: this.fileSignature, + }; + const outer = this.ctx.converter.namespaceSignatureOf(decl); + if (outer !== undefined) { + signature.declaringNamespace = outer; + } + + while (body !== undefined && ts.isModuleDeclaration(body)) { + const innerSignature: NamespaceSignatureDto = { + name: ts.isIdentifier(body.name) ? body.name.text : "%unk", + declaringFile: this.fileSignature, + declaringNamespace: signature, + }; + signature = innerSignature; + body = body.body; + } + if (body === undefined || !ts.isModuleBlock(body)) { + // Ambient namespace without a body. + return { signature, classes: [], namespaces: [] }; + } + + const contents = this.buildScope(body.statements, signature); + return { + signature, + classes: contents.classes, + namespaces: contents.namespaces, + }; + } + + /** Loose scope statements -> `%dflt` method (impl continues below). */ + private buildDefaultMethod( + declaringClass: ClassSignatureDto, + statements: readonly ts.Statement[], + ): MethodDto { + const m = new MethodContext(this.ctx, declaringClass, DEFAULT_ARK_METHOD_NAME); + m.emitPrologue([]); + const lowerer = new StmtLowerer(m); + for (const statement of statements) { + lowerer.lowerStatement(statement); + } + return { + signature: { + declaringClass, + name: DEFAULT_ARK_METHOD_NAME, + parameters: [], + returnType: VOID_TYPE, + }, + modifiers: 0, + decorators: [], + body: m.build(), + }; + } +} + +// ---------------------------------------------------------------------- +// Export helpers +// ---------------------------------------------------------------------- + +function declarationName(node: ts.Node): string | undefined { + if ( + (ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isFunctionDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isModuleDeclaration(node)) && + node.name !== undefined && + ts.isIdentifier(node.name) + ) { + return node.name.text; + } + return undefined; +} + +function exportTypeOfDeclaration(node: ts.Node): ExportTypeValue { + if (ts.isClassDeclaration(node) || ts.isEnumDeclaration(node)) { + return ExportType.CLASS; + } + if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) { + return ExportType.METHOD; + } + if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { + return ExportType.TYPE; + } + if (ts.isModuleDeclaration(node)) { + return ExportType.NAMESPACE; + } + if (ts.isVariableStatement(node) || ts.isVariableDeclaration(node)) { + return ExportType.LOCAL; + } + return ExportType.UNKNOWN; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts new file mode 100644 index 000000000..5bd6488b8 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as ts from "typescript"; +import { TEMP_LOCAL_PREFIX } from "../dto/constants"; +import { BodyDto, ClassDto, LocalDeclDto, MethodDto } from "../dto/model"; +import { ClassSignatureDto, FileSignatureDto } from "../dto/signatures"; +import { ClassTypeDto, TypeDto, UNKNOWN_TYPE } from "../dto/types"; +import { LocalDto } from "../dto/values"; +import { TypeConverter } from "../types/convert"; +import { CfgBuilder } from "./cfg"; +import { Diagnostics } from "./diagnostics"; + +/** + * Registry for anonymous methods (`%AM$`, closures) and anonymous + * classes (`%AC$`, object literals) created while lowering bodies. + * Everything registered here is attached to the file's `%dflt` class / + * top-level class list when the file is assembled. + */ +export interface AnonymousRegistry { + defaultClassSignature: ClassSignatureDto; + methods: MethodDto[]; + classes: ClassDto[]; + nextMethodId: number; + nextClassId: number; +} + +/** Shared per-file lowering context. */ +export interface LoweringContext { + checker: ts.TypeChecker; + converter: TypeConverter; + fileSignatureFor(sf: ts.SourceFile): FileSignatureDto; + diagnostics: Diagnostics; + anonymous: AnonymousRegistry; +} + +/** + * Per-method lowering state: locals table, temp counter, CFG builder. + * + * Prologue convention (matches ArkAnalyzer): + * `param_i := ParameterRef(i)` for each parameter, then `this := ThisRef(classType)`. + */ +export class MethodContext { + readonly cfg = new CfgBuilder(); + private readonly locals = new Map(); + private tempCount = 0; + + constructor( + readonly ctx: LoweringContext, + readonly declaringClass: ClassSignatureDto, + readonly methodName: string, + /** In static methods `this` refers to the class itself (static field access). */ + readonly isStaticMethod: boolean = false, + ) {} + + get checker(): ts.TypeChecker { + return this.ctx.checker; + } + + get converter(): TypeConverter { + return this.ctx.converter; + } + + get diagnostics(): Diagnostics { + return this.ctx.diagnostics; + } + + thisType(): ClassTypeDto { + return { _: "ClassType", signature: this.declaringClass }; + } + + /** + * Local by name, declared on first use. + * A known (non-Unknown) type upgrades a previously recorded UnknownType. + */ + getOrCreateLocal(name: string, type: TypeDto = UNKNOWN_TYPE): LocalDto { + const existing = this.locals.get(name); + if (existing !== undefined) { + if (existing.type._ === "UnknownType" && type._ !== "UnknownType") { + existing.type = type; + } + return existing; + } + const local: LocalDto = { _: "Local", name, type }; + this.locals.set(name, local); + return local; + } + + /** Fresh temp local `%N`. */ + newTemp(type: TypeDto = UNKNOWN_TYPE): LocalDto { + const name = `${TEMP_LOCAL_PREFIX}${this.tempCount++}`; + const local: LocalDto = { _: "Local", name, type }; + this.locals.set(name, local); + return local; + } + + /** Emit the standard prologue: parameter assignments, then `this := ThisRef`. */ + emitPrologue(parameters: { name: string; type: TypeDto }[]): void { + parameters.forEach((param, index) => { + const local = this.getOrCreateLocal(param.name, param.type); + this.cfg.emit({ + _: "AssignStmt", + left: local, + right: { _: "ParameterRef", index, type: param.type }, + }); + }); + const thisType = this.thisType(); + const thisLocal = this.getOrCreateLocal("this", thisType); + this.cfg.emit({ + _: "AssignStmt", + left: thisLocal, + right: { _: "ThisRef", type: thisType }, + }); + } + + build(): BodyDto { + const cfg = this.cfg.finalize(); + const locals: LocalDeclDto[] = [...this.locals.values()].map((l) => ({ + name: l.name, + type: l.type, + })); + return { locals, cfg }; + } +} diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts new file mode 100644 index 000000000..2455f5b45 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -0,0 +1,781 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Statement lowering: ts.Statement -> stmts/terminators in the CFG builder. + * + * Any statement kind that cannot be lowered degrades to a raw fallback stmt + * plus a diagnostic — the frontend never fails on parseable input. + */ + +import * as ts from "typescript"; +import { MethodSignatureDto, UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; +import { BOOLEAN_TYPE, NUMBER_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOWN_TYPE } from "../dto/types"; +import { LocalDto, ValueDto } from "../dto/values"; +import { buildParameters, modifiersOf, returnTypeOf } from "./astUtils"; +import { Label } from "./cfg"; +import { unsupportedStmt } from "./diagnostics"; +import { ExprLowerer, LoweringError, constant } from "./exprLowering"; +import { MethodContext } from "./methodBuilder"; + +/** Target labels for break/continue resolution. */ +interface BreakableContext { + kind: "loop" | "switch" | "labeled-block"; + breakTarget: Label; + continueTarget?: Label; + label?: string; + /** finallyScopes depth at the moment this breakable was entered. */ + finallyDepth?: number; +} + +export class StmtLowerer { + readonly expr: ExprLowerer; + private readonly breakables: BreakableContext[] = []; + /** finally blocks of the enclosing try statements (outermost first). */ + private readonly finallyScopes: ts.Block[] = []; + private pendingLabel: string | undefined; + + constructor(private readonly m: MethodContext) { + // Nested function bodies (closures, object-literal methods) are lowered + // with a fresh StmtLowerer over their own MethodContext. + this.expr = new ExprLowerer(m, (nestedContext, body) => { + const nested = new StmtLowerer(nestedContext); + if (ts.isBlock(body)) { + nested.lowerStatements(body.statements); + } else { + nestedContext.cfg.ret(nested.expr.lowerToImmediate(body)); + } + }); + } + + lowerStatements(statements: readonly ts.Statement[]): void { + for (const statement of statements) { + this.lowerStatement(statement); + } + } + + lowerStatement(node: ts.Statement): void { + try { + this.lowerStatementImpl(node); + } catch (e) { + if (e instanceof LoweringError) { + this.m.diagnostics.warn(node, `unsupported statement: ${e.message}`); + this.m.cfg.emit(unsupportedStmt(node)); + return; + } + throw e; + } + } + + private lowerStatementImpl(node: ts.Statement): void { + // The pending label (from a LabeledStatement) applies only to the directly wrapped stmt. + const label = this.pendingLabel; + this.pendingLabel = undefined; + + if (ts.isVariableStatement(node)) { + this.lowerVariableDeclarations(node.declarationList); + return; + } + if (ts.isIfStatement(node)) { + this.lowerIf(node); + return; + } + if (ts.isWhileStatement(node)) { + this.lowerWhile(node, label); + return; + } + if (ts.isDoStatement(node)) { + this.lowerDoWhile(node, label); + return; + } + if (ts.isForStatement(node)) { + this.lowerFor(node, label); + return; + } + if (ts.isForOfStatement(node)) { + this.lowerForOf(node, label); + return; + } + if (ts.isForInStatement(node)) { + this.lowerForIn(node, label); + return; + } + if (ts.isSwitchStatement(node)) { + this.lowerSwitch(node, label); + return; + } + if (ts.isBreakStatement(node)) { + this.lowerBreak(node); + return; + } + if (ts.isContinueStatement(node)) { + this.lowerContinue(node); + return; + } + if (ts.isLabeledStatement(node)) { + this.lowerLabeled(node); + return; + } + if (ts.isTryStatement(node)) { + this.lowerTry(node); + return; + } + if (ts.isExpressionStatement(node)) { + this.expr.lowerDiscarded(node.expression); + return; + } + if (ts.isReturnStatement(node)) { + // JS evaluates the return value BEFORE finally blocks run; capture it + // in a temp so finally-code mutations cannot change what is returned. + let value = node.expression !== undefined ? this.expr.lowerToImmediate(node.expression) : undefined; + if (value !== undefined && value._ === "Local" && this.finallyScopes.length > 0) { + value = this.expr.materialize(value, value.type); + } + this.emitFinallies(0); + this.m.cfg.ret(value); + return; + } + if (ts.isThrowStatement(node)) { + // Exceptions propagate through finally blocks: run them before the throw. + const value = this.expr.lowerToImmediate(node.expression); + this.emitFinallies(0); + this.m.cfg.throwValue(value); + return; + } + if (ts.isBlock(node)) { + this.lowerStatements(node.statements); + return; + } + if (node.kind === ts.SyntaxKind.EmptyStatement) { + return; + } + if (ts.isFunctionDeclaration(node)) { + // Top-level / namespace-level functions are lowered by the scope + // builder (fileBuilder); NESTED functions would otherwise be lost — + // lift them onto the file's %dflt class, like closures but keeping + // their real name (call sites resolve them as %dflt static calls). + const parent: ts.Node | undefined = node.parent; + const handledByScopeBuilder = + parent !== undefined && (ts.isSourceFile(parent) || ts.isModuleBlock(parent)); + if (!handledByScopeBuilder) { + this.lowerNestedFunction(node); + } + return; + } + if ( + ts.isClassDeclaration(node) || + ts.isInterfaceDeclaration(node) || + ts.isEnumDeclaration(node) || + ts.isModuleDeclaration(node) || + ts.isTypeAliasDeclaration(node) || + ts.isImportDeclaration(node) || + ts.isExportDeclaration(node) || + ts.isExportAssignment(node) || + ts.isImportEqualsDeclaration(node) + ) { + // Declarations are handled at file/class level, not as body statements. + return; + } + throw new LoweringError(ts.SyntaxKind[node.kind]); + } + + // ------------------------------------------------------------------ + // Control flow + // ------------------------------------------------------------------ + + private lowerIf(node: ts.IfStatement): void { + const cfg = this.m.cfg; + const thenLabel = cfg.newLabel(); + const endLabel = cfg.newLabel(); + const elseLabel = node.elseStatement !== undefined ? cfg.newLabel() : endLabel; + + this.expr.lowerCondition(node.expression, thenLabel, elseLabel); + cfg.placeLabel(thenLabel); + this.lowerStatement(node.thenStatement); + cfg.goto(endLabel); + if (node.elseStatement !== undefined) { + cfg.placeLabel(elseLabel); + this.lowerStatement(node.elseStatement); + cfg.goto(endLabel); + } + cfg.placeLabel(endLabel); + } + + private lowerWhile(node: ts.WhileStatement, label: string | undefined): void { + const cfg = this.m.cfg; + const headLabel = cfg.newLabel(); + const bodyLabel = cfg.newLabel(); + const exitLabel = cfg.newLabel(); + + cfg.placeLabel(headLabel); + this.expr.lowerCondition(node.expression, bodyLabel, exitLabel); + cfg.placeLabel(bodyLabel); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => { + this.lowerStatement(node.statement); + }); + cfg.goto(headLabel); + cfg.placeLabel(exitLabel); + } + + private lowerDoWhile(node: ts.DoStatement, label: string | undefined): void { + const cfg = this.m.cfg; + const bodyLabel = cfg.newLabel(); + const condLabel = cfg.newLabel(); + const exitLabel = cfg.newLabel(); + + cfg.placeLabel(bodyLabel); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: condLabel, label }, () => { + this.lowerStatement(node.statement); + }); + cfg.placeLabel(condLabel); + this.expr.lowerCondition(node.expression, bodyLabel, exitLabel); + cfg.placeLabel(exitLabel); + } + + private lowerFor(node: ts.ForStatement, label: string | undefined): void { + const cfg = this.m.cfg; + if (node.initializer !== undefined) { + if (ts.isVariableDeclarationList(node.initializer)) { + this.lowerVariableDeclarations(node.initializer); + } else { + this.expr.lowerDiscarded(node.initializer); + } + } + const headLabel = cfg.newLabel(); + const bodyLabel = cfg.newLabel(); + const updateLabel = cfg.newLabel(); + const exitLabel = cfg.newLabel(); + + cfg.placeLabel(headLabel); + if (node.condition !== undefined) { + this.expr.lowerCondition(node.condition, bodyLabel, exitLabel); + } else { + cfg.goto(bodyLabel); + } + cfg.placeLabel(bodyLabel); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: updateLabel, label }, () => { + this.lowerStatement(node.statement); + }); + cfg.goto(updateLabel); + cfg.placeLabel(updateLabel); + if (node.incrementor !== undefined) { + this.expr.lowerDiscarded(node.incrementor); + } + cfg.goto(headLabel); + cfg.placeLabel(exitLabel); + } + + /** + * `for (const v of iterable)` — iterator protocol, ArkAnalyzer-shaped: + * %it := iterable.Symbol.iterator() + * head: %res := %it.next(); %done := %res.done + * if (%done == true) exit else body + * body: v := %res.value; ... + */ + private lowerForOf(node: ts.ForOfStatement, label: string | undefined): void { + const cfg = this.m.cfg; + const iterable = this.expr.lowerToLocal(node.expression); + + const iterator = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ + _: "AssignStmt", + left: iterator, + right: { + _: "InstanceCallExpr", + instance: iterable, + method: unknownMethod("Symbol.iterator"), + args: [], + }, + }); + + const headLabel = cfg.newLabel(); + const bodyLabel = cfg.newLabel(); + const exitLabel = cfg.newLabel(); + + cfg.placeLabel(headLabel); + const result = this.m.newTemp(UNKNOWN_TYPE); + cfg.emit({ + _: "AssignStmt", + left: result, + right: { _: "InstanceCallExpr", instance: iterator, method: unknownMethod("next"), args: [] }, + }); + const done = this.m.newTemp(BOOLEAN_TYPE); + cfg.emit({ + _: "AssignStmt", + left: done, + right: { + _: "InstanceFieldRef", + instance: result, + field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "done", type: BOOLEAN_TYPE }, + }, + }); + // if (done == true) -> exit, else -> body + cfg.branch(this.expr.relation("==", done, constant("true", BOOLEAN_TYPE)), exitLabel, bodyLabel); + + cfg.placeLabel(bodyLabel); + const binding = this.loopBinding(node.initializer); + cfg.emit({ + _: "AssignStmt", + left: binding.local, + right: { + _: "InstanceFieldRef", + instance: result, + field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "value", type: binding.local.type }, + }, + }); + if (binding.pattern !== undefined) { + this.lowerBindingPattern(binding.pattern, binding.local); + } + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: headLabel, label }, () => { + this.lowerStatement(node.statement); + }); + cfg.goto(headLabel); + cfg.placeLabel(exitLabel); + } + + /** + * `for (const k in obj)` — index loop over `Object.keys(obj)`: + * %keys := Object.keys(obj); %i := 0 + * head: %len := %keys.length; if (%i < %len) body else exit + * body: k := %keys[%i]; ...; %i := %i ++ + */ + private lowerForIn(node: ts.ForInStatement, label: string | undefined): void { + const cfg = this.m.cfg; + const target = this.expr.lowerToImmediate(node.expression); + + const keys = this.m.newTemp({ _: "ArrayType", elementType: { _: "StringType" }, dimensions: 1 }); + cfg.emit({ + _: "AssignStmt", + left: keys, + right: { + _: "StaticCallExpr", + method: { + declaringClass: { name: "Object", declaringFile: UNKNOWN_FILE_SIGNATURE }, + name: "keys", + parameters: [], + returnType: { _: "ArrayType", elementType: { _: "StringType" }, dimensions: 1 }, + }, + args: [target], + }, + }); + const index = this.m.newTemp(NUMBER_TYPE); + cfg.emit({ _: "AssignStmt", left: index, right: constant("0", NUMBER_TYPE) }); + + const headLabel = cfg.newLabel(); + const bodyLabel = cfg.newLabel(); + const exitLabel = cfg.newLabel(); + + cfg.placeLabel(headLabel); + const length = this.m.newTemp(NUMBER_TYPE); + cfg.emit({ + _: "AssignStmt", + left: length, + right: { + _: "InstanceFieldRef", + instance: keys, + field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "length", type: NUMBER_TYPE }, + }, + }); + cfg.branch(this.expr.relation("<", index, length), bodyLabel, exitLabel); + + cfg.placeLabel(bodyLabel); + const binding = this.loopBinding(node.initializer); + cfg.emit({ + _: "AssignStmt", + left: binding.local, + right: { _: "ArrayRef", array: keys, index, type: { _: "StringType" } }, + }); + if (binding.pattern !== undefined) { + this.lowerBindingPattern(binding.pattern, binding.local); + } + const continueLabel = cfg.newLabel(); + this.inBreakable({ kind: "loop", breakTarget: exitLabel, continueTarget: continueLabel, label }, () => { + this.lowerStatement(node.statement); + }); + cfg.goto(continueLabel); + cfg.placeLabel(continueLabel); + cfg.emit({ _: "AssignStmt", left: index, right: { _: "UnopExpr", op: "++", arg: index } }); + cfg.goto(headLabel); + cfg.placeLabel(exitLabel); + } + + /** The loop variable of for-of/for-in; destructuring goes through a temp + pattern. */ + private loopBinding(initializer: ts.ForInitializer): { local: LocalDto; pattern?: ts.BindingPattern } { + if (ts.isVariableDeclarationList(initializer)) { + const decl = initializer.declarations[0]; + if (decl !== undefined && ts.isIdentifier(decl.name)) { + return { local: this.m.getOrCreateLocal(decl.name.text, this.m.converter.typeOfNode(decl.name)) }; + } + if (decl !== undefined) { + return { local: this.m.newTemp(UNKNOWN_TYPE), pattern: decl.name as ts.BindingPattern }; + } + throw new LoweringError("empty loop binding"); + } + if (ts.isIdentifier(initializer)) { + return { local: this.m.getOrCreateLocal(initializer.text, this.m.converter.typeOfNode(initializer)) }; + } + throw new LoweringError("unsupported loop binding"); + } + + private lowerSwitch(node: ts.SwitchStatement, label: string | undefined): void { + const cfg = this.m.cfg; + const discriminant = this.expr.lowerToImmediate(node.expression); + const exitLabel = cfg.newLabel(); + + const clauses = node.caseBlock.clauses; + const bodyLabels = clauses.map(() => cfg.newLabel()); + + // Comparison chain (=== per case, default last). + let defaultIndex = -1; + clauses.forEach((clause, i) => { + if (ts.isCaseClause(clause)) { + const nextTest = cfg.newLabel(); + const caseValue = this.expr.lowerToImmediate(clause.expression); + cfg.branch(this.expr.relation("===", discriminant, caseValue), bodyLabels[i], nextTest); + cfg.placeLabel(nextTest); + } else { + defaultIndex = i; + } + }); + cfg.goto(defaultIndex >= 0 ? bodyLabels[defaultIndex] : exitLabel); + + // Bodies in source order with natural fallthrough. + this.inBreakable({ kind: "switch", breakTarget: exitLabel, label }, () => { + clauses.forEach((clause, i) => { + cfg.placeLabel(bodyLabels[i]); + for (const statement of clause.statements) { + this.lowerStatement(statement); + } + // fallthrough to the next body (or exit) happens via placeLabel/goto below + }); + }); + cfg.goto(exitLabel); + cfg.placeLabel(exitLabel); + } + + private lowerBreak(node: ts.BreakStatement): void { + const target = this.findBreakable(node.label?.text, /* forContinue */ false); + if (target === undefined) { + throw new LoweringError(`break outside of a breakable context`); + } + // Run finally blocks of try statements entered INSIDE the target breakable. + this.emitFinallies(target.finallyDepth ?? 0); + this.m.cfg.goto(target.breakTarget); + } + + private lowerContinue(node: ts.ContinueStatement): void { + const target = this.findBreakable(node.label?.text, /* forContinue */ true); + if (target === undefined || target.continueTarget === undefined) { + throw new LoweringError(`continue outside of a loop`); + } + // Run finally blocks of try statements entered INSIDE the target loop. + this.emitFinallies(target.finallyDepth ?? 0); + this.m.cfg.goto(target.continueTarget); + } + + private lowerLabeled(node: ts.LabeledStatement): void { + const inner = node.statement; + if ( + ts.isWhileStatement(inner) || + ts.isDoStatement(inner) || + ts.isForStatement(inner) || + ts.isForOfStatement(inner) || + ts.isForInStatement(inner) || + ts.isSwitchStatement(inner) + ) { + this.pendingLabel = node.label.text; + this.lowerStatement(inner); + return; + } + // Labeled block: breakable region. + const cfg = this.m.cfg; + const exitLabel = cfg.newLabel(); + this.inBreakable({ kind: "labeled-block", breakTarget: exitLabel, label: node.label.text }, () => { + this.lowerStatement(inner); + }); + cfg.goto(exitLabel); + cfg.placeLabel(exitLabel); + } + + /** + * `try { A } catch (e) { B } finally { C }`. + * + * The DTO has no trap tables and non-if blocks allow at most one successor, + * so exception edges cannot be expressed. ArkAnalyzer simply DROPS catch + * blocks; we instead keep them analyzable via a synthetic nondeterministic + * branch on an undefined `%N` local: + * + * if (%exc != 0) -> catch else -> try + * try: A; goto join + * catch: e := CaughtExceptionRef; B; goto join + * join: C (finally, shared by both paths) + * + * Finally blocks are DUPLICATED on abrupt exits: every return/throw and + * every break/continue that leaves the try emits a copy of C (innermost + * scopes first) before its terminator, so C never drops out of the IR even + * when the try body always exits abruptly. + */ + private lowerTry(node: ts.TryStatement): void { + const cfg = this.m.cfg; + const joinLabel = cfg.newLabel(); + const tryLabel = cfg.newLabel(); + + if (node.finallyBlock !== undefined) { + this.finallyScopes.push(node.finallyBlock); + } + try { + if (node.catchClause !== undefined) { + const catchLabel = cfg.newLabel(); + const excFlag = this.m.newTemp(BOOLEAN_TYPE); + cfg.branch(this.expr.truthyCondition(excFlag), catchLabel, tryLabel); + + cfg.placeLabel(tryLabel); + this.lowerStatement(node.tryBlock); + cfg.goto(joinLabel); + + cfg.placeLabel(catchLabel); + const decl = node.catchClause.variableDeclaration; + if (decl !== undefined && ts.isIdentifier(decl.name)) { + const caughtType = + decl.type !== undefined ? this.m.converter.convertTypeNode(decl.type) : UNKNOWN_TYPE; + const caught = this.m.getOrCreateLocal(decl.name.text, caughtType); + cfg.emit({ + _: "AssignStmt", + left: caught, + right: { _: "CaughtExceptionRef", type: caughtType }, + }); + } + this.lowerStatement(node.catchClause.block); + cfg.goto(joinLabel); + } else { + cfg.goto(tryLabel); + cfg.placeLabel(tryLabel); + this.lowerStatement(node.tryBlock); + cfg.goto(joinLabel); + } + } finally { + if (node.finallyBlock !== undefined) { + this.finallyScopes.pop(); + } + } + + // Normal (fall-through) path. + cfg.placeLabel(joinLabel); + if (node.finallyBlock !== undefined) { + this.lowerStatement(node.finallyBlock); + } + } + + /** + * Emit copies of the enclosing finally blocks with stack depth > `downTo`, + * innermost first — used before abrupt exits (return/throw/break/continue). + * While a finally body is being emitted, its own scope (and deeper ones) is + * masked so a nested abrupt exit only re-runs the OUTER finallies. + */ + private emitFinallies(downTo: number): void { + for (let i = this.finallyScopes.length - 1; i >= downTo; i--) { + const masked = this.finallyScopes.splice(i); + try { + this.lowerStatement(masked[0]); + } finally { + this.finallyScopes.push(...masked); + } + } + } + + private inBreakable(context: BreakableContext, body: () => void): void { + context.finallyDepth = this.finallyScopes.length; + this.breakables.push(context); + try { + body(); + } finally { + this.breakables.pop(); + } + } + + private findBreakable(label: string | undefined, forContinue: boolean): BreakableContext | undefined { + for (let i = this.breakables.length - 1; i >= 0; i--) { + const context = this.breakables[i]; + if (label !== undefined) { + if (context.label === label && (!forContinue || context.kind === "loop")) { + return context; + } + } else if (!forContinue || context.kind === "loop") { + return context; + } + } + return undefined; + } + + lowerVariableDeclarations(list: ts.VariableDeclarationList): void { + for (const decl of list.declarations) { + this.lowerVariableDeclaration(decl); + } + } + + private lowerVariableDeclaration(decl: ts.VariableDeclaration): void { + if (!ts.isIdentifier(decl.name)) { + // Destructuring: evaluate the initializer into a temp, then unpack. + if (decl.initializer === undefined) { + throw new LoweringError("destructuring declaration without initializer"); + } + const source = this.expr.lowerToLocal(decl.initializer); + this.lowerBindingPattern(decl.name, source); + return; + } + const declaredType = + decl.type !== undefined + ? this.m.converter.convertTypeNode(decl.type) + : this.m.converter.typeOfNode(decl.name); + const local = this.m.getOrCreateLocal(decl.name.text, declaredType); + if (decl.initializer !== undefined) { + const value = this.expr.lowerExpr(decl.initializer); + this.m.cfg.emit({ _: "AssignStmt", left: local, right: value }); + } + } + + /** A function declared inside a method body -> method of the file's %dflt class. */ + private lowerNestedFunction(decl: ts.FunctionDeclaration): void { + if (decl.name === undefined || decl.body === undefined) { + return; + } + const registry = this.m.ctx.anonymous; + const declaringClass = registry.defaultClassSignature; + const { parameters, prologueParams } = buildParameters(this.m.ctx, decl); + + const nested = new MethodContext(this.m.ctx, declaringClass, decl.name.text); + nested.emitPrologue(prologueParams); + new StmtLowerer(nested).lowerStatements(decl.body.statements); + + registry.methods.push({ + signature: { + declaringClass, + name: decl.name.text, + parameters, + returnType: returnTypeOf(this.m.ctx, decl), + }, + modifiers: modifiersOf(decl), + decorators: [], + body: nested.build(), + }); + } + + // ------------------------------------------------------------------ + // Destructuring + // ------------------------------------------------------------------ + + /** `{a, b: {c}, d = 1}` / `[x, , y]` unpacked from `source` via field/array refs. */ + private lowerBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { + if (ts.isObjectBindingPattern(pattern)) { + for (const element of pattern.elements) { + if (element.dotDotDotToken !== undefined) { + throw new LoweringError("rest element in object destructuring"); + } + const propName = + element.propertyName !== undefined + ? propertyNameText(element.propertyName) + : ts.isIdentifier(element.name) + ? element.name.text + : undefined; + if (propName === undefined) { + throw new LoweringError("computed property in destructuring"); + } + const ref: ValueDto = { + _: "InstanceFieldRef", + instance: source, + field: { + declaringClass: + source.type._ === "ClassType" ? source.type.signature : UNKNOWN_CLASS_SIGNATURE, + name: propName, + type: this.bindingType(element.name), + }, + }; + this.bindDestructured(element.name, ref, element.initializer); + } + return; + } + // Array pattern. + pattern.elements.forEach((element, index) => { + if (ts.isOmittedExpression(element)) { + return; + } + if (element.dotDotDotToken !== undefined) { + throw new LoweringError("rest element in array destructuring"); + } + const ref: ValueDto = { + _: "ArrayRef", + array: source, + index: constant(String(index), NUMBER_TYPE), + type: this.bindingType(element.name), + }; + this.bindDestructured(element.name, ref, element.initializer); + }); + } + + private bindingType(name: ts.BindingName): TypeDto { + return ts.isIdentifier(name) ? this.m.converter.typeOfNode(name) : UNKNOWN_TYPE; + } + + private bindDestructured(target: ts.BindingName, ref: ValueDto, defaultInit: ts.Expression | undefined): void { + if (ts.isIdentifier(target)) { + const local = this.m.getOrCreateLocal(target.text, this.m.converter.typeOfNode(target)); + this.m.cfg.emit({ _: "AssignStmt", left: local, right: ref }); + if (defaultInit !== undefined) { + this.emitDefaultValue(local, defaultInit); + } + return; + } + // Nested pattern: unpack through a temp. + const temp = this.m.newTemp(UNKNOWN_TYPE); + this.m.cfg.emit({ _: "AssignStmt", left: temp, right: ref }); + if (defaultInit !== undefined) { + this.emitDefaultValue(temp, defaultInit); + } + this.lowerBindingPattern(target, temp); + } + + /** `if (local === undefined) local := ` */ + private emitDefaultValue(local: LocalDto, defaultInit: ts.Expression): void { + const cfg = this.m.cfg; + const setLabel = cfg.newLabel(); + const doneLabel = cfg.newLabel(); + cfg.branch( + this.expr.relation("===", local, constant("undefined", UNDEFINED_TYPE)), + setLabel, + doneLabel, + ); + cfg.placeLabel(setLabel); + cfg.emit({ _: "AssignStmt", left: local, right: this.expr.lowerExpr(defaultInit) }); + cfg.goto(doneLabel); + cfg.placeLabel(doneLabel); + } +} + +function propertyNameText(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return undefined; +} + +function unknownMethod(name: string): MethodSignatureDto { + return { + declaringClass: UNKNOWN_CLASS_SIGNATURE, + name, + parameters: [], + returnType: UNKNOWN_TYPE, + }; +} diff --git a/jacodb-ets/ts-frontend/src/serialize.ts b/jacodb-ets/ts-frontend/src/serialize.ts new file mode 100644 index 000000000..419e05fc4 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/serialize.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EtsFileDto } from "./dto/model"; + +/** + * Serialize an EtsFileDto to JSON consumable by Kotlin `EtsFileDto.loadFromJson`. + * + * `JSON.stringify` drops `undefined`-valued fields, which is exactly what we want: + * optional DTO fields correspond to Kotlin constructor parameters with defaults. + * `null` values (e.g. `superClassName: null`, `GlobalRef.ref: null`) are preserved. + */ +export function serializeEtsFile(file: EtsFileDto, pretty: boolean = false): string { + return pretty ? JSON.stringify(file, null, 2) : JSON.stringify(file); +} diff --git a/jacodb-ets/ts-frontend/src/types/convert.ts b/jacodb-ets/ts-frontend/src/types/convert.ts new file mode 100644 index 000000000..47be6e0dd --- /dev/null +++ b/jacodb-ets/ts-frontend/src/types/convert.ts @@ -0,0 +1,545 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Conversion of TypeScript types into `TypeDto`. + * + * Two sources of type information: + * - syntactic `ts.TypeNode` annotations (preferred: predictable output), and + * - `ts.Type` objects from the TypeChecker (used for inference). + * + * Conventions verified against ArkAnalyzer ground truth (test resources repos//etsir): + * - a class/interface/enum declared in a project file -> ClassType{signature}; + * - an enum MEMBER value -> EnumValueType{signature, name}; + * - unresolved / ambient named types -> UnclearReferenceType{name, typeParameters}; + * - anything exotic degrades to UnknownType (never crash). + */ + +import * as ts from "typescript"; +import { + ClassSignatureDto, + FileSignatureDto, + MethodParameterDto, + MethodSignatureDto, + NamespaceSignatureDto, + UNKNOWN_CLASS_SIGNATURE, +} from "../dto/signatures"; +import { + ANY_TYPE, + ArrayTypeDto, + BOOLEAN_TYPE, + ClassTypeDto, + GenericTypeDto, + NEVER_TYPE, + NULL_TYPE, + NUMBER_TYPE, + STRING_TYPE, + TypeDto, + UNDEFINED_TYPE, + UNKNOWN_TYPE, + UnclearReferenceTypeDto, + VOID_TYPE, +} from "../dto/types"; + +/** Guard against deeply nested / self-referential types. */ +const MAX_DEPTH = 8; + +export class TypeConverter { + constructor( + private readonly checker: ts.TypeChecker, + private readonly fileSignatureFor: (sf: ts.SourceFile) => FileSignatureDto, + ) {} + + // ------------------------------------------------------------------ + // Signatures + // ------------------------------------------------------------------ + + /** Class-like signature (class / interface / enum / struct) with its namespace chain. */ + classSignatureOf(decl: ts.Declaration & { name?: ts.DeclarationName }): ClassSignatureDto { + const name = decl.name !== undefined && ts.isIdentifier(decl.name) ? decl.name.text : ""; + const signature: ClassSignatureDto = { + name, + declaringFile: this.fileSignatureFor(decl.getSourceFile()), + }; + const ns = this.namespaceSignatureOf(decl); + if (ns !== undefined) { + signature.declaringNamespace = ns; + } + return signature; + } + + /** Namespace chain of a declaration (innermost first), or undefined at file level. */ + namespaceSignatureOf(node: ts.Node): NamespaceSignatureDto | undefined { + let current: ts.Node | undefined = node.parent; + while (current !== undefined) { + if (ts.isModuleDeclaration(current) && ts.isIdentifier(current.name)) { + const signature: NamespaceSignatureDto = { + name: current.name.text, + declaringFile: this.fileSignatureFor(current.getSourceFile()), + }; + const outer = this.namespaceSignatureOf(current); + if (outer !== undefined) { + signature.declaringNamespace = outer; + } + return signature; + } + current = current.parent; + } + return undefined; + } + + // ------------------------------------------------------------------ + // Syntactic conversion (type annotations) + // ------------------------------------------------------------------ + + convertTypeNode(node: ts.TypeNode | undefined, depth: number = 0): TypeDto { + if (node === undefined) { + return UNKNOWN_TYPE; + } + if (depth > MAX_DEPTH) { + return UNKNOWN_TYPE; + } + try { + return this.convertTypeNodeImpl(node, depth); + } catch { + return UNKNOWN_TYPE; + } + } + + private convertTypeNodeImpl(node: ts.TypeNode, depth: number): TypeDto { + switch (node.kind) { + case ts.SyntaxKind.AnyKeyword: + return ANY_TYPE; + case ts.SyntaxKind.UnknownKeyword: + return UNKNOWN_TYPE; + case ts.SyntaxKind.BooleanKeyword: + return BOOLEAN_TYPE; + case ts.SyntaxKind.NumberKeyword: + case ts.SyntaxKind.BigIntKeyword: + return NUMBER_TYPE; + case ts.SyntaxKind.StringKeyword: + return STRING_TYPE; + case ts.SyntaxKind.VoidKeyword: + return VOID_TYPE; + case ts.SyntaxKind.NeverKeyword: + return NEVER_TYPE; + case ts.SyntaxKind.UndefinedKeyword: + return UNDEFINED_TYPE; + default: + break; + } + + if (ts.isLiteralTypeNode(node)) { + return this.convertLiteralTypeNode(node); + } + if (ts.isParenthesizedTypeNode(node)) { + return this.convertTypeNode(node.type, depth); + } + if (ts.isArrayTypeNode(node)) { + return foldArray(this.convertTypeNode(node.elementType, depth + 1)); + } + if (ts.isTupleTypeNode(node)) { + return { + _: "TupleType", + types: node.elements.map((e) => this.convertTypeNode(unwrapTupleMember(e), depth + 1)), + }; + } + if (ts.isUnionTypeNode(node)) { + return { _: "UnionType", types: node.types.map((t) => this.convertTypeNode(t, depth + 1)) }; + } + if (ts.isIntersectionTypeNode(node)) { + return { _: "IntersectionType", types: node.types.map((t) => this.convertTypeNode(t, depth + 1)) }; + } + if (ts.isFunctionTypeNode(node)) { + return { + _: "FunctionType", + signature: this.functionSignatureFromTypeNode(node, depth), + }; + } + if (ts.isTypeReferenceNode(node)) { + return this.convertTypeReference(node, depth); + } + if (ts.isTemplateLiteralTypeNode(node)) { + return STRING_TYPE; + } + // keyof/typeof/indexed access/conditional/mapped/type literals etc. + return UNKNOWN_TYPE; + } + + private convertLiteralTypeNode(node: ts.LiteralTypeNode): TypeDto { + const literal = node.literal; + if (literal.kind === ts.SyntaxKind.NullKeyword) { + return NULL_TYPE; + } + if (literal.kind === ts.SyntaxKind.TrueKeyword) { + return { _: "LiteralType", literal: true }; + } + if (literal.kind === ts.SyntaxKind.FalseKeyword) { + return { _: "LiteralType", literal: false }; + } + if (ts.isStringLiteral(literal)) { + return { _: "LiteralType", literal: literal.text }; + } + if (ts.isNumericLiteral(literal)) { + return { _: "LiteralType", literal: Number(literal.text) }; + } + if ( + ts.isPrefixUnaryExpression(literal) && + literal.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(literal.operand) + ) { + return { _: "LiteralType", literal: -Number(literal.operand.text) }; + } + return UNKNOWN_TYPE; + } + + private convertTypeReference(node: ts.TypeReferenceNode, depth: number): TypeDto { + const name = entityNameToString(node.typeName); + const typeArgs = node.typeArguments?.map((t) => this.convertTypeNode(t, depth + 1)); + + // `Array` is a proper array type. + if (name === "Array" && typeArgs !== undefined && typeArgs.length === 1) { + return foldArray(typeArgs[0]); + } + + const symbol = this.resolveSymbol(node.typeName); + if (symbol !== undefined) { + const classDecl = findClassLikeDeclaration(symbol); + if (classDecl !== undefined && isProjectDeclaration(classDecl)) { + const result: ClassTypeDto = { _: "ClassType", signature: this.classSignatureOf(classDecl) }; + if (typeArgs !== undefined && typeArgs.length > 0) { + result.typeParameters = typeArgs; + } + return result; + } + const typeParamDecl = symbol.declarations?.find(ts.isTypeParameterDeclaration); + if (typeParamDecl !== undefined) { + // At USAGE sites a type parameter is just a name; + // constraint/default are emitted only in typeParameters declarations. + return { _: "GenericType", name: typeParamDecl.name.text }; + } + const aliasDecl = symbol.declarations?.find(ts.isTypeAliasDeclaration); + if (aliasDecl !== undefined && isProjectDeclaration(aliasDecl) && depth < MAX_DEPTH) { + // Follow the alias target (AliasType with a LocalSignature would require + // the declaring-method context; the resolved target is more useful downstream). + return this.convertTypeNode(aliasDecl.type, depth + 1); + } + } + + const result: UnclearReferenceTypeDto = { _: "UnclearReferenceType", name }; + if (typeArgs !== undefined && typeArgs.length > 0) { + result.typeParameters = typeArgs; + } + return result; + } + + /** Type parameter declaration -> GenericType{name, constraint?, defaultType?}. */ + convertTypeParameter(decl: ts.TypeParameterDeclaration, depth: number = 0): GenericTypeDto { + const result: GenericTypeDto = { _: "GenericType", name: decl.name.text }; + if (decl.constraint !== undefined) { + result.constraint = this.convertTypeNode(decl.constraint, depth + 1); + } + if (decl.default !== undefined) { + result.defaultType = this.convertTypeNode(decl.default, depth + 1); + } + return result; + } + + /** Type parameter list of a class/method, or undefined when absent. */ + convertTypeParameters( + decls: readonly ts.TypeParameterDeclaration[] | undefined, + ): TypeDto[] | undefined { + if (decls === undefined || decls.length === 0) { + return undefined; + } + return decls.map((d) => this.convertTypeParameter(d)); + } + + private functionSignatureFromTypeNode(node: ts.FunctionTypeNode, depth: number): MethodSignatureDto { + const parameters: MethodParameterDto[] = node.parameters.map((p) => { + const param: MethodParameterDto = { + name: ts.isIdentifier(p.name) ? p.name.text : "%pat", + type: this.convertTypeNode(p.type, depth + 1), + }; + if (p.questionToken !== undefined) { + param.isOptional = true; + } + if (p.dotDotDotToken !== undefined) { + param.isRest = true; + } + return param; + }); + return { + declaringClass: UNKNOWN_CLASS_SIGNATURE, + name: "", + parameters, + returnType: this.convertTypeNode(node.type, depth + 1), + }; + } + + private resolveSymbol(name: ts.EntityName): ts.Symbol | undefined { + try { + let symbol = this.checker.getSymbolAtLocation(name); + if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = this.checker.getAliasedSymbol(symbol); + } + return symbol; + } catch { + return undefined; + } + } + + // ------------------------------------------------------------------ + // Checker-based conversion (inference) + // ------------------------------------------------------------------ + + /** Resolve the (possibly inferred) type of a node. */ + typeOfNode(node: ts.Node): TypeDto { + try { + return this.convertType(this.checker.getTypeAtLocation(node)); + } catch { + return UNKNOWN_TYPE; + } + } + + convertType(type: ts.Type | undefined, depth: number = 0): TypeDto { + if (type === undefined || depth > MAX_DEPTH) { + return UNKNOWN_TYPE; + } + try { + return this.convertTypeImpl(type, depth); + } catch { + return UNKNOWN_TYPE; + } + } + + private convertTypeImpl(type: ts.Type, depth: number): TypeDto { + const flags = type.flags; + + if (flags & ts.TypeFlags.Any) return ANY_TYPE; + if (flags & ts.TypeFlags.Unknown) return UNKNOWN_TYPE; + if (flags & ts.TypeFlags.Void) return VOID_TYPE; + if (flags & ts.TypeFlags.Never) return NEVER_TYPE; + if (flags & ts.TypeFlags.Null) return NULL_TYPE; + if (flags & ts.TypeFlags.Undefined) return UNDEFINED_TYPE; + + // Enum member values -> EnumValueType (checked before generic literals: + // enum members carry literal flags too). + const enumValue = this.tryEnumValueType(type); + if (enumValue !== undefined) { + return enumValue; + } + + if (flags & ts.TypeFlags.StringLiteral) { + return { _: "LiteralType", literal: (type as ts.StringLiteralType).value }; + } + if (flags & ts.TypeFlags.NumberLiteral) { + return { _: "LiteralType", literal: (type as ts.NumberLiteralType).value }; + } + if (flags & ts.TypeFlags.BooleanLiteral) { + const intrinsicName = (type as unknown as { intrinsicName?: string }).intrinsicName; + return { _: "LiteralType", literal: intrinsicName === "true" }; + } + + if (flags & ts.TypeFlags.BooleanLike) return BOOLEAN_TYPE; + if (flags & ts.TypeFlags.NumberLike) return NUMBER_TYPE; + if (flags & ts.TypeFlags.BigIntLike) return NUMBER_TYPE; + if (flags & ts.TypeFlags.StringLike) return STRING_TYPE; + + if (flags & ts.TypeFlags.TypeParameter) { + return { _: "GenericType", name: this.typeName(type) }; + } + + if (type.isUnion()) { + return { _: "UnionType", types: type.types.map((t) => this.convertType(t, depth + 1)) }; + } + if (type.isIntersection()) { + return { _: "IntersectionType", types: type.types.map((t) => this.convertType(t, depth + 1)) }; + } + + if (this.checker.isArrayType(type)) { + const element = this.checker.getTypeArguments(type as ts.TypeReference)[0]; + return foldArray(this.convertType(element, depth + 1)); + } + if (this.checker.isTupleType(type)) { + const elements = this.checker.getTypeArguments(type as ts.TypeReference); + return { _: "TupleType", types: elements.map((t) => this.convertType(t, depth + 1)) }; + } + + if (flags & ts.TypeFlags.Object) { + return this.convertObjectType(type as ts.ObjectType, depth); + } + + return UNKNOWN_TYPE; + } + + private tryEnumValueType(type: ts.Type): TypeDto | undefined { + const symbol = type.symbol as ts.Symbol | undefined; + const memberDecl = symbol?.declarations?.find(ts.isEnumMember); + if (memberDecl === undefined) { + return undefined; + } + const enumDecl = memberDecl.parent; + if (!isProjectDeclaration(enumDecl)) { + return undefined; + } + return { + _: "EnumValueType", + signature: this.classSignatureOf(enumDecl), + name: ts.isIdentifier(memberDecl.name) ? memberDecl.name.text : memberDecl.name.getText(), + }; + } + + private convertObjectType(type: ts.ObjectType, depth: number): TypeDto { + const symbol = type.symbol as ts.Symbol | undefined; + const name = symbol?.getName(); + + // Instances of project classes/interfaces/enums -> ClassType. + const classDecl = symbol !== undefined ? findClassLikeDeclaration(symbol) : undefined; + if (classDecl !== undefined && isProjectDeclaration(classDecl)) { + const typeArgs = + (type.objectFlags & ts.ObjectFlags.Reference) !== 0 + ? this.checker.getTypeArguments(type as ts.TypeReference) + : []; + const result: ClassTypeDto = { _: "ClassType", signature: this.classSignatureOf(classDecl) }; + if (typeArgs.length > 0) { + result.typeParameters = typeArgs.map((t) => this.convertType(t, depth + 1)); + } + return result; + } + + // Function-like object types (function values, arrows, methods) -> FunctionType. + const callSignatures = type.getCallSignatures(); + if (callSignatures.length > 0) { + return this.functionTypeFromSignature(callSignatures[0], depth); + } + + if (symbol !== undefined && name !== undefined && name !== "__type" && name !== "__object") { + const typeArgs = + (type.objectFlags & ts.ObjectFlags.Reference) !== 0 + ? this.checker.getTypeArguments(type as ts.TypeReference) + : []; + const result: UnclearReferenceTypeDto = { _: "UnclearReferenceType", name }; + if (typeArgs.length > 0) { + result.typeParameters = typeArgs.map((t) => this.convertType(t, depth + 1)); + } + return result; + } + + return UNKNOWN_TYPE; + } + + private functionTypeFromSignature(signature: ts.Signature, depth: number): TypeDto { + try { + const declaration = signature.getDeclaration() as ts.SignatureDeclaration | undefined; + const parameters: MethodParameterDto[] = signature.getParameters().map((p) => { + const paramDecl = p.valueDeclaration; + let paramType: TypeDto = UNKNOWN_TYPE; + if (paramDecl !== undefined) { + paramType = this.convertType( + this.checker.getTypeOfSymbolAtLocation(p, paramDecl), + depth + 1, + ); + } + const param: MethodParameterDto = { name: p.getName(), type: paramType }; + if (paramDecl !== undefined && ts.isParameter(paramDecl)) { + if (paramDecl.questionToken !== undefined) { + param.isOptional = true; + } + if (paramDecl.dotDotDotToken !== undefined) { + param.isRest = true; + } + } + return param; + }); + const returnType = this.convertType(this.checker.getReturnTypeOfSignature(signature), depth + 1); + const name = + declaration !== undefined && + (ts.isFunctionDeclaration(declaration) || ts.isMethodDeclaration(declaration)) && + declaration.name !== undefined && + ts.isIdentifier(declaration.name) + ? declaration.name.text + : ""; + return { + _: "FunctionType", + signature: { + declaringClass: UNKNOWN_CLASS_SIGNATURE, + name, + parameters, + returnType, + }, + }; + } catch { + return UNKNOWN_TYPE; + } + } + + private typeName(type: ts.Type): string { + if (type.symbol !== undefined) { + return type.symbol.getName(); + } + try { + return this.checker.typeToString(type); + } catch { + return "%unk"; + } + } +} + +// ---------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------- + +/** `T[][]` folds into ArrayType{T, dimensions: 2}. */ +function foldArray(element: TypeDto): ArrayTypeDto { + if (element._ === "ArrayType") { + return { _: "ArrayType", elementType: element.elementType, dimensions: element.dimensions + 1 }; + } + return { _: "ArrayType", elementType: element, dimensions: 1 }; +} + +function unwrapTupleMember(node: ts.TypeNode): ts.TypeNode { + if (ts.isNamedTupleMember(node)) { + return node.type; + } + return node; +} + +function entityNameToString(name: ts.EntityName): string { + if (ts.isIdentifier(name)) { + return name.text; + } + return `${entityNameToString(name.left)}.${name.right.text}`; +} + +/** Class-like declaration of a symbol (class / interface / enum). */ +function findClassLikeDeclaration( + symbol: ts.Symbol, +): (ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration) | undefined { + return symbol.declarations?.find( + (d): d is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration => + ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d) || ts.isEnumDeclaration(d), + ); +} + +/** + * Whether a declaration belongs to project code (as opposed to ambient/lib `.d.ts`). + * Ambient types are emitted as UnclearReferenceType — same as ArkAnalyzer does + * for types not belonging to the analyzed project. + */ +function isProjectDeclaration(decl: ts.Declaration): boolean { + return !decl.getSourceFile().isDeclarationFile; +} diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts new file mode 100644 index 000000000..095309d57 --- /dev/null +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -0,0 +1,372 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Invariant checker for emitted EtsFileDto. + * + * Verifies the structural contracts required by the Kotlin side + * (`EtsFileDto.loadFromJson` + `Convert.kt` + `EtsBlockCfg` init + `Linearize.kt`) + * BEFORE anything is written to disk. Violations returned as human-readable strings; + * an empty list means the file is safe to hand over to Kotlin. + */ + +import { FORBIDDEN_LOCAL_PREFIX } from "./dto/constants"; +import { isBinaryOp, isRelationOp, isUnaryOp } from "./dto/ops"; +import { BodyDto, ClassDto, EtsFileDto, MethodDto, NamespaceDto } from "./dto/model"; +import { StmtDto } from "./dto/stmts"; +import { ValueDto } from "./dto/values"; + +const EXPR_KINDS = new Set([ + "NewExpr", + "NewArrayExpr", + "DeleteExpr", + "AwaitExpr", + "YieldExpr", + "TypeOfExpr", + "InstanceOfExpr", + "CastExpr", + "UnopExpr", + "BinopExpr", + "ConditionExpr", + "InstanceCallExpr", + "StaticCallExpr", + "PtrCallExpr", +]); + +const REF_KINDS = new Set([ + "ThisRef", + "ParameterRef", + "CaughtExceptionRef", + "GlobalRef", + "ClosureFieldRef", + "ArrayRef", + "InstanceFieldRef", + "StaticFieldRef", +]); + +const IMMEDIATE_KINDS = new Set(["Local", "Constant"]); + +const KNOWN_VALUE_KINDS = new Set([...EXPR_KINDS, ...REF_KINDS, ...IMMEDIATE_KINDS]); + +const KNOWN_STMT_KINDS = new Set([ + "NopStmt", + "AssignStmt", + "CallStmt", + "ReturnVoidStmt", + "ReturnStmt", + "ThrowStmt", + "IfStmt", +]); + +const CALL_EXPR_KINDS = new Set(["InstanceCallExpr", "StaticCallExpr", "PtrCallExpr"]); + +/** LValue kinds accepted by Kotlin Convert as AssignStmt.left (CastExpr is stripped there). */ +const LVALUE_KINDS = new Set(["Local", "ArrayRef", "InstanceFieldRef", "StaticFieldRef"]); + +export function validateEtsFile(file: EtsFileDto): string[] { + const errors: string[] = []; + const ctx = file.signature.fileName; + + for (const clazz of file.classes) { + validateClass(clazz, ctx, errors); + } + for (const ns of file.namespaces) { + validateNamespace(ns, ctx, errors); + } + return errors; +} + +function validateNamespace(ns: NamespaceDto, ctx: string, errors: string[]): void { + const nsCtx = `${ctx}::${ns.signature.name}`; + for (const clazz of ns.classes ?? []) { + validateClass(clazz, nsCtx, errors); + } + for (const inner of ns.namespaces ?? []) { + validateNamespace(inner, nsCtx, errors); + } +} + +function validateClass(clazz: ClassDto, ctx: string, errors: string[]): void { + const classCtx = `${ctx}::${clazz.signature.name}`; + if (clazz.superClassName === undefined) { + errors.push(`${classCtx}: superClassName must be present (use "" for none)`); + } + for (const method of clazz.methods) { + validateMethod(method, classCtx, errors); + } +} + +function validateMethod(method: MethodDto, ctx: string, errors: string[]): void { + const methodCtx = `${ctx}::${method.signature.name}`; + if (method.body !== undefined) { + validateBody(method.body, methodCtx, errors); + } +} + +function validateBody(body: BodyDto, ctx: string, errors: string[]): void { + const err = (msg: string) => errors.push(`${ctx}: ${msg}`); + + // --- Locals table --- + const declaredLocals = new Set(); + for (const local of body.locals) { + if (declaredLocals.has(local.name)) { + err(`duplicate local '${local.name}' in body.locals`); + } + declaredLocals.add(local.name); + if (local.name.startsWith(FORBIDDEN_LOCAL_PREFIX)) { + err(`local '${local.name}' uses reserved prefix '${FORBIDDEN_LOCAL_PREFIX}'`); + } + } + + // --- Blocks: ids, successors, terminators --- + const blocks = body.cfg.blocks; + const blockCount = blocks.length; + blocks.forEach((block, index) => { + const blockCtx = `block ${block.id}`; + if (block.id !== index) { + err(`${blockCtx}: id must equal its index ${index}`); + } + for (const succ of block.successors) { + if (succ < 0 || succ >= blockCount) { + err(`${blockCtx}: successor ${succ} out of range [0, ${blockCount})`); + } + } + for (const pred of block.predecessors ?? []) { + if (pred < 0 || pred >= blockCount) { + err(`${blockCtx}: predecessor ${pred} out of range [0, ${blockCount})`); + } + } + + block.stmts.forEach((stmt, stmtIndex) => { + const isLast = stmtIndex === block.stmts.length - 1; + if (!isLast && isTerminator(stmt)) { + err(`${blockCtx}: terminator '${stmt._}' at position ${stmtIndex} is not the last stmt`); + } + validateStmt(stmt, `${blockCtx}, stmt ${stmtIndex}`, declaredLocals, err); + }); + + const last = block.stmts.length > 0 ? block.stmts[block.stmts.length - 1] : undefined; + if (last !== undefined && last._ === "IfStmt") { + if (block.successors.length !== 2) { + err(`${blockCtx}: ends with IfStmt but has ${block.successors.length} successors (need exactly 2: [false, true])`); + } + } else if (last !== undefined && (last._ === "ReturnStmt" || last._ === "ReturnVoidStmt" || last._ === "ThrowStmt")) { + if (block.successors.length !== 0) { + err(`${blockCtx}: ends with '${last._}' but has ${block.successors.length} successors`); + } + } else { + if (block.successors.length > 1) { + err(`${blockCtx}: non-branching block has ${block.successors.length} successors (max 1)`); + } + } + }); + + // --- Predecessor/successor consistency (when predecessors are emitted) --- + if (blocks.length > 0 && blocks.every((b) => b.predecessors !== undefined)) { + const expectedPreds = new Map>(); + blocks.forEach((b) => expectedPreds.set(b.id, new Set())); + for (const b of blocks) { + for (const succ of b.successors) { + expectedPreds.get(succ)?.add(b.id); + } + } + for (const b of blocks) { + const actual = new Set(b.predecessors); + const expected = expectedPreds.get(b.id)!; + if (actual.size !== expected.size || [...expected].some((p) => !actual.has(p))) { + err(`block ${b.id}: predecessors [${[...actual]}] inconsistent with successors (expected [${[...expected]}])`); + } + } + } +} + +function isTerminator(stmt: StmtDto): boolean { + return stmt._ === "ReturnVoidStmt" || stmt._ === "ReturnStmt" || stmt._ === "ThrowStmt" || stmt._ === "IfStmt"; +} + +function validateStmt( + stmt: StmtDto, + ctx: string, + declaredLocals: Set, + err: (msg: string) => void, +): void { + // Raw fallback values are ONLY legal as the RHS of a Local assignment: + // Kotlin's ensureOneAddress rejects EtsRawEntity in every other position. + // Kotlin Convert strips a CastExpr on the LHS before that check, so + // `CastExpr(Local) := ` is a Local assignment too — mirror that here. + const effectiveLeft = + stmt._ === "AssignStmt" ? (stmt.left._ === "CastExpr" ? stmt.left.arg : stmt.left) : undefined; + const rawAllowedFor = + stmt._ === "AssignStmt" && effectiveLeft!._ === "Local" ? stmt.right : undefined; + const values = stmtOperands(stmt); + for (const value of values) { + validateValue(value, ctx, declaredLocals, err, value === rawAllowedFor); + } + + switch (stmt._) { + case "AssignStmt": { + let left = stmt.left; + if (left._ === "CastExpr") { + left = left.arg; // Kotlin Convert strips a cast on the LHS + } + if (!LVALUE_KINDS.has(left._)) { + err(`${ctx}: AssignStmt.left has kind '${left._}', expected one of ${[...LVALUE_KINDS].join("/")}`); + } + break; + } + case "CallStmt": { + if (!CALL_EXPR_KINDS.has(stmt.expr._)) { + err(`${ctx}: CallStmt.expr has kind '${stmt.expr._}', expected a call expr`); + } + break; + } + case "IfStmt": { + if (stmt.condition._ !== "ConditionExpr") { + err(`${ctx}: IfStmt.condition has kind '${(stmt.condition as ValueDto)._}', expected ConditionExpr`); + } + break; + } + default: + break; + } +} + +/** Direct value operands of a statement (non-recursive). */ +function stmtOperands(stmt: StmtDto): ValueDto[] { + switch (stmt._) { + case "AssignStmt": + return [stmt.left, stmt.right]; + case "CallStmt": + return [stmt.expr]; + case "ReturnStmt": + case "ThrowStmt": + return [stmt.arg]; + case "IfStmt": + return [stmt.condition]; + default: + return []; + } +} + +function validateValue( + value: ValueDto, + ctx: string, + declaredLocals: Set, + err: (msg: string) => void, + rawAllowed: boolean = false, +): void { + if (!KNOWN_VALUE_KINDS.has(value._)) { + // Raw fallback value: Kotlin's RawValueSerializer requires a "type" key. + if ((value as { type?: unknown }).type === undefined) { + err(`${ctx}: raw value of kind '${value._}' is missing required 'type'`); + } + if (!rawAllowed) { + err( + `${ctx}: raw value of kind '${value._}' in an operand position — ` + + `raw values are only legal as the RHS of a Local assignment`, + ); + } + return; // do not recurse into unknown shapes + } + + switch (value._) { + case "Local": + if (!declaredLocals.has(value.name)) { + err(`${ctx}: local '${value.name}' is not declared in body.locals`); + } + if (value.name.startsWith(FORBIDDEN_LOCAL_PREFIX)) { + err(`${ctx}: local '${value.name}' uses reserved prefix '${FORBIDDEN_LOCAL_PREFIX}'`); + } + break; + case "UnopExpr": + if (!isUnaryOp(value.op)) { + err(`${ctx}: unknown unary op '${value.op}'`); + } + break; + case "BinopExpr": + if (!isBinaryOp(value.op)) { + err(`${ctx}: unknown binary op '${value.op}'`); + } + break; + case "ConditionExpr": + if (!isRelationOp(value.op)) { + err(`${ctx}: unknown relation op '${value.op}'`); + } + break; + case "InstanceCallExpr": + if (value.instance._ !== "Local") { + err(`${ctx}: InstanceCallExpr.instance has kind '${value.instance._}', must be Local`); + } + break; + case "InstanceFieldRef": + if (value.instance._ !== "Local") { + err(`${ctx}: InstanceFieldRef.instance has kind '${value.instance._}', must be Local`); + } + break; + case "ArrayRef": + if (EXPR_KINDS.has(value.index._)) { + err(`${ctx}: ArrayRef.index has expr kind '${value.index._}', must be an immediate or ref`); + } + break; + case "PtrCallExpr": + // Kotlin Convert casts ptr to EtsValue — expr kinds would throw a ClassCastException. + if (EXPR_KINDS.has(value.ptr._)) { + err(`${ctx}: PtrCallExpr.ptr has expr kind '${value.ptr._}', must be an immediate or ref`); + } + break; + default: + break; + } + + for (const child of valueOperands(value)) { + validateValue(child, ctx, declaredLocals, err); + } +} + +/** Direct value operands of a value (non-recursive). */ +export function valueOperands(value: ValueDto): ValueDto[] { + switch (value._) { + case "NewArrayExpr": + return [value.size]; + case "DeleteExpr": + case "AwaitExpr": + case "YieldExpr": + case "TypeOfExpr": + case "InstanceOfExpr": + case "CastExpr": + case "UnopExpr": + return [value.arg]; + case "BinopExpr": + case "ConditionExpr": + return [value.left, value.right]; + case "InstanceCallExpr": + return [value.instance, ...value.args]; + case "StaticCallExpr": + return [...value.args]; + case "PtrCallExpr": + return [value.ptr, ...value.args]; + case "GlobalRef": + return value.ref !== null ? [value.ref] : []; + case "ClosureFieldRef": + return [value.base]; + case "ArrayRef": + return [value.array, value.index]; + case "InstanceFieldRef": + return [value.instance]; + default: + return []; + } +} diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts new file mode 100644 index 000000000..e4afe5aea --- /dev/null +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { BasicBlockDto, MethodDto } from "../src/dto/model"; +import { IfStmtDto, StmtDto } from "../src/dto/stmts"; +import { defaultMethod, lower, methodByName } from "./util"; + +function blocksOf(method: MethodDto): BasicBlockDto[] { + if (method.body === undefined) throw new Error("no body"); + return method.body.cfg.blocks; +} + +function lastStmt(block: BasicBlockDto): StmtDto | undefined { + return block.stmts[block.stmts.length - 1]; +} + +function ifBlocks(blocks: BasicBlockDto[]): BasicBlockDto[] { + return blocks.filter((b) => lastStmt(b)?._ === "IfStmt"); +} + +/** Collect all stmts of all blocks. */ +function allStmts(method: MethodDto): StmtDto[] { + return blocksOf(method).flatMap((b) => b.stmts); +} + +describe("control flow lowering", () => { + it("lowers if/else into a diamond with [false, true] successor order", () => { + const { file } = lower(` + function f(a: number): number { + if (a > 0) { return 1; } else { return 2; } + } + `); + const blocks = blocksOf(methodByName(file, "f")); + const entry = blocks[0]; + const last = lastStmt(entry); + expect(last).toMatchObject({ + _: "IfStmt", + condition: { _: "ConditionExpr", op: ">" }, + }); + expect(entry.successors).toHaveLength(2); + const [falseTarget, trueTarget] = entry.successors; + // true branch: return 1; false branch: return 2 + expect(lastStmt(blocks[trueTarget])).toMatchObject({ _: "ReturnStmt", arg: { value: "1" } }); + expect(lastStmt(blocks[falseTarget])).toMatchObject({ _: "ReturnStmt", arg: { value: "2" } }); + }); + + it("normalizes truthiness: booleans as != false, others as != 0", () => { + const { file } = lower(` + function f(b: boolean, n: number): void { + if (b) { console.log(1); } + if (n) { console.log(2); } + } + `); + const conditions = ifBlocks(blocksOf(methodByName(file, "f"))).map( + (b) => (lastStmt(b) as IfStmtDto).condition, + ); + expect(conditions[0]).toMatchObject({ + op: "!=", + left: { _: "Local", name: "b" }, + right: { _: "Constant", value: "false", type: { _: "BooleanType" } }, + }); + expect(conditions[1]).toMatchObject({ + op: "!=", + left: { _: "Local", name: "n" }, + right: { _: "Constant", value: "0", type: { _: "NumberType" } }, + }); + }); + + it("swaps branches for negated conditions", () => { + const { file } = lower(` + function f(b: boolean): number { + if (!b) { return 1; } + return 2; + } + `); + const blocks = blocksOf(methodByName(file, "f")); + const entry = blocks[0]; + // `!b` swaps targets: true branch of the emitted `b != false` goes to `return 2`. + const [falseTarget, trueTarget] = entry.successors; + expect(lastStmt(blocks[trueTarget])).toMatchObject({ _: "ReturnStmt", arg: { value: "2" } }); + expect(lastStmt(blocks[falseTarget])).toMatchObject({ _: "ReturnStmt", arg: { value: "1" } }); + }); + + it("lowers while loops with a back edge", () => { + const { file } = lower(` + function f(n: number): number { + let i = 0; + while (i < n) { i++; } + return i; + } + `); + const blocks = blocksOf(methodByName(file, "f")); + const headBlock = ifBlocks(blocks)[0]; + const [, bodyId] = headBlock.successors; // [exit, body] + const body = blocks[bodyId]; + expect(body.successors).toEqual([headBlock.id]); // back edge + expect(body.stmts.some((s) => s._ === "AssignStmt" && s.right._ === "UnopExpr")).toBe(true); + }); + + it("lowers do-while with the body before the condition", () => { + const { file } = lower(` + function f(n: number): number { + let i = 0; + do { i++; } while (i < n); + return i; + } + `); + const blocks = blocksOf(methodByName(file, "f")); + // entry falls into the body; condition block branches back to the body + const condBlock = ifBlocks(blocks)[0]; + const [, trueTarget] = condBlock.successors; + const bodyBlock = blocks[trueTarget]; + expect(bodyBlock.stmts.some((s) => s._ === "AssignStmt" && s.right._ === "UnopExpr")).toBe(true); + expect(bodyBlock.successors).toEqual([condBlock.id]); + }); + + it("lowers for loops: continue targets the update block", () => { + const { file } = lower(` + function f(): number { + let s = 0; + for (let i = 0; i < 10; i++) { + if (i === 5) { continue; } + s += i; + } + return s; + } + `); + const blocks = blocksOf(methodByName(file, "f")); + // The continue block jumps to the update block which contains i := i ++ + const continueTargets = blocks + .filter((b) => b.successors.length === 1) + .map((b) => blocks[b.successors[0]]); + const updateBlocks = continueTargets.filter((b) => + b.stmts.some( + (s) => + s._ === "AssignStmt" && + s.left._ === "Local" && + s.left.name === "i" && + s.right._ === "UnopExpr" && + s.right.op === "++", + ), + ); + expect(updateBlocks.length).toBeGreaterThanOrEqual(2); // from body end AND from continue + }); + + it("lowers break out of a loop", () => { + const { file } = lower(` + function f(): number { + let i = 0; + while (true) { + if (i > 3) { break; } + i++; + } + return i; + } + `); + const blocks = blocksOf(methodByName(file, "f")); + const returnBlock = blocks.find((b) => lastStmt(b)?._ === "ReturnStmt")!; + // some block inside the loop jumps straight to the return block's region + expect(returnBlock.predecessors!.length).toBeGreaterThan(0); + }); + + it("lowers ternary into a temp diamond", () => { + const { file } = lower("let a = 1;\nlet x = a > 0 ? 'pos' : 'neg';"); + const blocks = blocksOf(defaultMethod(file)); + expect(blocks.length).toBeGreaterThanOrEqual(4); + const assignsToTemp = blocks + .flatMap((b) => b.stmts) + .filter( + (s): s is Extract => + s._ === "AssignStmt" && + s.left._ === "Local" && + s.left.name.startsWith("%") && + s.right._ === "Constant", + ); + const values = assignsToTemp.map((s) => (s.right as { value: string }).value).sort(); + expect(values).toEqual(["neg", "pos"]); + }); + + it("lowers switch with === chain and fallthrough", () => { + const { file } = lower(` + function f(x: number): string { + switch (x) { + case 1: + case 2: + return "small"; + case 3: + return "three"; + default: + return "big"; + } + } + `); + const method = methodByName(file, "f"); + const stmts = allStmts(method); + const conditions = stmts.filter( + (s): s is IfStmtDto => s._ === "IfStmt" && s.condition.op === "===", + ); + expect(conditions).toHaveLength(3); + // empty case 1 falls through to case 2's body + const returns = stmts.filter((s) => s._ === "ReturnStmt"); + expect(returns).toHaveLength(3); + }); + + it("lowers for-of via the iterator protocol", () => { + const { file } = lower(` + let arr = [1, 2, 3]; + for (const v of arr) { console.log(v); } + `); + const method = defaultMethod(file); + const stmts = allStmts(method); + const calls = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "InstanceCallExpr"); + const methodNames = calls.map( + (s) => ((s as { right: { method: { name: string } } }).right.method.name), + ); + expect(methodNames).toContain("Symbol.iterator"); + expect(methodNames).toContain("next"); + const fieldReads = stmts + .filter((s) => s._ === "AssignStmt" && s.right._ === "InstanceFieldRef") + .map((s) => (s as { right: { field: { name: string } } }).right.field.name); + expect(fieldReads).toContain("done"); + expect(fieldReads).toContain("value"); + // loop variable is bound + expect(method.body!.locals.some((l) => l.name === "v")).toBe(true); + }); + + it("lowers for-in via Object.keys indexing", () => { + const { file } = lower(` + let obj = [1]; + for (const k in obj) { console.log(k); } + `); + const stmts = allStmts(defaultMethod(file)); + const staticCalls = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "StaticCallExpr"); + expect(staticCalls.length).toBeGreaterThan(0); + expect(staticCalls[0]).toMatchObject({ + right: { method: { declaringClass: { name: "Object" }, name: "keys" } }, + }); + }); + + it("supports labeled break from nested loops", () => { + const { file } = lower(` + function f(): number { + let c = 0; + outer: for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + if (j > i) { break outer; } + c++; + } + } + return c; + } + `); + // must lower without raw fallbacks + const stmts = allStmts(methodByName(file, "f")); + expect(stmts.every((s) => s._ !== ("UnsupportedStmt" as never))).toBe(true); + }); + + it("drops unreachable code after return", () => { + const { file } = lower(` + function f(): number { + return 1; + console.log("never"); + } + `); + const stmts = allStmts(methodByName(file, "f")); + expect(stmts.some((s) => s._ === "CallStmt")).toBe(false); + }); + + it("logical operators stay value-level binops (ArkAnalyzer-compatible)", () => { + const { file } = lower(` + function f(a: boolean, b: boolean): void { + if (a && b) { console.log(1); } + } + `); + const blocks = blocksOf(methodByName(file, "f")); + const entry = blocks[0]; + const binop = entry.stmts.find((s) => s._ === "AssignStmt" && s.right._ === "BinopExpr"); + expect(binop).toMatchObject({ right: { op: "&&" } }); + expect(lastStmt(entry)).toMatchObject({ + _: "IfStmt", + condition: { op: "!=", right: { value: "false" } }, + }); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts new file mode 100644 index 000000000..8a587bff4 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; +import { Modifier } from "../src/dto/constants"; +import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model"; +import { lower, singleBlockStmts } from "./util"; + +const FILE_SIG = { projectName: "proj", fileName: "test.ts" }; + +function classByName(file: EtsFileDto, name: string): ClassDto { + const clazz = file.classes.find((c) => c.signature.name === name); + if (clazz === undefined) throw new Error(`class '${name}' not found`); + return clazz; +} + +function methodOf(clazz: ClassDto, name: string): MethodDto { + const method = clazz.methods.find((m) => m.signature.name === name); + if (method === undefined) { + throw new Error(`method '${name}' not found in ${clazz.signature.name}: ${clazz.methods.map((m) => m.signature.name)}`); + } + return method; +} + +describe("class lowering", () => { + const source = ` + class Point { + x: number = 1; + y: number = 2; + private static counter: number = 0; + tag?: string; + + constructor(x: number) { + this.x = x; + } + + dist(): number { + return this.x + this.y; + } + + static reset(): void { + Point.counter = 0; + } + } + `; + + it("builds the ClassDto with fields and methods", () => { + const { file } = lower(source); + const clazz = classByName(file, "Point"); + expect(clazz.category).toBe(0); + expect(clazz.superClassName).toBe(""); + expect(clazz.fields.map((f) => f.signature.name)).toEqual(["x", "y", "counter", "tag"]); + + const counter = clazz.fields.find((f) => f.signature.name === "counter")!; + expect(counter.modifiers).toBe(Modifier.PRIVATE | Modifier.STATIC); + const tag = clazz.fields.find((f) => f.signature.name === "tag")!; + expect(tag.questionToken).toBe(true); + + expect(clazz.methods.map((m) => m.signature.name).sort()).toEqual([ + "%instInit", + "%statInit", + "constructor", + "dist", + "reset", + ]); + }); + + it("synthesizes %instInit with instance field initializers", () => { + const { file } = lower(source); + const instInit = methodOf(classByName(file, "Point"), "%instInit"); + const stmts = singleBlockStmts(instInit); + expect(stmts[0]).toMatchObject({ _: "AssignStmt", left: { name: "this" }, right: { _: "ThisRef" } }); + expect(stmts[1]).toMatchObject({ + _: "AssignStmt", + left: { + _: "InstanceFieldRef", + instance: { _: "Local", name: "this" }, + field: { name: "x", type: { _: "NumberType" } }, + }, + right: { _: "Constant", value: "1" }, + }); + expect(stmts[2]).toMatchObject({ left: { field: { name: "y" } }, right: { value: "2" } }); + expect(stmts[stmts.length - 1]).toEqual({ _: "ReturnVoidStmt" }); + }); + + it("synthesizes %statInit with static field initializers", () => { + const { file } = lower(source); + const statInit = methodOf(classByName(file, "Point"), "%statInit"); + expect(statInit.modifiers & Modifier.STATIC).toBe(Modifier.STATIC); + const stmts = singleBlockStmts(statInit); + expect(stmts[1]).toMatchObject({ + _: "AssignStmt", + left: { _: "StaticFieldRef", field: { name: "counter" } }, + right: { _: "Constant", value: "0" }, + }); + }); + + it("shapes the explicit constructor: prologue, %instInit call, body, return this", () => { + const { file } = lower(source); + const ctor = methodOf(classByName(file, "Point"), "constructor"); + expect(ctor.signature.returnType).toMatchObject({ _: "ClassType", signature: { name: "Point" } }); + const stmts = singleBlockStmts(ctor); + expect(stmts[0]).toMatchObject({ left: { name: "x" }, right: { _: "ParameterRef", index: 0 } }); + expect(stmts[1]).toMatchObject({ left: { name: "this" }, right: { _: "ThisRef" } }); + expect(stmts[2]).toMatchObject({ + _: "CallStmt", + expr: { _: "InstanceCallExpr", instance: { name: "this" }, method: { name: "%instInit" } }, + }); + expect(stmts[3]).toMatchObject({ + _: "AssignStmt", + left: { _: "InstanceFieldRef", field: { name: "x" } }, + right: { _: "Local", name: "x" }, + }); + expect(stmts[stmts.length - 1]).toMatchObject({ _: "ReturnStmt", arg: { _: "Local", name: "this" } }); + }); + + it("synthesizes a default constructor when absent", () => { + const { file } = lower("class Empty {}"); + const ctor = methodOf(classByName(file, "Empty"), "constructor"); + const stmts = singleBlockStmts(ctor); + expect(stmts).toHaveLength(3); + expect(stmts[0]).toMatchObject({ right: { _: "ThisRef" } }); + expect(stmts[1]).toMatchObject({ _: "CallStmt", expr: { method: { name: "%instInit" } } }); + expect(stmts[2]).toMatchObject({ _: "ReturnStmt", arg: { name: "this" } }); + }); + + it("handles inheritance and implements clauses", () => { + const { file } = lower(` + interface Shaped { area(): number; } + class Base {} + class Derived extends Base implements Shaped { + area(): number { return 0; } + } + `); + const derived = classByName(file, "Derived"); + expect(derived.superClassName).toBe("Base"); + expect(derived.implementedInterfaceNames).toEqual(["Shaped"]); + }); + + it("lowers parameter properties into fields and constructor assignments", () => { + const { file } = lower(` + class Vec { + constructor(private readonly x: number, public y: number) {} + } + `); + const vec = classByName(file, "Vec"); + expect(vec.fields.map((f) => f.signature.name)).toEqual(["x", "y"]); + const x = vec.fields[0]; + expect(x.modifiers & Modifier.PRIVATE).toBe(Modifier.PRIVATE); + expect(x.modifiers & Modifier.READONLY).toBe(Modifier.READONLY); + + const ctor = methodOf(vec, "constructor"); + const stmts = singleBlockStmts(ctor); + const fieldAssigns = stmts.filter( + (s) => s._ === "AssignStmt" && s.left._ === "InstanceFieldRef", + ); + expect(fieldAssigns).toHaveLength(2); + }); + + it("marks abstract classes and keeps abstract methods bodyless", () => { + const { file } = lower(` + abstract class A { + abstract run(): void; + helper(): number { return 1; } + } + `); + const a = classByName(file, "A"); + expect(a.modifiers & Modifier.ABSTRACT).toBe(Modifier.ABSTRACT); + const run = methodOf(a, "run"); + expect(run.body).toBeUndefined(); + expect(run.modifiers & Modifier.ABSTRACT).toBe(Modifier.ABSTRACT); + expect(methodOf(a, "helper").body).toBeDefined(); + }); +}); + +describe("interface lowering", () => { + it("builds category-2 classes with bodyless methods", () => { + const { file } = lower(` + export interface Checker { + limit?: number; + check(value: string): boolean; + } + `); + const checker = classByName(file, "Checker"); + expect(checker.category).toBe(2); + expect(checker.modifiers & Modifier.EXPORT).toBe(Modifier.EXPORT); + + const limit = checker.fields.find((f) => f.signature.name === "limit")!; + expect(limit.questionToken).toBe(true); + + const check = methodOf(checker, "check"); + expect(check.body).toBeUndefined(); + expect(check.signature.parameters).toEqual([{ name: "value", type: { _: "StringType" } }]); + expect(check.signature.returnType).toEqual({ _: "BooleanType" }); + }); +}); + +describe("enum lowering", () => { + it("builds category-3 classes with static EnumValueType fields and %statInit values", () => { + const { file } = lower(` + enum Color { Red, Green = 10, Blue } + `); + const color = classByName(file, "Color"); + expect(color.category).toBe(3); + expect(color.fields).toHaveLength(3); + expect(color.fields[0]).toMatchObject({ + signature: { + name: "Red", + type: { _: "EnumValueType", signature: { name: "Color" }, name: "Red" }, + }, + modifiers: Modifier.STATIC, + }); + + const statInit = methodOf(color, "%statInit"); + const stmts = singleBlockStmts(statInit); + const values = stmts + .filter((s) => s._ === "AssignStmt" && s.left._ === "StaticFieldRef") + .map((s) => (s as { right: { value: string } }).right.value); + expect(values).toEqual(["0", "10", "11"]); + }); + + it("supports string enums", () => { + const { file } = lower(`enum Dir { Up = "UP", Down = "DOWN" }`); + const statInit = methodOf(classByName(file, "Dir"), "%statInit"); + const stmts = singleBlockStmts(statInit); + const assigns = stmts.filter((s) => s._ === "AssignStmt" && s.left._ === "StaticFieldRef"); + expect(assigns[0]).toMatchObject({ right: { _: "Constant", value: "UP", type: { _: "StringType" } } }); + }); +}); + +describe("namespace lowering", () => { + it("builds NamespaceDto with its own %dflt class and declared classes", () => { + const { file } = lower(` + namespace Outer { + export class Inner {} + export function util(): number { return 1; } + let counter = 0; + namespace Nested { + export enum E { A } + } + } + `); + expect(file.namespaces).toHaveLength(1); + const outer = file.namespaces[0]; + expect(outer.signature).toEqual({ name: "Outer", declaringFile: FILE_SIG }); + + const classNames = outer.classes!.map((c) => c.signature.name); + expect(classNames).toContain("%dflt"); + expect(classNames).toContain("Inner"); + + const inner = outer.classes!.find((c) => c.signature.name === "Inner")!; + expect(inner.signature.declaringNamespace).toEqual({ name: "Outer", declaringFile: FILE_SIG }); + + const dflt = outer.classes!.find((c) => c.signature.name === "%dflt")!; + expect(dflt.methods.map((m) => m.signature.name)).toContain("util"); + + const nested = outer.namespaces![0]; + expect(nested.signature).toEqual({ + name: "Nested", + declaringFile: FILE_SIG, + declaringNamespace: { name: "Outer", declaringFile: FILE_SIG }, + }); + expect(nested.classes!.map((c) => c.signature.name)).toContain("E"); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/cli.spec.ts b/jacodb-ets/ts-frontend/test/cli.spec.ts new file mode 100644 index 000000000..e0f66941e --- /dev/null +++ b/jacodb-ets/ts-frontend/test/cli.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parseArgs } from "../src/index"; + +describe("parseArgs", () => { + it("parses plain positional arguments", () => { + const args = parseArgs(["in.ts", "out.json"]); + expect(args).toMatchObject({ input: "in.ts", output: "out.json", verbose: false }); + }); + + it("parses flags in any position", () => { + const args = parseArgs(["-p", "-e", "in", "out", "-v"]); + expect(args).toMatchObject({ project: true, entrypoints: true, verbose: true }); + }); + + it("parses -t with a separate value token", () => { + const args = parseArgs(["-t", "2", "in", "out"]); + expect(args).toMatchObject({ typeInference: 2, input: "in", output: "out" }); + }); + + it("tolerates the Kotlin quirk of a single '-t 1' token", () => { + const args = parseArgs(["-t 1", "in", "out"]); + expect(args).toMatchObject({ typeInference: 1, input: "in", output: "out" }); + }); + + it("treats -t without numeric value as level 1", () => { + const args = parseArgs(["in", "out", "-t"]); + expect(args).toMatchObject({ typeInference: 1, input: "in", output: "out" }); + }); + + it("rejects unknown options", () => { + expect(typeof parseArgs(["--nope", "in", "out"])).toBe("string"); + }); + + it("rejects wrong positional count", () => { + expect(typeof parseArgs(["onlyone"])).toBe("string"); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts new file mode 100644 index 000000000..db0d7cb95 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; +import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; + +function allStmts(source: string): StmtDto[] { + const { file } = lower(source); + return file.classes + .flatMap((c) => c.methods) + .flatMap((m) => m.body?.cfg.blocks ?? []) + .flatMap((b) => b.stmts); +} + +describe("closures", () => { + it("lifts arrow functions into %AM methods with FunctionType use-site locals", () => { + const { file } = lower(` + let arr = [1, 2, 3]; + arr.map((x: number) => x * 2); + `); + const closure = methodByName(file, "%AM0$%dflt"); + expect(closure.body).toBeDefined(); + expect(closure.signature.parameters).toEqual([{ name: "x", type: { _: "NumberType" } }]); + + // Expression body returns the value. + const stmts = singleBlockStmts(closure); + expect(stmts[stmts.length - 1]._).toBe("ReturnStmt"); + + // Use site: the callback argument is a Local typed FunctionType. + const callArgs = singleBlockStmts(defaultMethod(file)) + .filter((s): s is Extract => s._ === "CallStmt") + .flatMap((s) => s.expr.args); + expect(callArgs).toContainEqual( + expect.objectContaining({ + _: "Local", + name: "%AM0$%dflt", + type: expect.objectContaining({ _: "FunctionType" }), + }), + ); + }); + + it("lowers function expressions and block bodies", () => { + const { file } = lower(` + const f = function (a: number): number { + let b = a + 1; + return b; + }; + f(1); + `); + const closure = methodByName(file, "%AM0$%dflt"); + const stmts = singleBlockStmts(closure); + expect(stmts.some((s) => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "b")).toBe(true); + }); + + it("numbers nested closures by their enclosing method", () => { + const { file } = lower(` + const outer = () => { + const inner = () => 1; + return inner; + }; + `); + expect(methodByName(file, "%AM0$%dflt")).toBeDefined(); + expect(methodByName(file, "%AM1$%AM0$%dflt")).toBeDefined(); + }); +}); + +describe("object literals", () => { + it("creates %AC classes with fields and per-property stores", () => { + const { file } = lower(` + let radius = 3; + const shape = { kind: "circle", radius, describe(): string { return this.kind; } }; + `); + const anonClass = file.classes.find((c) => c.signature.name.startsWith("%AC0")); + expect(anonClass).toBeDefined(); + expect(anonClass!.category).toBe(5); + expect(anonClass!.fields.map((f) => f.signature.name)).toEqual(["kind", "radius"]); + expect(anonClass!.methods.map((m) => m.signature.name)).toEqual(["describe"]); + expect(anonClass!.methods[0].body).toBeDefined(); + + const stmts = singleBlockStmts(defaultMethod(file)); + const newExpr = stmts.find( + (s): s is AssignStmtDto => s._ === "AssignStmt" && s.right._ === "NewExpr", + ); + expect(newExpr).toBeDefined(); + const fieldStores = stmts.filter( + (s) => s._ === "AssignStmt" && s.left._ === "InstanceFieldRef", + ); + expect(fieldStores).toHaveLength(2); + }); +}); + +describe("destructuring", () => { + it("unpacks object patterns with renames and defaults", () => { + const stmts = allStmts(` + const config = { host: "localhost", port: 8080 }; + const { host, port: p, missing = 42 } = config; + `); + const fieldReads = stmts.filter( + (s): s is AssignStmtDto => s._ === "AssignStmt" && s.right._ === "InstanceFieldRef", + ); + const bound = fieldReads.map((s) => ({ + local: (s.left as { name: string }).name, + field: (s.right as { field: { name: string } }).field.name, + })); + expect(bound).toContainEqual({ local: "host", field: "host" }); + expect(bound).toContainEqual({ local: "p", field: "port" }); + expect(bound).toContainEqual({ local: "missing", field: "missing" }); + // default: guarded by === undefined check + expect( + stmts.some( + (s) => + s._ === "IfStmt" && + s.condition.op === "===" && + (s.condition.right as { value?: string }).value === "undefined", + ), + ).toBe(true); + }); + + it("unpacks array patterns with holes and nesting", () => { + const stmts = allStmts(` + const data: [number, number, [string, string]] = [1, 2, ["a", "b"]]; + const [first, , [inner]] = data; + `); + const arrayReads = stmts.filter( + (s): s is AssignStmtDto => s._ === "AssignStmt" && s.right._ === "ArrayRef", + ); + const indices = arrayReads.map((s) => (s.right as { index: { value: string } }).index.value); + expect(indices).toContain("0"); + expect(indices).toContain("2"); + expect(indices).not.toContain("1"); // hole skipped + expect(stmts.some((s) => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "inner")).toBe(true); + }); + + it("supports destructuring in for-of", () => { + const stmts = allStmts(` + const pairs: [string, number][] = [["a", 1]]; + for (const [key, value] of pairs) { + console.log(key, value); + } + `); + expect(stmts.some((s) => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "key")).toBe(true); + expect(stmts.some((s) => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "value")).toBe(true); + }); +}); + +describe("optional chaining", () => { + it("guards property access with a null check diamond", () => { + const stmts = allStmts(` + class Box { size: number = 1; } + function f(b: Box | null): number | undefined { + return b?.size; + } + `); + const nullCheck = stmts.find( + (s) => s._ === "IfStmt" && (s.condition.right as { value?: string }).value === "null", + ); + expect(nullCheck).toBeDefined(); + const undefinedAssign = stmts.find( + (s) => + s._ === "AssignStmt" && + (s.right as { value?: string }).value === "undefined", + ); + expect(undefinedAssign).toBeDefined(); + }); + + it("guards optional calls", () => { + const stmts = allStmts(` + function f(cb?: () => void): void { + cb?.(); + } + `); + expect(stmts.some((s) => s._ === "IfStmt")).toBe(true); + expect(stmts.some((s) => s._ === "AssignStmt" && s.right._ === "PtrCallExpr")).toBe(true); + }); +}); + +describe("generators", () => { + it("lowers yield expressions", () => { + const { file } = lower(` + function* gen(): Generator { + yield 1; + yield 2; + } + `); + const gen = methodByName(file, "gen"); + const stmts = gen.body!.cfg.blocks.flatMap((b) => b.stmts); + const yields = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "YieldExpr"); + expect(yields).toHaveLength(2); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/corpus.spec.ts b/jacodb-ets/ts-frontend/test/corpus.spec.ts new file mode 100644 index 000000000..5121f6031 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/corpus.spec.ts @@ -0,0 +1,62 @@ +import * as fs from "fs"; +import * as path from "path"; +import { describe, expect, it } from "vitest"; +import { EtsFileDto } from "../src/dto/model"; +import { lower } from "./util"; + +const FIXTURES_DIR = path.join(__dirname, "fixtures"); + +/** Count raw fallback statements/values (any "Unsupported*" discriminator). */ +function countRawFallbacks(file: EtsFileDto): number { + let count = 0; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (value !== null && typeof value === "object") { + const kind = (value as { _?: string })._; + if (kind !== undefined && kind.startsWith("Unsupported")) { + count++; + } + Object.values(value).forEach(visit); + } + }; + visit(file); + return count; +} + +describe("corpus: realistic TS/JS programs lower cleanly", () => { + const fixtures = fs.readdirSync(FIXTURES_DIR).filter((f) => f.endsWith(".ts") || f.endsWith(".js")); + + it("has fixtures to run", () => { + expect(fixtures.length).toBeGreaterThanOrEqual(5); + }); + + for (const fixture of fixtures) { + it(`lowers ${fixture} with zero invariant violations and zero raw fallbacks`, () => { + const source = fs.readFileSync(path.join(FIXTURES_DIR, fixture), "utf-8"); + // lower() throws on any invariant violation. + const { file, diagnostics } = lower(source, "corpus", fixture); + expect(countRawFallbacks(file)).toBe(0); + expect(diagnostics.messages).toEqual([]); + + // Sanity: every method body has at least the prologue and a terminator. + const allMethods = [ + ...file.classes.flatMap((c) => c.methods), + ...file.namespaces.flatMap(function collect(ns): typeof file.classes[0]["methods"] { + return [ + ...(ns.classes ?? []).flatMap((c) => c.methods), + ...(ns.namespaces ?? []).flatMap(collect), + ]; + }), + ]; + for (const method of allMethods) { + if (method.body !== undefined) { + const stmts = method.body.cfg.blocks.flatMap((b) => b.stmts); + expect(stmts.length).toBeGreaterThanOrEqual(2); + } + } + }); + } +}); diff --git a/jacodb-ets/ts-frontend/test/fixtures/algorithms.ts b/jacodb-ets/ts-frontend/test/fixtures/algorithms.ts new file mode 100644 index 000000000..f82d28d94 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/fixtures/algorithms.ts @@ -0,0 +1,62 @@ +export function fibonacci(n: number): number { + if (n <= 1) { + return n; + } + let prev = 0; + let curr = 1; + for (let i = 2; i <= n; i++) { + const next = prev + curr; + prev = curr; + curr = next; + } + return curr; +} + +export function quickSort(arr: number[]): number[] { + if (arr.length <= 1) { + return arr; + } + const pivot = arr[0]; + const left: number[] = []; + const right: number[] = []; + for (let i = 1; i < arr.length; i++) { + if (arr[i] < pivot) { + left.push(arr[i]); + } else { + right.push(arr[i]); + } + } + return quickSort(left).concat([pivot], quickSort(right)); +} + +export function wordFrequencies(text: string): Record { + const counts: Record = {}; + const words = text.split(" "); + for (const word of words) { + const key = word.toLowerCase(); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +export function sumEvens(limit: number): number { + let total = 0; + let i = 0; + while (true) { + i++; + if (i > limit) { + break; + } + if (i % 2 !== 0) { + continue; + } + total += i; + } + return total; +} + +export const pipeline = (values: number[]): number => + values + .map((v) => v * 2) + .filter((v) => v > 4) + .reduce((acc, v) => acc + v, 0); diff --git a/jacodb-ets/ts-frontend/test/fixtures/calculator.ts b/jacodb-ets/ts-frontend/test/fixtures/calculator.ts new file mode 100644 index 000000000..aa5774691 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/fixtures/calculator.ts @@ -0,0 +1,60 @@ +export enum Operation { + Add, + Sub, + Mul, + Div, +} + +export interface HistoryEntry { + op: Operation; + left: number; + right: number; + result: number; +} + +export class Calculator { + private history: HistoryEntry[] = []; + static instances: number = 0; + + constructor(private readonly precision: number = 2) { + Calculator.instances++; + } + + apply(op: Operation, left: number, right: number): number { + let result: number; + switch (op) { + case Operation.Add: + result = left + right; + break; + case Operation.Sub: + result = left - right; + break; + case Operation.Mul: + result = left * right; + break; + case Operation.Div: + if (right === 0) { + throw new Error("division by zero"); + } + result = left / right; + break; + default: + throw new Error(`unknown operation: ${op}`); + } + this.history.push({ op, left, right, result }); + return result; + } + + lastResult(): number | undefined { + const entry = this.history[this.history.length - 1]; + return entry?.result; + } + + undo(): boolean { + if (this.history.length === 0) { + return false; + } + this.history.pop(); + return true; + } +} diff --git a/jacodb-ets/ts-frontend/test/fixtures/events.js b/jacodb-ets/ts-frontend/test/fixtures/events.js new file mode 100644 index 000000000..add34f3cd --- /dev/null +++ b/jacodb-ets/ts-frontend/test/fixtures/events.js @@ -0,0 +1,42 @@ +function createEmitter() { + const handlers = {}; + return { + on(name, handler) { + const list = handlers[name] ?? []; + list.push(handler); + handlers[name] = list; + }, + emit(name, payload) { + const list = handlers[name]; + if (!list) { + return 0; + } + let delivered = 0; + for (const handler of list) { + try { + handler(payload); + delivered++; + } catch (err) { + console.error("handler failed", err); + } + } + return delivered; + }, + }; +} + +function main() { + const emitter = createEmitter(); + let total = 0; + emitter.on("tick", function (n) { + total += n; + }); + for (let i = 0; i < 10; i++) { + emitter.emit("tick", i); + } + const summary = "total: " + total; + console.log(summary); + return total; +} + +main(); diff --git a/jacodb-ets/ts-frontend/test/fixtures/geometry.ts b/jacodb-ets/ts-frontend/test/fixtures/geometry.ts new file mode 100644 index 000000000..8e5745030 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/fixtures/geometry.ts @@ -0,0 +1,54 @@ +export namespace Geometry { + export class Point { + constructor( + public x: number = 0, + public y: number = 0, + ) {} + + distanceTo(other: Point): number { + const dx = this.x - other.x; + const dy = this.y - other.y; + return Math.sqrt(dx * dx + dy * dy); + } + } + + export namespace Shapes { + export abstract class Shape { + abstract area(): number; + + describe(): string { + return `area = ${this.area()}`; + } + } + + export class Rect extends Shape { + constructor( + private readonly w: number, + private readonly h: number, + ) { + super(); + } + + area(): number { + return this.w * this.h; + } + } + } +} + +const origin = new Geometry.Point(); +const p = new Geometry.Point(3, 4); +const { x, y } = p; +console.log(x, y, origin.distanceTo(p)); + +const sizes = [1, 2, 3].map((n) => { + const rect = new Geometry.Shapes.Rect(n, n + 1); + return rect.area(); +}); +let biggest = 0; +for (const [index, size] of sizes.entries()) { + if (size > biggest) { + biggest = size; + } + console.log(`#${index}: ${size}`); +} diff --git a/jacodb-ets/ts-frontend/test/fixtures/state_machine.ts b/jacodb-ets/ts-frontend/test/fixtures/state_machine.ts new file mode 100644 index 000000000..f28160673 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/fixtures/state_machine.ts @@ -0,0 +1,63 @@ +type StateName = "idle" | "running" | "paused" | "stopped"; + +interface Transition { + from: StateName; + to: StateName; + guard?: (payload: unknown) => boolean; +} + +export class StateMachine { + current: StateName = "idle"; + private transitions: Transition[] = []; + private listeners: ((from: StateName, to: StateName) => void)[] = []; + + allow(from: StateName, to: StateName): void { + this.transitions.push({ from, to }); + } + + onChange(listener: (from: StateName, to: StateName) => void): void { + this.listeners.push(listener); + } + + fire(to: StateName, payload?: unknown): boolean { + let matched: Transition | undefined; + for (const t of this.transitions) { + if (t.from === this.current && t.to === to) { + matched = t; + break; + } + } + if (matched === undefined) { + return false; + } + try { + if (matched.guard !== undefined && !matched.guard(payload)) { + return false; + } + } catch (e) { + console.error(`guard failed: ${e}`); + return false; + } finally { + console.log(`transition attempt: ${this.current} -> ${to}`); + } + const from = this.current; + this.current = to; + for (const listener of this.listeners) { + listener(from, to); + } + return true; + } +} + +export function demo(): StateName { + const machine = new StateMachine(); + machine.allow("idle", "running"); + machine.allow("running", "paused"); + machine.onChange((from, to) => { + console.log(`changed: ${from} -> ${to}`); + }); + machine.fire("running"); + const label = machine.current === "running" ? "started" : "not started"; + console.log(label); + return machine.current; +} diff --git a/jacodb-ets/ts-frontend/test/imports.spec.ts b/jacodb-ets/ts-frontend/test/imports.spec.ts new file mode 100644 index 000000000..721b80399 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/imports.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { Modifier } from "../src/dto/constants"; +import { lower } from "./util"; + +describe("import infos", () => { + it("builds infos for every import flavor", () => { + const { file } = lower(` + import def from "./mod-default"; + import { a, b as c } from "./mod-named"; + import * as ns from "./mod-namespace"; + import "./mod-sideeffect"; + `); + expect(file.importInfos).toEqual([ + { importName: "def", importType: "Identifier", importFrom: "./mod-default", modifiers: 0 }, + { importName: "a", importType: "NamedImports", importFrom: "./mod-named", modifiers: 0 }, + { importName: "c", importType: "NamedImports", importFrom: "./mod-named", nameBeforeAs: "b", modifiers: 0 }, + { importName: "ns", importType: "NamespaceImport", importFrom: "./mod-namespace", modifiers: 0 }, + { importName: "", importType: "", importFrom: "./mod-sideeffect", modifiers: 0 }, + ]); + }); + + it("handles combined default + named imports", () => { + const { file } = lower(`import def, { x } from "./m";`); + expect(file.importInfos).toEqual([ + { importName: "def", importType: "Identifier", importFrom: "./m", modifiers: 0 }, + { importName: "x", importType: "NamedImports", importFrom: "./m", modifiers: 0 }, + ]); + }); +}); + +describe("export infos", () => { + it("builds infos for exported declarations", () => { + const { file } = lower(` + export class C {} + export enum E { A } + export function f(): void {} + export interface I {} + export type T = number; + export const v = 1; + export namespace N {} + `); + const byName = Object.fromEntries(file.exportInfos.map((e) => [e.exportName, e])); + expect(byName["C"].exportType).toBe(1); // CLASS + expect(byName["E"].exportType).toBe(1); // CLASS (enum) + expect(byName["f"].exportType).toBe(2); // METHOD + expect(byName["I"].exportType).toBe(4); // TYPE + expect(byName["T"].exportType).toBe(4); // TYPE + expect(byName["v"].exportType).toBe(3); // LOCAL + expect(byName["N"].exportType).toBe(0); // NAMESPACE + expect(byName["C"].modifiers & Modifier.EXPORT).toBe(Modifier.EXPORT); + }); + + it("builds infos for re-exports with nameBeforeAs and exportFrom", () => { + const { file } = lower(` + export { X, Y as Z } from "./other"; + export * from "./star"; + export * as bundle from "./bundle"; + `); + expect(file.exportInfos).toEqual([ + { exportName: "X", exportType: 9, modifiers: 0, exportFrom: "./other" }, + { exportName: "Z", exportType: 9, nameBeforeAs: "Y", modifiers: 0, exportFrom: "./other" }, + { exportName: "*", exportType: 9, modifiers: 0, exportFrom: "./star" }, + { exportName: "bundle", exportType: 0, nameBeforeAs: "*", modifiers: 0, exportFrom: "./bundle" }, + ]); + }); + + it("resolves local re-exports through the checker", () => { + const { file } = lower(` + class Local {} + function helper(): void {} + export { Local, helper }; + `); + const byName = Object.fromEntries(file.exportInfos.map((e) => [e.exportName, e])); + expect(byName["Local"].exportType).toBe(1); + expect(byName["helper"].exportType).toBe(2); + }); + + it("handles export default", () => { + const { file } = lower(` + class Main {} + export default Main; + `); + expect(file.exportInfos).toEqual([ + { exportName: "Main", exportType: 1, modifiers: Modifier.DEFAULT }, + ]); + }); + + it("handles export default declarations", () => { + const { file } = lower(`export default class Widget {}`); + const widget = file.exportInfos.find((e) => e.exportName === "Widget"); + expect(widget).toBeDefined(); + expect(widget!.exportType).toBe(1); + expect(widget!.modifiers & Modifier.DEFAULT).toBe(Modifier.DEFAULT); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/lowering.spec.ts b/jacodb-ets/ts-frontend/test/lowering.spec.ts new file mode 100644 index 000000000..a2aeb62af --- /dev/null +++ b/jacodb-ets/ts-frontend/test/lowering.spec.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from "vitest"; +import { StmtDto, AssignStmtDto, CallStmtDto } from "../src/dto/stmts"; +import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; + +/** Statements of the %dflt method between the prologue (`this := ThisRef`) and the final return. */ +function bodyStmts(source: string): StmtDto[] { + const { file } = lower(source); + const stmts = singleBlockStmts(defaultMethod(file)); + // prologue of %dflt: [this := ThisRef]; epilogue: ReturnVoidStmt + return stmts.slice(1, -1); +} + +function assigns(stmts: StmtDto[]): AssignStmtDto[] { + return stmts.filter((s): s is AssignStmtDto => s._ === "AssignStmt"); +} + +describe("straight-line lowering", () => { + it("lowers literal initializers to constants", () => { + const stmts = bodyStmts(`let x = 42; let s = "hi"; let b = true; let n = null; let u = undefined;`); + expect(stmts).toEqual([ + asgn("x", { _: "Constant", value: "42", type: { _: "NumberType" } }), + asgn("s", { _: "Constant", value: "hi", type: { _: "StringType" } }), + asgn("b", { _: "Constant", value: "true", type: { _: "BooleanType" } }), + asgn("n", { _: "Constant", value: "null", type: { _: "NullType" } }), + asgn("u", { _: "Constant", value: "undefined", type: { _: "UndefinedType" } }), + ]); + + function asgn(name: string, right: unknown) { + return { _: "AssignStmt", left: expect.objectContaining({ _: "Local", name }), right }; + } + }); + + it("lowers binary expressions with immediate operands", () => { + const stmts = bodyStmts("let a = 1; let b = 2; let c = a + b;"); + const c = assigns(stmts)[2]; + expect(c.right).toEqual({ + _: "BinopExpr", + op: "+", + left: expect.objectContaining({ _: "Local", name: "a" }), + right: expect.objectContaining({ _: "Local", name: "b" }), + type: { _: "NumberType" }, + }); + }); + + it("hoists nested expressions into %N temps", () => { + const stmts = bodyStmts("let a = 1; let b = 2; let d = (a + b) * 2;"); + const all = assigns(stmts); + // a, b, %0 := a + b, d := %0 * 2 + expect(all).toHaveLength(4); + expect(all[2].left).toMatchObject({ _: "Local", name: "%0" }); + expect(all[2].right).toMatchObject({ _: "BinopExpr", op: "+" }); + expect(all[3].left).toMatchObject({ _: "Local", name: "d" }); + expect(all[3].right).toMatchObject({ + _: "BinopExpr", + op: "*", + left: { _: "Local", name: "%0" }, + right: { _: "Constant", value: "2" }, + }); + }); + + it("lowers comparisons in value position to ConditionExpr", () => { + const stmts = bodyStmts("let a = 1; let e = a < 2;"); + expect(assigns(stmts)[1].right).toMatchObject({ + _: "ConditionExpr", + op: "<", + type: { _: "BooleanType" }, + }); + }); + + it("lowers method calls on values to InstanceCallExpr with a Local instance", () => { + const stmts = bodyStmts(`console.log("hello");`); + const call = stmts.find((s): s is CallStmtDto => s._ === "CallStmt"); + expect(call).toBeDefined(); + expect(call!.expr).toMatchObject({ + _: "InstanceCallExpr", + instance: { _: "Local", name: "console" }, + method: { name: "log" }, + args: [{ _: "Constant", value: "hello", type: { _: "StringType" } }], + }); + }); + + it("lowers free project function calls to StaticCallExpr on %dflt", () => { + const source = ` + function add(a: number, b: number): number { return a + b; } + let s = add(1, 2); + `; + const { file } = lower(source); + const stmts = singleBlockStmts(defaultMethod(file)); + const sAssign = stmts.find( + (s): s is AssignStmtDto => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "s", + ); + expect(sAssign!.right).toMatchObject({ + _: "StaticCallExpr", + method: { + declaringClass: { name: "%dflt", declaringFile: { projectName: "proj", fileName: "test.ts" } }, + name: "add", + parameters: [ + { name: "a", type: { _: "NumberType" } }, + { name: "b", type: { _: "NumberType" } }, + ], + returnType: { _: "NumberType" }, + }, + args: [ + { _: "Constant", value: "1" }, + { _: "Constant", value: "2" }, + ], + }); + }); + + it("lowers function declarations to %dflt methods with prologue and immediate return", () => { + const source = "function add(a: number, b: number): number { return a + b; }"; + const { file } = lower(source); + const method = methodByName(file, "add"); + const stmts = singleBlockStmts(method); + expect(stmts[0]).toMatchObject({ + _: "AssignStmt", + left: { _: "Local", name: "a" }, + right: { _: "ParameterRef", index: 0, type: { _: "NumberType" } }, + }); + expect(stmts[1]).toMatchObject({ + _: "AssignStmt", + left: { _: "Local", name: "b" }, + right: { _: "ParameterRef", index: 1 }, + }); + expect(stmts[2]).toMatchObject({ _: "AssignStmt", left: { _: "Local", name: "this" }, right: { _: "ThisRef" } }); + // return arg is an immediate: %0 := a + b; return %0 + expect(stmts[3]).toMatchObject({ _: "AssignStmt", left: { _: "Local", name: "%0" } }); + expect(stmts[4]).toEqual({ _: "ReturnStmt", arg: expect.objectContaining({ _: "Local", name: "%0" }) }); + }); + + it("lowers `new` into NewExpr + constructor call", () => { + const stmts = bodyStmts("class C {}\nlet o = new C();"); + const all = assigns(stmts); + const classSig = { name: "C", declaringFile: { projectName: "proj", fileName: "test.ts" } }; + expect(all[0]).toMatchObject({ + left: { _: "Local", name: "%0" }, + right: { _: "NewExpr", classType: { _: "ClassType", signature: classSig } }, + }); + expect(all[1]).toMatchObject({ + left: { _: "Local", name: "%0" }, + right: { + _: "InstanceCallExpr", + instance: { _: "Local", name: "%0" }, + method: { declaringClass: classSig, name: "constructor" }, + args: [], + }, + }); + expect(all[2]).toMatchObject({ left: { _: "Local", name: "o" }, right: { _: "Local", name: "%0" } }); + }); + + it("lowers ambient `new` with the %unk file signature", () => { + const stmts = bodyStmts("let d = new Date();"); + expect(assigns(stmts)[0].right).toMatchObject({ + _: "NewExpr", + classType: { + _: "ClassType", + signature: { name: "Date", declaringFile: { projectName: "%unk", fileName: "%unk" } }, + }, + }); + }); + + it("lowers array literals to NewArrayExpr plus indexed stores", () => { + const stmts = bodyStmts("let arr = [10, 20];"); + const all = assigns(stmts); + expect(all[0].right).toEqual({ + _: "NewArrayExpr", + elementType: { _: "NumberType" }, + size: { _: "Constant", value: "2", type: { _: "NumberType" } }, + }); + expect(all[1]).toMatchObject({ + left: { _: "ArrayRef", index: { _: "Constant", value: "0" } }, + right: { _: "Constant", value: "10" }, + }); + expect(all[2]).toMatchObject({ + left: { _: "ArrayRef", index: { _: "Constant", value: "1" } }, + right: { _: "Constant", value: "20" }, + }); + expect(all[3]).toMatchObject({ left: { _: "Local", name: "arr" } }); + }); + + it("lowers element reads and writes through ArrayRef", () => { + const stmts = bodyStmts("let arr = [1]; let v = arr[0]; arr[0] = 5;"); + const all = assigns(stmts); + const read = all.find((a) => a.left._ === "Local" && a.left.name === "v")!; + expect(read.right).toMatchObject({ _: "ArrayRef", array: { _: "Local", name: "arr" } }); + const write = all[all.length - 1]; + expect(write).toMatchObject({ + left: { _: "ArrayRef", array: { _: "Local", name: "arr" } }, + right: { _: "Constant", value: "5" }, + }); + }); + + it("lowers instance field reads/writes through InstanceFieldRef", () => { + const source = ` + class A { f: number = 0; } + let a = new A(); + let v = a.f; + a.f = 5; + `; + const stmts = bodyStmts(source); + const all = assigns(stmts); + const classSig = { name: "A", declaringFile: { projectName: "proj", fileName: "test.ts" } }; + const read = all.find((s) => s.left._ === "Local" && s.left.name === "v")!; + expect(read.right).toEqual({ + _: "InstanceFieldRef", + instance: expect.objectContaining({ _: "Local", name: "a" }), + field: { declaringClass: classSig, name: "f", type: { _: "NumberType" } }, + }); + const write = all[all.length - 1]; + expect(write.left).toMatchObject({ _: "InstanceFieldRef", field: { name: "f" } }); + expect(write.right).toEqual({ _: "Constant", value: "5", type: { _: "NumberType" } }); + }); + + it("lowers static field access (enum members) through StaticFieldRef", () => { + const stmts = bodyStmts("enum E { A, B }\nlet v = E.A;"); + const read = assigns(stmts)[0]; + expect(read.right).toEqual({ + _: "StaticFieldRef", + field: { + declaringClass: { name: "E", declaringFile: { projectName: "proj", fileName: "test.ts" } }, + name: "A", + type: { + _: "EnumValueType", + signature: { name: "E", declaringFile: { projectName: "proj", fileName: "test.ts" } }, + name: "A", + }, + }, + }); + }); + + it("desugars compound assignment into load-op-store", () => { + const stmts = bodyStmts("let x = 1; x += 2;"); + const compound = assigns(stmts)[1]; + expect(compound).toMatchObject({ + left: { _: "Local", name: "x" }, + right: { + _: "BinopExpr", + op: "+", + left: { _: "Local", name: "x" }, + right: { _: "Constant", value: "2" }, + }, + }); + }); + + it("lowers increments with the ++ unop", () => { + const stmts = bodyStmts("let x = 1; x++;"); + const inc = assigns(stmts).find((a) => a.right._ === "UnopExpr")!; + expect(inc).toMatchObject({ + left: { _: "Local", name: "x" }, + right: { _: "UnopExpr", op: "++", arg: { _: "Local", name: "x" } }, + }); + }); + + it("returns the OLD value for postfix and the NEW value for prefix inc/dec on fields", () => { + const source = ` + class C { f: number = 0; } + let c = new C(); + let post = c.f++; + let pre = ++c.f; + `; + const stmts = bodyStmts(source); + const all = assigns(stmts); + + // postfix: %old := c.f; %new := %old ++; c.f := %new; post := %old + const postAssign = all.find((a) => a.left._ === "Local" && a.left.name === "post")!; + const postSource = (postAssign.right as { name: string }).name; + const oldLoad = all.find((a) => a.left._ === "Local" && a.left.name === postSource)!; + expect(oldLoad.right._).toBe("InstanceFieldRef"); // holds the OLD value + + // prefix: %old := c.f; %new := %old ++; c.f := %new; pre := %new + const preAssign = all.find((a) => a.left._ === "Local" && a.left.name === "pre")!; + const preSource = (preAssign.right as { name: string }).name; + const newCompute = all.find((a) => a.left._ === "Local" && a.left.name === preSource)!; + expect(newCompute.right._).toBe("UnopExpr"); // holds the UPDATED value + }); + + it("ignores TS fake `this` parameters", () => { + const { file } = lower(` + function f(this: Window, x: number): number { + return x; + } + `); + const method = methodByName(file, "f"); + expect(method.signature.parameters).toEqual([{ name: "x", type: { _: "NumberType" } }]); + const stmts = singleBlockStmts(method); + // x binds to ParameterRef(0) — the fake `this` must not shift indices + expect(stmts[0]).toMatchObject({ + left: { _: "Local", name: "x" }, + right: { _: "ParameterRef", index: 0 }, + }); + // and the `this` local still comes from ThisRef, not a ParameterRef + expect(stmts[1]).toMatchObject({ left: { name: "this" }, right: { _: "ThisRef" } }); + }); + + it("lifts nested function declarations onto the %dflt class", () => { + const source = ` + function outer(n: number): number { + function inner(k: number): number { + return k + 1; + } + return inner(n); + } + `; + const { file } = lower(source); + // inner must not be lost: it becomes a %dflt method with its real name + const inner = methodByName(file, "inner"); + expect(inner.body).toBeDefined(); + expect(inner.signature.parameters).toEqual([{ name: "k", type: { _: "NumberType" } }]); + // and the call site resolves to it as a static call on %dflt + const outer = methodByName(file, "outer"); + const call = outer + .body!.cfg.blocks.flatMap((b) => b.stmts) + .find((s) => s._ === "AssignStmt" && s.right._ === "StaticCallExpr"); + expect(call).toMatchObject({ + right: { method: { declaringClass: { name: "%dflt" }, name: "inner" } }, + }); + }); + + it("lowers template literals into string concatenation chains", () => { + const stmts = bodyStmts("let n = 1; let s = `a${n}b`;"); + const all = assigns(stmts); + // n, %0 := "a" + n, %1 := %0 + "b", s := %1 + expect(all[1].right).toMatchObject({ + _: "BinopExpr", + op: "+", + left: { _: "Constant", value: "a" }, + right: { _: "Local", name: "n" }, + }); + expect(all[2].right).toMatchObject({ + _: "BinopExpr", + op: "+", + left: { _: "Local", name: "%0" }, + right: { _: "Constant", value: "b" }, + }); + expect(all[3]).toMatchObject({ left: { _: "Local", name: "s" } }); + }); + + it("lowers throw statements", () => { + const { file } = lower(`throw new Error("boom");`); + const stmts = singleBlockStmts(defaultMethod(file)); + const last = stmts[stmts.length - 1]; + expect(last).toMatchObject({ _: "ThrowStmt", arg: { _: "Local" } }); + }); + + it("degrades unsupported expressions to raw fallback values hoisted into temps", () => { + const { file, diagnostics } = lower("let p = /abc/g;"); + const stmts = singleBlockStmts(defaultMethod(file)); + // raw values are only legal as the RHS of a Local assignment: %0 := ; p := %0 + const rawAssign = stmts.find( + (s): s is AssignStmtDto => s._ === "AssignStmt" && (s.right as { _: string })._ === "UnsupportedValue", + ); + expect(rawAssign).toBeDefined(); + expect(rawAssign!.left).toMatchObject({ _: "Local", name: "%0" }); + expect((rawAssign!.right as unknown as { type: unknown }).type).toBeDefined(); + const pAssign = stmts.find( + (s): s is AssignStmtDto => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "p", + ); + expect(pAssign!.right).toMatchObject({ _: "Local", name: "%0" }); + expect(diagnostics.messages.length).toBeGreaterThan(0); + }); + + it("hoists raw fallbacks out of ref stores and call arguments", () => { + const { file } = lower(` + let parts = [1, ...[2, 3]]; + console.log(...parts); + `); + const stmts = singleBlockStmts(defaultMethod(file)); + for (const s of stmts) { + if (s._ === "AssignStmt" && s.left._ !== "Local") { + expect((s.right as { _: string })._).not.toMatch(/^Unsupported/); + } + if (s._ === "CallStmt") { + for (const arg of s.expr.args) { + expect((arg as { _: string })._).not.toMatch(/^Unsupported/); + } + } + } + }); + + it("lowers typeof/await/cast expressions", () => { + const stmts = bodyStmts(` + let x = 1; + let t = typeof x; + let c = x as unknown; + `); + const all = assigns(stmts); + expect(all[1].right).toMatchObject({ _: "TypeOfExpr", arg: { _: "Local", name: "x" } }); + expect(all[2].right).toMatchObject({ _: "CastExpr", arg: { _: "Local", name: "x" }, type: { _: "UnknownType" } }); + }); + + it("lowers instanceof", () => { + const stmts = bodyStmts("class C {}\nlet o = new C();\nlet b = o instanceof C;"); + const check = assigns(stmts).find((a) => a.right._ === "InstanceOfExpr")!; + expect(check.right).toMatchObject({ + _: "InstanceOfExpr", + arg: { _: "Local", name: "o" }, + checkType: { _: "ClassType", signature: { name: "C" } }, + }); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/serialize.spec.ts b/jacodb-ets/ts-frontend/test/serialize.spec.ts new file mode 100644 index 000000000..65f8cd0e2 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/serialize.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { serializeEtsFile } from "../src/serialize"; +import { defaultMethod, lower } from "./util"; + +describe("wire format", () => { + it("an empty file serializes to the exact %dflt skeleton", () => { + const { file } = lower("", "myProject", "src/foo.ts"); + const json = JSON.parse(serializeEtsFile(file)); + + const fileSig = { projectName: "myProject", fileName: "src/foo.ts" }; + const classSig = { name: "%dflt", declaringFile: fileSig }; + const classType = { _: "ClassType", signature: classSig }; + expect(json).toEqual({ + signature: fileSig, + namespaces: [], + classes: [ + { + signature: classSig, + modifiers: 0, + decorators: [], + category: 0, + superClassName: "", + implementedInterfaceNames: [], + fields: [], + methods: [ + { + signature: { + declaringClass: classSig, + name: "%dflt", + parameters: [], + returnType: { _: "VoidType" }, + }, + modifiers: 0, + decorators: [], + body: { + locals: [{ name: "this", type: classType }], + cfg: { + blocks: [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { + _: "AssignStmt", + left: { _: "Local", name: "this", type: classType }, + right: { _: "ThisRef", type: classType }, + }, + { _: "ReturnVoidStmt" }, + ], + }, + ], + }, + }, + }, + ], + }, + ], + importInfos: [], + exportInfos: [], + }); + }); + + it("drops undefined optional fields but keeps nulls", () => { + const { file } = lower(""); + file.classes[0].typeParameters = undefined; + file.classes[0].superClassName = null; + const json = JSON.parse(serializeEtsFile(file)); + expect("typeParameters" in json.classes[0]).toBe(false); + expect(json.classes[0].superClassName).toBeNull(); + }); + + it("does not emit a discriminator for body.locals entries", () => { + const { file } = lower(""); + const json = JSON.parse(serializeEtsFile(file)); + const local = json.classes[0].methods[0].body.locals[0]; + expect(Object.keys(local).sort()).toEqual(["name", "type"]); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/try.spec.ts b/jacodb-ets/ts-frontend/test/try.spec.ts new file mode 100644 index 000000000..fba28c09b --- /dev/null +++ b/jacodb-ets/ts-frontend/test/try.spec.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; +import { BasicBlockDto } from "../src/dto/model"; +import { defaultMethod, lower, methodByName } from "./util"; + +function blocksOf(source: string, method: string = "f"): BasicBlockDto[] { + const { file } = lower(source); + const m = method === "%dflt" ? defaultMethod(file) : methodByName(file, method); + return m.body!.cfg.blocks; +} + +describe("try/catch/finally lowering", () => { + it("keeps catch code reachable and binds CaughtExceptionRef", () => { + const blocks = blocksOf(` + function f(): number { + let r = 0; + try { + r = 1; + } catch (e) { + console.log(e); + r = 2; + } + return r; + } + `); + const stmts = blocks.flatMap((b) => b.stmts); + // catch binding: e := CaughtExceptionRef + const caught = stmts.find( + (s) => s._ === "AssignStmt" && s.right._ === "CaughtExceptionRef", + ); + expect(caught).toMatchObject({ left: { _: "Local", name: "e" } }); + // catch body code is present (r = 2) + const rAssigns = stmts.filter( + (s) => s._ === "AssignStmt" && s.left._ === "Local" && s.left.name === "r", + ); + const values = rAssigns + .map((s) => (s as { right: { value?: string } }).right.value) + .filter((v) => v !== undefined); + expect(values).toContain("1"); + expect(values).toContain("2"); + // entry branches nondeterministically between try and catch + expect(blocks[0].successors).toHaveLength(2); + }); + + it("joins try and catch paths on the finally block", () => { + const blocks = blocksOf(` + function f(): void { + try { + console.log("try"); + } catch { + console.log("catch"); + } finally { + console.log("finally"); + } + } + `); + // find the block containing the finally call: it must have 2 predecessors + const finallyBlock = blocks.find((b) => + b.stmts.some( + (s) => + s._ === "CallStmt" && + s.expr._ === "InstanceCallExpr" && + (s.expr.args[0] as { value?: string })?.value === "finally", + ), + ); + expect(finallyBlock).toBeDefined(); + expect(finallyBlock!.predecessors!.length).toBe(2); + }); + + it("handles try/finally without catch", () => { + const blocks = blocksOf(` + function f(): void { + try { + console.log("try"); + } finally { + console.log("finally"); + } + } + `); + const stmts = blocks.flatMap((b) => b.stmts); + const logged = stmts + .filter((s) => s._ === "CallStmt") + .map((s) => ((s as { expr: { args: { value?: string }[] } }).expr.args[0] ?? {}).value); + expect(logged).toEqual(["try", "finally"]); + }); + + it("duplicates finally before return (finally never drops out of the IR)", () => { + const blocks = blocksOf(` + function f(): number { + try { + return 1; + } finally { + console.log("fin"); + } + } + `); + // try body always returns — the finally must still be present, + // duplicated BEFORE the ReturnStmt in the same path. + const returnBlock = blocks.find((b) => b.stmts.some((s) => s._ === "ReturnStmt"))!; + const stmtKinds = returnBlock.stmts.map((s) => s._); + const callIdx = returnBlock.stmts.findIndex( + (s) => + s._ === "CallStmt" && + ((s.expr.args[0] ?? {}) as { value?: string }).value === "fin", + ); + const retIdx = stmtKinds.indexOf("ReturnStmt"); + expect(callIdx).toBeGreaterThanOrEqual(0); + expect(callIdx).toBeLessThan(retIdx); + }); + + it("captures the return value before the finally runs", () => { + const blocks = blocksOf(` + function f(): number { + let x = 1; + try { + return x; + } finally { + x = 2; + } + } + `); + const returnBlock = blocks.find((b) => b.stmts.some((s) => s._ === "ReturnStmt"))!; + const ret = returnBlock.stmts.find((s) => s._ === "ReturnStmt") as { arg: { name?: string } }; + // returned value is a snapshot temp, not `x` (which finally mutates) + expect(ret.arg.name).toMatch(/^%/); + const copyIdx = returnBlock.stmts.findIndex( + (s) => s._ === "AssignStmt" && (s.left as { name?: string }).name === ret.arg.name, + ); + const mutateIdx = returnBlock.stmts.findIndex( + (s) => + s._ === "AssignStmt" && + (s.left as { name?: string }).name === "x" && + (s.right as { value?: string }).value === "2", + ); + expect(copyIdx).toBeGreaterThanOrEqual(0); + expect(mutateIdx).toBeGreaterThan(copyIdx); // snapshot BEFORE the finally mutation + }); + + it("duplicates finally on break out of the try, but not for loops inside the try", () => { + const stmts = (blocks: { stmts: { _: string }[] }[]) => blocks.flatMap((b) => b.stmts); + + // break LEAVES the try -> finally duplicated on the break path + the normal path + const breakOut = blocksOf(` + function f(): void { + while (true) { + try { + break; + } finally { + console.log("fin"); + } + } + } + `); + // The try body always breaks, so the normal-path finally is unreachable + // and dropped; the surviving copy comes from the break-path duplication. + const finCalls = stmts(breakOut).filter( + (s) => + s._ === "CallStmt" && + (((s as { expr: { args: { value?: string }[] } }).expr.args[0] ?? {}).value === "fin"), + ); + expect(finCalls).toHaveLength(1); + + // break stays INSIDE the try (loop is inside) -> no duplication, single finally + const breakIn = blocksOf(` + function f(): void { + try { + while (true) { + break; + } + } finally { + console.log("fin"); + } + } + `); + const finCallsIn = stmts(breakIn).filter( + (s) => + s._ === "CallStmt" && + (((s as { expr: { args: { value?: string }[] } }).expr.args[0] ?? {}).value === "fin"), + ); + expect(finCallsIn).toHaveLength(1); + }); + + it("duplicates nested finallies innermost-first on return", () => { + const blocks = blocksOf(` + function f(): number { + try { + try { + return 1; + } finally { + console.log("inner"); + } + } finally { + console.log("outer"); + } + } + `); + const returnBlock = blocks.find((b) => b.stmts.some((s) => s._ === "ReturnStmt"))!; + const order = returnBlock.stmts + .filter((s) => s._ === "CallStmt") + .map((s) => ((s as { expr: { args: { value?: string }[] } }).expr.args[0] ?? {}).value); + expect(order).toEqual(["inner", "outer"]); + }); + + it("supports throw inside try and nested try", () => { + const blocks = blocksOf(` + function f(x: number): number { + try { + if (x > 0) { + throw new Error("positive"); + } + try { + return 1; + } catch (inner) { + return 2; + } + } catch (outer) { + return 3; + } + } + `); + const stmts = blocks.flatMap((b) => b.stmts); + expect(stmts.some((s) => s._ === "ThrowStmt")).toBe(true); + const caught = stmts.filter((s) => s._ === "AssignStmt" && s.right._ === "CaughtExceptionRef"); + expect(caught).toHaveLength(2); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/types.spec.ts b/jacodb-ets/ts-frontend/test/types.spec.ts new file mode 100644 index 000000000..39fb1882d --- /dev/null +++ b/jacodb-ets/ts-frontend/test/types.spec.ts @@ -0,0 +1,225 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { FileSignatureDto } from "../src/dto/signatures"; +import { TypeDto } from "../src/dto/types"; +import { TypeConverter } from "../src/types/convert"; +import { compile, findNode, findVariable } from "./util"; + +const FILE_SIG: FileSignatureDto = { projectName: "proj", fileName: "test.ts" }; + +function makeConverter(source: string): { converter: TypeConverter; sourceFile: ts.SourceFile } { + const { checker, sourceFile } = compile(source); + const converter = new TypeConverter(checker, () => FILE_SIG); + return { converter, sourceFile }; +} + +/** Convert the type ANNOTATION of variable `name`. */ +function annotationOf(source: string, name: string = "x"): TypeDto { + const { converter, sourceFile } = makeConverter(source); + const decl = findVariable(sourceFile, name); + return converter.convertTypeNode(decl.type); +} + +/** Convert the INFERRED type of variable `name`. */ +function inferredOf(source: string, name: string = "x"): TypeDto { + const { converter, sourceFile } = makeConverter(source); + const decl = findVariable(sourceFile, name); + return converter.typeOfNode(decl.name); +} + +describe("convertTypeNode (annotations)", () => { + it("converts primitive keywords", () => { + expect(annotationOf("let x: number;")).toEqual({ _: "NumberType" }); + expect(annotationOf("let x: string;")).toEqual({ _: "StringType" }); + expect(annotationOf("let x: boolean;")).toEqual({ _: "BooleanType" }); + expect(annotationOf("let x: any;")).toEqual({ _: "AnyType" }); + expect(annotationOf("let x: unknown;")).toEqual({ _: "UnknownType" }); + expect(annotationOf("let x: undefined;")).toEqual({ _: "UndefinedType" }); + expect(annotationOf("let x: never;")).toEqual({ _: "NeverType" }); + expect(annotationOf("let x: bigint;")).toEqual({ _: "NumberType" }); + }); + + it("converts literal types with bare JSON primitives", () => { + expect(annotationOf(`let x: "hello";`)).toEqual({ _: "LiteralType", literal: "hello" }); + expect(annotationOf("let x: 42;")).toEqual({ _: "LiteralType", literal: 42 }); + expect(annotationOf("let x: -1;")).toEqual({ _: "LiteralType", literal: -1 }); + expect(annotationOf("let x: true;")).toEqual({ _: "LiteralType", literal: true }); + expect(annotationOf("let x: null;")).toEqual({ _: "NullType" }); + }); + + it("converts arrays and folds dimensions", () => { + expect(annotationOf("let x: number[];")).toEqual({ + _: "ArrayType", + elementType: { _: "NumberType" }, + dimensions: 1, + }); + expect(annotationOf("let x: string[][];")).toEqual({ + _: "ArrayType", + elementType: { _: "StringType" }, + dimensions: 2, + }); + expect(annotationOf("let x: Array;")).toEqual({ + _: "ArrayType", + elementType: { _: "NumberType" }, + dimensions: 1, + }); + }); + + it("converts tuples", () => { + expect(annotationOf("let x: [number, string];")).toEqual({ + _: "TupleType", + types: [{ _: "NumberType" }, { _: "StringType" }], + }); + }); + + it("converts unions and intersections", () => { + expect(annotationOf("let x: number | string;")).toEqual({ + _: "UnionType", + types: [{ _: "NumberType" }, { _: "StringType" }], + }); + expect(annotationOf("interface A {}\ninterface B {}\nlet x: A & B;")).toEqual({ + _: "IntersectionType", + types: [ + { _: "ClassType", signature: { name: "A", declaringFile: FILE_SIG } }, + { _: "ClassType", signature: { name: "B", declaringFile: FILE_SIG } }, + ], + }); + }); + + it("converts project classes/interfaces/enums to ClassType", () => { + expect(annotationOf("class C {}\nlet x: C;")).toEqual({ + _: "ClassType", + signature: { name: "C", declaringFile: FILE_SIG }, + }); + expect(annotationOf("interface I { f(): void }\nlet x: I;")).toEqual({ + _: "ClassType", + signature: { name: "I", declaringFile: FILE_SIG }, + }); + expect(annotationOf("enum E { A, B }\nlet x: E;")).toEqual({ + _: "ClassType", + signature: { name: "E", declaringFile: FILE_SIG }, + }); + }); + + it("passes generic arguments to ClassType", () => { + expect(annotationOf("class Box { v: T }\nlet x: Box;")).toEqual({ + _: "ClassType", + signature: { name: "Box", declaringFile: FILE_SIG }, + typeParameters: [{ _: "NumberType" }], + }); + }); + + it("converts ambient types to UnclearReferenceType", () => { + expect(annotationOf("let x: Promise;")).toEqual({ + _: "UnclearReferenceType", + name: "Promise", + typeParameters: [{ _: "NumberType" }], + }); + expect(annotationOf("let x: SomethingUndeclared;")).toEqual({ + _: "UnclearReferenceType", + name: "SomethingUndeclared", + }); + }); + + it("follows type aliases to their target", () => { + expect(annotationOf("type MyNum = number;\nlet x: MyNum;")).toEqual({ _: "NumberType" }); + }); + + it("converts function types", () => { + expect(annotationOf("let x: (a: number, b?: string) => boolean;")).toEqual({ + _: "FunctionType", + signature: { + declaringClass: { name: "", declaringFile: { projectName: "%unk", fileName: "%unk" } }, + name: "", + parameters: [ + { name: "a", type: { _: "NumberType" } }, + { name: "b", type: { _: "StringType" }, isOptional: true }, + ], + returnType: { _: "BooleanType" }, + }, + }); + }); + + it("resolves namespace-qualified names with the namespace chain", () => { + const type = annotationOf("namespace N { export class C {} }\nlet x: N.C;"); + expect(type).toEqual({ + _: "ClassType", + signature: { + name: "C", + declaringFile: FILE_SIG, + declaringNamespace: { name: "N", declaringFile: FILE_SIG }, + }, + }); + }); + + it("converts generic parameters of functions", () => { + const { converter, sourceFile } = makeConverter("function f(v: T): T { return v; }"); + const fn = findNode(sourceFile, ts.isFunctionDeclaration)!; + expect(converter.convertTypeParameters(fn.typeParameters)).toEqual([ + { _: "GenericType", name: "T", constraint: { _: "StringType" } }, + ]); + expect(converter.convertTypeNode(fn.parameters[0].type)).toEqual({ + _: "GenericType", + name: "T", + }); + }); + + it("degrades exotic types to UnknownType", () => { + expect(annotationOf("let x: keyof { a: number };")).toEqual({ _: "UnknownType" }); + expect(annotationOf("let x: { a: number };")).toEqual({ _: "UnknownType" }); + expect(annotationOf("let x;")).toEqual({ _: "UnknownType" }); + expect(annotationOf("let x: `a${string}`;")).toEqual({ _: "StringType" }); + }); +}); + +describe("typeOfNode (inference)", () => { + it("widens let-declarations to primitives", () => { + expect(inferredOf("let x = 42;")).toEqual({ _: "NumberType" }); + expect(inferredOf(`let x = "s";`)).toEqual({ _: "StringType" }); + expect(inferredOf("let x = true;")).toEqual({ _: "BooleanType" }); + }); + + it("keeps literal types for const-declarations", () => { + expect(inferredOf("const x = 42;")).toEqual({ _: "LiteralType", literal: 42 }); + expect(inferredOf(`const x = "s";`)).toEqual({ _: "LiteralType", literal: "s" }); + }); + + it("infers arrays", () => { + expect(inferredOf("let x = [1, 2, 3];")).toEqual({ + _: "ArrayType", + elementType: { _: "NumberType" }, + dimensions: 1, + }); + }); + + it("infers project class instances", () => { + expect(inferredOf("class C {}\nlet x = new C();")).toEqual({ + _: "ClassType", + signature: { name: "C", declaringFile: FILE_SIG }, + }); + }); + + it("infers enum member access as EnumValueType", () => { + expect(inferredOf("enum E { A, B }\nconst x = E.A;")).toEqual({ + _: "EnumValueType", + signature: { name: "E", declaringFile: FILE_SIG }, + name: "A", + }); + }); + + it("infers function values as FunctionType", () => { + const type = inferredOf("function f(a: number): string { return '' + a; }\nconst x = f;"); + expect(type).toMatchObject({ + _: "FunctionType", + signature: { + name: "f", + parameters: [{ name: "a", type: { _: "NumberType" } }], + returnType: { _: "StringType" }, + }, + }); + }); + + it("never throws on weird nodes", () => { + expect(inferredOf("let x = Symbol('s');")).toBeDefined(); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/util.ts b/jacodb-ets/ts-frontend/test/util.ts new file mode 100644 index 000000000..295f86b28 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/util.ts @@ -0,0 +1,121 @@ +import * as ts from "typescript"; +import { EtsFileDto, MethodDto } from "../src/dto/model"; +import { StmtDto } from "../src/dto/stmts"; +import { Diagnostics } from "../src/lowering/diagnostics"; +import { buildEtsFile } from "../src/lowering/fileBuilder"; +import { validateEtsFile } from "../src/validate"; + +export interface Compiled { + program: ts.Program; + checker: ts.TypeChecker; + sourceFile: ts.SourceFile; +} + +export const COMPILER_OPTIONS: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + strict: false, + allowJs: true, + checkJs: false, + noEmit: true, + skipLibCheck: true, +}; + +/** Compile an in-memory source file with the real TypeScript compiler + default libs. */ +export function compile(source: string, fileName: string = "test.ts"): Compiled { + const host = ts.createCompilerHost(COMPILER_OPTIONS); + const originalGetSourceFile = host.getSourceFile.bind(host); + const originalFileExists = host.fileExists.bind(host); + const originalReadFile = host.readFile.bind(host); + + host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) => { + if (name === fileName) { + return ts.createSourceFile(fileName, source, languageVersion, /* setParentNodes */ true); + } + return originalGetSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile); + }; + host.fileExists = (name) => name === fileName || originalFileExists(name); + host.readFile = (name) => (name === fileName ? source : originalReadFile(name)); + + const program = ts.createProgram([fileName], COMPILER_OPTIONS, host); + const sourceFile = program.getSourceFile(fileName); + if (sourceFile === undefined) { + throw new Error(`failed to load in-memory source file: ${fileName}`); + } + return { program, checker: program.getTypeChecker(), sourceFile }; +} + +/** Find the first node satisfying a predicate (depth-first). */ +export function findNode( + root: ts.Node, + predicate: (node: ts.Node) => node is T, +): T | undefined { + let result: T | undefined; + const visit = (node: ts.Node): void => { + if (result !== undefined) return; + if (predicate(node)) { + result = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(root); + return result; +} + +export interface Lowered { + file: EtsFileDto; + diagnostics: Diagnostics; +} + +/** Run the full lowering pipeline on an in-memory source; asserts invariants hold. */ +export function lower(source: string, projectName: string = "proj", fileName: string = "test.ts"): Lowered { + const { program, sourceFile } = compile(source, fileName); + const diagnostics = new Diagnostics(); + const file = buildEtsFile(program, sourceFile, { projectName, fileName }, diagnostics); + const violations = validateEtsFile(file); + if (violations.length > 0) { + throw new Error(`invariant violations:\n${violations.join("\n")}`); + } + return { file, diagnostics }; +} + +/** The `%dflt` method of the `%dflt` class. */ +export function defaultMethod(file: EtsFileDto): MethodDto { + const clazz = file.classes.find((c) => c.signature.name === "%dflt"); + if (clazz === undefined) throw new Error("no %dflt class"); + const method = clazz.methods.find((m) => m.signature.name === "%dflt"); + if (method === undefined) throw new Error("no %dflt method"); + return method; +} + +/** Method by name across all classes of the file. */ +export function methodByName(file: EtsFileDto, name: string): MethodDto { + for (const clazz of file.classes) { + for (const method of clazz.methods) { + if (method.signature.name === name) return method; + } + } + throw new Error(`method '${name}' not found`); +} + +/** All statements of a single-block body (asserts the body has exactly one block). */ +export function singleBlockStmts(method: MethodDto): StmtDto[] { + if (method.body === undefined) throw new Error("method has no body"); + const blocks = method.body.cfg.blocks; + if (blocks.length !== 1) throw new Error(`expected 1 block, got ${blocks.length}`); + return blocks[0].stmts; +} + +/** Find the variable declaration with the given name. */ +export function findVariable(root: ts.Node, name: string): ts.VariableDeclaration { + const decl = findNode( + root, + (n): n is ts.VariableDeclaration => + ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name, + ); + if (decl === undefined) { + throw new Error(`variable '${name}' not found`); + } + return decl; +} diff --git a/jacodb-ets/ts-frontend/test/validate.spec.ts b/jacodb-ets/ts-frontend/test/validate.spec.ts new file mode 100644 index 000000000..94bd76287 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/validate.spec.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "vitest"; +import { EtsFileDto, BasicBlockDto, BodyDto } from "../src/dto/model"; +import { StmtDto } from "../src/dto/stmts"; +import { UNKNOWN_TYPE, NUMBER_TYPE } from "../src/dto/types"; +import { LocalDto, ValueDto } from "../src/dto/values"; +import { validateEtsFile } from "../src/validate"; + +const FILE_SIG = { projectName: "p", fileName: "f.ts" }; +const CLASS_SIG = { name: "C", declaringFile: FILE_SIG }; +const METHOD_SIG = { + declaringClass: CLASS_SIG, + name: "m", + parameters: [], + returnType: { _: "VoidType" } as const, +}; + +function local(name: string): LocalDto { + return { _: "Local", name, type: UNKNOWN_TYPE }; +} + +function fileWithBody(body: BodyDto): EtsFileDto { + return { + signature: FILE_SIG, + namespaces: [], + classes: [ + { + signature: CLASS_SIG, + modifiers: 0, + decorators: [], + superClassName: "", + implementedInterfaceNames: [], + fields: [], + methods: [{ signature: METHOD_SIG, modifiers: 0, decorators: [], body }], + }, + ], + importInfos: [], + exportInfos: [], + }; +} + +function bodyWithBlocks(blocks: BasicBlockDto[], locals: string[] = []): BodyDto { + return { + locals: locals.map((name) => ({ name, type: UNKNOWN_TYPE })), + cfg: { blocks }, + }; +} + +function violations(body: BodyDto): string[] { + return validateEtsFile(fileWithBody(body)); +} + +describe("validateEtsFile", () => { + it("accepts an empty cfg", () => { + expect(violations(bodyWithBlocks([]))).toEqual([]); + }); + + it("rejects block ids not equal to their index", () => { + const errs = violations( + bodyWithBlocks([{ id: 1, successors: [], predecessors: [], stmts: [{ _: "ReturnVoidStmt" }] }]), + ); + expect(errs.some((e) => e.includes("id must equal its index"))).toBe(true); + }); + + it("rejects out-of-range successors", () => { + const errs = violations( + bodyWithBlocks([{ id: 0, successors: [5], predecessors: [], stmts: [{ _: "NopStmt" }] }]), + ); + expect(errs.some((e) => e.includes("successor 5 out of range"))).toBe(true); + }); + + it("requires exactly 2 successors for an IfStmt block", () => { + const ifStmt: StmtDto = { + _: "IfStmt", + condition: { + _: "ConditionExpr", + op: "==", + left: local("a"), + right: { _: "Constant", value: "0", type: NUMBER_TYPE }, + type: { _: "BooleanType" }, + }, + }; + const errs = violations( + bodyWithBlocks( + [ + { id: 0, successors: [1], predecessors: [], stmts: [ifStmt] }, + { id: 1, successors: [], predecessors: [0], stmts: [{ _: "ReturnVoidStmt" }] }, + ], + ["a"], + ), + ); + expect(errs.some((e) => e.includes("ends with IfStmt but has 1 successors"))).toBe(true); + }); + + it("rejects successors on return blocks", () => { + const errs = violations( + bodyWithBlocks([ + { id: 0, successors: [1], predecessors: [], stmts: [{ _: "ReturnVoidStmt" }] }, + { id: 1, successors: [], predecessors: [0], stmts: [{ _: "ReturnVoidStmt" }] }, + ]), + ); + expect(errs.some((e) => e.includes("ends with 'ReturnVoidStmt' but has 1 successors"))).toBe(true); + }); + + it("rejects more than one successor on a non-branching block", () => { + const errs = violations( + bodyWithBlocks([ + { id: 0, successors: [1, 2], predecessors: [], stmts: [{ _: "NopStmt" }] }, + { id: 1, successors: [], predecessors: [0], stmts: [{ _: "ReturnVoidStmt" }] }, + { id: 2, successors: [], predecessors: [0], stmts: [{ _: "ReturnVoidStmt" }] }, + ]), + ); + expect(errs.some((e) => e.includes("non-branching block has 2 successors"))).toBe(true); + }); + + it("rejects terminators in the middle of a block", () => { + const errs = violations( + bodyWithBlocks([ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "ReturnVoidStmt" }, { _: "NopStmt" }], + }, + ]), + ); + expect(errs.some((e) => e.includes("terminator 'ReturnVoidStmt' at position 0"))).toBe(true); + }); + + it("rejects undeclared locals", () => { + const errs = violations( + bodyWithBlocks([ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "ReturnStmt", arg: local("ghost") }], + }, + ]), + ); + expect(errs.some((e) => e.includes("local 'ghost' is not declared"))).toBe(true); + }); + + it("rejects locals with the reserved _tmp prefix", () => { + const errs = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "ReturnStmt", arg: local("_tmp0") }], + }, + ], + ["_tmp0"], + ), + ); + expect(errs.some((e) => e.includes("reserved prefix '_tmp'"))).toBe(true); + }); + + it("rejects non-Local instance in InstanceCallExpr", () => { + const badCall = { + _: "InstanceCallExpr", + instance: { _: "Constant", value: "1", type: NUMBER_TYPE } as unknown as LocalDto, + method: METHOD_SIG, + args: [], + } as const; + const errs = violations( + bodyWithBlocks([ + { id: 0, successors: [], predecessors: [], stmts: [{ _: "CallStmt", expr: badCall }, { _: "ReturnVoidStmt" }] }, + ]), + ); + expect(errs.some((e) => e.includes("InstanceCallExpr.instance"))).toBe(true); + }); + + it("rejects expr-kind ArrayRef index", () => { + const arrayRef: ValueDto = { + _: "ArrayRef", + array: local("arr"), + index: { _: "BinopExpr", op: "+", left: local("i"), right: local("j") }, + type: UNKNOWN_TYPE, + }; + const errs = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "AssignStmt", left: local("x"), right: arrayRef }, { _: "ReturnVoidStmt" }], + }, + ], + ["arr", "i", "j", "x"], + ), + ); + expect(errs.some((e) => e.includes("ArrayRef.index has expr kind"))).toBe(true); + }); + + it("rejects expr-kind PtrCallExpr ptr", () => { + const badPtrCall = { + _: "PtrCallExpr", + ptr: { _: "BinopExpr", op: "+", left: local("a"), right: local("b") }, + method: METHOD_SIG, + args: [], + } as const; + const errs = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { _: "CallStmt", expr: badPtrCall as never }, + { _: "ReturnVoidStmt" }, + ], + }, + ], + ["a", "b"], + ), + ); + expect(errs.some((e) => e.includes("PtrCallExpr.ptr has expr kind"))).toBe(true); + }); + + it("rejects raw fallback values outside Local-assignment RHS", () => { + const rawValue = { _: "SomeExoticValue", type: UNKNOWN_TYPE } as unknown as ValueDto; + // raw as a call argument — forbidden (Kotlin ensureOneAddress rejects EtsRawEntity) + const errs = violations( + bodyWithBlocks([ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { _: "CallStmt", expr: { _: "StaticCallExpr", method: METHOD_SIG, args: [rawValue] } }, + { _: "ReturnVoidStmt" }, + ], + }, + ]), + ); + expect(errs.some((e) => e.includes("raw value") && e.includes("operand position"))).toBe(true); + + // raw as the RHS of a Local assignment — the one allowed position + const ok = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "AssignStmt", left: local("x"), right: rawValue }, { _: "ReturnVoidStmt" }], + }, + ], + ["x"], + ), + ); + expect(ok).toEqual([]); + + // Kotlin strips CastExpr on the LHS, so CastExpr(Local) := is a + // Local assignment too and must not be flagged. + const okCast = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { + _: "AssignStmt", + left: { _: "CastExpr", arg: local("x"), type: UNKNOWN_TYPE }, + right: rawValue, + }, + { _: "ReturnVoidStmt" }, + ], + }, + ], + ["x"], + ), + ); + expect(okCast).toEqual([]); + }); + + it("requires 'type' on raw fallback values", () => { + const rawValue = { _: "SomeExoticValue", whatever: 1 } as unknown as ValueDto; + const errs = violations( + bodyWithBlocks([ + { + id: 0, + successors: [], + predecessors: [], + stmts: [{ _: "AssignStmt", left: local("x"), right: rawValue }, { _: "ReturnVoidStmt" }], + }, + ], ["x"]), + ); + expect(errs.some((e) => e.includes("missing required 'type'"))).toBe(true); + }); + + it("rejects bad assign targets", () => { + const errs = violations( + bodyWithBlocks( + [ + { + id: 0, + successors: [], + predecessors: [], + stmts: [ + { + _: "AssignStmt", + left: { _: "BinopExpr", op: "+", left: local("a"), right: local("b") }, + right: local("a"), + }, + { _: "ReturnVoidStmt" }, + ], + }, + ], + ["a", "b"], + ), + ); + expect(errs.some((e) => e.includes("AssignStmt.left has kind 'BinopExpr'"))).toBe(true); + }); + + it("checks predecessor/successor consistency", () => { + const errs = violations( + bodyWithBlocks([ + { id: 0, successors: [1], predecessors: [], stmts: [{ _: "NopStmt" }] }, + { id: 1, successors: [], predecessors: [], stmts: [{ _: "ReturnVoidStmt" }] }, + ]), + ); + expect(errs.some((e) => e.includes("predecessors [] inconsistent"))).toBe(true); + }); +}); diff --git a/jacodb-ets/ts-frontend/tsconfig.json b/jacodb-ets/ts-frontend/tsconfig.json new file mode 100644 index 000000000..e086267ac --- /dev/null +++ b/jacodb-ets/ts-frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2020"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/jacodb-ets/ts-frontend/vitest.config.ts b/jacodb-ets/ts-frontend/vitest.config.ts new file mode 100644 index 000000000..e44173280 --- /dev/null +++ b/jacodb-ets/ts-frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.spec.ts"], + watch: false, + }, +});