mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
committed by
GitHub
parent
cc0ee2edc6
commit
3db46813f8
@@ -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([]);
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user