@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.
- package/CHANGELOG.md +57 -0
- package/README.md +6 -4
- package/package.json +10 -11
- package/vendor/ftview/SKILL.md +34 -5
- package/vendor/ftview/lib/display-tree.d.ts +99 -0
- package/vendor/ftview/lib/display-tree.js +306 -0
- package/vendor/ftview/lib/edit/operations.d.ts +85 -1
- package/vendor/ftview/lib/edit/operations.js +201 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +3 -0
- package/vendor/ftview/lib/edit/prepare.js +35 -1
- package/vendor/ftview/lib/edit/tools.js +249 -5
- package/vendor/ftview/lib/handoff.d.ts +58 -0
- package/vendor/ftview/lib/handoff.js +95 -0
- package/vendor/ftview/lib/index.d.ts +1 -0
- package/vendor/ftview/lib/index.js +3 -1
- package/vendor/ftview/lib/schema.d.ts +68 -0
- package/vendor/ftview/lib/schema.js +156 -0
- package/vendor/ftview/lib/vendor-tools.d.ts +17 -0
- package/vendor/ftview/lib/vendor-tools.js +74 -0
- package/vendor/ftview/lib/vendor.d.ts +56 -0
- package/vendor/ftview/lib/vendor.js +143 -0
- package/vendor/l5x/SKILL.md +27 -7
- package/vendor/l5x/lib/engine/change-set.d.ts +9 -1
- package/vendor/l5x/lib/engine/change-set.js +32 -7
- package/vendor/l5x/lib/engine/documentation.d.ts +58 -0
- package/vendor/l5x/lib/engine/documentation.js +237 -0
- package/vendor/l5x/lib/engine/instructions.d.ts +50 -0
- package/vendor/l5x/lib/engine/instructions.js +107 -0
- package/vendor/l5x/lib/engine/ladder.d.ts +74 -0
- package/vendor/l5x/lib/engine/ladder.js +209 -0
- package/vendor/l5x/lib/index.d.ts +1 -0
- package/vendor/l5x/lib/index.js +3 -1
- package/vendor/l5x/lib/model/routines.js +6 -1
- package/vendor/l5x/lib/session.d.ts +20 -0
- package/vendor/l5x/lib/session.js +36 -0
- package/vendor/l5x/lib/tools/document.d.ts +18 -0
- package/vendor/l5x/lib/tools/document.js +119 -0
- package/vendor/l5x/lib/tools/drafts.js +109 -3
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module dsh-engineering-ftview/edit/tools
|
|
9
9
|
*/
|
|
10
|
-
import { createHash } from 'node:crypto';
|
|
11
|
-
import {
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { EngineeringError, integer, object, optionalInteger, optionalString, requiredString, string, success, tool } from "../core.js";
|
|
12
14
|
import { EDIT_COVERAGE, prepareEdit } from "./prepare.js";
|
|
15
|
+
import { exportApplication } from "../vendor.js";
|
|
16
|
+
import { allMatched, verifyDisplays } from "../handoff.js";
|
|
13
17
|
/** The view handle parameter, described once. */
|
|
14
18
|
const VIEW_ID = string('View handle returned by ftview_open. Omit when exactly one export is open in this chat.');
|
|
15
19
|
/** The draft handle parameter, described once. */
|
|
@@ -27,6 +31,41 @@ function nextValidate(draftId) {
|
|
|
27
31
|
function objectCount(display, name) {
|
|
28
32
|
return display.objects.filter(object => object.name === name).length;
|
|
29
33
|
}
|
|
34
|
+
/** One display by name, with a repairable failure. */
|
|
35
|
+
function displayOf(view, name) {
|
|
36
|
+
const display = view.app.displays.find(candidate => candidate.name === name);
|
|
37
|
+
if (display === undefined) {
|
|
38
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No display is named ${JSON.stringify(name)} in this export.`, 'Call ftview_list with kind "display" to see the display names.', view.app.displays.slice(0, 5).map(candidate => candidate.name));
|
|
39
|
+
}
|
|
40
|
+
return display;
|
|
41
|
+
}
|
|
42
|
+
/** Parse one JSON object argument into string attributes. */
|
|
43
|
+
function jsonObject(value, name) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return {};
|
|
46
|
+
if (typeof value !== 'string')
|
|
47
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} must be a JSON object string.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(value);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} is not valid JSON.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
56
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} must be a JSON object.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
57
|
+
}
|
|
58
|
+
const result = {};
|
|
59
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
60
|
+
if (typeof entry === 'number' || typeof entry === 'boolean')
|
|
61
|
+
result[key] = String(entry);
|
|
62
|
+
else if (typeof entry === 'string')
|
|
63
|
+
result[key] = entry;
|
|
64
|
+
else
|
|
65
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name}.${key} must be a string, number or boolean.`, 'Pass scalar values only.');
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
30
69
|
/**
|
|
31
70
|
* The editing tools.
|
|
32
71
|
*
|
|
@@ -131,9 +170,146 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
131
170
|
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
132
171
|
},
|
|
133
172
|
}),
|
|
173
|
+
tool({
|
|
174
|
+
name: 'ftview_draft_display_create',
|
|
175
|
+
description: 'Stage a brand-new display with a schema-valid skeleton. Nothing is written until ftview_apply.',
|
|
176
|
+
parameters: object({
|
|
177
|
+
view_id: VIEW_ID,
|
|
178
|
+
name: string('New display name, unique in the export. Example: "Arya Overview".'),
|
|
179
|
+
width: integer('Display width in pixels. Example: 800.', { minimum: 1, maximum: 16384 }),
|
|
180
|
+
height: integer('Display height in pixels. Example: 600.', { minimum: 1, maximum: 16384 }),
|
|
181
|
+
display_type: string('Optional display type as Studio writes it, for example "replace" or "onTop".'),
|
|
182
|
+
back_color: string('Optional background colour. Example: "#C6C6C6".'),
|
|
183
|
+
display_number: integer('Optional display number.', { minimum: 0 }),
|
|
184
|
+
file: string('Optional export-relative file path. Defaults to "Displays/<name>.xml".'),
|
|
185
|
+
}, ['name', 'width', 'height']),
|
|
186
|
+
async run(args, exec) {
|
|
187
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
188
|
+
const name = requiredString(args, 'name');
|
|
189
|
+
if (view.app.displays.some(display => display.name === name)) {
|
|
190
|
+
throw new EngineeringError('COMPONENT_EXISTS', `A display named ${JSON.stringify(name)} already exists in this export.`, 'Pass a new display name.');
|
|
191
|
+
}
|
|
192
|
+
const displayType = optionalString(args, 'display_type');
|
|
193
|
+
const backColor = optionalString(args, 'back_color');
|
|
194
|
+
const displayNumber = optionalInteger(args, 'display_number');
|
|
195
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
196
|
+
kind: 'display_create',
|
|
197
|
+
file: optionalString(args, 'file') ?? `Displays/${name}.xml`,
|
|
198
|
+
name,
|
|
199
|
+
width: optionalInteger(args, 'width') ?? 800,
|
|
200
|
+
height: optionalInteger(args, 'height') ?? 600,
|
|
201
|
+
...(displayType === undefined ? {} : { displayType }),
|
|
202
|
+
...(backColor === undefined ? {} : { backColor }),
|
|
203
|
+
...(displayNumber === undefined ? {} : { displayNumber }),
|
|
204
|
+
});
|
|
205
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
tool({
|
|
209
|
+
name: 'ftview_draft_object_add',
|
|
210
|
+
description: 'Stage one new object inside a display: geometry, attributes and tag connections. Validated against the vendor schema on ftview_validate. Nothing is written until ftview_apply.',
|
|
211
|
+
parameters: object({
|
|
212
|
+
view_id: VIEW_ID,
|
|
213
|
+
display: string('Display name as listed by ftview_list.'),
|
|
214
|
+
type: string('Object element type as the export writes it, e.g. "momentaryButton", "text", "panel", "numericDisplay", "gotoButton".'),
|
|
215
|
+
name: string('New object name, unique inside the display.'),
|
|
216
|
+
attributes_json: string('Object attributes as a JSON object, e.g. {"height":"50","width":"120","left":"20","top":"40"}.'),
|
|
217
|
+
caption: string('Optional caption text (text objects and buttons).'),
|
|
218
|
+
connections_json: string('Optional connections as a JSON object, e.g. {"Value":"SystemRunning"}.'),
|
|
219
|
+
parent: string('Optional group name to add inside. Omit to add at the display root.'),
|
|
220
|
+
index: integer('Optional position among siblings, for z-order.', { minimum: 0 }),
|
|
221
|
+
}, ['display', 'type', 'name']),
|
|
222
|
+
async run(args, exec) {
|
|
223
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
224
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
225
|
+
const attributes = { ...jsonObject(args.attributes_json, 'attributes_json') };
|
|
226
|
+
const caption = optionalString(args, 'caption');
|
|
227
|
+
if (caption !== undefined)
|
|
228
|
+
attributes.caption = caption;
|
|
229
|
+
const connections = jsonObject(optionalString(args, 'connections_json'), 'connections_json');
|
|
230
|
+
const parent = optionalString(args, 'parent');
|
|
231
|
+
const index = optionalInteger(args, 'index');
|
|
232
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
233
|
+
kind: 'object_add',
|
|
234
|
+
file: display.file,
|
|
235
|
+
type: requiredString(args, 'type'),
|
|
236
|
+
name: requiredString(args, 'name'),
|
|
237
|
+
attributes,
|
|
238
|
+
...(parent === undefined ? {} : { parent }),
|
|
239
|
+
...(index === undefined ? {} : { index }),
|
|
240
|
+
...(Object.keys(connections).length === 0 ? {} : { connections }),
|
|
241
|
+
});
|
|
242
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
tool({
|
|
246
|
+
name: 'ftview_draft_object_set',
|
|
247
|
+
description: 'Stage attribute, caption or connection changes on one existing object. Nothing is written until ftview_apply.',
|
|
248
|
+
parameters: object({
|
|
249
|
+
view_id: VIEW_ID,
|
|
250
|
+
display: string('Display name as listed by ftview_list.'),
|
|
251
|
+
object: string('Object name inside the display.'),
|
|
252
|
+
attributes_json: string('Attributes to set as a JSON object, e.g. {"visible":"0","left":"30"}.'),
|
|
253
|
+
caption: string('Optional new caption text.'),
|
|
254
|
+
connections_json: string('Connections to set as a JSON object, e.g. {"Value":"SystemRunning"}.'),
|
|
255
|
+
}, ['display', 'object']),
|
|
256
|
+
async run(args, exec) {
|
|
257
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
258
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
259
|
+
const object = requiredString(args, 'object');
|
|
260
|
+
const attributes = jsonObject(args.attributes_json, 'attributes_json');
|
|
261
|
+
const connections = jsonObject(optionalString(args, 'connections_json'), 'connections_json');
|
|
262
|
+
const caption = optionalString(args, 'caption');
|
|
263
|
+
if (Object.keys(attributes).length === 0 && Object.keys(connections).length === 0 && caption === undefined) {
|
|
264
|
+
throw new EngineeringError('INVALID_ARGUMENT', 'No change was requested.', 'Pass attributes_json, connections_json or caption.');
|
|
265
|
+
}
|
|
266
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
267
|
+
kind: 'object_set',
|
|
268
|
+
file: display.file,
|
|
269
|
+
object,
|
|
270
|
+
attributes,
|
|
271
|
+
...(caption === undefined ? {} : { caption }),
|
|
272
|
+
...(Object.keys(connections).length === 0 ? {} : { connections }),
|
|
273
|
+
});
|
|
274
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
tool({
|
|
278
|
+
name: 'ftview_draft_object_delete',
|
|
279
|
+
description: 'Stage the removal of one object from a display. Nothing is written until ftview_apply.',
|
|
280
|
+
parameters: object({
|
|
281
|
+
view_id: VIEW_ID,
|
|
282
|
+
display: string('Display name as listed by ftview_list.'),
|
|
283
|
+
object: string('Object name to remove.'),
|
|
284
|
+
}, ['display', 'object']),
|
|
285
|
+
async run(args, exec) {
|
|
286
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
287
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
288
|
+
const object = requiredString(args, 'object');
|
|
289
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), { kind: 'object_delete', file: display.file, object });
|
|
290
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
tool({
|
|
294
|
+
name: 'ftview_draft_object_order',
|
|
295
|
+
description: 'Stage a z-order move for one object among its siblings. Nothing is written until ftview_apply.',
|
|
296
|
+
parameters: object({
|
|
297
|
+
view_id: VIEW_ID,
|
|
298
|
+
display: string('Display name as listed by ftview_list.'),
|
|
299
|
+
object: string('Object name to move.'),
|
|
300
|
+
index: integer('New position among the object siblings, 0 = back.', { minimum: 0 }),
|
|
301
|
+
}, ['display', 'object', 'index']),
|
|
302
|
+
async run(args, exec) {
|
|
303
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
304
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
305
|
+
const object = requiredString(args, 'object');
|
|
306
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), { kind: 'object_order', file: display.file, object, index: optionalInteger(args, 'index') ?? 0 });
|
|
307
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
134
310
|
tool({
|
|
135
311
|
name: 'ftview_validate',
|
|
136
|
-
description: 'Prepare the staged edits: apply them to a re-parsed copy,
|
|
312
|
+
description: 'Prepare the staged edits: apply them to a re-parsed copy, validate the changed displays against the installed Gfx-ME schema, and report the changed files and findings. Writes nothing.',
|
|
137
313
|
parameters: object({ draft_id: DRAFT_ID }, []),
|
|
138
314
|
async run(args, exec) {
|
|
139
315
|
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
@@ -142,16 +318,47 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
142
318
|
const byRule = {};
|
|
143
319
|
for (const finding of prepared.findings)
|
|
144
320
|
byRule[finding.rule] = (byRule[finding.rule] ?? 0) + 1;
|
|
145
|
-
|
|
321
|
+
const schemaErrors = prepared.schema.findings.filter(finding => finding.error);
|
|
322
|
+
return success(`${draft.operations.length} operation(s); ${prepared.texts.size} file(s) change; ${prepared.findings.length} finding(s); schema ${prepared.schema.ran ? `ran (${schemaErrors.length} error(s))` : 'unavailable'}.`, {
|
|
146
323
|
draft_id: draft.id,
|
|
147
324
|
draft_sha256: prepared.hash,
|
|
148
325
|
changed_files: [...prepared.texts.keys()],
|
|
149
326
|
added: prepared.added,
|
|
150
327
|
findings: prepared.findings,
|
|
151
328
|
by_rule: byRule,
|
|
329
|
+
schema: prepared.schema,
|
|
152
330
|
}, [{ tool: 'ftview_apply', args: { draft_id: draft.id }, why: 'Write the prepared export to a new folder.' }], EDIT_COVERAGE);
|
|
153
331
|
},
|
|
154
332
|
}),
|
|
333
|
+
tool({
|
|
334
|
+
name: 'ftview_verify_import',
|
|
335
|
+
description: 'Export the application again through the vendor engine and compare it object by object with the staged draft, proving whether the import landed. Writes only a temporary export folder.',
|
|
336
|
+
parameters: object({
|
|
337
|
+
view_id: VIEW_ID,
|
|
338
|
+
draft_id: DRAFT_ID,
|
|
339
|
+
application: string('Application name as ftview_apps lists it. The same application the package was imported into.'),
|
|
340
|
+
}, ['application']),
|
|
341
|
+
async run(args, exec) {
|
|
342
|
+
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
343
|
+
const view = session.require(draft.viewId, exec);
|
|
344
|
+
const prepared = await prepareEdit(fs, view.app, draft.operations, exec.signal);
|
|
345
|
+
const expected = new Map([...prepared.texts].filter(([file]) => /\.xml$/iu.test(file) && !/batchimport/iu.test(file)));
|
|
346
|
+
if (expected.size === 0) {
|
|
347
|
+
throw new EngineeringError('NOTHING_TO_VERIFY', 'This draft changes no display, so there is nothing to compare.', 'Stage a display edit first, import it in Studio, then run this again.');
|
|
348
|
+
}
|
|
349
|
+
const root = join(tmpdir(), `ftview-verify-${randomUUID()}`);
|
|
350
|
+
const exported = await exportApplication(requiredString(args, 'application'), root);
|
|
351
|
+
if (exported.error !== undefined) {
|
|
352
|
+
throw new EngineeringError('VENDOR_EXPORT_FAILED', exported.error, 'Close the application in Studio if it is open, then retry.');
|
|
353
|
+
}
|
|
354
|
+
const results = await verifyDisplays(fs, root, expected);
|
|
355
|
+
const matched = results.filter(result => result.matched).length;
|
|
356
|
+
const differences = results.flatMap(result => result.differences.map(difference => ({ file: result.file, ...difference })));
|
|
357
|
+
return success(`${matched}/${results.length} display(s) match the draft${allMatched(results) ? '' : `; ${differences.length} difference(s)`}. Export kept at ${root}.`, { application: requiredString(args, 'application'), export_root: root, displays: results, differences, matched: allMatched(results) }, matched === results.length
|
|
358
|
+
? [{ tool: 'ftview_draft_display_create', why: 'Author the next screen against the verified export.' }]
|
|
359
|
+
: [{ tool: 'ftview_validate', args: { draft_id: draft.id }, why: 'Re-read the draft findings before correcting the difference.' }], EDIT_COVERAGE);
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
155
362
|
tool({
|
|
156
363
|
name: 'ftview_apply',
|
|
157
364
|
description: 'Write the prepared edits to a NEW export folder: changed files get the edits, other text exports are copied verbatim. The source folder is never modified.',
|
|
@@ -163,6 +370,10 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
163
370
|
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
164
371
|
const view = session.require(draft.viewId, exec);
|
|
165
372
|
const prepared = await prepareEdit(fs, view.app, draft.operations, exec.signal);
|
|
373
|
+
const schemaErrors = prepared.schema.findings.filter(finding => finding.error);
|
|
374
|
+
if (schemaErrors.length > 0) {
|
|
375
|
+
throw new EngineeringError('SCHEMA_INVALID', `${schemaErrors.length} schema error(s); nothing was written. First: ${schemaErrors[0]?.file}: ${schemaErrors[0]?.message}`, 'Fix the offending attributes or elements and run ftview_validate again; apply refuses a document the vendor schema rejects.', schemaErrors.slice(0, 5).map(finding => `${finding.file}: ${finding.message}`));
|
|
376
|
+
}
|
|
166
377
|
const outputRoot = await fs.resolve(requiredString(args, 'output_root'), {
|
|
167
378
|
...(exec.cwd === undefined ? {} : { cwd: exec.cwd }),
|
|
168
379
|
...(exec.signal === undefined ? {} : { signal: exec.signal }),
|
|
@@ -190,8 +401,41 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
190
401
|
await fs.writeText(`${outputRoot}/${file}`, text, { kind: 'createIfAbsent' }, exec.signal);
|
|
191
402
|
written += 1;
|
|
192
403
|
}
|
|
404
|
+
// The vendor's own import file: Studio's Batch Import reads this list
|
|
405
|
+
// from the Displays folder. Writing it here is what turns a folder of
|
|
406
|
+
// XML into a one-step import.
|
|
407
|
+
const changedDisplays = [...prepared.texts.keys()]
|
|
408
|
+
.filter(file => /^Displays\/.+\.xml$/iu.test(file) && !/batchimport/iu.test(file));
|
|
409
|
+
let manifest;
|
|
410
|
+
if (changedDisplays.length > 0) {
|
|
411
|
+
const entries = changedDisplays
|
|
412
|
+
.map(file => ` <import importFile="${file.replace(/^Displays\//u, '').replaceAll('&', '&').replaceAll('"', '"')}"/>`)
|
|
413
|
+
.join('\n');
|
|
414
|
+
manifest = `Displays/BatchImport_${view.app.name}.xml`;
|
|
415
|
+
await fs.writeText(`${outputRoot}/${manifest}`, `<?xml version="1.0" encoding="UTF-8"?>\n<gfxImport>\n${entries}\n</gfxImport>\n`, { kind: 'createIfAbsent' }, exec.signal);
|
|
416
|
+
written += 1;
|
|
417
|
+
const steps = [
|
|
418
|
+
`# Import ${view.app.name}`,
|
|
419
|
+
'',
|
|
420
|
+
`Open FactoryTalk View Studio → Application Manager, open **${view.app.name}**, and import the displays listed in \`Displays/BatchImport_${view.app.name}.xml\` (${changedDisplays.length} file(s)).`,
|
|
421
|
+
'',
|
|
422
|
+
'Then:',
|
|
423
|
+
'1. Verify each display opens without warnings.',
|
|
424
|
+
'2. Create the runtime application (`.mer`).',
|
|
425
|
+
'3. Run `ftview_verify_import` with the draft id to prove the import matches this draft.',
|
|
426
|
+
'',
|
|
427
|
+
'Changed displays:',
|
|
428
|
+
...changedDisplays.map(file => `- ${file}`),
|
|
429
|
+
'',
|
|
430
|
+
].join('\n');
|
|
431
|
+
await fs.writeText(`${outputRoot}/IMPORT.md`, steps, { kind: 'createIfAbsent' }, exec.signal);
|
|
432
|
+
written += 1;
|
|
433
|
+
}
|
|
193
434
|
drafts.remove(exec, draft.id);
|
|
194
|
-
return success(`Wrote ${written} file(s) to ${outputRoot}.`, { output_root: outputRoot, draft_id: draft.id, files_written: written, changed_files: [...prepared.texts.keys()]
|
|
435
|
+
return success(`Wrote ${written} file(s) to ${outputRoot}${manifest === undefined ? '' : `; import manifest ${manifest}`}.`, { output_root: outputRoot, draft_id: draft.id, files_written: written, changed_files: [...prepared.texts.keys()], ...(manifest === undefined ? {} : { manifest }), schema: prepared.schema }, [
|
|
436
|
+
{ tool: 'ftview_open', args: { root: outputRoot }, why: 'Reopen the written export and check it.' },
|
|
437
|
+
{ tool: 'ftview_verify_import', args: { application: view.app.name }, why: 'After Studio imports it, prove the import matches this draft.' },
|
|
438
|
+
], APPLY_COVERAGE);
|
|
195
439
|
},
|
|
196
440
|
}),
|
|
197
441
|
];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that what Studio imported is what we authored.
|
|
3
|
+
*
|
|
4
|
+
* An export is the vendor's own view of an application. Comparing a fresh
|
|
5
|
+
* export against the prepared draft, object by object, is the only evidence
|
|
6
|
+
* that the import took: file hashes would say nothing about what the engine
|
|
7
|
+
* kept, normalised or rejected.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-ftview/handoff
|
|
10
|
+
*/
|
|
11
|
+
import type { FsService } from './core.ts';
|
|
12
|
+
/** One object as it exists in a display. */
|
|
13
|
+
export interface ObjectSignature {
|
|
14
|
+
/** Element type, e.g. `momentaryButton`. */
|
|
15
|
+
readonly type: string;
|
|
16
|
+
/** Object name when it has one. */
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
/** Attributes, decoded, sorted by name. */
|
|
19
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
20
|
+
/** Nested objects, in document order. */
|
|
21
|
+
readonly children: readonly ObjectSignature[];
|
|
22
|
+
}
|
|
23
|
+
/** One difference between intended and imported. */
|
|
24
|
+
export interface SignatureDifference {
|
|
25
|
+
/** Where the difference sits, e.g. `Main Screen > StartButton > caption`. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/** What the draft had. */
|
|
28
|
+
readonly expected?: string;
|
|
29
|
+
/** What the import produced. */
|
|
30
|
+
readonly actual?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Compare one display's intended and imported trees. */
|
|
33
|
+
export interface DisplayVerification {
|
|
34
|
+
/** Export-relative display file. */
|
|
35
|
+
readonly file: string;
|
|
36
|
+
/** True when the trees are identical. */
|
|
37
|
+
readonly matched: boolean;
|
|
38
|
+
/** Differences, capped by the caller. */
|
|
39
|
+
readonly differences: readonly SignatureDifference[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Signature of one display document: its objects, nested, without layout noise.
|
|
43
|
+
*
|
|
44
|
+
* @param xml - Display XML.
|
|
45
|
+
* @returns Root signature.
|
|
46
|
+
*/
|
|
47
|
+
export declare function signatureOf(xml: string): ObjectSignature;
|
|
48
|
+
/**
|
|
49
|
+
* Compare the prepared draft's displays with a fresh export.
|
|
50
|
+
*
|
|
51
|
+
* @param fs - Filesystem provider.
|
|
52
|
+
* @param root - Export folder produced by the vendor after the import.
|
|
53
|
+
* @param expected - Export-relative file to intended XML.
|
|
54
|
+
* @returns One verification per display.
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyDisplays(fs: FsService, root: string, expected: ReadonlyMap<string, string>): Promise<readonly DisplayVerification[]>;
|
|
57
|
+
/** Whether every verification matched. */
|
|
58
|
+
export declare function allMatched(results: readonly DisplayVerification[]): boolean;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that what Studio imported is what we authored.
|
|
3
|
+
*
|
|
4
|
+
* An export is the vendor's own view of an application. Comparing a fresh
|
|
5
|
+
* export against the prepared draft, object by object, is the only evidence
|
|
6
|
+
* that the import took: file hashes would say nothing about what the engine
|
|
7
|
+
* kept, normalised or rejected.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-ftview/handoff
|
|
10
|
+
*/
|
|
11
|
+
import { attributeOf, parseDisplayTree } from "./display-tree.js";
|
|
12
|
+
/** One signature of a node, recursively. */
|
|
13
|
+
function signatureOfNode(node) {
|
|
14
|
+
const attributes = {};
|
|
15
|
+
for (const name of Object.keys(node.attributes).sort())
|
|
16
|
+
attributes[name] = attributeOf(node, name) ?? '';
|
|
17
|
+
return {
|
|
18
|
+
type: node.type,
|
|
19
|
+
...(node.attributes.name === undefined ? {} : { name: attributeOf(node, 'name') }),
|
|
20
|
+
attributes,
|
|
21
|
+
children: node.children.filter(child => child.attributes.name !== undefined || ['group', 'connections', 'states'].includes(child.type)).map(signatureOfNode),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Signature of one display document: its objects, nested, without layout noise.
|
|
26
|
+
*
|
|
27
|
+
* @param xml - Display XML.
|
|
28
|
+
* @returns Root signature.
|
|
29
|
+
*/
|
|
30
|
+
export function signatureOf(xml) {
|
|
31
|
+
const tree = parseDisplayTree(xml);
|
|
32
|
+
return { type: tree.root.type, attributes: {}, children: tree.root.children.map(signatureOfNode) };
|
|
33
|
+
}
|
|
34
|
+
/** Collect differences between two signatures at one path. */
|
|
35
|
+
function diff(expected, actual, path, out) {
|
|
36
|
+
if (out.length >= 50)
|
|
37
|
+
return;
|
|
38
|
+
if (expected.type !== actual.type) {
|
|
39
|
+
out.push({ path, expected: expected.type, actual: actual.type });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const names = new Set([...Object.keys(expected.attributes), ...Object.keys(actual.attributes)]);
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (expected.attributes[name] !== actual.attributes[name]) {
|
|
45
|
+
out.push({ path: `${path} @${name}`, expected: expected.attributes[name], actual: actual.attributes[name] });
|
|
46
|
+
if (out.length >= 50)
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const expectedChildren = expected.children;
|
|
51
|
+
const actualChildren = actual.children;
|
|
52
|
+
for (let index = 0; index < Math.max(expectedChildren.length, actualChildren.length); index += 1) {
|
|
53
|
+
const left = expectedChildren[index];
|
|
54
|
+
const right = actualChildren[index];
|
|
55
|
+
const label = left?.name ?? right?.name ?? `child[${index}]`;
|
|
56
|
+
if (left === undefined) {
|
|
57
|
+
out.push({ path: `${path} > ${label}`, actual: 'present in import, missing in draft' });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (right === undefined) {
|
|
61
|
+
out.push({ path: `${path} > ${label}`, expected: 'present in draft, missing in import' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
diff(left, right, `${path} > ${label}`, out);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compare the prepared draft's displays with a fresh export.
|
|
69
|
+
*
|
|
70
|
+
* @param fs - Filesystem provider.
|
|
71
|
+
* @param root - Export folder produced by the vendor after the import.
|
|
72
|
+
* @param expected - Export-relative file to intended XML.
|
|
73
|
+
* @returns One verification per display.
|
|
74
|
+
*/
|
|
75
|
+
export async function verifyDisplays(fs, root, expected) {
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const [file, intended] of expected) {
|
|
78
|
+
const bytes = await fs.readBytes(`${root}/${file}`, undefined, 64 * 1024 * 1024);
|
|
79
|
+
const actual = new TextDecoder('utf-8').decode(bytes);
|
|
80
|
+
const differences = [];
|
|
81
|
+
try {
|
|
82
|
+
diff(signatureOf(intended), signatureOf(actual), file, differences);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
results.push({ file, matched: false, differences: [{ path: file, expected: 'a parsable display', actual: error instanceof Error ? error.message : String(error) }] });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
results.push({ file, matched: differences.length === 0, differences });
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
/** Whether every verification matched. */
|
|
93
|
+
export function allMatched(results) {
|
|
94
|
+
return results.every(result => result.matched);
|
|
95
|
+
}
|
|
@@ -27,5 +27,6 @@ export interface Config {
|
|
|
27
27
|
export declare function apply(ctx: PluginContext, config?: Config): void;
|
|
28
28
|
export { ViewSession } from './session.ts';
|
|
29
29
|
export { ftviewTools } from './tools.ts';
|
|
30
|
+
export { vendorTools } from './vendor-tools.ts';
|
|
30
31
|
export { EngineeringError } from './core.ts';
|
|
31
32
|
export type { Coverage, NextStep, Success, ToolDefinition } from './core.ts';
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { stringFs } from "./core.js";
|
|
10
10
|
import { ViewSession } from "./session.js";
|
|
11
11
|
import { ftviewTools } from "./tools.js";
|
|
12
|
+
import { vendorTools } from "./vendor-tools.js";
|
|
12
13
|
/** Plugin name in a composition. */
|
|
13
14
|
export const name = 'ftview-read';
|
|
14
15
|
/** Services this plugin cannot register without. */
|
|
@@ -28,9 +29,10 @@ export function apply(ctx, config = {}) {
|
|
|
28
29
|
}
|
|
29
30
|
const session = new ViewSession(stringFs(ctx.fs), maxFiles);
|
|
30
31
|
ctx.provide?.(VIEWS_SERVICE, session);
|
|
31
|
-
for (const definition of ftviewTools(session))
|
|
32
|
+
for (const definition of [...ftviewTools(session), ...vendorTools(stringFs(ctx.fs))])
|
|
32
33
|
ctx.tools.register(definition);
|
|
33
34
|
}
|
|
34
35
|
export { ViewSession } from "./session.js";
|
|
35
36
|
export { ftviewTools } from "./tools.js";
|
|
37
|
+
export { vendorTools } from "./vendor-tools.js";
|
|
36
38
|
export { EngineeringError } from "./core.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
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 type { Coverage } from './core.ts';
|
|
13
|
+
/** One file's validation outcome. */
|
|
14
|
+
export interface SchemaFinding {
|
|
15
|
+
/** File or display name the finding belongs to. */
|
|
16
|
+
readonly file: string;
|
|
17
|
+
/** Schema message, already rendered by .NET. */
|
|
18
|
+
readonly message: string;
|
|
19
|
+
/** True when the schema rejected the document. */
|
|
20
|
+
readonly error: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** One validation run. */
|
|
23
|
+
export interface SchemaValidation {
|
|
24
|
+
/** Schema file used, or the reason validation could not run. */
|
|
25
|
+
readonly schema?: string;
|
|
26
|
+
/** Findings across every document. */
|
|
27
|
+
readonly findings: readonly SchemaFinding[];
|
|
28
|
+
/** True when validation actually ran. */
|
|
29
|
+
readonly ran: boolean;
|
|
30
|
+
/** Why it could not run, when it did not. */
|
|
31
|
+
readonly unavailable?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Coverage note shared by every schema-dependent operation. */
|
|
34
|
+
export declare const SCHEMA_COVERAGE: Coverage;
|
|
35
|
+
/** The schema directory, or undefined when Studio is not installed here. */
|
|
36
|
+
export declare function schemaDirectory(): string | undefined;
|
|
37
|
+
/** Every installed schema version, newest last. */
|
|
38
|
+
export declare function schemaVersions(): readonly string[];
|
|
39
|
+
/** The declared schema of one display, from `xsi:noNamespaceSchemaLocation`. */
|
|
40
|
+
export declare function declaredSchema(xml: string): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Pick the schema file for one document.
|
|
43
|
+
*
|
|
44
|
+
* A display names its schema; if that file is not installed, the newest
|
|
45
|
+
* installed schema is used and the caller is told which one ran.
|
|
46
|
+
*
|
|
47
|
+
* @param xml - Display XML, used only for its declared schema.
|
|
48
|
+
* @returns Absolute schema path and its file name, or undefined.
|
|
49
|
+
*/
|
|
50
|
+
export declare function schemaFor(xml: string): {
|
|
51
|
+
path: string;
|
|
52
|
+
name: string;
|
|
53
|
+
} | undefined;
|
|
54
|
+
/** One item handed to the validator. */
|
|
55
|
+
export interface SchemaDocument {
|
|
56
|
+
/** Name reported in findings. */
|
|
57
|
+
readonly file: string;
|
|
58
|
+
/** Display XML. */
|
|
59
|
+
readonly xml: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate one batch of display documents against the installed schema.
|
|
63
|
+
*
|
|
64
|
+
* @param documents - Documents to check; an empty list runs nothing.
|
|
65
|
+
* @param executable - PowerShell to use; defaults to `powershell.exe`.
|
|
66
|
+
* @returns Findings, whether validation ran, and the schema used.
|
|
67
|
+
*/
|
|
68
|
+
export declare function validateSchema(documents: readonly SchemaDocument[], executable?: string): Promise<SchemaValidation>;
|