Generalizes runApproveGate beyond set_pou_code by adding:
- runApproveGateOp({slug, oldText, newText}) — writes synthetic
before/after files into a tmpdir, spawns 'phobiCS-tui approve' on
them, cleans up the tmpdir on return. Used for ops that don't have
a clean existing-file -> proposed-file mapping (create/delete/rename
/add).
- gateOpForTool({enabled, slug, oldText, newText}) — MCP-tool-shaped
wrapper. Returns null when the op should proceed (gate disabled,
accepted, or no-existing); returns a {content, isError} block-
response otherwise. Lets each tool gate with one if-statement.
Wired into the 9 modifying tools (with --approve-edits on, each one
prompts via the TUI before applying):
- create_pou all-green diff: name + type + language + parent
- create_property all-green diff: name + type + parent FB
- create_method all-green diff: name + return type + parent FB
- create_dut all-green diff: name + DUT type + parent
- create_gvl all-green diff: name + parent + (optional decl)
- create_folder all-green diff: name + parent
- delete_object all-red diff: object + project
- rename_object del+add: old name -> new name
- add_library all-green diff: library name + project
set_pou_code keeps using the existing runApproveGate (which composes
real merged file content via the IMPL_SENTINEL split, giving the
nicest possible diff against the real mirror file).
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import * as fs from 'fs/promises';
|
|
import { composeMergedContent, IMPL_SENTINEL, runApproveGateOp } 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;');
|
|
});
|
|
});
|
|
|
|
describe('runApproveGateOp', () => {
|
|
it('writes before/after files and spawns the TUI with both paths', async () => {
|
|
const captured: { existing?: string; staged?: string } = {};
|
|
const spawnFn = vi.fn(async (existingPath: string, stagedPath: string) => {
|
|
captured.existing = existingPath;
|
|
captured.staged = stagedPath;
|
|
const oldText = await fs.readFile(existingPath, 'utf8');
|
|
const newText = await fs.readFile(stagedPath, 'utf8');
|
|
expect(oldText).toBe('OLD');
|
|
expect(newText).toBe('NEW');
|
|
return { status: 'accepted' as const };
|
|
});
|
|
const result = await runApproveGateOp({
|
|
slug: 'create-Application/MyFB',
|
|
oldText: 'OLD',
|
|
newText: 'NEW',
|
|
spawnFn,
|
|
});
|
|
expect(result.status).toBe('accepted');
|
|
expect(spawnFn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('passes the rejected status through', async () => {
|
|
const spawnFn = vi.fn(async () => ({
|
|
status: 'rejected' as const,
|
|
message: 'nope',
|
|
}));
|
|
const result = await runApproveGateOp({
|
|
slug: 'delete-Foo',
|
|
oldText: 'something',
|
|
newText: '',
|
|
spawnFn,
|
|
});
|
|
expect(result.status).toBe('rejected');
|
|
});
|
|
|
|
it('cleans up the temp dir after the spawn returns', async () => {
|
|
let dirSeen: string | undefined;
|
|
const spawnFn = vi.fn(async (existingPath: string) => {
|
|
dirSeen = existingPath.replace(/[\\/][^\\/]+$/, '');
|
|
// dir should exist while we're in here
|
|
await fs.access(dirSeen);
|
|
return { status: 'accepted' as const };
|
|
});
|
|
await runApproveGateOp({ slug: 'x', oldText: '', newText: 'Y', spawnFn });
|
|
// After: the dir must be gone
|
|
await expect(fs.access(dirSeen!)).rejects.toThrow();
|
|
});
|
|
});
|