0
0
Fork 0

feat(approve-gate): wire set_pou_code through phobiCS-tui when --approve-edits

When --approve-edits is on, set_pou_code:
  1. finds the existing mirror .st for the POU (best-effort glob; no
     match -> skip the gate, proceed)
  2. composes the proposed merged content (decl/impl swapped at the
     IMPL_SENTINEL line, keeping the unchanged half)
  3. writes the proposal to <existing>.staged
  4. spawns 'phobiCS-tui approve <existing> <staged>' with stdio
     inherited so the user sees and answers it
  5. cleans up .staged regardless of outcome

Exit 0 -> accepted -> apply the change. Exit 1 -> user-facing
'rejected' response (isError=false; the user actively said no).
Exit 2 -> error response (isError=true).

When the flag is off (default), set_pou_code runs unchanged -- this
keeps existing scripted flows from regressing.
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-04-28 22:32:39 +02:00
parent 67384a7dd7
commit 7848c845a2
3 changed files with 190 additions and 0 deletions

117
src/approve-gate.ts Normal file
View file

@ -0,0 +1,117 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
export const IMPL_SENTINEL = '(* === IMPLEMENTATION === *)';
export interface SetPouCodeArgs {
declarationCode?: string;
implementationCode?: string;
}
export type GateResult =
| { status: 'accepted' }
| { status: 'rejected'; message: string }
| { status: 'no-existing' }
| { status: 'error'; message: string };
function splitOnSentinel(text: string): { decl: string; impl: string } {
const idx = text.indexOf(IMPL_SENTINEL);
if (idx < 0) {
return { decl: text, impl: '' };
}
const decl = text.slice(0, idx).replace(/\s+$/, '');
const after = text.slice(idx + IMPL_SENTINEL.length);
const impl = after.replace(/^\r?\n/, '');
return { decl, impl };
}
export function composeMergedContent(existing: string, args: SetPouCodeArgs): string {
const { decl, impl } = splitOnSentinel(existing);
const newDecl = args.declarationCode ?? decl;
const newImpl = args.implementationCode ?? impl;
return [newDecl, IMPL_SENTINEL, newImpl].join('\n');
}
async function findMirrorFile(
projectFilePath: string,
pouPath: string
): Promise<string | null> {
const projectDir = path.dirname(path.resolve(projectFilePath));
const mirrorRoot = path.join(projectDir, 'mcp-mirror');
try {
await fs.access(mirrorRoot);
} catch {
return null;
}
const leaf = pouPath.split(/[./]/).pop()!;
const matches: string[] = [];
await collectMatches(mirrorRoot, `${leaf}.st`, matches);
return matches.length === 1 ? matches[0] : null;
}
async function collectMatches(dir: string, leafName: string, out: string[]): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectMatches(full, leafName, out);
} else if (e.isFile() && e.name === leafName) {
out.push(full);
}
}
}
async function spawnApproveTui(existingPath: string, stagedPath: string): Promise<GateResult> {
const tuiBin = path.join(__dirname, 'tui', 'index.js');
return new Promise<GateResult>((resolve) => {
const child = spawn(process.execPath, [tuiBin, 'approve', existingPath, stagedPath], {
stdio: ['inherit', 'inherit', 'inherit'],
});
child.on('exit', (code) => {
if (code === 0) resolve({ status: 'accepted' });
else if (code === 1)
resolve({ status: 'rejected', message: 'User rejected the change in phobiCS-tui.' });
else
resolve({
status: 'error',
message: `phobiCS-tui exited with code ${code}.`,
});
});
});
}
export interface RunGateOpts {
projectFilePath: string;
pouPath: string;
args: SetPouCodeArgs;
spawnFn?: typeof spawnApproveTui;
}
export async function runApproveGate(opts: RunGateOpts): Promise<GateResult> {
const existingPath = await findMirrorFile(opts.projectFilePath, opts.pouPath);
if (!existingPath) {
return { status: 'no-existing' };
}
let existing: string;
try {
existing = await fs.readFile(existingPath, 'utf8');
} catch (err) {
return { status: 'error', message: `read failed: ${(err as Error).message}` };
}
const proposed = composeMergedContent(existing, opts.args);
const stagedPath = `${existingPath}.staged`;
await fs.writeFile(stagedPath, proposed, 'utf8');
try {
const spawnFn = opts.spawnFn ?? spawnApproveTui;
return await spawnFn(existingPath, stagedPath);
} finally {
await fs.rm(stagedPath, { force: true });
}
}

View file

@ -26,6 +26,7 @@ import { inspectProjectFile } from './inspect';
import { parseProfileName } from './detect';
import { decideOpenProjectPreflight } from './preflight';
import { readSelection } from './state-read';
import { runApproveGate } from './approve-gate';
/**
* Classifier for `bump_project_version --level=auto`.
@ -1106,6 +1107,29 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
},
['ensure_project_open', 'find_object_by_path']
);
if (config.approveEdits) {
const gate = await runApproveGate({
projectFilePath: escProjPath,
pouPath: sanPouPath,
args: {
declarationCode: args.declarationCode,
implementationCode: args.implementationCode,
},
});
if (gate.status === 'rejected') {
return {
content: [{ type: 'text' as const, text: gate.message }],
isError: false,
};
}
if (gate.status === 'error') {
return {
content: [{ type: 'text' as const, text: `Approve gate error: ${gate.message}` }],
isError: true,
};
}
// 'accepted' or 'no-existing' → fall through and apply the change.
}
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { composeMergedContent, IMPL_SENTINEL } from '../../src/approve-gate';
const existing = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
'END_VAR',
IMPL_SENTINEL,
'counter := counter + 1;',
].join('\n');
describe('composeMergedContent', () => {
it('overrides only declaration when only declarationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\nEND_VAR',
implementationCode: undefined,
});
expect(out).toContain('counter : DINT := 0;');
expect(out).toContain('counter := counter + 1;');
});
it('overrides only implementation when only implementationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: undefined,
implementationCode: 'counter := counter + 2;',
});
expect(out).toContain('counter : INT := 0;');
expect(out).toContain('counter := counter + 2;');
});
it('overrides both when both are provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM X\nVAR\nEND_VAR',
implementationCode: 'x := 1;',
});
expect(out).toBe(['PROGRAM X', 'VAR', 'END_VAR', IMPL_SENTINEL, 'x := 1;'].join('\n'));
});
it('handles existing files with no sentinel by appending one', () => {
const noSentinel = 'PROGRAM PLC_PRG\nVAR\nEND_VAR';
const out = composeMergedContent(noSentinel, {
declarationCode: undefined,
implementationCode: 'x := 1;',
});
expect(out).toContain(IMPL_SENTINEL);
expect(out).toContain('x := 1;');
});
});