0
0
Fork 0

feat: add 5 application-build & object tools (SP21 coverage, phase 3)

application_build (generate_code/rebuild/clean), check_online_change,
move_object, get_signature_crc, set_exclude_from_build.

API per SP21 ScriptApplication.pyi / ScriptObject.pyi.
Plan: docs/superpowers/plans/2026-06-12-sp21-api-coverage.md (phase 3).
This commit is contained in:
Karstein Phobic Nyvold Kvistad 2026-06-12 13:07:06 +02:00
parent fb7d886a33
commit 44a58ff2bf
7 changed files with 400 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import sys, scriptengine as script_engine, os, traceback
ACTION = "{ACTION}"
try:
print("DEBUG: application_build_action script: Action='%s', Project='%s'" % (ACTION, PROJECT_FILE_PATH))
primary_project = ensure_project_open(PROJECT_FILE_PATH)
action = ACTION.lower().strip()
if action not in ('generate_code', 'rebuild', 'clean'):
raise ValueError("Invalid action '%s'. Must be 'generate_code', 'rebuild' or 'clean'." % ACTION)
target_app = None
try:
target_app = primary_project.active_application
except Exception as e:
print("WARN: Could not get active application: %s. Searching..." % e)
if not target_app:
for child in primary_project.get_children(True):
if hasattr(child, 'is_application') and child.is_application and hasattr(child, 'build'):
target_app = child
break
if not target_app:
raise RuntimeError("No application found in project.")
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
if not hasattr(target_app, action):
raise TypeError("Application '%s' does not support %s()." % (app_name, action))
print("DEBUG: Calling %s() on app '%s'..." % (action, app_name))
getattr(target_app, action)()
print("DEBUG: %s executed." % action)
print("Action: %s" % action)
print("Application: %s" % app_name)
print("SCRIPT_SUCCESS: %s executed for application '%s'. Use get_compile_messages for details." % (action, app_name))
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error executing %s for project %s: %s\n%s" % (ACTION, PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)

View file

@ -0,0 +1,29 @@
import sys, scriptengine as script_engine, os, traceback
try:
print("DEBUG: check_online_change script: Project='%s'" % PROJECT_FILE_PATH)
primary_project = ensure_project_open(PROJECT_FILE_PATH)
target_app = None
try:
target_app = primary_project.active_application
except Exception as e:
print("WARN: Could not get active application: %s" % e)
if not target_app:
raise RuntimeError("No active application found in project.")
app_name = getattr(target_app, 'get_name', lambda: "Unknown")()
if not hasattr(target_app, 'is_online_change_possible'):
raise TypeError("is_online_change_possible is not available on this SP (needs 3.5.10.0+).")
possible = target_app.is_online_change_possible()
print("Application: %s" % app_name)
print("Online Change Possible: %s" % possible)
print("SCRIPT_SUCCESS: Online change check done.")
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error checking online change for project %s: %s\n%s" % (PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)

View file

@ -0,0 +1,38 @@
import sys, scriptengine as script_engine, os, traceback
OBJECT_PATH = "{OBJECT_PATH}"
try:
print("DEBUG: get_signature_crc script: Object='%s', Project='%s'" % (OBJECT_PATH, PROJECT_FILE_PATH))
primary_project = ensure_project_open(PROJECT_FILE_PATH)
if not OBJECT_PATH:
raise ValueError("Object path empty.")
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "target POU")
if not target_object:
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
obj_name = getattr(target_object, 'get_name', lambda: OBJECT_PATH)()
if not hasattr(target_object, 'get_signature_crc'):
raise TypeError("Object '%s' does not support get_signature_crc()." % obj_name)
# Needs a successful build first (compile_project). Parent application
# is found automatically when omitted.
crc = target_object.get_signature_crc()
if crc is None:
raise RuntimeError(
"Signature CRC is None for '%s' -- the application probably has not been "
"built yet. Run compile_project first." % obj_name)
# CRC may be an IronPython long -- print with %s, never json.
print("Object: %s" % obj_name)
print("Signature CRC: %s" % crc)
print("SCRIPT_SUCCESS: Signature CRC read.")
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error reading signature CRC for '%s' in project %s: %s\n%s" % (
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)

View file

@ -0,0 +1,43 @@
import sys, scriptengine as script_engine, os, traceback
OBJECT_PATH = "{OBJECT_PATH}"
NEW_PARENT_PATH = "{NEW_PARENT_PATH}"
NEW_INDEX = {NEW_INDEX}
try:
print("DEBUG: move_object script: Object='%s', NewParent='%s', Index=%s, Project='%s'" % (
OBJECT_PATH, NEW_PARENT_PATH, NEW_INDEX, PROJECT_FILE_PATH))
primary_project = ensure_project_open(PROJECT_FILE_PATH)
if not OBJECT_PATH:
raise ValueError("Object path empty.")
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "object to move")
if not target_object:
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
if NEW_PARENT_PATH:
new_parent = find_object_by_path_robust(primary_project, NEW_PARENT_PATH, "new parent")
if not new_parent:
raise ValueError("New parent not found at path: %s" % NEW_PARENT_PATH)
parent_name = getattr(new_parent, 'get_name', lambda: NEW_PARENT_PATH)()
else:
# Empty parent path = move to project top level.
new_parent = primary_project
parent_name = "<project root>"
target_object.move(new_parent, NEW_INDEX)
primary_project.save()
print("DEBUG: move + save OK")
print("Moved: %s" % OBJECT_PATH)
print("New Parent: %s" % parent_name)
print("Index: %s" % NEW_INDEX)
print("SCRIPT_SUCCESS: Object moved. Project saved.")
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error moving object '%s' in project %s: %s\n%s" % (
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)

View file

@ -0,0 +1,45 @@
import sys, scriptengine as script_engine, os, traceback
OBJECT_PATH = "{OBJECT_PATH}"
EXCLUDE = {EXCLUDE}
try:
print("DEBUG: set_exclude_from_build script: Object='%s', Exclude=%s, Project='%s'" % (
OBJECT_PATH, EXCLUDE, PROJECT_FILE_PATH))
primary_project = ensure_project_open(PROJECT_FILE_PATH)
if not OBJECT_PATH:
raise ValueError("Object path empty.")
target_object = find_object_by_path_robust(primary_project, OBJECT_PATH, "target object")
if not target_object:
raise ValueError("Object not found at path: %s" % OBJECT_PATH)
obj_name = getattr(target_object, 'get_name', lambda: OBJECT_PATH)()
try:
if not target_object.exclude_from_build_is_valid:
raise TypeError("exclude_from_build is not valid for object '%s' (type %s)." % (
obj_name, type(target_object).__name__))
except AttributeError:
print("DEBUG: exclude_from_build_is_valid not available; trying setter directly.")
target_object.exclude_from_build = EXCLUDE
primary_project.save()
effective = "unknown"
try:
effective = str(target_object.effectively_excluded_from_build)
except Exception:
pass
print("Object: %s" % obj_name)
print("Exclude From Build: %s" % EXCLUDE)
print("Effectively Excluded: %s" % effective)
print("SCRIPT_SUCCESS: exclude_from_build set. Project saved.")
sys.exit(0)
except Exception as e:
detailed_error = traceback.format_exc()
error_message = "Error setting exclude_from_build for '%s' in project %s: %s\n%s" % (
OBJECT_PATH, PROJECT_FILE_PATH, e, detailed_error)
print(error_message)
print("SCRIPT_ERROR: %s" % error_message)
sys.exit(1)

View file

@ -2686,6 +2686,135 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
}
);
// ─── Application Build & Object Tools (SP21 coverage phase 3) ────────
// API: SP21 ScriptApplication.pyi / ScriptObject.pyi.
s.tool(
'application_build',
"Runs a build action on the active application: 'generate_code' (full code generation, what F11 does), 'rebuild' (clean + build), or 'clean' (remove compile info for this application). For a plain incremental build use compile_project. Check results with get_compile_messages.",
{
projectFilePath: z.string().describe("Path to the project file."),
action: z.enum(['generate_code', 'rebuild', 'clean']).describe("Build action to run."),
},
async (args: { projectFilePath: string; action: 'generate_code' | 'rebuild' | 'clean' }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'application_build_action',
{ PROJECT_FILE_PATH: escaped, ACTION: args.action },
['ensure_project_open']
);
const result = await executor.executeScript(script, 300_000);
return formatToolResponse(result, `${args.action} executed. Use get_compile_messages for details.`);
}
);
s.tool(
'check_online_change',
"Checks whether an ONLINE CHANGE is currently possible for the active application (app.is_online_change_possible) — i.e. whether download_to_device would do an online change instead of a full download. Read-only.",
{
projectFilePath: z.string().describe("Path to the project file."),
},
async (args: { projectFilePath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'check_online_change',
{ PROJECT_FILE_PATH: escaped },
['ensure_project_open']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
const m = result.output.match(/Online Change Possible:\s*(.+)/);
return { content: [{ type: 'text' as const, text: `Online change possible: ${m ? m[1].trim() : 'unknown'}` }], isError: false };
}
);
s.tool(
'move_object',
"Moves an object to a new parent in the project tree (obj.move) and saves. Pass an empty/omitted newParentPath to move to the project top level.",
{
projectFilePath: z.string().describe("Path to the project file."),
objectPath: z.string().describe("Path of the object to move (e.g. 'Application/MyPOU')."),
newParentPath: z.string().optional().describe("Path of the new parent (e.g. 'Application/Folder1'). Omit for project top level."),
newIndex: z.number().int().optional().describe("Index within the new parent. Default -1 (append)."),
},
async (args: { projectFilePath: string; objectPath: string; newParentPath?: string; newIndex?: number }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'move_object',
{
PROJECT_FILE_PATH: escaped,
OBJECT_PATH: sanitizePouPath(args.objectPath),
NEW_PARENT_PATH: args.newParentPath ? sanitizePouPath(args.newParentPath) : '',
NEW_INDEX: String(args.newIndex ?? -1),
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`Object '${args.objectPath}' moved to '${args.newParentPath || '<project root>'}'. Project saved.`,
escaped,
mirrorCtx
);
}
);
s.tool(
'get_signature_crc',
"Reads the signature CRC of a POU (obj.get_signature_crc) — changes when the POU's public interface changes, useful for API-compatibility checks. Requires a successful build first (compile_project). Read-only.",
{
projectFilePath: z.string().describe("Path to the project file."),
objectPath: z.string().describe("Path of the POU (e.g. 'Application/MyFB')."),
},
async (args: { projectFilePath: string; objectPath: string }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'get_signature_crc',
{ PROJECT_FILE_PATH: escaped, OBJECT_PATH: sanitizePouPath(args.objectPath) },
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
const success = result.success && result.output.includes('SCRIPT_SUCCESS');
if (!success) {
return formatToolResponse(result, '');
}
const m = result.output.match(/Signature CRC:\s*(.+)/);
return { content: [{ type: 'text' as const, text: `${args.objectPath} signature CRC: ${m ? m[1].trim() : 'unknown'}` }], isError: false };
}
);
s.tool(
'set_exclude_from_build',
"Sets or clears the 'Exclude from build' flag on an object (obj.exclude_from_build) and saves. Excluded objects are ignored by the compiler. Note a parent's true value overrides a child's false.",
{
projectFilePath: z.string().describe("Path to the project file."),
objectPath: z.string().describe("Path of the object (e.g. 'Application/TestPOU')."),
exclude: z.boolean().describe("true = exclude from build; false = include."),
},
async (args: { projectFilePath: string; objectPath: string; exclude: boolean }) => {
const escaped = resolvePath(args.projectFilePath, workspaceDir);
const script = scriptManager.prepareScriptWithHelpers(
'set_exclude_from_build',
{
PROJECT_FILE_PATH: escaped,
OBJECT_PATH: sanitizePouPath(args.objectPath),
EXCLUDE: pyBool(args.exclude),
},
['ensure_project_open', 'find_object_by_path']
);
const result = await executor.executeScript(script);
return await formatModifyingResponse(
result,
`exclude_from_build=${args.exclude} set on '${args.objectPath}'. Project saved.`,
escaped,
mirrorCtx
);
}
);
// Extract a JSON block between marker lines and pretty-print it; if no
// markers found, return the raw output. Used by the device tools so the
// agent actually sees the scan results / reachability candidates.

View file

@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import * as path from 'path';
import { ScriptManager } from '../../src/script-manager';
/**
* Script-preparation tests for the SP21-coverage phase 3 application build
* & object tools. No CODESYS required.
*/
describe('E2E Script Preparation — build & object tools (SP21 coverage phase 3)', () => {
const scriptsDir = path.join(__dirname, '..', '..', 'src', 'scripts');
const mgr = new ScriptManager(scriptsDir);
const P = { PROJECT_FILE_PATH: 'C:\\test.project' };
it('application_build_action prepares all three actions', () => {
for (const action of ['generate_code', 'rebuild', 'clean']) {
const script = mgr.prepareScriptWithHelpers(
'application_build_action', { ...P, ACTION: action }, ['ensure_project_open']
);
expect(script).toContain(`ACTION = "${action}"`);
expect(script).toContain('active_application');
expect(script).toContain('SCRIPT_SUCCESS');
}
});
it('check_online_change prepares', () => {
const script = mgr.prepareScriptWithHelpers('check_online_change', P, ['ensure_project_open']);
expect(script).toContain('is_online_change_possible');
expect(script).toContain('SCRIPT_SUCCESS');
});
it('move_object prepares with parent and index', () => {
const script = mgr.prepareScriptWithHelpers(
'move_object',
{ ...P, OBJECT_PATH: 'Application/MyPOU', NEW_PARENT_PATH: 'Application/Folder1', NEW_INDEX: '-1' },
['ensure_project_open', 'find_object_by_path']
);
expect(script).toContain('def find_object_by_path_robust');
expect(script).toContain('NEW_INDEX = -1');
expect(script).toContain('.move(');
expect(script).toContain('SCRIPT_SUCCESS');
});
it('get_signature_crc prepares', () => {
const script = mgr.prepareScriptWithHelpers(
'get_signature_crc',
{ ...P, OBJECT_PATH: 'Application/MyFB' },
['ensure_project_open', 'find_object_by_path']
);
expect(script).toContain('get_signature_crc');
expect(script).toContain('SCRIPT_SUCCESS');
});
it('set_exclude_from_build prepares with flag', () => {
const script = mgr.prepareScriptWithHelpers(
'set_exclude_from_build',
{ ...P, OBJECT_PATH: 'Application/TestPOU', EXCLUDE: 'True' },
['ensure_project_open', 'find_object_by_path']
);
expect(script).toContain('EXCLUDE = True');
expect(script).toContain('exclude_from_build');
expect(script).toContain('SCRIPT_SUCCESS');
});
it('all phase-3 scripts are ASCII-only (IronPython 2.7 constraint)', () => {
const names = [
'application_build_action', 'check_online_change', 'move_object',
'get_signature_crc', 'set_exclude_from_build',
];
for (const name of names) {
const content = mgr.loadTemplate(name);
// eslint-disable-next-line no-control-regex
expect(/^[\x00-\x7F]*$/.test(content), `${name}.py must be ASCII-only`).toBe(true);
}
});
});