From 9a21cd78ce928669b8fefb56bb8ecd2d0bda5a87 Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Thu, 22 Oct 2026 23:56:24 +0530 Subject: [PATCH] fix(javascript): preserve inherited aliases past erased class fields (#5040) --- src/domain/javascript/javascriptAstValues.ts | 6 ++ ...tSemanticAnalysis.member_mutations.test.ts | 61 +++++++++++++++++++ .../javascriptSemanticObjectReferences.ts | 12 +++- .../javascriptArtifactGraphEndpoints.test.ts | 34 +++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 95e1ced762..9d59246c0a 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -2,6 +2,12 @@ import * as t from "@babel/types"; import type { JavaScriptSemanticPrimitive } from "./javascriptSemanticValueTypes.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT } from "./javascriptSemanticResourceLimits.js"; +/** Type-only class fields neither initialize own slots nor shadow inherited members. */ +export const isRuntimeJavaScriptClassProperty = ( + node: t.Node, +): node is t.ClassProperty => + t.isClassProperty(node) && node.declare !== true && node.abstract !== true; + /** TypeScript receiver annotations occupy no positional runtime parameter slot. */ export const runtimeFunctionParameters = ( node: t.Function, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index a9145a92cd..97a4a7acaf 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -9,6 +9,67 @@ const resultValue = (body: string) => "result", ).returnSites[0]?.value; +it.each([ + ["field = shared;", "declare field: { mode: string };", "new Derived()"], + [ + "get field() { return shared; }", + "declare field: { mode: string };", + "new Derived()", + ], + [ + "static field = shared;", + "declare static field: { mode: string };", + "Derived", + ], + [ + "static get field() { return shared; }", + "declare static field: { mode: string };", + "Derived", + ], +])( + "follows inherited references past erased class fields: %s", + (base, declaration, receiver) => { + expect( + resultValue(` + const shared = { mode: "initial" }; + class Base { ${base} } + class Derived extends Base { ${declaration} } + ${receiver}.field.mode = "updated"; + return shared.mode; + `)?.status, + ).toBe("unknown"); + }, +); + +it.each(["field;", "field = { mode: 'other' };"])( + "preserves a real derived field's shadowing: %s", + (field) => { + expect( + resultValue(` + const shared = { mode: "initial" }; + class Base { field = shared; } + class Derived extends Base { ${field} } + const instance = new Derived(); + if (instance.field) instance.field.mode = "updated"; + return shared.mode; + `), + ).toEqual({ status: "literal", value: "initial" }); + }, +); + +it("follows inherited references past an abstract field declaration", () => { + expect( + resultValue(` + const shared = { mode: "initial" }; + class Base { field = shared; } + abstract class Derived extends Base { abstract field: { mode: string }; } + const Constructor = Derived as unknown as new () => Base; + new Constructor().field.mode = "updated"; + return shared.mode; + `)?.status, + ).toBe("unknown"); +}); + const mutations = [ 'const shared = { mode: "initial" }; const options = { [key]: shared }; options.foo.mode = "updated"; return shared.mode;', 'const shared = { mode: "initial" }; const original = [shared]; const copy = [...original]; copy[0].mode = "updated"; return shared.mode;', diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 9bb014bc3f..0decdd0d15 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -4,6 +4,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { + isRuntimeJavaScriptClassProperty, semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -118,7 +119,7 @@ const instanceFieldReferences = ( ? current.klass.body.body : current.klass.body.body.slice(0, before); for (const member of [...members].reverse()) { - if (!t.isClassProperty(member) || member.static) continue; + if (!isRuntimeJavaScriptClassProperty(member) || member.static) continue; const key = semanticStaticPropertyKey(member.key, member.computed); if (key !== null && shadowed.has(key)) continue; if (key !== null) shadowed.add(key); @@ -169,10 +170,15 @@ const classPropertyDescriptors = ( ? -1 : klass.body.body.indexOf(initializedBefore); klass.body.body.forEach((member, index) => { - if (before >= 0 && index >= before && t.isClassProperty(member)) return; + if ( + before >= 0 && + index >= before && + isRuntimeJavaScriptClassProperty(member) + ) + return; if ( (t.isClassMethod(member) || - (kind === "static" && t.isClassProperty(member))) && + (kind === "static" && isRuntimeJavaScriptClassProperty(member))) && member.static === isStatic ) indexedMembers.push({ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts index 16538d92d6..f9bc3ed48a 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -10,6 +10,40 @@ import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javasc import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; +it("keeps an endpoint uncertain after mutation through a declared inherited field", async () => { + const root = await createTestTempDirectory("rea-js-erased-field-"); + await writeFile( + join(root, "app.ts"), + ` + const options = { url: "/before" }; + class Base { field = options; } + class Derived extends Base { declare field: { url: string }; } + new Derived().field.url = "/after"; + const target = options.url; + fetch(target); + `, + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const endpoints = graph.nodes.filter(({ kind }) => kind === "endpoint"); + expect(endpoints).toHaveLength(1); + expect(endpoints[0]?.observations[0]).toMatchObject({ + properties: { value: "target", resolution: "partial" }, + evidence: { + authority: "ast-static-analysis", + state: "observed", + limitations: [expect.stringContaining("not an exact literal")], + }, + }); + } finally { + await reader.close(); + } +}); + it("keeps ambiguous endpoint candidates inferred through artifact graph projection", async () => { const root = await createTestTempDirectory("rea-js-endpoint-certainty-"); await writeFile(join(root, "app.js"), 'form.get("shared"); fetch("shared");');