Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions oracle-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Persistent Oracle Server for Go fuzz testing framework.
*
* Unlike cross-validate.ts which handles a single request and exits,
* this server stays alive and handles multiple requests via line-delimited JSON.
*
* Protocol:
* - Each line of stdin is a JSON request: {"oldTree": ..., "diff": ...}
* - Each request gets a JSON response on stdout: {"html": ..., "tree": ..., "error": ...}
* - Server exits when stdin closes
*
* This reduces per-request overhead from ~300ms (process spawn) to ~20-50ms (IPC only).
*/

const readline = require("readline");
const { TreeRenderer } = require("./dist/state/tree-renderer");
const { createLogger } = require("./dist/utils/logger");

// Create a silent logger to avoid console output
const logger = createLogger({ level: "error" });

// Create readline interface for line-by-line processing
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
});

// Process each line as a separate request
rl.on("line", (line) => {
try {
const data = JSON.parse(line);

// Create a fresh renderer for each request to avoid state leakage
const renderer = new TreeRenderer(logger);

// Apply the old tree first (initial state)
if (data.oldTree) {
renderer.applyUpdate(data.oldTree);
}

// Apply the diff
const result = renderer.applyUpdate(data.diff);

// Get the current tree state from renderer
const finalTree = renderer.getTreeState();

const output = {
html: result.html,
tree: finalTree,
error: null,
};

// Write response as single line
console.log(JSON.stringify(output));
} catch (err) {
const output = {
html: "",
tree: null,
error: err instanceof Error ? err.message : String(err),
};
console.log(JSON.stringify(output));
}
});

// Handle stdin close
rl.on("close", () => {
process.exit(0);
});

// Handle errors gracefully - use stdout to maintain JSON protocol
// Exit after uncaught exception as process state may be corrupted
process.on("uncaughtException", (err) => {
console.log(JSON.stringify({ html: "", tree: null, error: err.message }));
rl.close();
process.exit(1);
});
120 changes: 105 additions & 15 deletions state/tree-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,81 @@ function isRangeNode(node: any): boolean {
);
}

/**
* Checks if a node or any of its nested children contains a range structure.
*
* This is needed for detecting structure transitions like:
* - {{if .ShowList}}<ul>{{range .Items}}...{{end}}</ul>{{else}}<p>Hidden</p>{{end}}
*
* When ShowList changes from true to false, the outer node doesn't have `d` directly,
* but its child at position "0" does. We need to detect this nested range to know
* that a full replacement is required instead of a merge.
*
* @param node - The tree node to check
* @returns true if this node or any nested child has a range structure
*/
function hasRangeAnywhere(node: any, depth = 0): boolean {
// Depth limit to prevent potential stack overflow on deeply nested or malformed trees
const MAX_DEPTH = 50;
if (depth > MAX_DEPTH || node == null || typeof node !== "object" || Array.isArray(node)) {
return false;
}

// Check if this node itself is a range
if (isRangeNode(node)) {
return true;
}

// Check numeric keys (dynamic positions) for nested ranges
for (const key of Object.keys(node)) {
if (/^\d+$/.test(key)) {
const child = node[key];
if (child != null && typeof child === "object" && !Array.isArray(child)) {
if (hasRangeAnywhere(child, depth + 1)) {
return true;
}
}
}
}

return false;
}

/**
* Determines if a structure transition requires full replacement instead of merge.
*
* This handles cases where:
* 1. Old structure has a range (directly or nested) but new structure doesn't
* 2. New structure has statics (indicating a complete structure definition)
* 3. Old structure has dynamics that new structure doesn't have
*
* @param existing - The existing tree node
* @param update - The update tree node
* @returns true if the update should fully replace existing instead of merging
*/
function shouldFullReplace(existing: any, update: any): boolean {
// If update doesn't have statics, it's a partial update, not a replacement
if (!update.s || !Array.isArray(update.s)) {
return false;
}

// Check for range→non-range transition (including nested ranges)
if (hasRangeAnywhere(existing) && !hasRangeAnywhere(update)) {
return true;
}

// Check if existing has dynamics that update doesn't have
for (const key of Object.keys(existing)) {
if (/^\d+$/.test(key) && !(key in update)) {
// Existing has a dynamic position that update doesn't
// If update has statics, this is a structure change
return true;
}
}

return false;
}

/**
* Handles tree state management and HTML reconstruction logic for LiveTemplate.
*/
Expand Down Expand Up @@ -132,14 +207,17 @@ export class TreeRenderer {
return update;
}

// Detect range→non-range transition: when existing has a range structure
// but update does NOT, we must do a full replacement instead of merge.
// Otherwise, the old range items would be preserved and rendered with
// the new (else clause) statics, causing wrong content.
// See isRangeNode() for definition of "range" vs "non-range" structures.
if (isRangeNode(existing) && !isRangeNode(update)) {
// Detect structure transitions that require full replacement instead of merge.
// This handles:
// 1. Direct range→non-range: existing has d/s arrays, update doesn't
// 2. Nested range→non-range: existing has range in a child, update doesn't
// 3. Structure changes: existing has dynamics that update doesn't have
//
// Without this check, old range data would be preserved and mixed with
// new statics, causing wrong content or memory leaks.
if (shouldFullReplace(existing, update)) {
this.logger.debug(
`[deepMerge] Range→non-range transition at path ${currentPath}, replacing instead of merging`
`[deepMerge] Structure transition at path ${currentPath}, replacing instead of merging`
);
return update;
}
Expand Down Expand Up @@ -274,10 +352,11 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
currentItems[updateIndex] = {
...currentItems[updateIndex],
...changes,
};
currentItems[updateIndex] = this.mergeRangeItem(
currentItems[updateIndex],
changes,
statePath
);
}
break;
}
Expand Down Expand Up @@ -592,10 +671,11 @@ export class TreeRenderer {
);
const changes = operation[2];
if (updateIndex >= 0 && changes) {
currentItems[updateIndex] = {
...currentItems[updateIndex],
...changes,
};
currentItems[updateIndex] = this.mergeRangeItem(
currentItems[updateIndex],
changes,
statePath || ""
);
}
break;
}
Expand Down Expand Up @@ -815,4 +895,14 @@ export class TreeRenderer {
(item: any) => this.getItemKey(item, statics, statePath) === key
);
}

/**
* Merges changes into a range item using deep merge to preserve statics.
* When the server sends partial updates like {"5": {"0": "new text"}},
* we need to merge this into the existing item's field 5, not replace it.
* Shallow spread would lose the statics ({"s": [...]}) that the client has cached.
*/
private mergeRangeItem(item: any, changes: any, statePath: string): any {
return this.deepMergeTreeNodes(item, changes, `${statePath}.item`);
}
}
145 changes: 145 additions & 0 deletions tests/tree-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,149 @@ describe("TreeRenderer", () => {
expect(elseResult.html).toContain("<p>No items available</p>");
});
});

describe("render - NESTED range to non-range transition", () => {
/**
* This tests the case where a range is nested inside another structure:
*
* Template:
* {{if .ShowList}}
* <ul>
* {{range .Items}}
* <li id="{{.ID}}">{{.Text}}</li>
* {{end}}
* </ul>
* {{else}}
* <p>List is hidden</p>
* {{end}}
*
* When ShowList goes from true to false:
* - Position 0 changes from {0: {range...}, s: ["<ul>", "</ul>"]}
* - To {s: ["<p>List is hidden</p>"]}
*
* The nested range at position 0.0 must be removed, not preserved.
*/
it("should replace nested range structure with else clause", () => {
// Initial state: ShowList=true with items
// Structure: position 0 contains a UL wrapper with nested range at 0.0
const initialUpdate = {
s: ["<div>", "</div>"],
0: {
// The if-branch: UL wrapper containing the range
0: {
// The range (nested at position 0.0)
d: [
{ 0: "item-1", 1: "Apple", _k: "item-1" },
{ 0: "item-2", 1: "Banana", _k: "item-2" },
],
s: ['<li id="', '">', "</li>"],
},
s: ["<ul>", "</ul>"], // UL wrapper statics
},
};
const initialResult = renderer.applyUpdate(initialUpdate);

expect(initialResult.html).toContain("<ul>");
expect(initialResult.html).toContain('<li id="item-1">Apple</li>');
expect(initialResult.html).toContain('<li id="item-2">Banana</li>');
expect(initialResult.html).toContain("</ul>");

// Update: ShowList=false, show else clause
// The nested range should be completely replaced
const elseUpdate = {
0: {
// The else-branch: just a paragraph
s: ["<p>List is hidden</p>"],
},
};
const elseResult = renderer.applyUpdate(elseUpdate);

// Verify the tree state doesn't contain orphaned range data
const treeState = renderer.getTreeState();
expect(treeState[0]).not.toHaveProperty("d");
expect(treeState[0][0]).toBeUndefined(); // Nested range should be gone

// Should NOT contain old items
expect(elseResult.html).not.toContain("Apple");
expect(elseResult.html).not.toContain("Banana");
expect(elseResult.html).not.toContain("<ul>");
expect(elseResult.html).not.toContain("<li");

// Should contain else content
expect(elseResult.html).toContain("<p>List is hidden</p>");
});

it("should handle transition back from else to nested range", () => {
// Start with else clause (ShowList=false)
const elseUpdate = {
s: ["<div>", "</div>"],
0: {
s: ["<p>List is hidden</p>"],
},
};
renderer.applyUpdate(elseUpdate);

// Transition to ShowList=true with items
const rangeUpdate = {
0: {
0: {
d: [
{ 0: "item-1", 1: "New Item", _k: "item-1" },
],
s: ['<li id="', '">', "</li>"],
},
s: ["<ul>", "</ul>"],
},
};
const rangeResult = renderer.applyUpdate(rangeUpdate);

// Should show the list now
expect(rangeResult.html).toContain("<ul>");
expect(rangeResult.html).toContain('<li id="item-1">New Item</li>');
expect(rangeResult.html).not.toContain("List is hidden");
});

it("should handle deeply nested range to non-range transition", () => {
// Initial: deeply nested structure with range at position 0.0.0
const initialUpdate = {
s: ["<main>", "</main>"],
0: {
s: ["<section>", "</section>"],
0: {
s: ["<div>", "</div>"],
0: {
// Range nested 3 levels deep
d: [
{ 0: "deep-1", 1: "Deep Item 1", _k: "deep-1" },
{ 0: "deep-2", 1: "Deep Item 2", _k: "deep-2" },
],
s: ['<span id="', '">', "</span>"],
},
},
},
};
const initialResult = renderer.applyUpdate(initialUpdate);

expect(initialResult.html).toContain('<span id="deep-1">Deep Item 1</span>');
expect(initialResult.html).toContain('<span id="deep-2">Deep Item 2</span>');

// Replace the entire nested structure with simple content
const simpleUpdate = {
0: {
s: ["<p>No content</p>"],
},
};
const simpleResult = renderer.applyUpdate(simpleUpdate);

// All nested range data should be gone
expect(simpleResult.html).not.toContain("Deep Item");
expect(simpleResult.html).not.toContain("<span");
expect(simpleResult.html).toContain("<p>No content</p>");

// Verify tree state is clean
const treeState = renderer.getTreeState();
expect(treeState[0]).not.toHaveProperty("d");
expect(treeState[0][0]).toBeUndefined();
});
});
Comment on lines +188 to +331
Copy link

Copilot AI Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests validate the shouldFullReplace logic for range-to-non-range transitions, but they don't test the actual bug described in the PR summary. The PR describes a bug where UPDATE operations (the "u" differential operation) on range items lose statics when using shallow spread instead of deep merge. These tests should include a scenario that uses differential operations like ["u", "item-key", {"5": {"0": "new text"}}] to verify that statics are preserved when updating a nested field within a range item. Without this, the core fix in applyDifferentialOpsToRange and applyDifferentialOpsToRangeMap (lines 353-361 and 676-682 in state/tree-renderer.ts) remains untested.

Copilot uses AI. Check for mistakes.
});
Loading