From 3db46813f80f84504c5b87c73a4bece82a8fdc5e Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:24:47 -0400 Subject: [PATCH] fix(javascript): parse plain TypeScript artifact dialects (#1102) * test(javascript): reproduce TypeScript source dialect loss Add native directory/ASAR and built public CLI owners with actual locked native TypeScript syntax/emit and independent Babel source-range oracles. Production and every workflow remain unchanged. Signed-off-by: Rudy Celekli * test(javascript): admit isolated native compiler inputs Use the actual TypeScript7 producer-requested --ignoreConfig flag when supplying isolated source filenames. Preserve every graph input, owning assertion, control and production file. Signed-off-by: Rudy Celekli * fix(javascript): parse plain TypeScript artifacts without JSX Select the plain .ts dialect using the artifact source path so valid angle-bracket assertions retain syntax-derived graph facts. Preserve existing JSX defaults and all other dialects. Document the inert artifact parser contract. Signed-off-by: Rudy Celekli --------- Signed-off-by: Rudy Celekli --- docs/javascript-artifact-reconstruction.md | 3 + .../javascript/JavaScriptArtifactAnalysis.ts | 2 +- .../javascript/javascriptSourceParser.ts | 8 +- ...avascriptArtifactTypeScriptDialect.test.ts | 38 ++++ ...avascriptArtifactTypeScriptDialect.test.ts | 39 ++++ .../javascriptArtifactTypeScriptDialect.ts | 176 ++++++++++++++++++ 6 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/cli/javascriptArtifactTypeScriptDialect.test.ts create mode 100644 tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts create mode 100644 tests/fixtures/javascriptArtifactTypeScriptDialect.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 8014def273..5238f42bea 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -140,6 +140,9 @@ missing bytes. This allows static JavaScript/Electron reconstruction to proceed for the embedded files while preserving the missing native/resource bytes as an unknown instead of silently treating them as absent or verified. +Plain `.ts` artifact sources use TypeScript syntax without JSX, including +angle-bracket type assertions. `.tsx` and `.jsx` retain JSX parsing. + Selected bounded text is then parsed as inert data to recover: - `package.json` metadata and declared main or renderer entrypoints; diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index cb5eb7149a..09c834745d 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -166,7 +166,7 @@ const analyzeArtifactFile = ( state.files.push({ file, javascript: null, semantic: null }); return; } - const parsed = parseJavaScriptSource(file.text.value); + const parsed = parseJavaScriptSource(file.text.value, file.path); if (parsed === null) { const analysis = failedJavaScriptStaticAnalysis(); state.files.push({ file, javascript: analysis, semantic: null }); diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index b68cedcad2..e86b1bca3f 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -6,6 +6,7 @@ export type ParsedJavaScriptSource = ReturnType; /** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */ export const parseJavaScriptSource = ( source: string, + sourcePath?: string, ): ParsedJavaScriptSource | null => { try { return parse(source, { @@ -16,7 +17,12 @@ export const parseJavaScriptSource = ( // forms, including parameter decorators. Without it a decorated // TypeScript source fails to parse at all rather than reporting // recovered syntax, which loses every fact derived from that source. - plugins: ["decorators-legacy", "jsx", "typescript"], + // Plain .ts artifacts admit angle-bracket type assertions instead of JSX. + // Unknown paths retain the existing JSX-capable parser contract. + plugins: + sourcePath?.toLowerCase().endsWith(".ts") === true + ? ["decorators-legacy", "typescript"] + : ["decorators-legacy", "jsx", "typescript"], }); } catch (cause: unknown) { // Unparseable source is represented by the null return. diff --git a/tests/boundary/cli/javascriptArtifactTypeScriptDialect.test.ts b/tests/boundary/cli/javascriptArtifactTypeScriptDialect.test.ts new file mode 100644 index 0000000000..06a28c27cf --- /dev/null +++ b/tests/boundary/cli/javascriptArtifactTypeScriptDialect.test.ts @@ -0,0 +1,38 @@ +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { + typeScriptDialectCases, + createTypeScriptDialectArtifact, + expectTypeScriptDialectReference, + expectValidTypeScriptInput, +} from "../../fixtures/javascriptArtifactTypeScriptDialect.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const format of ["directory", "asar"] as const) { + for (const command of ["analyze-javascript-application", "analyze"]) { + cliTest( + `${command} retains TypeScript dialect reference facts for ${format}`, + async ({ cli }) => { + for (const fixture of typeScriptDialectCases) + await expectValidTypeScriptInput(fixture.path, fixture.source); + const input = await createTypeScriptDialectArtifact( + format, + typeScriptDialectCases, + ); + const output = await cli.run({ arguments: [command, input, "--json"] }); + expect(output.exitCode).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.statistics).toMatchObject({ + parse_failures: 0, + invalid_utf8_files: 0, + }); + for (const fixture of typeScriptDialectCases) + expectTypeScriptDialectReference(result.graph, fixture); + }, + ); + } +} diff --git a/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts b/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts new file mode 100644 index 0000000000..b266dde878 --- /dev/null +++ b/tests/boundary/filesystem/javascriptArtifactTypeScriptDialect.test.ts @@ -0,0 +1,39 @@ +import { parse } from "@babel/parser"; +import { expect, it } from "vitest"; + +import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; +import { + typeScriptDialectCases, + createTypeScriptDialectArtifact, + expectTypeScriptDialectReference, + expectValidTypeScriptInput, +} from "../../fixtures/javascriptArtifactTypeScriptDialect.js"; + +for (const format of ["directory", "asar"] as const) { + for (const fixture of typeScriptDialectCases) { + it(`${format} retains ${fixture.path} syntax-derived reference facts`, async () => { + await expectValidTypeScriptInput(fixture.path, fixture.source); + const input = await createTypeScriptDialectArtifact(format, [fixture]); + const result = await reconstructJavaScriptArtifact({ input_path: input }); + expect(result.statistics).toMatchObject({ + parse_failures: 0, + invalid_utf8_files: 0, + }); + expectTypeScriptDialectReference(result.graph, fixture); + }); + } +} + +for (const extension of ["mts", "cts"] as const) { + it(`establishes the valid disambiguated ${extension} producer syntax`, async () => { + const source = + 'const identity = (value: T): T => value;\nimport "./dep.js";'; + await expectValidTypeScriptInput(`control.${extension}`, source); + expect( + parse(source, { + sourceType: "unambiguous", + plugins: [["typescript", { disallowAmbiguousJSXLike: true }]], + }).errors, + ).toEqual([]); + }); +} diff --git a/tests/fixtures/javascriptArtifactTypeScriptDialect.ts b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts new file mode 100644 index 0000000000..f0677b1757 --- /dev/null +++ b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +import { createPackageWithOptions } from "@electron/asar"; +import { parse } from "@babel/parser"; +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +export const typeScriptDialectCases = [ + { + path: "assertion.ts", + kind: "static-import", + source: 'const value = 1;\nimport "./dep.js";', + }, + { + path: "wrapped.ts", + kind: "require", + source: 'const value = require("./dep.js");', + }, + { + path: "as.ts", + kind: "static-import", + source: 'const value = 1 as number;\nimport "./dep.js";', + }, + { + path: "wrapped-as.ts", + kind: "require", + source: 'const value = require("./dep.js") as unknown;', + }, + { + path: "generic.ts", + kind: "static-import", + source: 'const identity = (value: T): T => value;\nimport "./dep.js";', + }, + { + path: "component.tsx", + kind: "static-import", + source: 'const element =
;\nimport "./dep.js";', + }, + { + path: "component.jsx", + kind: "static-import", + source: 'const element =
;\nimport "./dep.js";', + }, +] as const; + +const execute = promisify(execFile); + +/** Establish syntax and emitted bytes with the actual locked native compiler. */ +export const expectValidTypeScriptInput = async ( + path: string, + source: string, +): Promise => { + const directory = await createTestTempDirectory("rea-typescript-producer-"); + const compiler = resolve("node_modules/typescript/bin/tsc"); + const input = join(directory, path); + await Promise.all([ + writeFile(input, source), + writeFile(join(directory, "dep.js"), "export const dependency = true;"), + ]); + const output = join(directory, "emitted"); + // --noCheck isolates syntax/emit from ambient React/Node/module type facts. + // This supplemental oracle does not claim complete TypeScript type validity. + await execute(process.execPath, [ + compiler, + "--ignoreConfig", + "--noCheck", + "--allowJs", + "--target", + "es2022", + "--module", + "esnext", + "--jsx", + "preserve", + "--outDir", + output, + input, + ]); + const suffix = path.endsWith(".mts") + ? ".mjs" + : path.endsWith(".cts") + ? ".cjs" + : path.endsWith(".tsx") || path.endsWith(".jsx") + ? ".jsx" + : ".js"; + const emitted = await readFile( + join(output, path.replace(/\.(?:mts|cts|tsx|jsx|ts)$/u, suffix)), + "utf8", + ); + expect( + parse(emitted, { sourceType: "unambiguous", plugins: ["jsx"] }).errors, + ).toEqual([]); +}; + +/** Build actual directory or ASAR input without evaluating any source. */ +export const createTypeScriptDialectArtifact = async ( + format: "directory" | "asar", + cases: readonly (typeof typeScriptDialectCases)[number][], +): Promise => { + const root = await createTestTempDirectory("rea-typescript-dialect-"); + const directory = join(root, "source"); + await mkdir(directory); + await Promise.all([ + writeFile(join(directory, "dep.js"), "export const dependency = true;"), + ...cases.map(({ path, source }) => + writeFile(join(directory, path), source), + ), + ]); + if (format === "directory") return directory; + const archive = join(root, "app.asar"); + await createPackageWithOptions(directory, archive, {}); + return archive; +}; + +/** Compare digest-bound graph evidence with the producer's actual reference. */ +export const expectTypeScriptDialectReference = ( + graph: JavaScriptApplicationGraph, + fixture: (typeof typeScriptDialectCases)[number], +): void => { + const parsed = parse(fixture.source, { + sourceType: "unambiguous", + plugins: fixture.path.endsWith(".ts") + ? ["typescript"] + : ["typescript", "jsx"], + }); + const statement = parsed.program.body[0]; + const initializer = + statement?.type === "VariableDeclaration" + ? statement.declarations[0]?.init + : undefined; + const target = + fixture.kind === "require" + ? initializer?.type === "TSTypeAssertion" || + initializer?.type === "TSAsExpression" + ? initializer.expression + : undefined + : parsed.program.body.find(({ type }) => type === "ImportDeclaration"); + if (target?.loc === undefined || target.loc === null) + throw new Error("Missing producer reference location"); + const edges = graph.edges.filter( + ({ relation, properties, evidence }) => + relation === "imports" && + properties.kind === fixture.kind && + properties.specifier === "./dep.js" && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === fixture.path, + ); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ + properties: { resolved_path: "dep.js" }, + evidence: { + artifact: { + available: true, + sha256: createHash("sha256").update(fixture.source).digest("hex"), + }, + location: { + available: true, + value: { + kind: "source-range", + source: fixture.path, + start: { + line: target.loc.start.line, + column: target.loc.start.column, + }, + end: { line: target.loc.end.line, column: target.loc.end.column }, + }, + }, + }, + }); +};