@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
@@ -7,9 +7,13 @@
7
7
  *
8
8
  * @module dsh-engineering-ftview/edit/tools
9
9
  */
10
- import { createHash } from 'node:crypto';
11
- import { EngineeringError, object, optionalString, requiredString, string, success, tool } from "../core.js";
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, report the changed files and the new structural findings. Writes nothing.',
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
- return success(`${draft.operations.length} operation(s); ${prepared.texts.size} file(s) change; ${prepared.findings.length} finding(s).`, {
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('&', '&amp;').replaceAll('"', '&quot;')}"/>`)
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()] }, [{ tool: 'ftview_open', args: { root: outputRoot }, why: 'Reopen the written export and check it.' }], APPLY_COVERAGE);
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>;