@aryacode/dsh-engineering 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +6 -4
  3. package/package.json +10 -11
  4. package/vendor/ftview/SKILL.md +34 -5
  5. package/vendor/ftview/lib/display-tree.d.ts +99 -0
  6. package/vendor/ftview/lib/display-tree.js +306 -0
  7. package/vendor/ftview/lib/edit/operations.d.ts +85 -1
  8. package/vendor/ftview/lib/edit/operations.js +201 -0
  9. package/vendor/ftview/lib/edit/prepare.d.ts +3 -0
  10. package/vendor/ftview/lib/edit/prepare.js +35 -1
  11. package/vendor/ftview/lib/edit/tools.js +249 -5
  12. package/vendor/ftview/lib/handoff.d.ts +58 -0
  13. package/vendor/ftview/lib/handoff.js +95 -0
  14. package/vendor/ftview/lib/index.d.ts +1 -0
  15. package/vendor/ftview/lib/index.js +3 -1
  16. package/vendor/ftview/lib/schema.d.ts +68 -0
  17. package/vendor/ftview/lib/schema.js +156 -0
  18. package/vendor/ftview/lib/vendor-tools.d.ts +17 -0
  19. package/vendor/ftview/lib/vendor-tools.js +74 -0
  20. package/vendor/ftview/lib/vendor.d.ts +56 -0
  21. package/vendor/ftview/lib/vendor.js +143 -0
  22. package/vendor/l5x/SKILL.md +27 -7
  23. package/vendor/l5x/lib/engine/change-set.d.ts +9 -1
  24. package/vendor/l5x/lib/engine/change-set.js +32 -7
  25. package/vendor/l5x/lib/engine/documentation.d.ts +58 -0
  26. package/vendor/l5x/lib/engine/documentation.js +237 -0
  27. package/vendor/l5x/lib/engine/instructions.d.ts +50 -0
  28. package/vendor/l5x/lib/engine/instructions.js +107 -0
  29. package/vendor/l5x/lib/engine/ladder.d.ts +74 -0
  30. package/vendor/l5x/lib/engine/ladder.js +209 -0
  31. package/vendor/l5x/lib/index.d.ts +1 -0
  32. package/vendor/l5x/lib/index.js +3 -1
  33. package/vendor/l5x/lib/model/routines.js +6 -1
  34. package/vendor/l5x/lib/session.d.ts +20 -0
  35. package/vendor/l5x/lib/session.js +36 -0
  36. package/vendor/l5x/lib/tools/document.d.ts +18 -0
  37. package/vendor/l5x/lib/tools/document.js +119 -0
  38. package/vendor/l5x/lib/tools/drafts.js +109 -3
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Validate display XML against the installed FactoryTalk View schema.
3
+ *
4
+ * The vendor ships `Gfx-ME*.xsd` with Studio. Rather than re-implement an XSD
5
+ * engine, this module asks .NET (through PowerShell) to validate in process,
6
+ * which is exact and needs no third-party package. When the schema or the host
7
+ * is unavailable the caller gets a clear `schema_unavailable` result instead of
8
+ * a silent pass.
9
+ *
10
+ * @module dsh-engineering-ftview/schema
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ /** Where Studio installs its display schemas. */
16
+ const SCHEMA_ROOTS = [
17
+ 'C:/Program Files (x86)/Rockwell Software/RSView Enterprise/Schemas',
18
+ 'C:/Program Files/Rockwell Software/RSView Enterprise/Schemas',
19
+ ];
20
+ /** Coverage note shared by every schema-dependent operation. */
21
+ export const SCHEMA_COVERAGE = {
22
+ complete: false,
23
+ unverified: [
24
+ 'sequence order and occurrence counts are enforced by Studio, not by this check',
25
+ 'Studio import and runtime behaviour',
26
+ 'semantic intent of the logic behind a display',
27
+ ],
28
+ };
29
+ /** The schema directory, or undefined when Studio is not installed here. */
30
+ export function schemaDirectory() {
31
+ const override = process.env.FTVIEW_SCHEMA_DIR;
32
+ if (override !== undefined && override !== '')
33
+ return existsSync(override) ? override : undefined;
34
+ return SCHEMA_ROOTS.find(root => existsSync(root));
35
+ }
36
+ /** Every installed schema version, newest last. */
37
+ export function schemaVersions() {
38
+ const directory = schemaDirectory();
39
+ if (directory === undefined)
40
+ return [];
41
+ const version = (name) => Number.parseInt(name.replace(/\D+/gu, ''), 10);
42
+ return readdirSync(directory)
43
+ .filter(name => /^Gfx-ME\d+\.xsd$/iu.test(name))
44
+ .sort((left, right) => version(left) - version(right));
45
+ }
46
+ /** The declared schema of one display, from `xsi:noNamespaceSchemaLocation`. */
47
+ export function declaredSchema(xml) {
48
+ const match = /xsi:noNamespaceSchemaLocation\s*=\s*"([^"]+)"/u.exec(xml);
49
+ return match?.[1];
50
+ }
51
+ /**
52
+ * Pick the schema file for one document.
53
+ *
54
+ * A display names its schema; if that file is not installed, the newest
55
+ * installed schema is used and the caller is told which one ran.
56
+ *
57
+ * @param xml - Display XML, used only for its declared schema.
58
+ * @returns Absolute schema path and its file name, or undefined.
59
+ */
60
+ export function schemaFor(xml) {
61
+ const directory = schemaDirectory();
62
+ if (directory === undefined)
63
+ return undefined;
64
+ const declared = declaredSchema(xml);
65
+ if (declared !== undefined && existsSync(join(directory, declared)))
66
+ return { path: join(directory, declared), name: declared };
67
+ const versions = schemaVersions();
68
+ if (versions.length === 0)
69
+ return undefined;
70
+ const newest = versions[versions.length - 1];
71
+ return { path: join(directory, newest), name: newest };
72
+ }
73
+ /** PowerShell that validates a batch of XML strings against one schema. */
74
+ const VALIDATOR = `
75
+ $ErrorActionPreference = 'Stop'
76
+ $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
77
+ $schemas = New-Object System.Xml.Schema.XmlSchemaSet
78
+ $null = $schemas.Add('', [string]$payload.schema)
79
+ $results = New-Object System.Collections.ArrayList
80
+ foreach ($item in $payload.items) {
81
+ $messages = New-Object 'System.Collections.Generic.List[string]'
82
+ $settings = New-Object System.Xml.XmlReaderSettings
83
+ $settings.ValidationType = [System.Xml.ValidationType]::Schema
84
+ $settings.Schemas = $schemas
85
+ $settings.ValidationFlags = $settings.ValidationFlags -bor [System.Xml.Schema.XmlSchemaValidationFlags]::ReportValidationWarnings
86
+ $handler = [System.Xml.Schema.ValidationEventHandler]{ param($sender, $eventArgs) $messages.Add("$($eventArgs.Severity): $($eventArgs.Message)") }
87
+ $settings.add_ValidationEventHandler($handler)
88
+ $reader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader([string]$item.xml)), $settings)
89
+ try { while ($reader.Read()) { } } catch { $messages.Add("error: $($_.Exception.Message)") } finally { $reader.Dispose() }
90
+ [void]$results.Add([pscustomobject]@{ file = [string]$item.file; messages = @($messages) })
91
+ }
92
+ $results | ConvertTo-Json -Depth 4 -Compress
93
+ `;
94
+ /**
95
+ * Validate one batch of display documents against the installed schema.
96
+ *
97
+ * @param documents - Documents to check; an empty list runs nothing.
98
+ * @param executable - PowerShell to use; defaults to `powershell.exe`.
99
+ * @returns Findings, whether validation ran, and the schema used.
100
+ */
101
+ export async function validateSchema(documents, executable = 'powershell.exe') {
102
+ if (documents.length === 0)
103
+ return { findings: [], ran: true };
104
+ // Only Studio documents declare a schema. A hand-made <display> file is not
105
+ // one, and validating it against Gfx-ME would report a root element the
106
+ // fixture never claimed to be. Such files are skipped, with a reason.
107
+ const declared = documents.filter(document => declaredSchema(document.xml) !== undefined);
108
+ if (declared.length === 0) {
109
+ return {
110
+ findings: [],
111
+ ran: false,
112
+ unavailable: 'None of the changed documents declares xsi:noNamespaceSchemaLocation, so there is no vendor schema to validate against. Studio exports and authored displays always declare one.',
113
+ };
114
+ }
115
+ const first = declared[0];
116
+ const schema = schemaFor(first.xml);
117
+ if (schema === undefined) {
118
+ return {
119
+ findings: [],
120
+ ran: false,
121
+ unavailable: 'No Gfx-ME*.xsd schema is installed on this host. Install FactoryTalk View Studio, or set FTVIEW_SCHEMA_DIR to the folder that holds Gfx-ME*.xsd.',
122
+ };
123
+ }
124
+ const payload = JSON.stringify({ schema: schema.path, items: declared });
125
+ const encoded = Buffer.from(VALIDATOR, 'utf16le').toString('base64');
126
+ const output = await runPowerShell(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], payload);
127
+ if (output.code !== 0) {
128
+ return {
129
+ findings: [],
130
+ ran: false,
131
+ schema: schema.name,
132
+ unavailable: `The schema validator did not run: ${output.stderr.trim() || `exit ${output.code}`}`,
133
+ };
134
+ }
135
+ const parsed = JSON.parse(output.stdout);
136
+ const findings = [];
137
+ for (const result of Array.isArray(parsed) ? parsed : [parsed]) {
138
+ for (const message of result.messages ?? []) {
139
+ findings.push({ file: result.file, message, error: message.startsWith('Error') || message.startsWith('error:') });
140
+ }
141
+ }
142
+ return { findings, ran: true, schema: schema.name };
143
+ }
144
+ /** Run one process with stdin and collect its output. */
145
+ function runPowerShell(executable, args, stdin) {
146
+ return new Promise(resolvePromise => {
147
+ const child = spawn(executable, args, { windowsHide: true });
148
+ let stdout = '';
149
+ let stderr = '';
150
+ child.stdout?.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
151
+ child.stderr?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
152
+ child.on('error', error => resolvePromise({ code: -1, stdout, stderr: stderr || error.message }));
153
+ child.on('close', code => resolvePromise({ code: code ?? -1, stdout, stderr }));
154
+ child.stdin?.end(stdin, 'utf8');
155
+ });
156
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Tools that reach the installed ME engine.
3
+ *
4
+ * These are the only entry points that talk to Studio's world: listing the
5
+ * applications it knows and exporting one to a folder. Both are read-only with
6
+ * respect to the project.
7
+ *
8
+ * @module dsh-engineering-ftview/vendor-tools
9
+ */
10
+ import { type FsService, type ToolDefinition } from './core.ts';
11
+ /**
12
+ * The vendor tools.
13
+ *
14
+ * @param fs - Filesystem provider used to resolve the destination folder.
15
+ * @returns Tool definitions.
16
+ */
17
+ export declare function vendorTools(fs: FsService): readonly ToolDefinition[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Tools that reach the installed ME engine.
3
+ *
4
+ * These are the only entry points that talk to Studio's world: listing the
5
+ * applications it knows and exporting one to a folder. Both are read-only with
6
+ * respect to the project.
7
+ *
8
+ * @module dsh-engineering-ftview/vendor-tools
9
+ */
10
+ import { existsSync } from 'node:fs';
11
+ import { EngineeringError, object, optionalString, requiredString, string, success, tool } from "./core.js";
12
+ import { exportApplication, listApplications } from "./vendor.js";
13
+ /** What a vendor call cannot prove. */
14
+ const VENDOR_COVERAGE = {
15
+ complete: false,
16
+ unverified: [
17
+ 'the exported XML is a snapshot of the saved application, not of a running terminal',
18
+ 'display compile and download to a PanelView terminal',
19
+ 'Studio import behaviour',
20
+ ],
21
+ };
22
+ /**
23
+ * The vendor tools.
24
+ *
25
+ * @param fs - Filesystem provider used to resolve the destination folder.
26
+ * @returns Tool definitions.
27
+ */
28
+ export function vendorTools(fs) {
29
+ return [
30
+ tool({
31
+ name: 'ftview_apps',
32
+ description: 'List the FactoryTalk View ME applications installed on this host, through the vendor export server. Requires FactoryTalk View ME on the machine.',
33
+ parameters: object({
34
+ filter: string('Case-insensitive substring matched against the application name.'),
35
+ }, []),
36
+ async run(args) {
37
+ const filter = (optionalString(args, 'filter') ?? '').toLowerCase();
38
+ const listed = await listApplications();
39
+ if (listed.error !== undefined) {
40
+ throw new EngineeringError('VENDOR_UNAVAILABLE', listed.error, 'Install FactoryTalk View ME Studio on this host, or work from an existing export folder.');
41
+ }
42
+ const apps = (listed.value ?? []).filter(app => filter === '' || app.name.toLowerCase().includes(filter));
43
+ return success(`${apps.length} ME application(s)${filter === '' ? '' : ` matching ${JSON.stringify(filter)}`} on this host.`, { total: apps.length, apps }, [{ tool: 'ftview_export', args: { application: apps[0]?.name ?? 'NAME', output_root: 'C:/Exports/NAME' }, why: 'Export one application to a folder this surface can read.' }], VENDOR_COVERAGE);
44
+ },
45
+ }),
46
+ tool({
47
+ name: 'ftview_export',
48
+ description: 'Export one installed ME application (displays, global objects, tags) to a NEW folder through the vendor export server. The application is not modified.',
49
+ parameters: object({
50
+ application: string('Application name as ftview_apps lists it.'),
51
+ output_root: string('New folder for the export. It must not exist.'),
52
+ }, ['application', 'output_root']),
53
+ async run(args, exec) {
54
+ const application = requiredString(args, 'application');
55
+ const requested = requiredString(args, 'output_root');
56
+ const destination = await fs.resolve(requested, {
57
+ ...(exec.cwd === undefined ? {} : { cwd: exec.cwd }),
58
+ ...(exec.signal === undefined ? {} : { signal: exec.signal }),
59
+ });
60
+ if (existsSync(destination)) {
61
+ throw new EngineeringError('OUTPUT_EXISTS', `A file or folder already exists at ${requested}.`, 'Pass a new output_root; export never merges into an existing folder.');
62
+ }
63
+ const exported = await exportApplication(application, destination);
64
+ if (exported.error !== undefined) {
65
+ throw new EngineeringError('VENDOR_EXPORT_FAILED', exported.error, 'Check that the application is not open in Studio, then retry with a new output_root.');
66
+ }
67
+ const files = exported.value ?? [];
68
+ const displays = files.filter(file => /\/Displays\/.+\.xml$/iu.test(file) && !/batchimport/iu.test(file));
69
+ const manifests = files.filter(file => /batchimport/iu.test(file));
70
+ return success(`Exported ${application}: ${files.length} file(s), ${displays.length} display(s)${manifests.length === 0 ? '' : `, ${manifests.length} import manifest(s)`}.`, { application, output_root: destination, files, display_count: displays.length, manifests }, [{ tool: 'ftview_open', args: { root: destination }, why: 'Open the fresh export and author against it.' }], VENDOR_COVERAGE);
71
+ },
72
+ }),
73
+ ];
74
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Drive the installed FactoryTalk View ME engine.
3
+ *
4
+ * Studio ships two scriptable COM servers that matter here: `MEExportSvr`
5
+ * (list applications, export displays) and `AppMgrServer` (backup and restore).
6
+ * The export server runs out of process and works from a 64-bit host; the
7
+ * application manager is a 32-bit in-process server, so it is reached through
8
+ * the 32-bit PowerShell. Nothing here edits a project: export reads, backup
9
+ * copies.
10
+ *
11
+ * @module dsh-engineering-ftview/vendor
12
+ */
13
+ /** CLSID of the ME export server (`MEExport.exe`). */
14
+ export declare const EXPORT_CLSID = "9d882577-aa92-466b-a749-6d6b5093b623";
15
+ /** CLSID of the application manager (`ApplicationManagerServer.dll`). */
16
+ export declare const APP_MANAGER_CLSID = "4fead81a-cd72-42da-b659-f1a41499f0f9";
17
+ /** One installed ME application. */
18
+ export interface MeApplication {
19
+ /** Application name as Studio stores it. */
20
+ readonly name: string;
21
+ /** Target platform when the engine reports one. */
22
+ readonly platform?: string;
23
+ /** Version string when the engine reports one. */
24
+ readonly version?: string;
25
+ }
26
+ /** Result of one vendor call. */
27
+ export interface VendorResult<T> {
28
+ /** Parsed value on success. */
29
+ readonly value?: T;
30
+ /** Human-readable failure. */
31
+ readonly error?: string;
32
+ }
33
+ /**
34
+ * List every ME application the engine knows.
35
+ *
36
+ * @returns Applications, or the reason the engine could not be reached.
37
+ */
38
+ export declare function listApplications(): Promise<VendorResult<readonly MeApplication[]>>;
39
+ /**
40
+ * Export one application's displays and globals into a new folder.
41
+ *
42
+ * @param application - Application name as listed.
43
+ * @param destination - Folder to create and fill.
44
+ * @returns Files written, relative to the destination.
45
+ */
46
+ export declare function exportApplication(application: string, destination: string): Promise<VendorResult<readonly string[]>>;
47
+ /**
48
+ * Back one application up to a zip.
49
+ *
50
+ * @param application - Application name.
51
+ * @param zipPath - Destination zip; must not exist.
52
+ * @returns The zip path on success.
53
+ */
54
+ export declare function backupApplication(application: string, zipPath: string): Promise<VendorResult<string>>;
55
+ /** Size of one file, for reports. */
56
+ export declare function fileSize(path: string): number;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Drive the installed FactoryTalk View ME engine.
3
+ *
4
+ * Studio ships two scriptable COM servers that matter here: `MEExportSvr`
5
+ * (list applications, export displays) and `AppMgrServer` (backup and restore).
6
+ * The export server runs out of process and works from a 64-bit host; the
7
+ * application manager is a 32-bit in-process server, so it is reached through
8
+ * the 32-bit PowerShell. Nothing here edits a project: export reads, backup
9
+ * copies.
10
+ *
11
+ * @module dsh-engineering-ftview/vendor
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ import { existsSync, readdirSync, statSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /** CLSID of the ME export server (`MEExport.exe`). */
17
+ export const EXPORT_CLSID = '9d882577-aa92-466b-a749-6d6b5093b623';
18
+ /** CLSID of the application manager (`ApplicationManagerServer.dll`). */
19
+ export const APP_MANAGER_CLSID = '4fead81a-cd72-42da-b659-f1a41499f0f9';
20
+ /** Where the 32-bit PowerShell lives on this host. */
21
+ function powershell32() {
22
+ const path = join(process.env.WINDIR ?? 'C:/Windows', 'SysWOW64/WindowsPowerShell/v1.0/powershell.exe');
23
+ return existsSync(path) ? path : 'powershell.exe';
24
+ }
25
+ /** Run a PowerShell script with a JSON payload on stdin. */
26
+ function runPowerShell(executable, script, payload) {
27
+ const encoded = Buffer.from(script, 'utf16le').toString('base64');
28
+ return new Promise(resolvePromise => {
29
+ const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { windowsHide: true });
30
+ let stdout = '';
31
+ let stderr = '';
32
+ child.stdout?.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
33
+ child.stderr?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
34
+ child.on('error', error => resolvePromise({ code: -1, stdout, stderr: stderr || error.message }));
35
+ child.on('close', code => resolvePromise({ code: code ?? -1, stdout, stderr }));
36
+ child.stdin?.end(JSON.stringify(payload), 'utf8');
37
+ });
38
+ }
39
+ /** Script that lists applications through the export server. */
40
+ const LIST_SCRIPT = `
41
+ $ErrorActionPreference = 'Stop'
42
+ $p = [Console]::In.ReadToEnd() | ConvertFrom-Json
43
+ $t = [Type]::GetTypeFromCLSID([guid]$p.clsid)
44
+ $o = [Activator]::CreateInstance($t)
45
+ $names = @($o.GetMEAppNames())
46
+ $apps = @()
47
+ foreach ($name in $names) {
48
+ $platform = $null; $version = $null
49
+ try { $info = @($o.GetAppInfo([string]$name)); if ($info.Count -gt 0) { $platform = [string]$info[0] }; if ($info.Count -gt 1) { $version = [string]$info[1] } } catch { }
50
+ $apps += [pscustomobject]@{ name = [string]$name; platform = $platform; version = $version }
51
+ }
52
+ [pscustomobject]@{ apps = $apps } | ConvertTo-Json -Depth 4 -Compress
53
+ `;
54
+ /** Script that exports one application's displays. */
55
+ const EXPORT_SCRIPT = `
56
+ $ErrorActionPreference = 'Stop'
57
+ $p = [Console]::In.ReadToEnd() | ConvertFrom-Json
58
+ $t = [Type]::GetTypeFromCLSID([guid]$p.clsid)
59
+ $o = [Activator]::CreateInstance($t)
60
+ $o.LoadMEApp([string]$p.app)
61
+ try { $o.ExportAll([string]$p.dest) } finally { $o.UnloadMEApp() }
62
+ [pscustomobject]@{ ok = $true } | ConvertTo-Json -Compress
63
+ `;
64
+ /** Script that backs one application up to a zip through the application manager. */
65
+ const BACKUP_SCRIPT = `
66
+ $ErrorActionPreference = 'Stop'
67
+ $p = [Console]::In.ReadToEnd() | ConvertFrom-Json
68
+ $t = [Type]::GetTypeFromCLSID([guid]$p.clsid)
69
+ $o = [Activator]::CreateInstance($t)
70
+ $o.BackupLocal([string]$p.app, [string]$p.zip, '', 'ME')
71
+ [pscustomobject]@{ ok = $true } | ConvertTo-Json -Compress
72
+ `;
73
+ /**
74
+ * List every ME application the engine knows.
75
+ *
76
+ * @returns Applications, or the reason the engine could not be reached.
77
+ */
78
+ export async function listApplications() {
79
+ const result = await runPowerShell('powershell.exe', LIST_SCRIPT, { clsid: EXPORT_CLSID });
80
+ if (result.code !== 0)
81
+ return { error: `The ME export server is unavailable: ${result.stderr.trim() || `exit ${result.code}`}` };
82
+ try {
83
+ const parsed = JSON.parse(result.stdout);
84
+ return { value: parsed.apps ?? [] };
85
+ }
86
+ catch {
87
+ return { error: 'The ME export server returned an unreadable answer.' };
88
+ }
89
+ }
90
+ /**
91
+ * Export one application's displays and globals into a new folder.
92
+ *
93
+ * @param application - Application name as listed.
94
+ * @param destination - Folder to create and fill.
95
+ * @returns Files written, relative to the destination.
96
+ */
97
+ export async function exportApplication(application, destination) {
98
+ if (existsSync(destination))
99
+ return { error: `A file or folder already exists at ${destination}. Export into a new folder.` };
100
+ const result = await runPowerShell('powershell.exe', EXPORT_SCRIPT, { clsid: EXPORT_CLSID, app: application, dest: destination });
101
+ if (result.code !== 0)
102
+ return { error: `Export failed: ${result.stderr.trim() || `exit ${result.code}`}` };
103
+ if (!existsSync(destination))
104
+ return { error: 'The engine reported success but wrote no folder.' };
105
+ const files = [];
106
+ const walk = (directory, prefix) => {
107
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
108
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
109
+ if (entry.isDirectory())
110
+ walk(join(directory, entry.name), relative);
111
+ else
112
+ files.push(relative);
113
+ }
114
+ };
115
+ walk(destination, '');
116
+ return { value: files.sort() };
117
+ }
118
+ /**
119
+ * Back one application up to a zip.
120
+ *
121
+ * @param application - Application name.
122
+ * @param zipPath - Destination zip; must not exist.
123
+ * @returns The zip path on success.
124
+ */
125
+ export async function backupApplication(application, zipPath) {
126
+ if (existsSync(zipPath))
127
+ return { error: `A file already exists at ${zipPath}.` };
128
+ const result = await runPowerShell(powershell32(), BACKUP_SCRIPT, { clsid: APP_MANAGER_CLSID, app: application, zip: zipPath });
129
+ if (result.code !== 0)
130
+ return { error: `Backup failed: ${result.stderr.trim() || `exit ${result.code}`}` };
131
+ if (!existsSync(zipPath))
132
+ return { error: 'The application manager reported success but wrote no zip.' };
133
+ return { value: zipPath };
134
+ }
135
+ /** Size of one file, for reports. */
136
+ export function fileSize(path) {
137
+ try {
138
+ return statSync(path).size;
139
+ }
140
+ catch {
141
+ return 0;
142
+ }
143
+ }
@@ -56,12 +56,12 @@ this is a backup on request ("back up this project"). Build it as a copy plus
56
56
 
57
57
  ## Live reads
58
58
 
59
- The `l5x-live` entry reads declared scalar tags straight from a controller over EtherNet/IP, without Studio 5000, FactoryTalk Linx or the SDK. It may be disabled; if `poll_live_tags` is unavailable, say live reads are not enabled rather than trying another tool.
59
+ `l5x-live` reads declared scalar tags over EtherNet/IP with no Studio, FactoryTalk Linx or SDK. If `poll_live_tags` is unavailable, say live reads are not enabled here.
60
60
 
61
- - `poll_live_tags` takes an explicit IPv4 address, a CPU slot and 1 to 16 declared scalar tags (`BOOL`, `INT`, `DINT`, `REAL`), and returns timestamped sweeps, quality and the controller identity.
61
+ - `poll_live_tags` takes an explicit IPv4 address, a CPU slot and 1 to 16 declared scalars, and returns timestamped sweeps, quality and controller identity.
62
62
  - `start_plc_trend` samples the same way as a session-owned job the chart can follow; stop it through the job controls.
63
63
  - These are read-only. Never write controller data, force tags, toggle bits or edit online.
64
- - Report `live_logic_matches_export: "unverified"`: the read proves a value at a moment, not that the controller runs the exported logic, and sequential reads can miss transitions.
64
+ - Report `live_logic_matches_export: "unverified"`: a read proves a value at a moment, not that the controller runs the export.
65
65
 
66
66
  ## Reporting
67
67
 
@@ -81,18 +81,38 @@ The `l5x-live` entry reads declared scalar tags straight from a controller over
81
81
  The `l5x-studio` entry adds offline SDK work; it may be disabled. Call `studio_sdk_status` before using it.
82
82
 
83
83
  - `export_acd_to_l5x` writes one new full L5X from an ACD. No controller contact.
84
- - `verify_offline_l5x` imports a full L5X into a new offline ACD and builds it. Build needs project revision 37 or later; `probe: true` attempts a lower revision and reports the SDK error. A Studio-exported L5X builds; an L5X produced by `export_acd_to_l5x` may be refused by the SDK as "no changes".
85
- - `build_acd_from_draft` imports one changed RLL or ST routine into a new ACD copy, builds it and exports it. The baseline must be an L5X exported from that ACD in this chat, and neither file may change afterwards.
84
+ - `verify_offline_l5x` imports a full L5X into a new offline ACD and builds it. Build needs project revision 37+; `probe: true` tries a lower one and reports the SDK error. An L5X from `export_acd_to_l5x` may be refused as "no changes".
85
+ - `build_acd_from_draft` imports one changed RLL or ST routine into a new ACD copy, builds and exports it. The baseline must be an L5X exported from that ACD in this chat; neither file may change.
86
86
  - `studio_review_evidence` checks program, routine and rung or line names and verbatim quotes before you report findings.
87
- - `controller_approval_status` reports whether this session can ask a human. `capture_controller_snapshot`, `change_controller_mode` and `download_project` each require per-call human approval of the exact controller, route and target, and proceed only on `allowed-once`; `download_project` accepts only a `verification_id` from a passed build in this chat.
87
+ - `controller_approval_status` says whether this session can ask a human. `capture_controller_snapshot`, `change_controller_mode` and `download_project` need per-call approval of the exact target and proceed only on `allowed-once`; `download_project` takes a `verification_id` from a passed build in this chat.
88
88
  - `go_online`, `read_controller_mode` and `read_live_tag` are read-only and need no approval. Never write controller data, force tags, toggle bits or edit online.
89
89
 
90
90
  ## Editing
91
91
 
92
92
  Editing uses the `draft_*` family. It may be disabled in this profile; if a draft tool is unavailable, say editing is not enabled here rather than trying another tool.
93
93
 
94
- - One operation per call: `draft_rung`, `draft_st`, `draft_routine`, `draft_tag`, `draft_tag_description`, `draft_udt_member`, `draft_udt_description`. Each returns the draft id and the operation count.
94
+ - One operation per call: `draft_rung`, `draft_rung_comment`, `draft_st`, `draft_routine`, `draft_tag`, `draft_tag_description`, `draft_udt_member`, `draft_udt_description`.
95
95
  - `draft_validate` prepares the accumulated draft against the project and returns its hash, diff summary and check findings. Read the findings before applying.
96
96
  - `draft_apply` writes the draft to ONE new `.L5X` path. It never overwrites an existing file.
97
97
  - After applying, reopen the written file with `open_project` to review it.
98
98
  - Drafts belong to this chat. Another chat starts with none, and an applied draft is cleared.
99
+
100
+ ## Generate
101
+
102
+ Write ladder through the composer, not by inventing NeutralText.
103
+
104
+ - `list_instructions` gives the mnemonic, category and operand slots with their kinds; read it before composing.
105
+ - `compose_rung` builds one rung from a tree (`spec_json`) or a pattern (`start_stop_latch`, `fault_latch`, `ton_delay`, `interlock`), resolves every operand, type-checks each slot and parses its own output; anything unknown or mistyped is refused before staging.
106
+ - Pass `program`, `routine`, `mode` and `rung` to stage it (plus `comment`); without them it returns text only.
107
+ - `fault_latch` stages two rungs at `rung` and `rung + 1`; rung numbers are not renumbered, so pick free ones.
108
+
109
+ ## Documentation
110
+
111
+ `document_project` writes a handover bundle into a NEW folder and never touches the export:
112
+
113
+ - `PROJECT.md` — controller, revision, tasks, programs, counts;
114
+ - `io.md` / `io.csv` — modules and module-data tags;
115
+ - `tags.csv` and `tags-studio.csv` — every declared tag, and the `Tag Name,Description` shape Studio imports;
116
+ - `rungs/<program>.md` — each rung's number, comment, NeutralText, instructions and resolved tags.
117
+
118
+ The bundle is structure and citations, not prose: write the narrative yourself and use `draft_rung_comment` / `draft_tag_description` when it belongs in the project. No C&E, FAT/SAT or runtime proof.
@@ -5,6 +5,7 @@ type Operation = {
5
5
  readonly routine: string;
6
6
  readonly rung: number;
7
7
  readonly text: string;
8
+ readonly comment?: string;
8
9
  } | {
9
10
  readonly operation: 'delete_rung';
10
11
  readonly program: string;
@@ -17,6 +18,13 @@ type Operation = {
17
18
  readonly rung: number;
18
19
  readonly text: string;
19
20
  readonly before_rung?: number;
21
+ readonly comment?: string;
22
+ } | {
23
+ readonly operation: 'set_rung_comment';
24
+ readonly program: string;
25
+ readonly routine: string;
26
+ readonly rung: number;
27
+ readonly comment: string;
20
28
  } | {
21
29
  readonly operation: 'replace_st';
22
30
  readonly program: string;
@@ -59,7 +67,7 @@ type Operation = {
59
67
  readonly description: string;
60
68
  };
61
69
  /** Compact contract returned with validation errors and embedded in tool descriptions. */
62
- export declare const CHANGE_SET_HELP = "Operations (each also needs \"operation\"):\nreplace_rung {program,routine,rung,text}\ninsert_rung {program,routine,rung,text,before_rung?}\ndelete_rung {program,routine,rung}\nreplace_st {program,routine,code}\nadd_routine {program,name,language:\"RLL\"|\"ST\",code}\nadd_tag {scope,name,data_type,description,value? scalar, dimensions? \"4 2\", external_access? \"Read/Write\"|\"Read Only\"|\"None\"}\nset_tag_description {scope,name,description}\nadd_udt_member {udt,name,data_type,description}\nset_udt_description {udt,description}\nset_udt_member_description {udt,member,description}\nIdentifiers are at most 40 characters. Example: {\"version\":1,\"operations\":[{\"operation\":\"add_tag\",\"scope\":\"controller\",\"name\":\"SP_Start\",\"data_type\":\"REAL\",\"value\":80,\"description\":\"Start threshold\"}]}";
70
+ export declare const CHANGE_SET_HELP = "Operations (each also needs \"operation\"):\nreplace_rung {program,routine,rung,text,comment?}\ninsert_rung {program,routine,rung,text,before_rung?,comment?}\ndelete_rung {program,routine,rung}\nset_rung_comment {program,routine,rung,comment}\nreplace_st {program,routine,code}\nadd_routine {program,name,language:\"RLL\"|\"ST\",code}\nadd_tag {scope,name,data_type,description,value? scalar, dimensions? \"4 2\", external_access? \"Read/Write\"|\"Read Only\"|\"None\"}\nset_tag_description {scope,name,description}\nadd_udt_member {udt,name,data_type,description}\nset_udt_description {udt,description}\nset_udt_member_description {udt,member,description}\nIdentifiers are at most 40 characters. Example: {\"version\":1,\"operations\":[{\"operation\":\"add_tag\",\"scope\":\"controller\",\"name\":\"SP_Start\",\"data_type\":\"REAL\",\"value\":80,\"description\":\"Start threshold\"}]}";
63
71
  /** Limits are resolved by the plugin's configuration before tools call this module. */
64
72
  export interface ChangeSetLimits {
65
73
  readonly operations: number;
@@ -9,12 +9,13 @@ import { uniqueRegion, xmlRegions } from "./xml-regions.js";
9
9
  /** Scalar types the plugin can create with a literal initial value. */
10
10
  const SCALAR_TYPES = new Set(['BOOL', 'SINT', 'INT', 'DINT', 'LINT', 'USINT', 'UINT', 'UDINT', 'ULINT', 'REAL', 'LREAL']);
11
11
  /** Operation names accepted by a version-1 change set. */
12
- const SUPPORTED_OPERATIONS = ['replace_rung', 'insert_rung', 'delete_rung', 'replace_st', 'add_routine', 'add_tag', 'set_tag_description', 'add_udt_member', 'set_udt_description', 'set_udt_member_description'];
12
+ const SUPPORTED_OPERATIONS = ['replace_rung', 'insert_rung', 'delete_rung', 'set_rung_comment', 'replace_st', 'add_routine', 'add_tag', 'set_tag_description', 'add_udt_member', 'set_udt_description', 'set_udt_member_description'];
13
13
  /** Compact contract returned with validation errors and embedded in tool descriptions. */
14
14
  export const CHANGE_SET_HELP = `Operations (each also needs "operation"):
15
- replace_rung {program,routine,rung,text}
16
- insert_rung {program,routine,rung,text,before_rung?}
15
+ replace_rung {program,routine,rung,text,comment?}
16
+ insert_rung {program,routine,rung,text,before_rung?,comment?}
17
17
  delete_rung {program,routine,rung}
18
+ set_rung_comment {program,routine,rung,comment}
18
19
  replace_st {program,routine,code}
19
20
  add_routine {program,name,language:"RLL"|"ST",code}
20
21
  add_tag {scope,name,data_type,description,value? scalar, dimensions? "4 2", external_access? "Read/Write"|"Read Only"|"None"}
@@ -104,17 +105,21 @@ function parseOne(value, declaredTypes) {
104
105
  case 'replace_rung':
105
106
  case 'insert_rung':
106
107
  case 'delete_rung': {
107
- fields(row, row.operation === 'delete_rung' ? ['operation', 'program', 'routine', 'rung'] : row.operation === 'insert_rung' ? ['operation', 'program', 'routine', 'rung', 'text', 'before_rung'] : ['operation', 'program', 'routine', 'rung', 'text']);
108
+ fields(row, row.operation === 'delete_rung' ? ['operation', 'program', 'routine', 'rung'] : row.operation === 'insert_rung' ? ['operation', 'program', 'routine', 'rung', 'text', 'before_rung', 'comment'] : ['operation', 'program', 'routine', 'rung', 'text', 'comment']);
108
109
  const selected = { program: identifier(row, 'program'), routine: identifier(row, 'routine'), rung: integer(row, 'rung') };
109
110
  if (row.operation === 'delete_rung')
110
111
  return { operation: row.operation, ...selected };
111
112
  const source = text(row, 'text');
112
113
  if (parseLadder(source).issues.length > 0)
113
114
  throw new EngineeringError('INVALID_LADDER', 'Rung contains malformed or unsupported NeutralText structure');
115
+ const comment = row.comment === undefined ? {} : { comment: text(row, 'comment') };
114
116
  if (row.operation === 'replace_rung')
115
- return { operation: row.operation, ...selected, text: source };
116
- return { operation: row.operation, ...selected, text: source, ...(row.before_rung === undefined ? {} : { before_rung: integer(row, 'before_rung') }) };
117
+ return { operation: row.operation, ...selected, text: source, ...comment };
118
+ return { operation: row.operation, ...selected, text: source, ...comment, ...(row.before_rung === undefined ? {} : { before_rung: integer(row, 'before_rung') }) };
117
119
  }
120
+ case 'set_rung_comment':
121
+ fields(row, ['operation', 'program', 'routine', 'rung', 'comment']);
122
+ return { operation: row.operation, program: identifier(row, 'program'), routine: identifier(row, 'routine'), rung: integer(row, 'rung'), comment: text(row, 'comment') };
118
123
  case 'replace_st':
119
124
  fields(row, ['operation', 'program', 'routine', 'code']);
120
125
  return { operation: row.operation, program: identifier(row, 'program'), routine: identifier(row, 'routine'), code: text(row, 'code') };
@@ -314,7 +319,7 @@ function applyOperation(xml, operation, context) {
314
319
  if (operation.operation === 'insert_rung') {
315
320
  if (targets.length !== 0)
316
321
  throw new EngineeringError('NAME_COLLISION', 'Rung number already exists; no automatic renumbering is performed');
317
- const newRung = `<Rung Number="${operation.rung}" Type="N"><Text>${cdata(operation.text)}</Text></Rung>`;
322
+ const newRung = `<Rung Number="${operation.rung}" Type="N">${commentXml(operation.comment)}<Text>${cdata(operation.text)}</Text></Rung>`;
318
323
  if (operation.before_rung === undefined)
319
324
  return insert(xml, content, newRung);
320
325
  const before = rungs.filter(rung => Number(rung.attributes.Number) === operation.before_rung);
@@ -327,9 +332,29 @@ function applyOperation(xml, operation, context) {
327
332
  const target = targets[0];
328
333
  if (operation.operation === 'delete_rung')
329
334
  return replace(xml, target.start, target.end, '');
335
+ if (operation.operation === 'set_rung_comment') {
336
+ return replace(xml, target.start, target.end, rungWith(target, retainedText(xml, target), operation.comment));
337
+ }
338
+ if (operation.comment !== undefined) {
339
+ return replace(xml, target.start, target.end, rungWith(target, `<Text>${cdata(operation.text)}</Text>`, operation.comment));
340
+ }
330
341
  const contentText = uniqueRegion(target.children, 'Text');
331
342
  return replace(xml, contentText.start, contentText.end, `<Text>${cdata(operation.text)}</Text>`);
332
343
  }
344
+ /** A comment element, or nothing when the comment is cleared. */
345
+ function commentXml(comment) {
346
+ return comment === undefined || comment === '' ? '' : `<Comment>${cdata(comment)}</Comment>`;
347
+ }
348
+ /** The rung's existing `<Text>` element, verbatim, for a comment-only edit. */
349
+ function retainedText(xml, rung) {
350
+ const text = rung.children.find(child => child.kind === 'Text');
351
+ return text === undefined ? '<Text><![CDATA[]]></Text>' : xml.slice(text.start, text.end);
352
+ }
353
+ /** Rebuild one rung element with a comment and a text element. */
354
+ function rungWith(rung, text, comment) {
355
+ const type = rung.attributes.Type ?? 'N';
356
+ return `<Rung Number="${rung.attributes.Number}" Type="${type}">${commentXml(comment)}${text}</Rung>`;
357
+ }
333
358
  /** Every type name a change may reference: scalars plus declared types. */
334
359
  function declaredTypeNames(project) {
335
360
  const names = new Set([...SCALAR_TYPES].map(name => name.toLowerCase()));