From 65dc38c384fdf0c072106bc962511ee229270a4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Apr 2026 20:53:26 +0000 Subject: [PATCH 1/9] feat(playground): infer data structure labels from variable names - JS: extend array literal transform to call __dstructArrayLiteralWithName for const/assign/assignment-pattern RHS; keep unnamed helper for single-string literals and return-position literals - Runtime: build labeled literals via ControlledArray with displayLabel in addArray options - Redux: store displayLabel on ArrayData; show caption in array/map/matrix views - Python: attach AST parents and infer list names from assign/ann/aug assign; pass displayLabel in TrackedList addArray options; seed case arg labels from arg name Co-authored-by: Max Kayander --- .../dataStructures/array/model/arraySlice.ts | 22 +++- .../array/model/arrayStructure.ts | 2 + .../array/ui/ArrayStructureView.tsx | 69 +++++------ .../array/ui/StructureDisplayLabel.tsx | 24 ++++ .../map/ui/MapStructureView.tsx | 111 +++++++++--------- .../matrix/ui/MatrixStructureView.tsx | 4 + .../instrumentUserJsForLineTracking.test.ts | 4 +- .../transformJsArrayLiterals.test.ts | 25 +++- .../codeRunner/lib/setGlobalRuntimeContext.ts | 31 ++++- .../lib/transformJsArrayLiterals.ts | 63 +++++++++- .../treeViewer/hooks/useArgumentsParsing.ts | 1 + .../dstruct-runner/python/array_tracker.py | 2 +- .../python/array_tracker_transformer.py | 46 +++++++- src/packages/dstruct-runner/python/exec.py | 3 +- 14 files changed, 299 insertions(+), 108 deletions(-) create mode 100644 src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx diff --git a/src/entities/dataStructures/array/model/arraySlice.ts b/src/entities/dataStructures/array/model/arraySlice.ts index fd2adccc..5cb3e183 100644 --- a/src/entities/dataStructures/array/model/arraySlice.ts +++ b/src/entities/dataStructures/array/model/arraySlice.ts @@ -31,6 +31,8 @@ export type ArrayData = BaseStructureItem & { childNames?: string[]; colHeaders?: string[]; rowHeaders?: string[]; + /** Inferred from user code (e.g. `const nums = [...]`); shown above the structure in the viewer. */ + displayLabel?: string; }; export type ArrayDataState = BaseStructureState; @@ -44,12 +46,14 @@ const getInitialData = ( argType: ArgumentArrayType, parentName?: string, childNames?: string[], + displayLabel?: string, ): ArrayData => ({ ...getInitialDataBase(arrayDataAdapter), order, argType, parentName, childNames, + displayLabel, }); const initialState: ArrayDataState = {}; @@ -74,10 +78,18 @@ export const arrayStructureSlice = createSlice({ childNames?: string[]; order: number; argType: ArgumentArrayType; + displayLabel?: string; }>, ) => { - const { name, order, argType, parentName, childNames } = action.payload; - state[name] = getInitialData(order, argType, parentName, childNames); + const { name, order, argType, parentName, childNames, displayLabel } = + action.payload; + state[name] = getInitialData( + order, + argType, + parentName, + childNames, + displayLabel, + ); }, create: ( state, @@ -93,7 +105,7 @@ export const arrayStructureSlice = createSlice({ data: { argType, nodes, options }, }, } = action; - const { colorMap } = options ?? {}; + const { colorMap, displayLabel } = options ?? {}; const treeState = { ...getInitialData(999, argType), isRuntime: true, @@ -106,6 +118,10 @@ export const arrayStructureSlice = createSlice({ treeState.colorMap = colorMap; } + if (displayLabel !== undefined) { + treeState.displayLabel = displayLabel; + } + state[name] = treeState; }, delete: (state, action: NamedPayload) => { diff --git a/src/entities/dataStructures/array/model/arrayStructure.ts b/src/entities/dataStructures/array/model/arrayStructure.ts index dc8a38ca..032febf4 100644 --- a/src/entities/dataStructures/array/model/arrayStructure.ts +++ b/src/entities/dataStructures/array/model/arrayStructure.ts @@ -19,6 +19,8 @@ const ArrayBase = makeArrayBaseClass(Array); export type ControlledArrayRuntimeOptions = { length?: number; colorMap?: Record; + /** User source variable name (inferred by AST); shown as a viewer label, not the internal tree id. */ + displayLabel?: string; }; export function initControlledArray( diff --git a/src/entities/dataStructures/array/ui/ArrayStructureView.tsx b/src/entities/dataStructures/array/ui/ArrayStructureView.tsx index cc1bfc2b..18c9654c 100644 --- a/src/entities/dataStructures/array/ui/ArrayStructureView.tsx +++ b/src/entities/dataStructures/array/ui/ArrayStructureView.tsx @@ -8,6 +8,7 @@ import { } from "#/entities/dataStructures/array/model/arraySlice"; import { ArrayItem } from "./ArrayItem"; +import { StructureDisplayLabel } from "./StructureDisplayLabel"; type ArrayBracketProps = { argType: ArgumentType; @@ -81,46 +82,48 @@ export const ArrayStructureView: React.FC = ({ return arrayDataItemSelectors.selectAll(data.nodes); }, [data.nodes]); - const { argType } = data; + const { argType, displayLabel } = data; return ( - + {displayLabel ? : null} + - - - {items.length > 0 ? ( - items.map((item) => ( - + + + {items.length > 0 ? ( + items.map((item) => ( + + )) + ) : ( + - )) - ) : ( - - )} + )} + + - ); }; diff --git a/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx b/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx new file mode 100644 index 00000000..3c282590 --- /dev/null +++ b/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx @@ -0,0 +1,24 @@ +import { Typography } from "@mui/material"; +import React from "react"; + +type StructureDisplayLabelProps = { + label: string; +}; + +/** Small caption above a runtime structure when we inferred a variable name from user code. */ +export const StructureDisplayLabel: React.FC = ({ + label, +}) => ( + + {label} + +); diff --git a/src/entities/dataStructures/map/ui/MapStructureView.tsx b/src/entities/dataStructures/map/ui/MapStructureView.tsx index 1d4bcc08..b1e1b182 100644 --- a/src/entities/dataStructures/map/ui/MapStructureView.tsx +++ b/src/entities/dataStructures/map/ui/MapStructureView.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from "react"; import { arrayDataItemSelectors } from "#/entities/dataStructures/array/model/arraySlice"; import { type ArrayStructureViewProps } from "#/entities/dataStructures/array/ui/ArrayStructureView"; +import { StructureDisplayLabel } from "#/entities/dataStructures/array/ui/StructureDisplayLabel"; import { MapItem } from "#/entities/dataStructures/map/ui/MapItem"; type MapStructureViewProps = ArrayStructureViewProps; @@ -18,64 +19,68 @@ export const MapStructureView: React.FC = ({ [data.nodes], ); - return ( - td:first-of-type": { - borderTopLeftRadius: "4px", - }, - "& > td:last-child": { - borderTopRightRadius: "4px", - }, - "&::after": { - borderRadius: "4px 4px 0 0", - }, - }, - "& tr:last-child": { - "& > td:first-of-type": { - borderBottomLeftRadius: "4px", + return ( + + {displayLabel ? : null} + td:last-child": { - borderBottomRightRadius: "4px", + + "& tr:first-of-type": { + "& > td:first-of-type": { + borderTopLeftRadius: "4px", + }, + "& > td:last-child": { + borderTopRightRadius: "4px", + }, + "&::after": { + borderRadius: "4px 4px 0 0", + }, }, - "&::after": { - borderRadius: "0 0 4px 4px", + "& tr:last-child": { + "& > td:first-of-type": { + borderBottomLeftRadius: "4px", + }, + "& > td:last-child": { + borderBottomRightRadius: "4px", + }, + "&::after": { + borderRadius: "0 0 4px 4px", + }, }, - }, - ...sx, - }} - > - - {items.map((item) => ( - - ))} - {items.length === 0 && ( - - + + {items.map((item) => ( + - - )} - + ))} + {items.length === 0 && ( + + + + )} + + ); }; diff --git a/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx b/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx index ba5ab1be..e06ec088 100644 --- a/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx +++ b/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx @@ -6,6 +6,7 @@ import type { ArrayDataState, ArrayItemData, } from "#/entities/dataStructures/array/model/arraySlice"; +import { StructureDisplayLabel } from "#/entities/dataStructures/array/ui/StructureDisplayLabel"; import { MatrixRow } from "#/entities/dataStructures/matrix/ui/MatrixRow"; type MatrixStructureViewProps = { @@ -36,6 +37,9 @@ export const MatrixStructureView: React.FC = ({ overflow: "hidden", }} > + {data.displayLabel ? ( + + ) : null} { + it("replaces array literals with __dstructArrayLiteralWithName when bound to a variable", () => { const code = `return function f() { const a = [1, 2]; return a; };`; const { code: out, ok } = instrumentUserJsForLineTracking(code); expect(ok).toBe(true); - expect(out).toContain("__dstructArrayLiteral(1, 2)"); + expect(out).toContain('__dstructArrayLiteralWithName("a", 1, 2)'); }); }); diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index e8e33105..95f1ef22 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -30,10 +30,12 @@ const findSolution = ( }; describe("transformArrayLiteralsInSolution", () => { - it("replaces [] and [1, 2, 3] with __dstructArrayLiteral calls", () => { + it("uses __dstructArrayLiteralWithName when literal is RHS of const / assignment", () => { const code = `return function f() { const a = []; const b = [1, 2, 3]; + let c; + c = [4]; return b; };`; const ast = parse(code, { @@ -44,8 +46,25 @@ describe("transformArrayLiteralsInSolution", () => { expect(solution).toBeTruthy(); transformArrayLiteralsInSolution(solution!); const out = generate(ast).code; - expect(out).toContain("__dstructArrayLiteral()"); - expect(out).toContain("__dstructArrayLiteral(1, 2, 3)"); + expect(out).toContain('__dstructArrayLiteralWithName("a")'); + expect(out).toContain('__dstructArrayLiteralWithName("b", 1, 2, 3)'); + expect(out).toContain('__dstructArrayLiteralWithName("c", 4)'); + }); + + it("uses unnamed helper when only element is a string literal (avoid ambiguity)", () => { + const code = `return function f() { + const labels = ["one"]; + return labels; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain('__dstructArrayLiteral("one")'); + expect(out).not.toContain("__dstructArrayLiteralWithName"); }); it("preserves spread elements", () => { diff --git a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts index 0f786898..62e26ebe 100644 --- a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts +++ b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts @@ -1,7 +1,11 @@ import { PriorityQueue } from "@datastructures-js/priority-queue"; import { generate } from "short-uuid"; -import { getRuntimeArrayClass } from "#/entities/dataStructures/array/model/arrayStructure"; +import { generateArrayData } from "#/entities/dataStructures/array/lib/generateArrayData"; +import { + ControlledArray, + getRuntimeArrayClass, +} from "#/entities/dataStructures/array/model/arrayStructure"; import { getRuntimeSet } from "#/entities/dataStructures/array/model/setStructure"; import { getRuntimeString } from "#/entities/dataStructures/array/model/stringStructure"; import { @@ -63,7 +67,8 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { }, /** * Build a tracked array from literal elements (avoid `new Array(n)` length ambiguity - * in ArrayProxy). Used by AST transform for `[]` and `[1,2,3]`. + * in ArrayProxy). Used by AST transform for `[]` and `[1,2,3]` when the binding name + * is unknown or would be ambiguous (e.g. single string element). */ __dstructArrayLiteral: (...elements: unknown[]) => { const out = new ArrayProxy(0) as unknown[]; @@ -72,6 +77,27 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { } return out; }, + /** + * Same as `__dstructArrayLiteral` but records `displayLabel` for the viewer (inferred variable name). + */ + __dstructArrayLiteralWithName: ( + displayLabel: string, + ...elements: unknown[] + ) => { + const out: unknown[] = []; + for (let index = 0; index < elements.length; index += 1) { + out[index] = elements[index]; + } + const data = generateArrayData(out); + return new ControlledArray( + out as (string | number)[], + generate(), + data, + callstack, + true, + { displayLabel }, + ); + }, ArrayProxy, Uint32ArrayProxy, Int32ArrayProxy, @@ -115,6 +141,7 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { export const globalDefinitionsPrefix = ` const console = {...self.console, log: self.log, error: self.error, warn: self.warn, info: self.info}; const __dstructArrayLiteral = self.__dstructArrayLiteral; + const __dstructArrayLiteralWithName = self.__dstructArrayLiteralWithName; const Array = self.ArrayProxy; const Uint32Array = self.Uint32ArrayProxy; const Int32Array = self.Int32ArrayProxy; diff --git a/src/features/codeRunner/lib/transformJsArrayLiterals.ts b/src/features/codeRunner/lib/transformJsArrayLiterals.ts index 4b309709..162be20b 100644 --- a/src/features/codeRunner/lib/transformJsArrayLiterals.ts +++ b/src/features/codeRunner/lib/transformJsArrayLiterals.ts @@ -1,9 +1,54 @@ import { type NodePath } from "@babel/traverse"; import * as babelTypes from "@babel/types"; +const ARRAY_LITERAL_HELPER = "__dstructArrayLiteral"; +const ARRAY_LITERAL_NAMED_HELPER = "__dstructArrayLiteralWithName"; + +/** + * Single string literal as the only element is ambiguous with `__dstructArrayLiteralWithName("label", "x")`. + */ +const shouldUseUnnamedArrayLiteralHelper = ( + elements: ReadonlyArray< + babelTypes.Expression | babelTypes.SpreadElement | null + >, +): boolean => { + if (elements.length !== 1) return false; + const only = elements[0]; + if (only === null) return false; + return babelTypes.isStringLiteral(only); +}; + +const tryInferBindingNameFromArrayLiteralPath = ( + path: NodePath, +): string | null => { + const parent = path.parent; + if ( + babelTypes.isVariableDeclarator(parent) && + babelTypes.isIdentifier(parent.id) + ) { + return parent.id.name; + } + if ( + babelTypes.isAssignmentExpression(parent) && + parent.operator === "=" && + babelTypes.isIdentifier(parent.left) + ) { + return parent.left.name; + } + if ( + babelTypes.isAssignmentPattern(parent) && + babelTypes.isIdentifier(parent.left) + ) { + return parent.left.name; + } + return null; +}; + /** * Replace `[a, b, ...rest]` with `__dstructArrayLiteral(a, b, ...rest)` inside the solution * function so literals use the proxied `Array` (tracked), matching Python `TrackedList` behavior. + * When the literal is the RHS of a simple binding (`const x = [...]`, `x = [...]`, default param), + * uses `__dstructArrayLiteralWithName("x", ...)` so the viewer can show the variable name. * Runs before line probes so locations stay aligned with user source. */ export const transformArrayLiteralsInSolution = ( @@ -34,11 +79,21 @@ export const transformArrayLiteralsInSolution = ( } } + const bindingName = tryInferBindingNameFromArrayLiteralPath(path); + const useNamed = + bindingName !== null && + !shouldUseUnnamedArrayLiteralHelper(node.elements); + path.replaceWith( - babelTypes.callExpression( - babelTypes.identifier("__dstructArrayLiteral"), - args, - ), + useNamed + ? babelTypes.callExpression( + babelTypes.identifier(ARRAY_LITERAL_NAMED_HELPER), + [babelTypes.stringLiteral(bindingName), ...args], + ) + : babelTypes.callExpression( + babelTypes.identifier(ARRAY_LITERAL_HELPER), + args, + ), ); }, }); diff --git a/src/features/treeViewer/hooks/useArgumentsParsing.ts b/src/features/treeViewer/hooks/useArgumentsParsing.ts index 834176c6..7ad57039 100644 --- a/src/features/treeViewer/hooks/useArgumentsParsing.ts +++ b/src/features/treeViewer/hooks/useArgumentsParsing.ts @@ -441,6 +441,7 @@ const parseArrayArgument = ( childNames: childArgs?.map((arg) => arg.name), order: arg.order, argType: arg.type, + displayLabel: arg.name, }), ); if (newItems) { diff --git a/src/packages/dstruct-runner/python/array_tracker.py b/src/packages/dstruct-runner/python/array_tracker.py index 18e741f5..293bf214 100644 --- a/src/packages/dstruct-runner/python/array_tracker.py +++ b/src/packages/dstruct-runner/python/array_tracker.py @@ -33,7 +33,7 @@ def __init__(self, items: List[T], name: str, callstack: List[CallFrame]) -> Non "addArray", args={ "arrayData": self._generate_list_data(items), - "options": {"name": name} + "options": {"displayLabel": name}, } ) diff --git a/src/packages/dstruct-runner/python/array_tracker_transformer.py b/src/packages/dstruct-runner/python/array_tracker_transformer.py index 87547179..9f9d34cb 100644 --- a/src/packages/dstruct-runner/python/array_tracker_transformer.py +++ b/src/packages/dstruct-runner/python/array_tracker_transformer.py @@ -1,6 +1,5 @@ import ast -from typing import List, Dict, Any, Union, TypedDict, TypeVar -from shared_types import CallFrame +from typing import List, Dict, Any, Union, TypedDict, TypeVar, Optional T = TypeVar('T') @@ -15,6 +14,38 @@ class ListData(TypedDict): class ListOptions(TypedDict): name: str +def attach_ast_parents(root: ast.AST) -> None: + """Set ``node.parent`` for every node so transforms can infer binding names.""" + + def visit(node: ast.AST, parent: Optional[ast.AST]) -> None: + setattr(node, "parent", parent) + for child in ast.iter_child_nodes(node): + visit(child, node) + + visit(root, None) + + +def _infer_list_literal_display_name(list_node: ast.List) -> Optional[str]: + parent = getattr(list_node, "parent", None) + if parent is None: + return None + + if isinstance(parent, ast.Assign) and parent.value is list_node: + targets = parent.targets + if targets and all(isinstance(target, ast.Name) for target in targets): + return targets[0].id + + if isinstance(parent, ast.AnnAssign) and parent.value is list_node: + if isinstance(parent.target, ast.Name): + return parent.target.id + + if isinstance(parent, ast.AugAssign) and parent.value is list_node: + if isinstance(parent.target, ast.Name): + return parent.target.id + + return None + + class ListTrackingTransformer(ast.NodeTransformer): """AST transformer that replaces list operations with tracked list operations.""" @@ -22,11 +53,14 @@ def __init__(self) -> None: super().__init__() self.counter = 0 - def visit_List(self, node): - # Replace [1, 2, 3] with TrackedList([1, 2, 3], "auto_list_N", __callstack__) + def visit_List(self, node: ast.List) -> ast.Call: self.generic_visit(node) - list_name = f"auto_list_{self.counter}" - self.counter += 1 + inferred = _infer_list_literal_display_name(node) + if inferred is not None: + list_name: str = inferred + else: + list_name = f"auto_list_{self.counter}" + self.counter += 1 return ast.Call( func=ast.Name(id="TrackedList", ctx=ast.Load()), args=[ diff --git a/src/packages/dstruct-runner/python/exec.py b/src/packages/dstruct-runner/python/exec.py index d96fe458..814e64e6 100644 --- a/src/packages/dstruct-runner/python/exec.py +++ b/src/packages/dstruct-runner/python/exec.py @@ -4,7 +4,7 @@ import sys import json from array_tracker import TrackedList -from array_tracker_transformer import ListTrackingTransformer +from array_tracker_transformer import ListTrackingTransformer, attach_ast_parents from collection_tracker import TrackedDict, TrackedFrozenSet, TrackedSet from execution_location import clear_execution_source, set_execution_source from line_tracking_transformer import LineTrackingTransformer @@ -182,6 +182,7 @@ def safe_exec(code: str, args: list | None = None) -> ExecutionResult: ) # Transform the AST to track list ops, then statement-level line probes + attach_ast_parents(tree) list_transformer = ListTrackingTransformer() new_tree = list_transformer.visit(tree) line_transformer = LineTrackingTransformer() From 75b7ed045cea0dc0f9a14af7f7b33e25e36ab6e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 19:53:26 +0000 Subject: [PATCH 2/9] feat(args): display labels, rename popover, arg-N default ids - Add optional ArgumentObject.label (non-unique); getArgumentDisplayLabel falls back to arg-{order+1} - caseSlice.updateArgumentLabel + include label in args content signature - ArgInput: composite field labels; suffix rename opens Popover with debounced 300ms save + flush on close - JsonInput: suffixSlot + timeout passthrough; string/json inputs use 300ms debounce - New projects/cases: short-uuid argument ids instead of head/array keys; addCase binary tree uses uuid - useArgumentsParsing: structure displayLabel uses display name, not store id - i18n: rename tooltip strings (en + generated types) Co-authored-by: Max Kayander --- .../caseArgsContentSignature.test.ts | 3 + .../argument/lib/argumentObjectValidator.ts | 1 + .../argument/lib/caseArgsContentSignature.ts | 1 + .../argument/lib/getArgumentDisplayLabel.ts | 11 ++ src/entities/argument/lib/index.ts | 1 + src/entities/argument/model/caseSlice.ts | 14 ++ src/entities/argument/model/types.ts | 3 + src/features/argsEditor/ui/ArgInput.tsx | 34 ++++- src/features/argsEditor/ui/ArgsEditor.tsx | 9 +- .../argsEditor/ui/ArgumentRenameSuffix.tsx | 140 ++++++++++++++++++ .../treeViewer/hooks/useArgumentsParsing.ts | 3 +- src/i18n/en/index.ts | 5 + src/i18n/i18n-types.ts | 32 ++++ src/server/api/routers/project.ts | 29 ++-- src/shared/ui/molecules/JsonInput.tsx | 17 +++ 15 files changed, 284 insertions(+), 19 deletions(-) create mode 100644 src/entities/argument/lib/getArgumentDisplayLabel.ts create mode 100644 src/features/argsEditor/ui/ArgumentRenameSuffix.tsx diff --git a/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts b/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts index ee47a5c5..7f984eb0 100644 --- a/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts +++ b/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts @@ -51,6 +51,9 @@ describe("buildCaseArgsContentSignature", () => { expect( buildCaseArgsContentSignature("p", "c", [{ ...argument, input: "[1]" }]), ).not.toBe(base); + expect( + buildCaseArgsContentSignature("p", "c", [{ ...argument, label: "root" }]), + ).not.toBe(base); }); it("changes when parentName changes", () => { diff --git a/src/entities/argument/lib/argumentObjectValidator.ts b/src/entities/argument/lib/argumentObjectValidator.ts index fae35dfe..1cfc41b6 100644 --- a/src/entities/argument/lib/argumentObjectValidator.ts +++ b/src/entities/argument/lib/argumentObjectValidator.ts @@ -4,6 +4,7 @@ import { ArgumentType } from "../model/argumentObject"; export const argumentObjectValidator = z.object({ name: z.string(), + label: z.string().optional(), type: z.nativeEnum(ArgumentType), order: z.number(), input: z.string(), diff --git a/src/entities/argument/lib/caseArgsContentSignature.ts b/src/entities/argument/lib/caseArgsContentSignature.ts index 120f5d09..697899b7 100644 --- a/src/entities/argument/lib/caseArgsContentSignature.ts +++ b/src/entities/argument/lib/caseArgsContentSignature.ts @@ -14,6 +14,7 @@ export const buildCaseArgsContentSignature = ( const normalizedArguments = [...argumentsList] .map((argument) => ({ name: argument.name, + label: argument.label ?? null, parentName: argument.parentName ?? null, order: argument.order, type: argument.type, diff --git a/src/entities/argument/lib/getArgumentDisplayLabel.ts b/src/entities/argument/lib/getArgumentDisplayLabel.ts new file mode 100644 index 00000000..4ef28444 --- /dev/null +++ b/src/entities/argument/lib/getArgumentDisplayLabel.ts @@ -0,0 +1,11 @@ +import type { ArgumentObject } from "#/entities/argument/model/types"; + +/** + * User-visible argument name. Multiple arguments may share the same label; + * `ArgumentObject.name` remains the unique store key. + */ +export const getArgumentDisplayLabel = (arg: ArgumentObject): string => { + const trimmed = arg.label?.trim(); + if (trimmed) return trimmed; + return `arg-${arg.order + 1}`; +}; diff --git a/src/entities/argument/lib/index.ts b/src/entities/argument/lib/index.ts index 14f39eb0..04bc84e7 100644 --- a/src/entities/argument/lib/index.ts +++ b/src/entities/argument/lib/index.ts @@ -3,3 +3,4 @@ export { buildCaseArgsContentSignature } from "./caseArgsContentSignature"; export { isArgumentArrayType } from "./isArgumentArrayType"; export { isArgumentTreeType } from "./isArgumentTreeType"; export { isArgumentObjectValid } from "./isArgumentObjectValid"; +export { getArgumentDisplayLabel } from "./getArgumentDisplayLabel"; diff --git a/src/entities/argument/model/caseSlice.ts b/src/entities/argument/model/caseSlice.ts index 7bb77e1e..99047b3e 100644 --- a/src/entities/argument/model/caseSlice.ts +++ b/src/entities/argument/model/caseSlice.ts @@ -62,6 +62,20 @@ export const caseSlice = createSlice({ isParsed: false, }; }, + updateArgumentLabel: ( + state, + action: PayloadAction<{ name: string; label: string | undefined }>, + ) => { + const { name, label } = action.payload; + if (!state.args.entities[name]) return; + const nextLabel = + label === undefined || label.trim() === "" ? undefined : label.trim(); + argumentAdapter.updateOne(state.args, { + id: name, + changes: { label: nextLabel }, + }); + state.isEdited = true; + }, updateNodeData: ( state, action: PayloadAction>, diff --git a/src/entities/argument/model/types.ts b/src/entities/argument/model/types.ts index ce7ad2f4..39015df5 100644 --- a/src/entities/argument/model/types.ts +++ b/src/entities/argument/model/types.ts @@ -14,7 +14,10 @@ export type ArgumentArrayType = | ArgumentType.OBJECT; type BaseArgumentData = { + /** Stable unique id (Redux entity id, structure store key). */ name: string; + /** Optional display name; duplicates allowed. Falls back to `arg-${order + 1}`. */ + label?: string; parentName?: string; order: number; input: string; diff --git a/src/features/argsEditor/ui/ArgInput.tsx b/src/features/argsEditor/ui/ArgInput.tsx index 1b1478c9..f39b1dfe 100644 --- a/src/features/argsEditor/ui/ArgInput.tsx +++ b/src/features/argsEditor/ui/ArgInput.tsx @@ -1,13 +1,15 @@ "use client"; -import { TextField } from "@mui/material"; +import { Stack, TextField, Typography } from "@mui/material"; import Joi from "joi"; import React from "react"; +import { getArgumentDisplayLabel } from "#/entities/argument/lib"; import { ArgumentType } from "#/entities/argument/model/argumentObject"; import { argumentTypeLabels } from "#/entities/argument/model/argumentTypeLabels"; import { caseSlice } from "#/entities/argument/model/caseSlice"; import type { ArgumentObject } from "#/entities/argument/model/types"; +import { argumentRenameEndAdornment } from "#/features/argsEditor/ui/ArgumentRenameSuffix"; import { BooleanToggleInput } from "#/shared/ui/atoms/BooleanToggleInput"; import { DebouncedInput } from "#/shared/ui/molecules/DebouncedInput"; import { JsonInput } from "#/shared/ui/molecules/JsonInput"; @@ -33,6 +35,8 @@ const validationSchemaMap = { export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { const dispatch = useAppDispatch(); + const inputLabel = `${getArgumentDisplayLabel(arg)} (${argumentTypeLabels[arg.type]})`; + const renameSuffix = argumentRenameEndAdornment(arg.name, arg.label ?? ""); const handleChange = (value: string) => { dispatch( @@ -51,37 +55,57 @@ export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { case ArgumentType.GRAPH: return ( ); case ArgumentType.STRING: return ( ); case ArgumentType.BOOLEAN: - return ; + return ( + + + + {inputLabel} + + {renameSuffix} + + + + ); } return ( handleChange(ev.target.value)} + InputProps={{ endAdornment: renameSuffix }} /> ); }; diff --git a/src/features/argsEditor/ui/ArgsEditor.tsx b/src/features/argsEditor/ui/ArgsEditor.tsx index 6a193ef8..ca5e6c6a 100644 --- a/src/features/argsEditor/ui/ArgsEditor.tsx +++ b/src/features/argsEditor/ui/ArgsEditor.tsx @@ -15,7 +15,10 @@ import { useSnackbar } from "notistack"; import React, { useEffect } from "react"; import { generate } from "short-uuid"; -import { isArgumentObjectValid } from "#/entities/argument/lib"; +import { + getArgumentDisplayLabel, + isArgumentObjectValid, +} from "#/entities/argument/lib"; import { ArgumentType } from "#/entities/argument/model/argumentObject"; import { caseSlice, @@ -242,7 +245,9 @@ export const ArgsEditor: React.FC = ({ selectedCase }) => { onChange={(type) => handleArgTypeChange(arg, type)} /> handleDeleteArg(arg)} size="small" sx={{ top: -2 }} diff --git a/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx new file mode 100644 index 00000000..97bcc6f2 --- /dev/null +++ b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx @@ -0,0 +1,140 @@ +"use client"; + +import DriveFileRenameOutline from "@mui/icons-material/DriveFileRenameOutline"; +import { + IconButton, + InputAdornment, + Popover, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { caseSlice } from "#/entities/argument/model/caseSlice"; +import { useI18nContext } from "#/shared/hooks"; +import { useAppDispatch } from "#/store/hooks"; + +const SAVE_DEBOUNCE_MS = 300; + +type ArgumentRenameSuffixProps = { + argumentId: string; + labelDraft: string; +}; + +const normalizeLabel = (raw: string): string | undefined => { + const trimmed = raw.trim(); + return trimmed === "" ? undefined : trimmed; +}; + +export const ArgumentRenameSuffix: React.FC = ({ + argumentId, + labelDraft, +}) => { + const { LL } = useI18nContext(); + const dispatch = useAppDispatch(); + const titleId = useId(); + const [anchorEl, setAnchorEl] = useState(null); + const [localDraft, setLocalDraft] = useState(labelDraft); + const debounceRef = useRef | null>(null); + + const flushLabelToStore = useCallback( + (raw: string) => { + dispatch( + caseSlice.actions.updateArgumentLabel({ + name: argumentId, + label: normalizeLabel(raw), + }), + ); + }, + [argumentId, dispatch], + ); + + useEffect(() => { + setLocalDraft(labelDraft); + }, [labelDraft]); + + useEffect(() => { + if (!anchorEl) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + flushLabelToStore(localDraft); + }, SAVE_DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }, [anchorEl, flushLabelToStore, localDraft]); + + const handleOpen = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + flushLabelToStore(localDraft); + setAnchorEl(null); + }, [flushLabelToStore, localDraft]); + + const open = Boolean(anchorEl); + + return ( + <> + + + + + + + + {LL.ARGUMENT_DISPLAY_NAME()} + + setLocalDraft(event.target.value)} + placeholder={LL.ARGUMENT_DISPLAY_NAME_PLACEHOLDER()} + helperText={LL.ARGUMENT_DISPLAY_NAME_HELPER()} + /> + + + ); +}; + +export const argumentRenameEndAdornment = ( + argumentId: string, + labelDraft: string, +): React.ReactNode => ( + + + +); diff --git a/src/features/treeViewer/hooks/useArgumentsParsing.ts b/src/features/treeViewer/hooks/useArgumentsParsing.ts index 7ad57039..3d8eb592 100644 --- a/src/features/treeViewer/hooks/useArgumentsParsing.ts +++ b/src/features/treeViewer/hooks/useArgumentsParsing.ts @@ -5,6 +5,7 @@ import { generate } from "short-uuid"; import { buildCaseArgsContentSignature, + getArgumentDisplayLabel, isArgumentArrayType, isArgumentTreeType, } from "#/entities/argument/lib"; @@ -441,7 +442,7 @@ const parseArrayArgument = ( childNames: childArgs?.map((arg) => arg.name), order: arg.order, argType: arg.type, - displayLabel: arg.name, + displayLabel: getArgumentDisplayLabel(arg), }), ); if (newItems) { diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index a127b7b0..27614905 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -5,6 +5,11 @@ const en: BaseTranslation = { ADD_ARGUMENT: "Add argument", ADD_NEW_SOLUTION: "Add new solution", ARGUMENTS: "Arguments", + ARGUMENT_DISPLAY_NAME: "Display name", + ARGUMENT_DISPLAY_NAME_HELPER: + "Shown on argument inputs. Duplicates are allowed; empty uses arg-1, arg-2, … by order.", + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: "e.g. nums", + ARGUMENT_RENAME: "Rename display name", BROWSE: "Browse", BROWSE_PROJECTS: "Browse Projects", BACK: "Back", diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 194cc2ac..2c6ee9f0 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -48,6 +48,22 @@ type RootTranslation = { * A​r​g​u​m​e​n​t​s */ ARGUMENTS: string + /** + * D​i​s​p​l​a​y​ ​n​a​m​e + */ + ARGUMENT_DISPLAY_NAME: string + /** + * S​h​o​w​n​ ​o​n​ ​a​r​g​u​m​e​n​t​ ​i​n​p​u​t​s​.​ ​D​u​p​l​i​c​a​t​e​s​ ​a​r​e​ ​a​l​l​o​w​e​d​;​ ​e​m​p​t​y​ ​u​s​e​s​ ​a​r​g​-​1​,​ ​a​r​g​-​2​,​ ​…​ ​b​y​ ​o​r​d​e​r​. + */ + ARGUMENT_DISPLAY_NAME_HELPER: string + /** + * e​.​g​.​ ​n​u​m​s + */ + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: string + /** + * R​e​n​a​m​e​ ​d​i​s​p​l​a​y​ ​n​a​m​e + */ + ARGUMENT_RENAME: string /** * B​r​o​w​s​e */ @@ -890,6 +906,22 @@ export type TranslationFunctions = { * Arguments */ ARGUMENTS: () => LocalizedString + /** + * Display name + */ + ARGUMENT_DISPLAY_NAME: () => LocalizedString + /** + * Shown on argument inputs. Duplicates are allowed; empty uses arg-1, arg-2, … by order. + */ + ARGUMENT_DISPLAY_NAME_HELPER: () => LocalizedString + /** + * e.g. nums + */ + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: () => LocalizedString + /** + * Rename display name + */ + ARGUMENT_RENAME: () => LocalizedString /** * Browse */ diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index f27a8c6d..6eeaba83 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -39,36 +39,42 @@ const getDefaultArguments = (category: ProjectCategory): ArgumentObjectMap => { case ProjectCategory.SLIDING_WINDOW: case ProjectCategory.BACKTRACKING: case ProjectCategory.DYNAMIC_PROGRAMMING: - case ProjectCategory.BIT_MANIPULATION: + case ProjectCategory.BIT_MANIPULATION: { + const argumentId = generateShortUuid(); return { - array: { - name: "array", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.ARRAY, input: "[1,2,3]", }, }; + } case ProjectCategory.BINARY_TREE: - case ProjectCategory.BST: + case ProjectCategory.BST: { + const argumentId = generateShortUuid(); return { - head: { - name: "head", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.BINARY_TREE, input: "[1,2,3]", }, }; + } - case ProjectCategory.LINKED_LIST: + case ProjectCategory.LINKED_LIST: { + const argumentId = generateShortUuid(); return { - head: { - name: "head", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.LINKED_LIST, input: "[1,2,3]", }, }; + } case ProjectCategory.GRAPH: return { @@ -660,8 +666,9 @@ export const projectRouter = createTRPCRouter({ }); if (project?.category === ProjectCategory.BINARY_TREE) { - args["head"] = { - name: "head", + const argumentId = generateShortUuid(); + args[argumentId] = { + name: argumentId, order: 0, type: ArgumentType.BINARY_TREE, input: "[]", diff --git a/src/shared/ui/molecules/JsonInput.tsx b/src/shared/ui/molecules/JsonInput.tsx index a72eec20..b0f03233 100644 --- a/src/shared/ui/molecules/JsonInput.tsx +++ b/src/shared/ui/molecules/JsonInput.tsx @@ -11,11 +11,18 @@ type JsonInputProps = Omit & { value: string; onChange: (value: string) => void; validationSchema: Joi.Schema; + /** Debounce for persisting JSON text (passed to `DebouncedInput`). */ + timeout?: number; + /** Rendered inside `InputProps.endAdornment` before any inherited adornment (e.g. rename control). */ + suffixSlot?: React.ReactNode; }; export const JsonInput: React.FC = ({ onChange, validationSchema, + timeout, + suffixSlot, + InputProps, ...restProps }) => { const { LL } = useI18nContext(); @@ -50,6 +57,16 @@ export const JsonInput: React.FC = ({ error={!!inputError} helperText={inputError} fullWidth + timeout={timeout} + InputProps={{ + ...InputProps, + endAdornment: ( + <> + {suffixSlot} + {InputProps?.endAdornment} + + ), + }} {...restProps} /> ); From 956552b1e07cf570b0774ac95d4444b708ca084d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 22:00:54 +0000 Subject: [PATCH 3/9] fix(js-runner): infer display labels for new Array / new ArrayProxy - Extend array literal transform: append { displayLabel } to new Array/ArrayProxy when RHS is inferable; skip ambiguous new Array(singleNumber) length form - Require array literal parent to be actual RHS (init/right) for named literals - ArrayProxy constructor accepts optional trailing options object for displayLabel Co-authored-by: Max Kayander --- .../array/model/arrayStructure.ts | 38 ++++++-- .../transformJsArrayLiterals.test.ts | 33 +++++++ .../lib/transformJsArrayLiterals.ts | 94 +++++++++++++++++-- 3 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/entities/dataStructures/array/model/arrayStructure.ts b/src/entities/dataStructures/array/model/arrayStructure.ts index 032febf4..1c7133fb 100644 --- a/src/entities/dataStructures/array/model/arrayStructure.ts +++ b/src/entities/dataStructures/array/model/arrayStructure.ts @@ -271,22 +271,44 @@ export const getRuntimeArrayClass = (callstack: CallstackHelper) => class ArrayProxy extends ControlledArray { constructor(arrayLength: number); constructor(...items: Array) { - if (items.length === 1 && typeof items[0] === "number") { - const arrayLength = items[0]; - items = new Array(arrayLength); + let runtimeOptions: ControlledArrayRuntimeOptions | undefined; + let elementItems = items as Array; + const lastItem = elementItems.at(-1); + if ( + elementItems.length >= 2 && + lastItem !== null && + lastItem !== undefined && + typeof lastItem === "object" && + !Array.isArray(lastItem) && + "displayLabel" in lastItem + ) { + runtimeOptions = lastItem as ControlledArrayRuntimeOptions; + elementItems = elementItems.slice(0, -1) as Array; + } + + if (elementItems.length === 1 && typeof elementItems[0] === "number") { + const arrayLength = elementItems[0]; + elementItems = new Array(arrayLength) as Array; } if ( - items[0] && - typeof items[0] !== "number" && - typeof items[0] !== "string" + elementItems[0] && + typeof elementItems[0] !== "number" && + typeof elementItems[0] !== "string" ) { throw new Error("ArrayProxy can only contain numbers or strings"); } - const data = generateArrayData(items); + const data = generateArrayData(elementItems as T[]); - super(items, generate(), data, callstack, true); + super( + elementItems as T[], + generate(), + data, + callstack, + true, + runtimeOptions, + ); } static override from( diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index 95f1ef22..778873e0 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -81,6 +81,39 @@ describe("transformArrayLiteralsInSolution", () => { expect(out).toContain("__dstructArrayLiteral(...arr, 4)"); }); + it("appends displayLabel object to new Array / new ArrayProxy when binding is inferable", () => { + const code = `return function f() { + const nums = new Array(1, 2, 3); + const copy = new ArrayProxy(4, 5); + return nums; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toMatch(/new Array\(1,\s*2,\s*3,\s*\{[^}]*displayLabel:\s*"nums"/); + expect(out).toMatch(/new ArrayProxy\(4,\s*5,\s*\{[^}]*displayLabel:\s*"copy"/); + }); + + it("does not append displayLabel to ambiguous new Array(number) length form", () => { + const code = `return function f() { + const buf = new Array(10); + return buf; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain("new Array(10)"); + expect(out).not.toContain("displayLabel"); + }); + it("does not replace array used as computed object key", () => { const code = `return function f() { const o = { [[1, 2]]: 0 }; diff --git a/src/features/codeRunner/lib/transformJsArrayLiterals.ts b/src/features/codeRunner/lib/transformJsArrayLiterals.ts index 162be20b..176114d8 100644 --- a/src/features/codeRunner/lib/transformJsArrayLiterals.ts +++ b/src/features/codeRunner/lib/transformJsArrayLiterals.ts @@ -4,6 +4,10 @@ import * as babelTypes from "@babel/types"; const ARRAY_LITERAL_HELPER = "__dstructArrayLiteral"; const ARRAY_LITERAL_NAMED_HELPER = "__dstructArrayLiteralWithName"; +type InferableRhsPath = + | NodePath + | NodePath; + /** * Single string literal as the only element is ambiguous with `__dstructArrayLiteralWithName("label", "x")`. */ @@ -18,37 +22,53 @@ const shouldUseUnnamedArrayLiteralHelper = ( return babelTypes.isStringLiteral(only); }; -const tryInferBindingNameFromArrayLiteralPath = ( - path: NodePath, +const tryInferBindingNameFromRhsPath = ( + path: InferableRhsPath, ): string | null => { const parent = path.parent; + const node = path.node; if ( babelTypes.isVariableDeclarator(parent) && - babelTypes.isIdentifier(parent.id) + babelTypes.isIdentifier(parent.id) && + parent.init === node ) { return parent.id.name; } if ( babelTypes.isAssignmentExpression(parent) && parent.operator === "=" && - babelTypes.isIdentifier(parent.left) + babelTypes.isIdentifier(parent.left) && + parent.right === node ) { return parent.left.name; } if ( babelTypes.isAssignmentPattern(parent) && - babelTypes.isIdentifier(parent.left) + babelTypes.isIdentifier(parent.left) && + parent.right === node ) { return parent.left.name; } return null; }; +const isTrackedArrayCallee = ( + callee: babelTypes.Expression | babelTypes.V8IntrinsicIdentifier, +): boolean => { + if (babelTypes.isIdentifier(callee)) { + return callee.name === "Array" || callee.name === "ArrayProxy"; + } + return false; +}; + /** * Replace `[a, b, ...rest]` with `__dstructArrayLiteral(a, b, ...rest)` inside the solution * function so literals use the proxied `Array` (tracked), matching Python `TrackedList` behavior. * When the literal is the RHS of a simple binding (`const x = [...]`, `x = [...]`, default param), * uses `__dstructArrayLiteralWithName("x", ...)` so the viewer can show the variable name. + * + * Also appends `{ displayLabel: "x" }` to `new Array(...)` / `new ArrayProxy(...)` when the name + * is inferable so dynamic `new Array(1,2,3)` constructions get viewer labels (same as literals). * Runs before line probes so locations stay aligned with user source. */ export const transformArrayLiteralsInSolution = ( @@ -79,7 +99,7 @@ export const transformArrayLiteralsInSolution = ( } } - const bindingName = tryInferBindingNameFromArrayLiteralPath(path); + const bindingName = tryInferBindingNameFromRhsPath(path); const useNamed = bindingName !== null && !shouldUseUnnamedArrayLiteralHelper(node.elements); @@ -96,5 +116,67 @@ export const transformArrayLiteralsInSolution = ( ), ); }, + + NewExpression(path: NodePath) { + const { node, parent } = path; + if (!isTrackedArrayCallee(node.callee)) return; + + if ( + babelTypes.isObjectProperty(parent) && + parent.computed && + parent.key === node + ) { + return; + } + + const lastArg = node.arguments.at(-1); + if ( + lastArg && + babelTypes.isExpression(lastArg) && + !babelTypes.isSpreadElement(lastArg) && + babelTypes.isObjectExpression(lastArg) + ) { + const props = lastArg.properties; + const onlyDisplayLabel = + props.length === 1 && + babelTypes.isObjectProperty(props[0]) && + !props[0].computed && + babelTypes.isIdentifier(props[0].key, { name: "displayLabel" }); + if (onlyDisplayLabel) { + return; + } + } + + const bindingName = tryInferBindingNameFromRhsPath(path); + if (bindingName === null) return; + + if (node.arguments.length === 1) { + const only = node.arguments[0]; + if ( + only && + babelTypes.isExpression(only) && + !babelTypes.isSpreadElement(only) && + babelTypes.isNumericLiteral(only) + ) { + return; + } + } + + const displayLabelObject = babelTypes.objectExpression([ + babelTypes.objectProperty( + babelTypes.identifier("displayLabel"), + babelTypes.stringLiteral(bindingName), + false, + true, + ), + ]); + + path.replaceWith( + babelTypes.newExpression(node.callee, [ + ...node.arguments, + displayLabelObject, + ]), + ); + }, }); }; From cadbbd453719be5fcdb8c34b002c596063489b48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 20 May 2026 20:26:07 +0000 Subject: [PATCH 4/9] style: prettier wrap regex assertions in transform test --- .../lib/__tests__/transformJsArrayLiterals.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index 778873e0..a4613fe4 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -94,8 +94,12 @@ describe("transformArrayLiteralsInSolution", () => { const solution = findSolution(ast); transformArrayLiteralsInSolution(solution!); const out = generate(ast).code; - expect(out).toMatch(/new Array\(1,\s*2,\s*3,\s*\{[^}]*displayLabel:\s*"nums"/); - expect(out).toMatch(/new ArrayProxy\(4,\s*5,\s*\{[^}]*displayLabel:\s*"copy"/); + expect(out).toMatch( + /new Array\(1,\s*2,\s*3,\s*\{[^}]*displayLabel:\s*"nums"/, + ); + expect(out).toMatch( + /new ArrayProxy\(4,\s*5,\s*\{[^}]*displayLabel:\s*"copy"/, + ); }); it("does not append displayLabel to ambiguous new Array(number) length form", () => { From 4bc0707cb4082e180bda170fd6f308be5a26a0e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 22:08:01 +0000 Subject: [PATCH 5/9] fix(args): sync rename draft on popover open (avoid setState-in-effect lint) --- .../argsEditor/ui/ArgumentRenameSuffix.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx index 97bcc6f2..beccf05d 100644 --- a/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx +++ b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx @@ -50,10 +50,6 @@ export const ArgumentRenameSuffix: React.FC = ({ [argumentId, dispatch], ); - useEffect(() => { - setLocalDraft(labelDraft); - }, [labelDraft]); - useEffect(() => { if (!anchorEl) return; @@ -73,9 +69,13 @@ export const ArgumentRenameSuffix: React.FC = ({ }; }, [anchorEl, flushLabelToStore, localDraft]); - const handleOpen = useCallback((event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }, []); + const handleOpen = useCallback( + (event: React.MouseEvent) => { + setLocalDraft(labelDraft); + setAnchorEl(event.currentTarget); + }, + [labelDraft], + ); const handleClose = useCallback(() => { if (debounceRef.current) { From 724784e6dfd87f33b2b8fb09fb90ea91407e0dec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 1 Jun 2026 22:08:58 +0000 Subject: [PATCH 6/9] fix(args): use MUI v9 slotProps.input for rename adornment --- src/features/argsEditor/ui/ArgInput.tsx | 7 +++--- src/shared/ui/molecules/JsonInput.tsx | 33 +++++++++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/features/argsEditor/ui/ArgInput.tsx b/src/features/argsEditor/ui/ArgInput.tsx index f39b1dfe..166bc424 100644 --- a/src/features/argsEditor/ui/ArgInput.tsx +++ b/src/features/argsEditor/ui/ArgInput.tsx @@ -74,7 +74,7 @@ export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { onChange={handleChange} value={arg.input} timeout={300} - InputProps={{ endAdornment: renameSuffix }} + slotProps={{ input: { endAdornment: renameSuffix } }} /> ); @@ -84,8 +84,7 @@ export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { {inputLabel} @@ -105,7 +104,7 @@ export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { fullWidth size="small" onChange={(ev) => handleChange(ev.target.value)} - InputProps={{ endAdornment: renameSuffix }} + slotProps={{ input: { endAdornment: renameSuffix } }} /> ); }; diff --git a/src/shared/ui/molecules/JsonInput.tsx b/src/shared/ui/molecules/JsonInput.tsx index b0f03233..a1711bf8 100644 --- a/src/shared/ui/molecules/JsonInput.tsx +++ b/src/shared/ui/molecules/JsonInput.tsx @@ -13,7 +13,7 @@ type JsonInputProps = Omit & { validationSchema: Joi.Schema; /** Debounce for persisting JSON text (passed to `DebouncedInput`). */ timeout?: number; - /** Rendered inside `InputProps.endAdornment` before any inherited adornment (e.g. rename control). */ + /** Rendered in `slotProps.input.endAdornment` before any inherited adornment (e.g. rename control). */ suffixSlot?: React.ReactNode; }; @@ -22,7 +22,7 @@ export const JsonInput: React.FC = ({ validationSchema, timeout, suffixSlot, - InputProps, + slotProps, ...restProps }) => { const { LL } = useI18nContext(); @@ -49,6 +49,14 @@ export const JsonInput: React.FC = ({ } }; + const inputSlotProps = slotProps?.input; + const inheritedEndAdornment = + typeof inputSlotProps === "object" && + inputSlotProps !== null && + "endAdornment" in inputSlotProps + ? inputSlotProps.endAdornment + : null; + return ( = ({ helperText={inputError} fullWidth timeout={timeout} - InputProps={{ - ...InputProps, - endAdornment: ( - <> - {suffixSlot} - {InputProps?.endAdornment} - - ), + slotProps={{ + ...slotProps, + input: { + ...(typeof inputSlotProps === "object" && inputSlotProps !== null + ? inputSlotProps + : {}), + endAdornment: ( + <> + {suffixSlot} + {inheritedEndAdornment} + + ), + }, }} {...restProps} /> From d04e149c0609acc4d44829d6ae4b46f5f328d5a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Jun 2026 20:17:41 +0000 Subject: [PATCH 7/9] Fix untracked [] literals when line instrumentation is skipped Plain array literals do not use ArrayProxy, so DS labels and addArray frames only appear after the AST transform. Apply array literal rewriting whenever parse succeeds, not only when line probes are injected; fall back to transforming all functions if the return-function template is missing. Also recognize ??=/||=/&&= bindings for named literals, inherit displayLabel through Array.map, and always run transformed code in the worker. --- .../array/model/arrayStructure.ts | 27 ++- .../__tests__/getLevelsArrayTracking.test.ts | 180 ++++++++++++++++++ .../instrumentUserJsForLineTracking.test.ts | 10 +- .../transformJsArrayLiterals.test.ts | 17 ++ .../lib/instrumentUserJsForLineTracking.ts | 20 +- .../lib/transformJsArrayLiterals.ts | 42 +++- .../codeRunner/lib/workers/codeExec.worker.ts | 4 +- 7 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts diff --git a/src/entities/dataStructures/array/model/arrayStructure.ts b/src/entities/dataStructures/array/model/arrayStructure.ts index 1c7133fb..4d31ebcd 100644 --- a/src/entities/dataStructures/array/model/arrayStructure.ts +++ b/src/entities/dataStructures/array/model/arrayStructure.ts @@ -23,6 +23,15 @@ export type ControlledArrayRuntimeOptions = { displayLabel?: string; }; +const RUNTIME_DISPLAY_LABEL_KEY = "__dstructRuntimeDisplayLabel"; + +export const getRuntimeDisplayLabel = ( + array: ArrayBaseType, +): string | undefined => { + const label = Reflect.get(array, RUNTIME_DISPLAY_LABEL_KEY); + return typeof label === "string" ? label : undefined; +}; + export function initControlledArray( array: T, arrayData: EntityState, @@ -53,6 +62,14 @@ export function initControlledArray( value: callstack, enumerable: false, }, + ...(options?.displayLabel !== undefined + ? { + [RUNTIME_DISPLAY_LABEL_KEY]: { + value: options.displayLabel, + enumerable: false, + }, + } + : {}), }); if (addToCallstack) { @@ -186,13 +203,21 @@ export class ControlledArray extends ArrayBase { new Array(this.length), undefined, ); + const inheritedDisplayLabel = getRuntimeDisplayLabel(this); + const mapOptions: ControlledArrayRuntimeOptions | undefined = + inheritedDisplayLabel !== undefined || options !== undefined + ? { + ...options, + displayLabel: options?.displayLabel ?? inheritedDisplayLabel, + } + : undefined; const newArray = new ControlledArray( array as U[], id, data, this.callstack, true, - options, + mapOptions, ); for (let i = 0; i < this.length; i++) { newArray[i] = callback(this[i]!, i, this); diff --git a/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts b/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts new file mode 100644 index 00000000..32be3360 --- /dev/null +++ b/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import type { ArgumentObject } from "#/entities/argument/model/types"; +import { BinaryTreeNode } from "#/entities/dataStructures/binaryTree/model/binaryTreeNode"; +import type { TreeData } from "#/entities/dataStructures/node/model/nodeSlice"; +import { CallstackHelper } from "#/features/callstack/model/callstackSlice"; +import { createCaseRuntimeArgs } from "#/features/codeRunner/lib/createCaseRuntimeArgs"; +import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; +import { + globalDefinitionsPrefix, + setGlobalRuntimeContext, +} from "#/features/codeRunner/lib/setGlobalRuntimeContext"; + +function buildBinaryTreeStore(): { head: TreeData } { + const rootId = "root"; + const leftId = "left"; + const rightId = "right"; + const leftLeftId = "left-left"; + + return { + head: { + type: ArgumentType.BINARY_TREE, + order: 0, + maxDepth: 2, + rootId, + edges: { ids: [], entities: {} }, + initialEdges: null, + hiddenNodes: { ids: [], entities: {} }, + initialNodes: null, + isRuntime: false, + nodes: { + ids: [rootId, leftId, rightId, leftLeftId], + entities: { + [rootId]: { + id: rootId, + value: 1, + depth: 0, + argType: ArgumentType.BINARY_TREE, + childrenIds: [leftId, rightId], + x: 0, + y: 0, + }, + [leftId]: { + id: leftId, + value: 3, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: [leftLeftId, ""], + x: 0, + y: 0, + }, + [rightId]: { + id: rightId, + value: 2, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + [leftLeftId]: { + id: leftLeftId, + value: 4, + depth: 2, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + }, + }, + }, + }; +} + +const getLevelsCode = `/** + * @param {TreeNodeObject} head + */ +return function getLevels(head) { + const array = []; + + const dfs = (node, depth = 0) => { + array[depth] ??= []; + array[depth].push(node.val); + + node.setColor("green"); + + node.left && dfs(node.left, depth + 1); + node.right && dfs(node.right, depth + 1); + }; + + dfs(head); + + return array.map((values) => values.reduce((acc, curr) => acc + curr, 0) / values.length); +};`; + +describe("getLevels array tracking", () => { + it("emits addArray frames and displayLabel when instrumented", () => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const treeStore = buildBinaryTreeStore(); + const caseArgs: ArgumentObject[] = [ + { + name: "head", + type: ArgumentType.BINARY_TREE, + order: 0, + input: "", + }, + ]; + + const args = createCaseRuntimeArgs(callstack, treeStore, {}, caseArgs); + expect(args[0]).toBeInstanceOf(BinaryTreeNode); + + const { code: instrumented, ok } = + instrumentUserJsForLineTracking(getLevelsCode); + expect(ok).toBe(true); + + const prefixedCode = `${globalDefinitionsPrefix}\n${instrumented}`; + const run = new Function(prefixedCode) as () => ( + head: BinaryTreeNode, + ) => number[]; + callstack.clear(); + const result = run()(args[0] as BinaryTreeNode); + + expect(result).toHaveLength(3); + const addArrayFrames = callstack.frames.filter( + (frame) => frame.name === "addArray", + ); + expect(addArrayFrames.length).toBeGreaterThan(0); + const labeledFrame = addArrayFrames.find( + (frame) => + frame.name === "addArray" && + frame.args.options?.displayLabel === "array", + ); + expect(labeledFrame).toBeDefined(); + }); + + it("does not emit addArray for plain [] without instrumentation", () => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const treeStore = buildBinaryTreeStore(); + const caseArgs: ArgumentObject[] = [ + { + name: "head", + type: ArgumentType.BINARY_TREE, + order: 0, + input: "", + }, + ]; + const args = createCaseRuntimeArgs(callstack, treeStore, {}, caseArgs); + + const plainCode = `return function getLevels(head) { + const array = []; + const dfs = (node, depth = 0) => { + array[depth] ??= []; + array[depth].push(node.val); + node.setColor("green"); + if (node.left) dfs(node.left, depth + 1); + if (node.right) dfs(node.right, depth + 1); + }; + dfs(head); + return array.map((values) => values.reduce((acc, curr) => acc + curr, 0) / values.length); +};`; + + const prefixedCode = `${globalDefinitionsPrefix}\n${plainCode}`; + const run = new Function(prefixedCode) as () => ( + head: BinaryTreeNode, + ) => number[]; + callstack.clear(); + run()(args[0] as BinaryTreeNode); + + const addArrayFrames = callstack.frames.filter( + (frame) => frame.name === "addArray", + ); + expect(addArrayFrames).toHaveLength(0); + }); +}); diff --git a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts index dff440e9..dab9b686 100644 --- a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts +++ b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts @@ -24,12 +24,16 @@ describe("instrumentUserJsForLineTracking", () => { expect(ok).toBe(true); }); - it("skips instrumentation when there is no return function template", () => { - const code = `function run() { return 1; } + it("skips line probes without return function template but still transforms array literals", () => { + const code = `function run() { + const a = [1, 2]; + return a; +} return run;`; const { code: out, ok } = instrumentUserJsForLineTracking(code); expect(ok).toBe(false); - expect(out).toBe(code); + expect(out).toContain('__dstructArrayLiteralWithName("a", 1, 2)'); + expect(out).not.toContain("__dstructSetExecutionSource"); }); it("does not instrument top-level functions outside the returned solution", () => { diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index a4613fe4..2316e31a 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -102,6 +102,23 @@ describe("transformArrayLiteralsInSolution", () => { ); }); + it("names array literals on ??= assignment to an identifier", () => { + const code = `return function f() { + let bucket; + bucket ??= []; + return bucket; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + expect(solution).toBeTruthy(); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain('bucket ??= __dstructArrayLiteralWithName("bucket"'); + }); + it("does not append displayLabel to ambiguous new Array(number) length form", () => { const code = `return function f() { const buf = new Array(10); diff --git a/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts b/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts index 16caae1a..d490aa9c 100644 --- a/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts +++ b/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts @@ -4,7 +4,10 @@ import traverse, { type NodePath } from "@babel/traverse"; import * as babelTypes from "@babel/types"; import type { File } from "@babel/types"; -import { transformArrayLiteralsInSolution } from "#/features/codeRunner/lib/transformJsArrayLiterals"; +import { + transformArrayLiteralsInProgram, + transformArrayLiteralsInSolution, +} from "#/features/codeRunner/lib/transformJsArrayLiterals"; const insertProbesInBlock = (bodyPath: NodePath) => { const statements = bodyPath.get("body"); @@ -61,9 +64,8 @@ const findReturnedSolutionPath = (file: File): SolutionFnPath | null => { }; /** - * Injects `globalThis.__dstructSetExecutionSource(line, column)` before each statement - * in every function body **inside** the first `return ` (dStruct solution template). - * Line/column match the Monaco model (user code only). + * Rewrites user JS for the code runner: array literals → tracked helpers (always when parse + * succeeds), and optionally line probes for editor sync (`ok` reflects probes only). */ export const instrumentUserJsForLineTracking = ( code: string, @@ -76,12 +78,14 @@ export const instrumentUserJsForLineTracking = ( }); const solutionPath = findReturnedSolutionPath(ast); - if (!solutionPath) { - return { code, ok: false }; + if (solutionPath) { + transformArrayLiteralsInSolution(solutionPath); + } else { + transformArrayLiteralsInProgram(ast); + const out = generate(ast, { retainLines: false }); + return { code: out.code, ok: false }; } - transformArrayLiteralsInSolution(solutionPath); - // traverse() does not visit the root path; instrument the solution fn itself first. instrumentFunctionBody(solutionPath as NodePath); diff --git a/src/features/codeRunner/lib/transformJsArrayLiterals.ts b/src/features/codeRunner/lib/transformJsArrayLiterals.ts index 176114d8..15c87888 100644 --- a/src/features/codeRunner/lib/transformJsArrayLiterals.ts +++ b/src/features/codeRunner/lib/transformJsArrayLiterals.ts @@ -1,4 +1,5 @@ -import { type NodePath } from "@babel/traverse"; +import traverse, { type NodePath } from "@babel/traverse"; +import type { File } from "@babel/types"; import * as babelTypes from "@babel/types"; const ARRAY_LITERAL_HELPER = "__dstructArrayLiteral"; @@ -22,6 +23,8 @@ const shouldUseUnnamedArrayLiteralHelper = ( return babelTypes.isStringLiteral(only); }; +const ASSIGNMENT_OPERATORS_WITH_BINDING = new Set(["=", "??=", "||=", "&&="]); + const tryInferBindingNameFromRhsPath = ( path: InferableRhsPath, ): string | null => { @@ -36,7 +39,7 @@ const tryInferBindingNameFromRhsPath = ( } if ( babelTypes.isAssignmentExpression(parent) && - parent.operator === "=" && + ASSIGNMENT_OPERATORS_WITH_BINDING.has(parent.operator) && babelTypes.isIdentifier(parent.left) && parent.right === node ) { @@ -71,12 +74,10 @@ const isTrackedArrayCallee = ( * is inferable so dynamic `new Array(1,2,3)` constructions get viewer labels (same as literals). * Runs before line probes so locations stay aligned with user source. */ -export const transformArrayLiteralsInSolution = ( - solutionPath: NodePath< - babelTypes.FunctionExpression | babelTypes.ArrowFunctionExpression - >, +export const transformArrayLiteralsInFunction = ( + functionPath: NodePath, ): void => { - solutionPath.traverse({ + functionPath.traverse({ ArrayExpression(path: NodePath) { const { node, parent } = path; @@ -180,3 +181,30 @@ export const transformArrayLiteralsInSolution = ( }, }); }; + +export const transformArrayLiteralsInSolution = ( + solutionPath: NodePath< + babelTypes.FunctionExpression | babelTypes.ArrowFunctionExpression + >, +): void => { + transformArrayLiteralsInFunction( + solutionPath as NodePath, + ); +}; + +/** Fallback when the solution template (`return function …`) is missing. */ +export const transformArrayLiteralsInProgram = (file: File): void => { + traverse(file, { + FunctionDeclaration(path: NodePath) { + transformArrayLiteralsInFunction(path); + }, + FunctionExpression(path: NodePath) { + transformArrayLiteralsInFunction(path); + }, + ArrowFunctionExpression( + path: NodePath, + ) { + transformArrayLiteralsInFunction(path); + }, + }); +}; diff --git a/src/features/codeRunner/lib/workers/codeExec.worker.ts b/src/features/codeRunner/lib/workers/codeExec.worker.ts index 38cf82f4..f939a90c 100644 --- a/src/features/codeRunner/lib/workers/codeExec.worker.ts +++ b/src/features/codeRunner/lib/workers/codeExec.worker.ts @@ -113,9 +113,7 @@ self.addEventListener("message", (event: MessageEvent) => { arrayStore, caseArgs, ); - const { code: instrumentedCode, ok: instrumentOk } = - instrumentUserJsForLineTracking(code); - const codeToRun = instrumentOk ? instrumentedCode : code; + const { code: codeToRun } = instrumentUserJsForLineTracking(code); const prefixedCode = `${globalDefinitionsPrefix}\n${codeToRun}`; const startTimestamp = performance.now(); try { From b9e3d56ef3839984b14322ab9429a4ca20141afa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 16:14:09 +0000 Subject: [PATCH 8/9] refactor: DRY array literal runtime helpers and tidy types - Share buildTrackedArrayFromLiteralElements for named/unnamed literals - Collapse program-wide function traversal into one Babel visitor - Keep runtime display label helper module-private - Align Python ListOptions TypedDict with displayLabel field Co-authored-by: Max Kayander --- .../array/model/arrayStructure.ts | 2 +- .../codeRunner/lib/setGlobalRuntimeContext.ts | 52 +++++++++++-------- .../lib/transformJsArrayLiterals.ts | 14 ++--- .../dstruct-runner/python/array_tracker.py | 4 +- .../python/array_tracker_transformer.py | 4 +- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/entities/dataStructures/array/model/arrayStructure.ts b/src/entities/dataStructures/array/model/arrayStructure.ts index 4d31ebcd..05a07bc7 100644 --- a/src/entities/dataStructures/array/model/arrayStructure.ts +++ b/src/entities/dataStructures/array/model/arrayStructure.ts @@ -25,7 +25,7 @@ export type ControlledArrayRuntimeOptions = { const RUNTIME_DISPLAY_LABEL_KEY = "__dstructRuntimeDisplayLabel"; -export const getRuntimeDisplayLabel = ( +const getRuntimeDisplayLabel = ( array: ArrayBaseType, ): string | undefined => { const label = Reflect.get(array, RUNTIME_DISPLAY_LABEL_KEY); diff --git a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts index 62e26ebe..fdecc0b1 100644 --- a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts +++ b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts @@ -61,6 +61,33 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { const ObjectProxy = getRuntimeObject(callstack); + const buildTrackedArrayFromLiteralElements = ( + elements: unknown[], + displayLabel?: string, + ) => { + if (displayLabel !== undefined) { + const values: unknown[] = []; + for (let index = 0; index < elements.length; index += 1) { + values[index] = elements[index]; + } + const data = generateArrayData(values); + return new ControlledArray( + values as (string | number)[], + generate(), + data, + callstack, + true, + { displayLabel }, + ); + } + + const out = new ArrayProxy(0) as unknown[]; + for (let index = 0; index < elements.length; index += 1) { + out[index] = elements[index]; + } + return out; + }; + const context = { __dstructSetExecutionSource: (line: number, column?: number) => { setExecutionSource(line, column ?? 0); @@ -70,34 +97,15 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { * in ArrayProxy). Used by AST transform for `[]` and `[1,2,3]` when the binding name * is unknown or would be ambiguous (e.g. single string element). */ - __dstructArrayLiteral: (...elements: unknown[]) => { - const out = new ArrayProxy(0) as unknown[]; - for (let i = 0; i < elements.length; i += 1) { - out[i] = elements[i]; - } - return out; - }, + __dstructArrayLiteral: (...elements: unknown[]) => + buildTrackedArrayFromLiteralElements(elements), /** * Same as `__dstructArrayLiteral` but records `displayLabel` for the viewer (inferred variable name). */ __dstructArrayLiteralWithName: ( displayLabel: string, ...elements: unknown[] - ) => { - const out: unknown[] = []; - for (let index = 0; index < elements.length; index += 1) { - out[index] = elements[index]; - } - const data = generateArrayData(out); - return new ControlledArray( - out as (string | number)[], - generate(), - data, - callstack, - true, - { displayLabel }, - ); - }, + ) => buildTrackedArrayFromLiteralElements(elements, displayLabel), ArrayProxy, Uint32ArrayProxy, Int32ArrayProxy, diff --git a/src/features/codeRunner/lib/transformJsArrayLiterals.ts b/src/features/codeRunner/lib/transformJsArrayLiterals.ts index 15c87888..e13e4465 100644 --- a/src/features/codeRunner/lib/transformJsArrayLiterals.ts +++ b/src/features/codeRunner/lib/transformJsArrayLiterals.ts @@ -195,16 +195,10 @@ export const transformArrayLiteralsInSolution = ( /** Fallback when the solution template (`return function …`) is missing. */ export const transformArrayLiteralsInProgram = (file: File): void => { traverse(file, { - FunctionDeclaration(path: NodePath) { - transformArrayLiteralsInFunction(path); - }, - FunctionExpression(path: NodePath) { - transformArrayLiteralsInFunction(path); - }, - ArrowFunctionExpression( - path: NodePath, - ) { - transformArrayLiteralsInFunction(path); + "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression"(path) { + transformArrayLiteralsInFunction( + path as NodePath, + ); }, }); }; diff --git a/src/packages/dstruct-runner/python/array_tracker.py b/src/packages/dstruct-runner/python/array_tracker.py index 293bf214..0bfe414d 100644 --- a/src/packages/dstruct-runner/python/array_tracker.py +++ b/src/packages/dstruct-runner/python/array_tracker.py @@ -16,8 +16,8 @@ class ListData(TypedDict): ids: List[str] entities: Dict[str, ListItemData] -class ListOptions(TypedDict): - name: str +class ListOptions(TypedDict, total=False): + displayLabel: str class TrackedList(list, Generic[T]): """A proxy-like list class that tracks all operations performed on it.""" diff --git a/src/packages/dstruct-runner/python/array_tracker_transformer.py b/src/packages/dstruct-runner/python/array_tracker_transformer.py index 9f9d34cb..339edf3b 100644 --- a/src/packages/dstruct-runner/python/array_tracker_transformer.py +++ b/src/packages/dstruct-runner/python/array_tracker_transformer.py @@ -11,8 +11,8 @@ class ListData(TypedDict): ids: List[str] entities: Dict[str, ListItemData] -class ListOptions(TypedDict): - name: str +class ListOptions(TypedDict, total=False): + displayLabel: str def attach_ast_parents(root: ast.AST) -> None: """Set ``node.parent`` for every node so transforms can infer binding names.""" From 757725cf7a3afb1349c23dc9569ade4edbdf24f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Jul 2026 16:16:52 +0000 Subject: [PATCH 9/9] test: cover displayLabel behavior end-to-end, drop weak cases - Runtime tests: literal helper, map inheritance, plain [] gap, getLevels path - Redux: arraySlice.create and caseSlice.updateArgumentLabel - Args: getArgumentDisplayLabel fallbacks and duplicate labels - AST: transformArrayLiteralsInProgram fallback - Python: inline list literal emits displayLabel in addArray frame - Remove duplicate getLevels file and instrument tests that only checked ok:true Co-authored-by: Max Kayander --- .../__tests__/getArgumentDisplayLabel.test.ts | 43 ++++ .../model/__tests__/caseSlice.test.ts | 37 ++++ .../__tests__/arraySlice.displayLabel.test.ts | 34 ++++ .../arrayDisplayLabel.runtime.test.ts | 191 ++++++++++++++++++ .../__tests__/getLevelsArrayTracking.test.ts | 180 ----------------- .../instrumentUserJsForLineTracking.test.ts | 38 +--- .../transformJsArrayLiterals.test.ts | 21 +- .../dstruct-runner/python/test_exec.py | 17 ++ 8 files changed, 345 insertions(+), 216 deletions(-) create mode 100644 src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts create mode 100644 src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts create mode 100644 src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts delete mode 100644 src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts diff --git a/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts b/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts new file mode 100644 index 00000000..b9919175 --- /dev/null +++ b/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { getArgumentDisplayLabel } from "#/entities/argument/lib/getArgumentDisplayLabel"; +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import type { ArgumentObject } from "#/entities/argument/model/types"; + +const makeArg = ( + overrides: Partial & Pick, +): ArgumentObject => ({ + name: "uuid-key", + type: ArgumentType.ARRAY, + input: "[]", + ...overrides, +}); + +describe("getArgumentDisplayLabel", () => { + it("returns trimmed custom label when set", () => { + expect( + getArgumentDisplayLabel( + makeArg({ order: 0, label: " head " }), + ), + ).toBe("head"); + }); + + it("falls back to arg-N when label is missing or blank", () => { + expect(getArgumentDisplayLabel(makeArg({ order: 0 }))).toBe("arg-1"); + expect(getArgumentDisplayLabel(makeArg({ order: 2 }))).toBe("arg-3"); + expect(getArgumentDisplayLabel(makeArg({ order: 0, label: "" }))).toBe( + "arg-1", + ); + expect(getArgumentDisplayLabel(makeArg({ order: 0, label: " " }))).toBe( + "arg-1", + ); + }); + + it("allows duplicate labels across arguments (store keys stay unique)", () => { + const first = makeArg({ order: 0, name: "a", label: "nums" }); + const second = makeArg({ order: 1, name: "b", label: "nums" }); + expect(getArgumentDisplayLabel(first)).toBe("nums"); + expect(getArgumentDisplayLabel(second)).toBe("nums"); + expect(first.name).not.toBe(second.name); + }); +}); diff --git a/src/entities/argument/model/__tests__/caseSlice.test.ts b/src/entities/argument/model/__tests__/caseSlice.test.ts index 3cc831cf..a57d93b1 100644 --- a/src/entities/argument/model/__tests__/caseSlice.test.ts +++ b/src/entities/argument/model/__tests__/caseSlice.test.ts @@ -5,6 +5,7 @@ import { ArgumentType } from "#/entities/argument/model/argumentObject"; import { caseSlice, selectCaseArguments, + selectCaseIsEdited, } from "#/entities/argument/model/caseSlice"; import { callstackSlice } from "#/features/callstack/model/callstackSlice"; import { rootReducer } from "#/store/rootReducer"; @@ -76,4 +77,40 @@ describe("caseSlice selectors", () => { expect(secondArgs).not.toBe(firstArgs); expect(secondArgs).toEqual(firstArgs); }); + + it("updateArgumentLabel trims and clears blank labels", () => { + const store = configureStore({ reducer: rootReducer }); + + store.dispatch( + caseSlice.actions.setArguments({ + projectId: "project-1", + caseId: "case-1", + data: [ + { + name: "arg-id", + order: 0, + type: ArgumentType.ARRAY, + input: "[1]", + }, + ], + }), + ); + + store.dispatch( + caseSlice.actions.updateArgumentLabel({ + name: "arg-id", + label: " nums ", + }), + ); + expect(selectCaseArguments(store.getState())[0]?.label).toBe("nums"); + + store.dispatch( + caseSlice.actions.updateArgumentLabel({ + name: "arg-id", + label: " ", + }), + ); + expect(selectCaseArguments(store.getState())[0]?.label).toBeUndefined(); + expect(selectCaseIsEdited(store.getState())).toBe(true); + }); }); diff --git a/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts b/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts new file mode 100644 index 00000000..0f1c95f1 --- /dev/null +++ b/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import { arrayStructureSlice } from "#/entities/dataStructures/array/model/arraySlice"; + +describe("arrayStructureSlice.create displayLabel", () => { + it("persists displayLabel from runtime addArray options", () => { + const state = arrayStructureSlice.reducer( + {}, + arrayStructureSlice.actions.create({ + name: "runtime-array", + data: { + argType: ArgumentType.ARRAY, + options: { displayLabel: "array" }, + }, + }), + ); + + expect(state["runtime-array"]?.displayLabel).toBe("array"); + expect(state["runtime-array"]?.isRuntime).toBe(true); + }); + + it("omits displayLabel when options are absent", () => { + const state = arrayStructureSlice.reducer( + {}, + arrayStructureSlice.actions.create({ + name: "runtime-array", + data: { argType: ArgumentType.ARRAY }, + }), + ); + + expect(state["runtime-array"]?.displayLabel).toBeUndefined(); + }); +}); diff --git a/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts b/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts new file mode 100644 index 00000000..d00b4603 --- /dev/null +++ b/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import type { ArgumentObject } from "#/entities/argument/model/types"; +import { BinaryTreeNode } from "#/entities/dataStructures/binaryTree/model/binaryTreeNode"; +import type { TreeData } from "#/entities/dataStructures/node/model/nodeSlice"; +import { CallstackHelper } from "#/features/callstack/model/callstackSlice"; +import { createCaseRuntimeArgs } from "#/features/codeRunner/lib/createCaseRuntimeArgs"; +import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; +import { + globalDefinitionsPrefix, + setGlobalRuntimeContext, +} from "#/features/codeRunner/lib/setGlobalRuntimeContext"; + +type AddArrayFrame = { + name: "addArray"; + args: { options?: { displayLabel?: string } }; +}; + +const isAddArrayFrame = ( + frame: { name: string }, +): frame is AddArrayFrame => frame.name === "addArray"; + +const runInstrumentedSolution = ( + solutionBody: string, + invokeArgs: unknown[] = [], + paramList = "", +): { callstack: CallstackHelper; result: unknown } => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const code = `return function solve(${paramList}) {\n${solutionBody}\n};`; + const { code: instrumented } = instrumentUserJsForLineTracking(code); + const run = new Function(`${globalDefinitionsPrefix}\n${instrumented}`) as () => ( + ...args: unknown[] + ) => unknown; + + callstack.clear(); + const result = run()(...invokeArgs); + return { callstack, result }; +}; + +const addArrayFramesWithLabel = ( + callstack: CallstackHelper, + displayLabel: string, +) => + callstack.frames.filter( + (frame): frame is AddArrayFrame => + isAddArrayFrame(frame) && + frame.args.options?.displayLabel === displayLabel, + ); + +describe("array displayLabel runtime", () => { + it("records displayLabel when __dstructArrayLiteralWithName runs", () => { + const { callstack } = runInstrumentedSolution(` + const nums = [1, 2, 3]; + return nums; + `); + + expect(addArrayFramesWithLabel(callstack, "nums")).toHaveLength(1); + }); + + it("inherits displayLabel on array.map result", () => { + const { callstack } = runInstrumentedSolution(` + const array = [1, 2]; + return array.map((value) => value * 10); + `); + + expect(addArrayFramesWithLabel(callstack, "array").length).toBeGreaterThan( + 1, + ); + }); + + it("does not emit addArray for plain [] when transform is bypassed", () => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const run = new Function( + `${globalDefinitionsPrefix} +return function solve() { + const array = []; + array.push(1); + return array; +};`, + ) as () => () => unknown; + + callstack.clear(); + run()(); + + expect(callstack.frames.some(isAddArrayFrame)).toBe(false); + }); + + it("tracks getLevels-style nested arrays when instrumented", () => { + const treeStore = buildBinaryTreeFixture(); + const caseArgs: ArgumentObject[] = [ + { + name: "head", + type: ArgumentType.BINARY_TREE, + order: 0, + input: "", + }, + ]; + const setupCallstack = new CallstackHelper(); + const head = createCaseRuntimeArgs( + setupCallstack, + treeStore, + {}, + caseArgs, + )[0] as BinaryTreeNode; + + const { callstack, result } = runInstrumentedSolution( + ` + const array = []; + const dfs = (node, depth = 0) => { + array[depth] ??= []; + array[depth].push(node.val); + node.setColor("green"); + if (node.left) dfs(node.left, depth + 1); + if (node.right) dfs(node.right, depth + 1); + }; + dfs(head); + return array.map((values) => + values.reduce((sum, value) => sum + value, 0) / values.length, + ); + `, + [head], + "head", + ); + + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + expect(addArrayFramesWithLabel(callstack, "array").length).toBeGreaterThan( + 0, + ); + expect(callstack.frames.some((frame) => frame.name === "addArrayItem")).toBe( + true, + ); + }); +}); + +function buildBinaryTreeFixture(): { head: TreeData } { + const rootId = "root"; + const leftId = "left"; + const rightId = "right"; + + return { + head: { + type: ArgumentType.BINARY_TREE, + order: 0, + maxDepth: 1, + rootId, + edges: { ids: [], entities: {} }, + initialEdges: null, + hiddenNodes: { ids: [], entities: {} }, + initialNodes: null, + isRuntime: false, + nodes: { + ids: [rootId, leftId, rightId], + entities: { + [rootId]: { + id: rootId, + value: 1, + depth: 0, + argType: ArgumentType.BINARY_TREE, + childrenIds: [leftId, rightId], + x: 0, + y: 0, + }, + [leftId]: { + id: leftId, + value: 3, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + [rightId]: { + id: rightId, + value: 2, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + }, + }, + }, + }; +} diff --git a/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts b/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts deleted file mode 100644 index 32be3360..00000000 --- a/src/features/codeRunner/lib/__tests__/getLevelsArrayTracking.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ArgumentType } from "#/entities/argument/model/argumentObject"; -import type { ArgumentObject } from "#/entities/argument/model/types"; -import { BinaryTreeNode } from "#/entities/dataStructures/binaryTree/model/binaryTreeNode"; -import type { TreeData } from "#/entities/dataStructures/node/model/nodeSlice"; -import { CallstackHelper } from "#/features/callstack/model/callstackSlice"; -import { createCaseRuntimeArgs } from "#/features/codeRunner/lib/createCaseRuntimeArgs"; -import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; -import { - globalDefinitionsPrefix, - setGlobalRuntimeContext, -} from "#/features/codeRunner/lib/setGlobalRuntimeContext"; - -function buildBinaryTreeStore(): { head: TreeData } { - const rootId = "root"; - const leftId = "left"; - const rightId = "right"; - const leftLeftId = "left-left"; - - return { - head: { - type: ArgumentType.BINARY_TREE, - order: 0, - maxDepth: 2, - rootId, - edges: { ids: [], entities: {} }, - initialEdges: null, - hiddenNodes: { ids: [], entities: {} }, - initialNodes: null, - isRuntime: false, - nodes: { - ids: [rootId, leftId, rightId, leftLeftId], - entities: { - [rootId]: { - id: rootId, - value: 1, - depth: 0, - argType: ArgumentType.BINARY_TREE, - childrenIds: [leftId, rightId], - x: 0, - y: 0, - }, - [leftId]: { - id: leftId, - value: 3, - depth: 1, - argType: ArgumentType.BINARY_TREE, - childrenIds: [leftLeftId, ""], - x: 0, - y: 0, - }, - [rightId]: { - id: rightId, - value: 2, - depth: 1, - argType: ArgumentType.BINARY_TREE, - childrenIds: ["", ""], - x: 0, - y: 0, - }, - [leftLeftId]: { - id: leftLeftId, - value: 4, - depth: 2, - argType: ArgumentType.BINARY_TREE, - childrenIds: ["", ""], - x: 0, - y: 0, - }, - }, - }, - }, - }; -} - -const getLevelsCode = `/** - * @param {TreeNodeObject} head - */ -return function getLevels(head) { - const array = []; - - const dfs = (node, depth = 0) => { - array[depth] ??= []; - array[depth].push(node.val); - - node.setColor("green"); - - node.left && dfs(node.left, depth + 1); - node.right && dfs(node.right, depth + 1); - }; - - dfs(head); - - return array.map((values) => values.reduce((acc, curr) => acc + curr, 0) / values.length); -};`; - -describe("getLevels array tracking", () => { - it("emits addArray frames and displayLabel when instrumented", () => { - const callstack = new CallstackHelper(); - setGlobalRuntimeContext(callstack); - - const treeStore = buildBinaryTreeStore(); - const caseArgs: ArgumentObject[] = [ - { - name: "head", - type: ArgumentType.BINARY_TREE, - order: 0, - input: "", - }, - ]; - - const args = createCaseRuntimeArgs(callstack, treeStore, {}, caseArgs); - expect(args[0]).toBeInstanceOf(BinaryTreeNode); - - const { code: instrumented, ok } = - instrumentUserJsForLineTracking(getLevelsCode); - expect(ok).toBe(true); - - const prefixedCode = `${globalDefinitionsPrefix}\n${instrumented}`; - const run = new Function(prefixedCode) as () => ( - head: BinaryTreeNode, - ) => number[]; - callstack.clear(); - const result = run()(args[0] as BinaryTreeNode); - - expect(result).toHaveLength(3); - const addArrayFrames = callstack.frames.filter( - (frame) => frame.name === "addArray", - ); - expect(addArrayFrames.length).toBeGreaterThan(0); - const labeledFrame = addArrayFrames.find( - (frame) => - frame.name === "addArray" && - frame.args.options?.displayLabel === "array", - ); - expect(labeledFrame).toBeDefined(); - }); - - it("does not emit addArray for plain [] without instrumentation", () => { - const callstack = new CallstackHelper(); - setGlobalRuntimeContext(callstack); - - const treeStore = buildBinaryTreeStore(); - const caseArgs: ArgumentObject[] = [ - { - name: "head", - type: ArgumentType.BINARY_TREE, - order: 0, - input: "", - }, - ]; - const args = createCaseRuntimeArgs(callstack, treeStore, {}, caseArgs); - - const plainCode = `return function getLevels(head) { - const array = []; - const dfs = (node, depth = 0) => { - array[depth] ??= []; - array[depth].push(node.val); - node.setColor("green"); - if (node.left) dfs(node.left, depth + 1); - if (node.right) dfs(node.right, depth + 1); - }; - dfs(head); - return array.map((values) => values.reduce((acc, curr) => acc + curr, 0) / values.length); -};`; - - const prefixedCode = `${globalDefinitionsPrefix}\n${plainCode}`; - const run = new Function(prefixedCode) as () => ( - head: BinaryTreeNode, - ) => number[]; - callstack.clear(); - run()(args[0] as BinaryTreeNode); - - const addArrayFrames = callstack.frames.filter( - (frame) => frame.name === "addArray", - ); - expect(addArrayFrames).toHaveLength(0); - }); -}); diff --git a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts index dab9b686..7909e094 100644 --- a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts +++ b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts @@ -3,28 +3,16 @@ import { describe, expect, it } from "vitest"; import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; describe("instrumentUserJsForLineTracking", () => { - it("injects globalThis.__dstructSetExecutionSource before statements in returned function", () => { + it("injects line probes before statements in the returned solution function", () => { const code = `return function sum(a) { return a + 1; };`; const { code: out, ok } = instrumentUserJsForLineTracking(code); expect(ok).toBe(true); - expect(out).toContain("__dstructSetExecutionSource"); expect(out).toContain("globalThis.__dstructSetExecutionSource"); }); - it("instruments nested function bodies", () => { - const code = `return function outer() { - function inner() { - return 1; - } - return inner(); -};`; - const { ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - }); - - it("skips line probes without return function template but still transforms array literals", () => { + it("still transforms array literals when line probes cannot run", () => { const code = `function run() { const a = [1, 2]; return a; @@ -36,7 +24,7 @@ return run;`; expect(out).not.toContain("__dstructSetExecutionSource"); }); - it("does not instrument top-level functions outside the returned solution", () => { + it("does not instrument top-level helpers outside the returned solution", () => { const code = `function helper() { return 1; } @@ -57,24 +45,4 @@ return function main() { expect(mainIdx).toBeGreaterThan(helperIdx); expect(firstProbeAfterHelper).toBeGreaterThan(mainIdx); }); - - it("instruments return arrow function with block body", () => { - const code = `return () => { - const n = 1; - return n; -};`; - const { code: out, ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - expect(out).toContain("globalThis.__dstructSetExecutionSource"); - }); - - it("replaces array literals with __dstructArrayLiteralWithName when bound to a variable", () => { - const code = `return function f() { - const a = [1, 2]; - return a; -};`; - const { code: out, ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - expect(out).toContain('__dstructArrayLiteralWithName("a", 1, 2)'); - }); }); diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index 2316e31a..a2323337 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -9,7 +9,10 @@ import type { } from "@babel/types"; import { describe, expect, it } from "vitest"; -import { transformArrayLiteralsInSolution } from "#/features/codeRunner/lib/transformJsArrayLiterals"; +import { + transformArrayLiteralsInProgram, + transformArrayLiteralsInSolution, +} from "#/features/codeRunner/lib/transformJsArrayLiterals"; const findSolution = ( ast: File, @@ -151,3 +154,19 @@ describe("transformArrayLiteralsInSolution", () => { expect(out).not.toContain("__dstructArrayLiteral(1, 2)"); }); }); + +describe("transformArrayLiteralsInProgram", () => { + it("rewrites literals in any function when solution template is absent", () => { + const code = `function run() { + const rows = [1, 2]; + return rows; +}`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + transformArrayLiteralsInProgram(ast); + const out = generate(ast).code; + expect(out).toContain('__dstructArrayLiteralWithName("rows", 1, 2)'); + }); +}); diff --git a/src/packages/dstruct-runner/python/test_exec.py b/src/packages/dstruct-runner/python/test_exec.py index 2dfed4f8..c67f9f9c 100644 --- a/src/packages/dstruct-runner/python/test_exec.py +++ b/src/packages/dstruct-runner/python/test_exec.py @@ -80,6 +80,23 @@ def run(): ] self.assertGreaterEqual(len(read_frames), 3) + def test_inline_list_literal_infers_display_label(self) -> None: + code = """ +def solve(): + values = [1, 2] + return len(values) +""" + result = safe_exec(code, None) + self.assertIsNone(result["error"]) + labeled = [ + frame + for frame in result["callstack"] + if frame.get("name") == "addArray" + and frame.get("args", {}).get("options", {}).get("displayLabel") + == "values" + ] + self.assertEqual(len(labeled), 1) + def test_inline_dict_literal_emits_map_frames(self) -> None: code = """ def solve():