@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
@@ -29,7 +29,52 @@ export interface TagUpsertOperation {
29
29
  readonly type?: string;
30
30
  }
31
31
  /** One staged edit. */
32
- export type EditOperation = DisplaySetOperation | DisplayAddOperation | TagUpsertOperation;
32
+ export type EditOperation = DisplaySetOperation | DisplayAddOperation | TagUpsertOperation | DisplayCreateOperation | ObjectAddOperation | ObjectSetOperation | ObjectDeleteOperation | ObjectOrderOperation;
33
+ /** Create a new display file from a schema-valid skeleton. */
34
+ export interface DisplayCreateOperation {
35
+ readonly kind: 'display_create';
36
+ readonly file: string;
37
+ readonly name: string;
38
+ readonly width: number;
39
+ readonly height: number;
40
+ readonly displayType?: string;
41
+ readonly backColor?: string;
42
+ readonly displayNumber?: number;
43
+ }
44
+ /** Add one object to a display, optionally inside a named group. */
45
+ export interface ObjectAddOperation {
46
+ readonly kind: 'object_add';
47
+ readonly file: string;
48
+ readonly type: string;
49
+ readonly name: string;
50
+ readonly attributes: Readonly<Record<string, string>>;
51
+ readonly parent?: string;
52
+ readonly index?: number;
53
+ readonly connections?: Readonly<Record<string, string>>;
54
+ readonly caption?: string;
55
+ }
56
+ /** Change attributes, caption or connections on an existing object. */
57
+ export interface ObjectSetOperation {
58
+ readonly kind: 'object_set';
59
+ readonly file: string;
60
+ readonly object: string;
61
+ readonly attributes: Readonly<Record<string, string>>;
62
+ readonly caption?: string;
63
+ readonly connections?: Readonly<Record<string, string>>;
64
+ }
65
+ /** Remove one object from a display. */
66
+ export interface ObjectDeleteOperation {
67
+ readonly kind: 'object_delete';
68
+ readonly file: string;
69
+ readonly object: string;
70
+ }
71
+ /** Move one object within its siblings, changing z-order. */
72
+ export interface ObjectOrderOperation {
73
+ readonly kind: 'object_order';
74
+ readonly file: string;
75
+ readonly object: string;
76
+ readonly index: number;
77
+ }
33
78
  /** Escape one XML attribute value. */
34
79
  export declare function escapeAttribute(value: string): string;
35
80
  /** Escape one CSV cell when it needs quoting. */
@@ -60,3 +105,42 @@ export declare function renameDisplay(xml: string, name: string): string;
60
105
  * @returns The modified CSV.
61
106
  */
62
107
  export declare function upsertTag(csv: string, tag: TagUpsertOperation): string;
108
+ /**
109
+ * Build a new display document from a schema-valid skeleton.
110
+ *
111
+ * @param op - Display identity and size.
112
+ * @returns Whole XML text.
113
+ */
114
+ export declare function createDisplay(op: DisplayCreateOperation): string;
115
+ /**
116
+ * Add one object to a display.
117
+ *
118
+ * @param xml - Display XML.
119
+ * @param op - Object type, name, geometry and connections.
120
+ * @returns The modified XML.
121
+ */
122
+ export declare function addObject(xml: string, op: ObjectAddOperation): string;
123
+ /**
124
+ * Change attributes and connections on an existing object.
125
+ *
126
+ * @param xml - Display XML.
127
+ * @param op - Target object and the changes.
128
+ * @returns The modified XML.
129
+ */
130
+ export declare function setObject(xml: string, op: ObjectSetOperation): string;
131
+ /**
132
+ * Remove one object.
133
+ *
134
+ * @param xml - Display XML.
135
+ * @param op - Target object.
136
+ * @returns The modified XML.
137
+ */
138
+ export declare function deleteObject(xml: string, op: ObjectDeleteOperation): string;
139
+ /**
140
+ * Move one object within its siblings.
141
+ *
142
+ * @param xml - Display XML.
143
+ * @param op - Target object and the new index.
144
+ * @returns The modified XML.
145
+ */
146
+ export declare function orderObject(xml: string, op: ObjectOrderOperation): string;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { EngineeringError } from "../core.js";
11
11
  import { splitCsv } from "../model.js";
12
+ import { decodeEntities, findNamed, firstChildOfType, insertChild, parseDisplayTree, removeNode, renderElement, setAttribute as setNodeAttribute } from "../display-tree.js";
12
13
  /** Escape one XML attribute value. */
13
14
  export function escapeAttribute(value) {
14
15
  return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
@@ -124,3 +125,203 @@ export function upsertTag(csv, tag) {
124
125
  lines.push(cells.map(encodeCell).join(';'));
125
126
  return lines.join(eol);
126
127
  }
128
+ /** Elements that are parts of an object, never objects themselves. */
129
+ const RESERVED = new Set(['gfx', 'display', 'displaySettings', 'connections', 'connection', 'states', 'state', 'caption', 'imageSettings', 'animations', 'parameters', 'parameter', 'font', 'color']);
130
+ /** Depth of one node below the root. */
131
+ function depthOf(root, target) {
132
+ let found = -1;
133
+ const walkNode = (node, depth) => {
134
+ if (found >= 0)
135
+ return;
136
+ if (node === target) {
137
+ found = depth;
138
+ return;
139
+ }
140
+ for (const child of node.children)
141
+ walkNode(child, depth + 1);
142
+ };
143
+ walkNode(root, 0);
144
+ return found;
145
+ }
146
+ /** Parent of one node, or undefined for the root. */
147
+ function parentOf(root, target) {
148
+ let found;
149
+ const walkNode = (node) => {
150
+ if (found !== undefined)
151
+ return;
152
+ for (const child of node.children) {
153
+ if (child === target) {
154
+ found = node;
155
+ return;
156
+ }
157
+ walkNode(child);
158
+ }
159
+ };
160
+ walkNode(root);
161
+ return found;
162
+ }
163
+ /** Validate an object name and element type before writing anything. */
164
+ function assertObjectStyle(type, name) {
165
+ if (!/^[A-Za-z_][\w.:-]*$/u.test(type))
166
+ throw new EngineeringError('INVALID_OBJECT_TYPE', `${JSON.stringify(type)} is not an XML element name.`, 'Pass the object element name as the export writes it, for example "momentaryButton".');
167
+ if (RESERVED.has(type))
168
+ throw new EngineeringError('INVALID_OBJECT_TYPE', `${type} is part of a display, not an object type.`, 'Pass an object element such as "text", "panel", "momentaryButton" or "numericDisplay".');
169
+ if (!/^[A-Za-z_][\w .-]{0,79}$/u.test(name))
170
+ throw new EngineeringError('INVALID_OBJECT_NAME', `${JSON.stringify(name)} is not an object name.`, 'Use letters, digits, spaces, dots, underscores or dashes, at most 80 characters.');
171
+ }
172
+ /** A `<connections>` block at one depth. */
173
+ function connectionsXml(connections, depth) {
174
+ const lines = Object.entries(connections).map(([name, expression]) => {
175
+ if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
176
+ throw new EngineeringError('INVALID_CONNECTION', `${JSON.stringify(name)} is not a connection name.`, 'Pass the connection as the export writes it, for example "Value" or "Visibility".');
177
+ return `${' '.repeat(depth + 1)}<connection name="${escapeAttribute(name)}" expression="${escapeAttribute(expression)}"/>\n`;
178
+ });
179
+ return `${' '.repeat(depth)}<connections>\n${lines.join('')}${' '.repeat(depth)}</connections>\n`;
180
+ }
181
+ /**
182
+ * Build a new display document from a schema-valid skeleton.
183
+ *
184
+ * @param op - Display identity and size.
185
+ * @returns Whole XML text.
186
+ */
187
+ export function createDisplay(op) {
188
+ const settings = {};
189
+ if (op.displayType !== undefined)
190
+ settings.displayType = op.displayType;
191
+ settings.width = String(op.width);
192
+ settings.height = String(op.height);
193
+ if (op.backColor !== undefined)
194
+ settings.backColor = op.backColor;
195
+ if (op.displayNumber !== undefined)
196
+ settings.displayNumber = String(op.displayNumber);
197
+ const attributes = Object.entries(settings).map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('');
198
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<gfx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="Gfx-ME17.xsd">\n <displaySettings${attributes}/>\n</gfx>\n`;
199
+ }
200
+ /**
201
+ * Add one object to a display.
202
+ *
203
+ * @param xml - Display XML.
204
+ * @param op - Object type, name, geometry and connections.
205
+ * @returns The modified XML.
206
+ */
207
+ export function addObject(xml, op) {
208
+ assertObjectStyle(op.type, op.name);
209
+ const tree = parseDisplayTree(xml);
210
+ if (op.parent !== undefined) {
211
+ const parent = findNamed(tree.root, op.parent);
212
+ if (parent === undefined)
213
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.parent)} in this display.`, 'Call ftview_get to see the object names, then add inside an existing group.');
214
+ const depth = depthOf(tree.root, parent);
215
+ const attributes = { name: escapeAttribute(op.name), ...op.attributes };
216
+ const innerConnections = op.connections === undefined ? '' : connectionsXml(op.connections, depth + 1);
217
+ const element = renderElement(op.type, attributes, innerConnections === '' ? undefined : innerConnections, depth + 1);
218
+ return insertChild(tree, parent, element, op.index);
219
+ }
220
+ const attributes = { name: escapeAttribute(op.name), ...op.attributes };
221
+ if (op.caption !== undefined && attributes.caption === undefined)
222
+ attributes.caption = escapeAttribute(op.caption);
223
+ const innerConnections = op.connections === undefined ? '' : connectionsXml(op.connections, 1);
224
+ const element = renderElement(op.type, attributes, innerConnections === '' ? undefined : innerConnections, 1);
225
+ return insertChild(tree, tree.root, element, op.index);
226
+ }
227
+ /**
228
+ * Change attributes and connections on an existing object.
229
+ *
230
+ * @param xml - Display XML.
231
+ * @param op - Target object and the changes.
232
+ * @returns The modified XML.
233
+ */
234
+ export function setObject(xml, op) {
235
+ let current = xml;
236
+ for (const [name, value] of Object.entries(op.attributes)) {
237
+ if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
238
+ throw new EngineeringError('INVALID_ATTRIBUTE', `${JSON.stringify(name)} is not an XML attribute name.`, 'Pass a plain attribute name such as "visible" or "left".');
239
+ const tree = parseDisplayTree(current);
240
+ const node = findNamed(tree.root, op.object);
241
+ if (node === undefined)
242
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
243
+ current = setNodeAttribute(tree, node, name, value);
244
+ }
245
+ if (op.caption !== undefined) {
246
+ if (op.caption.length > 2000)
247
+ throw new EngineeringError('INVALID_CAPTION', 'A caption is limited to 2000 characters.', 'Shorten the caption.');
248
+ const tree = parseDisplayTree(current);
249
+ const node = findNamed(tree.root, op.object);
250
+ if (node === undefined)
251
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
252
+ current = setNodeAttribute(tree, node, 'caption', op.caption);
253
+ }
254
+ if (op.connections !== undefined) {
255
+ for (const name of Object.keys(op.connections)) {
256
+ if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
257
+ throw new EngineeringError('INVALID_CONNECTION', `${JSON.stringify(name)} is not a connection name.`, 'Pass the connection name as the export writes it.');
258
+ const tree = parseDisplayTree(current);
259
+ const node = findNamed(tree.root, op.object);
260
+ if (node === undefined)
261
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
262
+ const expression = op.connections[name];
263
+ let block = firstChildOfType(node, 'connections');
264
+ if (block === undefined) {
265
+ const depth = depthOf(tree.root, node);
266
+ const inserted = insertChild(tree, node, connectionsXml({ [name]: expression }, depth + 1));
267
+ current = inserted;
268
+ continue;
269
+ }
270
+ const existing = block.children.find(child => child.type === 'connection' && decodeEntities(child.attributes.name ?? '') === name);
271
+ if (existing !== undefined) {
272
+ const existingTree = parseDisplayTree(current);
273
+ const existingBlock = findNamed(existingTree.root, op.object);
274
+ if (existingBlock === undefined)
275
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
276
+ const connection = existingBlock.children.find(child => child.type === 'connections')?.children.find(child => child.type === 'connection' && decodeEntities(child.attributes.name ?? '') === name);
277
+ if (connection === undefined)
278
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `Connection ${JSON.stringify(name)} disappeared while editing.`, 'Reopen the display and retry.');
279
+ current = setNodeAttribute(existingTree, connection, 'expression', expression);
280
+ }
281
+ else {
282
+ const depth = depthOf(tree.root, block);
283
+ current = insertChild(tree, block, `${' '.repeat(depth + 1)}<connection name="${escapeAttribute(name)}" expression="${escapeAttribute(expression)}"/>\n`);
284
+ }
285
+ }
286
+ }
287
+ return current;
288
+ }
289
+ /**
290
+ * Remove one object.
291
+ *
292
+ * @param xml - Display XML.
293
+ * @param op - Target object.
294
+ * @returns The modified XML.
295
+ */
296
+ export function deleteObject(xml, op) {
297
+ const tree = parseDisplayTree(xml);
298
+ const node = findNamed(tree.root, op.object);
299
+ if (node === undefined)
300
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
301
+ return removeNode(tree, node);
302
+ }
303
+ /**
304
+ * Move one object within its siblings.
305
+ *
306
+ * @param xml - Display XML.
307
+ * @param op - Target object and the new index.
308
+ * @returns The modified XML.
309
+ */
310
+ export function orderObject(xml, op) {
311
+ const tree = parseDisplayTree(xml);
312
+ const node = findNamed(tree.root, op.object);
313
+ if (node === undefined)
314
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
315
+ const parent = parentOf(tree.root, node);
316
+ if (parent === undefined)
317
+ throw new EngineeringError('UNSUPPORTED_TARGET', 'The display root cannot be reordered.', 'Target an object inside the display.');
318
+ const element = xml.slice(node.start, node.end);
319
+ const without = removeNode(tree, node);
320
+ const next = parseDisplayTree(without);
321
+ const target = op.object;
322
+ const parentName = parent.type === tree.root.type ? undefined : decodeEntities(parent.attributes.name ?? '');
323
+ const container = parentName === undefined ? next.root : findNamed(next.root, parentName);
324
+ if (container === undefined)
325
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `The parent of ${JSON.stringify(target)} disappeared while reordering.`, 'Reopen the display and retry.');
326
+ return insertChild(next, container, element, op.index);
327
+ }
@@ -11,6 +11,7 @@ import { type Coverage, type FsService } from '../core.ts';
11
11
  import { type Finding } from '../checks.ts';
12
12
  import { type Application } from '../model.ts';
13
13
  import { type EditOperation } from './operations.ts';
14
+ import { type SchemaValidation } from '../schema.ts';
14
15
  /** One prepared edit set. */
15
16
  export interface PreparedEdit {
16
17
  /** Files whose text changed, keyed by export-relative path. */
@@ -23,6 +24,8 @@ export interface PreparedEdit {
23
24
  readonly findings: readonly Finding[];
24
25
  /** Stable hash over the source hashes and the operations. */
25
26
  readonly hash: string;
27
+ /** Vendor-schema validation of the changed and added displays. */
28
+ readonly schema: SchemaValidation;
26
29
  }
27
30
  /** Coverage for anything derived from an export. */
28
31
  export declare const EDIT_COVERAGE: Coverage;
@@ -11,7 +11,8 @@ import { createHash } from 'node:crypto';
11
11
  import { EngineeringError } from "../core.js";
12
12
  import { findingsOf } from "../checks.js";
13
13
  import { parseDisplay, parseTagCsv } from "../model.js";
14
- import { renameDisplay, setAttribute, upsertTag } from "./operations.js";
14
+ import { addObject, createDisplay, deleteObject, orderObject, renameDisplay, setAttribute, setObject, upsertTag } from "./operations.js";
15
+ import { validateSchema } from "../schema.js";
15
16
  /** Coverage for anything derived from an export. */
16
17
  export const EDIT_COVERAGE = {
17
18
  complete: false,
@@ -63,6 +64,31 @@ export async function prepareEdit(fs, app, operations, signal) {
63
64
  texts.set(operation.file, setAttribute(current, operation.object, operation.attribute, operation.value));
64
65
  continue;
65
66
  }
67
+ if (operation.kind === 'display_create') {
68
+ if (displayNames.has(operation.name) || added.includes(operation.file)) {
69
+ throw new EngineeringError('COMPONENT_EXISTS', `A display named ${JSON.stringify(operation.name)} already exists in this export or draft.`, 'Pass a new display name.');
70
+ }
71
+ if (app.files.some(file => file.file.toLowerCase() === operation.file.toLowerCase())) {
72
+ throw new EngineeringError('COMPONENT_EXISTS', `The export already has a file ${operation.file}.`, 'Pass a different file path.');
73
+ }
74
+ texts.set(operation.file, createDisplay(operation));
75
+ added.push(operation.file);
76
+ displayNames.add(operation.name);
77
+ continue;
78
+ }
79
+ if (operation.kind === 'object_add' || operation.kind === 'object_set' || operation.kind === 'object_delete' || operation.kind === 'object_order') {
80
+ const known = displayByFile.has(operation.file) || added.includes(operation.file);
81
+ if (!known) {
82
+ throw new EngineeringError('COMPONENT_NOT_FOUND', `The draft targets ${operation.file}, which this export no longer contains.`, 'Reopen the export and stage the edit again.');
83
+ }
84
+ const current = texts.get(operation.file) ?? await readText(fs, app.root, operation.file, signal);
85
+ const next = operation.kind === 'object_add' ? addObject(current, operation)
86
+ : operation.kind === 'object_set' ? setObject(current, operation)
87
+ : operation.kind === 'object_delete' ? deleteObject(current, operation)
88
+ : orderObject(current, operation);
89
+ texts.set(operation.file, next);
90
+ continue;
91
+ }
66
92
  if (operation.kind === 'display_add') {
67
93
  const source = app.displays.find(display => display.name === operation.copyFrom);
68
94
  if (source === undefined) {
@@ -122,12 +148,20 @@ export async function prepareEdit(fs, app, operations, signal) {
122
148
  return !changedFiles.has(file);
123
149
  });
124
150
  const findings = findingsOf({ displays, tags, tagErrors, diagnostics });
151
+ // Validate only the documents this draft writes: the unchanged ones were
152
+ // already read by the session, and re-validating 18 displays per call would
153
+ // make every edit pay for the whole export.
154
+ const changedDisplays = [...texts]
155
+ .filter(([file]) => /\.xml$/iu.test(file) && !/batchimport/iu.test(file))
156
+ .map(([file, text]) => ({ file, xml: text }));
157
+ const schema = await validateSchema(changedDisplays);
125
158
  const hashInput = app.files.map(file => `${file.file}:${file.sha256}`).join('\n') + '\n' + JSON.stringify(operations);
126
159
  return {
127
160
  texts,
128
161
  added,
129
162
  changed: [...app.files.map(file => file.file), ...added],
130
163
  findings,
164
+ schema,
131
165
  hash: createHash('sha256').update(hashInput).digest('hex'),
132
166
  };
133
167
  }