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 <rudy@gradiahq.com>

* 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 <rudy@gradiahq.com>

* 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 <rudy@gradiahq.com>

---------

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
This commit is contained in:
Rudy Mizrahi Celekli
2026-10-08 13:24:47 -04:00
committed by GitHub
parent cc0ee2edc6
commit 3db46813f8
6 changed files with 264 additions and 2 deletions
@@ -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;
@@ -166,7 +166,7 @@ const analyzeArtifactFile = <SemanticIr extends JavaScriptModuleSemanticIr>(
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 });
@@ -6,6 +6,7 @@ export type ParsedJavaScriptSource = ReturnType<typeof parse>;
/** 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.
@@ -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);
},
);
}
}
@@ -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 = <T,>(value: T): T => value;\nimport "./dep.js";';
await expectValidTypeScriptInput(`control.${extension}`, source);
expect(
parse(source, {
sourceType: "unambiguous",
plugins: [["typescript", { disallowAmbiguousJSXLike: true }]],
}).errors,
).toEqual([]);
});
}
+176
View File
@@ -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 = <number>1;\nimport "./dep.js";',
},
{
path: "wrapped.ts",
kind: "require",
source: 'const value = <unknown>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 = <T>(value: T): T => value;\nimport "./dep.js";',
},
{
path: "component.tsx",
kind: "static-import",
source: 'const element = <section />;\nimport "./dep.js";',
},
{
path: "component.jsx",
kind: "static-import",
source: 'const element = <section />;\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<void> => {
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<string> => {
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 },
},
},
},
});
};