fix(minecraft): semicolon now works in single line

Extract normalizeReplCode/rewriteTrailingExpressionToReturn from Brain to new repl-code-normalizer module, add test verifying single-line statements with trailing expressions return values (const nearestLog = [...]; nearestLog)
This commit is contained in:
Rin
2026-02-18 11:14:42 +08:00
committed by Neko Ayaka
parent 3f059527c3
commit cbcd23597f
4 changed files with 143 additions and 40 deletions
@@ -131,6 +131,15 @@ inv;
expect(result.returnValue).toContain('oak_sapling')
})
it('returns trailing expression values from single-line statements', async () => {
const brain: any = new Brain(createDeps('await skip()'))
const result = await brain.executeDebugRepl('const nearestLog = [{ name: "oak_log" }]; nearestLog')
expect(result.error).toBeUndefined()
expect(result.returnValue).toContain('oak_log')
})
it('queues exactly one synthetic follow-up on no-action result', async () => {
const brain: any = new Brain(createDeps('1 + 1'))
const enqueueSpy = vi.fn(async () => undefined)
@@ -26,6 +26,7 @@ import {
toErrorMessage,
} from './llmlogic'
import { generateBrainSystemPrompt } from './prompts/brain-prompt'
import { normalizeReplScript } from './repl-code-normalizer'
import { createCancellationToken } from './task-state'
interface BrainDeps {
@@ -507,46 +508,7 @@ export class Brain {
}
private normalizeReplCode(code: string): string {
// Simple top-level var persistence for REPL UX:
// const/let/var foo = ... -> globalThis.foo = ...
const normalized = code.replace(
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]+?)\s*(?:;\s*)?$/gm,
(_full: string, name: string, valueExpr: string) => `globalThis.${name} = ${valueExpr}`,
)
return this.rewriteTrailingExpressionToReturn(normalized)
}
private rewriteTrailingExpressionToReturn(code: string): string {
if (/\breturn\b/.test(code))
return code
const lines = code.split('\n')
let lastIndex = -1
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim().length > 0) {
lastIndex = i
break
}
}
if (lastIndex < 0)
return code
const rawLine = lines[lastIndex]
const trimmedLine = rawLine.trim()
if (trimmedLine.length === 0 || trimmedLine.startsWith('//'))
return code
if (trimmedLine === '}' || trimmedLine.endsWith('{'))
return code
const expression = trimmedLine.replace(/;$/, '').trim()
const blocked = /^(?:if|for|while|switch|const|let|var|function|class|try|catch|finally|return|throw|import|export)\b/
if (blocked.test(expression))
return code
const indent = rawLine.match(/^\s*/)?.[0] ?? ''
lines[lastIndex] = `${indent}return (${expression})`
return lines.join('\n')
return normalizeReplScript(code)
}
private toDebugReplActions(actions: Array<{
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { normalizeReplScript } from './repl-code-normalizer'
describe('normalizeReplScript', () => {
it('rewrites top-level variable declarations and trailing expression', () => {
const normalized = normalizeReplScript('const nearestLog = query.blocks().first(); nearestLog')
expect(normalized).toContain('globalThis.nearestLog = query.blocks().first();')
expect(normalized).toContain('return (nearestLog)')
expect(normalized).not.toContain('return (globalThis.nearestLog = query.blocks().first(); nearestLog)')
})
it('does not rewrite trailing expression when explicit return exists', () => {
const normalized = normalizeReplScript(`
const inv = [{ name: 'oak_log' }]
return inv
`)
expect(normalized).toContain('globalThis.inv = [{ name: \'oak_log\' }];')
expect(normalized).toContain('return inv')
expect(normalized).not.toContain('return (inv)')
})
it('leaves scripts unchanged when parser reports diagnostics', () => {
const script = 'const broken = ;'
expect(normalizeReplScript(script)).toBe(script)
})
it('keeps non-expression trailing statements without implicit return', () => {
const normalized = normalizeReplScript(`
const x = 1
if (x > 0) {
x
}
`)
expect(normalized).toContain('globalThis.x = 1;')
expect(normalized).not.toContain('return (')
})
})
@@ -0,0 +1,91 @@
import ts from 'typescript'
interface TextReplacement {
start: number
end: number
value: string
}
function getNodeText(sourceFile: ts.SourceFile, node: ts.Node): string {
const start = node.getStart(sourceFile, false)
const end = node.getEnd()
return sourceFile.text.slice(start, end)
}
function getVariableKeyword(flags: ts.NodeFlags): 'const' | 'let' | 'var' {
if (flags & ts.NodeFlags.Const)
return 'const'
if (flags & ts.NodeFlags.Let)
return 'let'
return 'var'
}
function normalizeVariableStatement(sourceFile: ts.SourceFile, statement: ts.VariableStatement): string {
const keyword = getVariableKeyword(statement.declarationList.flags)
const fragments: string[] = []
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
const initializer = declaration.initializer ? getNodeText(sourceFile, declaration.initializer) : 'undefined'
fragments.push(`globalThis.${declaration.name.text} = ${initializer};`)
continue
}
const declarationText = getNodeText(sourceFile, declaration)
fragments.push(`${keyword} ${declarationText};`)
}
return fragments.join('\n')
}
function applyReplacements(code: string, replacements: TextReplacement[]): string {
if (replacements.length === 0)
return code
let output = code
const sorted = [...replacements].sort((a, b) => b.start - a.start)
for (const replacement of sorted) {
output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`
}
return output
}
export function normalizeReplScript(code: string): string {
const sourceFile = ts.createSourceFile('repl.ts', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const diagnostics = (sourceFile as ts.SourceFile & {
parseDiagnostics?: readonly ts.DiagnosticWithLocation[]
}).parseDiagnostics
if (diagnostics && diagnostics.length > 0)
return code
const replacements: TextReplacement[] = []
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement))
continue
const start = statement.getStart(sourceFile, false)
const end = statement.getEnd()
replacements.push({
start,
end,
value: normalizeVariableStatement(sourceFile, statement),
})
}
const hasTopLevelReturn = sourceFile.statements.some(statement => ts.isReturnStatement(statement))
if (!hasTopLevelReturn && sourceFile.statements.length > 0) {
const lastStatement = sourceFile.statements[sourceFile.statements.length - 1]
if (ts.isExpressionStatement(lastStatement)) {
const expressionText = getNodeText(sourceFile, lastStatement.expression)
replacements.push({
start: lastStatement.getStart(sourceFile, false),
end: lastStatement.getEnd(),
value: `return (${expressionText})`,
})
}
}
return applyReplacements(code, replacements)
}