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:
parent
67384a7dd7
commit
7848c845a2
3 changed files with 190 additions and 0 deletions
117
src/approve-gate.ts
Normal file
117
src/approve-gate.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
49
tests/unit/approve-gate.test.ts
Normal file
49
tests/unit/approve-gate.test.ts
Normal 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;');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue