@aryacode/dsh-engineering 0.4.22 → 0.5.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 CHANGED
@@ -1,5 +1,83 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-10-27
4
+
5
+ Commissioned against the Laminator fixtures: a real Studio 5000 ACD exported to a 5.4 MB L5X, a
6
+ real FactoryTalk View ME export, and two MotionWorks projects.
7
+
8
+ ### Fixed — FactoryTalk View could not read a real Studio export
9
+
10
+ `ftview_open` reported **0 displays and 0 tags** for a genuine ME export. Five independent causes:
11
+
12
+ - **The tag CSV delimiter was hard-coded to `;`** while Studio exports are comma-separated and use a
13
+ leading `;` only as a comment prefix, so every data row parsed as one cell. The separator is now
14
+ detected per line with quote awareness.
15
+ - **The header was assumed to follow the `###002` line.** A real export writes the header first, then
16
+ `###002`, then `;Folders Section` / `;Tag Section` comments, and the parser took the first comment
17
+ for the header. It now finds a `Tag Name` header on either side of the version line.
18
+ - **Short rows were rejected.** Studio omits trailing empty columns, so 29-column headers come with
19
+ 23-column rows. Only over-long rows are errors now.
20
+ - **`<gfx>` displays were refused.** ME display XML is `<gfx>` (Gfx-ME17.xsd), not `<display>`.
21
+ - **UTF-16 files were decoded as UTF-8**, producing a root element of `\0g\0f\0x`; BOM sniffing now
22
+ selects UTF-16LE.
23
+
24
+ Also normalised the host contract: `scanApplication` accepts the `FsTarget` that `fs.resolve`
25
+ returns instead of calling string methods on it.
26
+
27
+ Verified on `Laminator/HMI/Main`: **18 displays, 361 tags, 0 parse errors** (was 0 and 0). Reading a
28
+ `<gfx>` export is supported; the staged edit tools were built for the `<display>` shape and are not
29
+ verified to rewrite `<gfx>` objects.
30
+
31
+ ### Fixed — Studio 5000 checks drowned real findings in false positives
32
+
33
+ `run_checks` on the Laminator export produced 562 findings, 315 of them "unresolved reference", on a
34
+ project that compiles. Every one was an alias to module I/O (`In_FF_ModulePresent` →
35
+ `FanFold_StraightCut_AENTR:2:I.7`): aliases carry no `DataType`, their targets are not declared
36
+ `<Tag>`s, and the fast resolver stopped instead of falling back to the canonical one.
37
+
38
+ - `resolveSymbol` now falls back to `resolveTag`, and an empty type with a trailing numeric part
39
+ resolves as a bit.
40
+ - A member of a module-defined or opaque type (`Drive_MCFG.CommFLT`, `Axis_MAH.PC`) and `S:`/`R:`
41
+ system flags are reported as coverage limits, not unresolved references.
42
+
43
+ **562 → 264 findings; unresolved 315 → 16**, with the unused-tag and multiple-write findings intact.
44
+
45
+ ### Fixed — Studio 5000 edits
46
+
47
+ - **`add_tag` required a value for a scalar tag.** Studio defaults scalars to zero, so declaring a
48
+ plain REAL was impossible without inventing a value; `value` is now optional and still validated
49
+ when supplied.
50
+ - **Live reads refused aliases.** `read_live_tags` and `poll_live_tags` ran the write rule, so
51
+ ordinary module-point BOOLs were rejected as "not a writable declared scalar". Reads and polls now
52
+ take an alias's type from the canonical resolver; writes still refuse an alias and name the tag to
53
+ write instead.
54
+ - **`export_acd_to_l5x` failed when Studio had the ACD open.** The SDK opens the file itself, so the
55
+ normal case — the project the user is working on — failed with "being used by another process". The
56
+ export now copies the ACD and opens the copy, with the source hash re-checked before and after.
57
+
58
+ Verified end to end on the Laminator export: `draft_tag` (REAL, no value) plus a rung replacement
59
+ validated and applied to a 5,391,840-byte L5X.
60
+
61
+ ### Fixed — MotionWorks IEC
62
+
63
+ - **`mw_get kind="block"` crashed with `KeyError: 'native_libraries'`.** The block interface is decoded
64
+ against the installed libraries as well as the saved project; the read service now supplies the live
65
+ inventory and falls back to the project's own registry when no IDE answers.
66
+ - **`mw_edit_variables` refused with "Live declarations disagree with saved baseline"** and nothing
67
+ else. The refusal now names the missing, unexpected and changed declarations with their live and
68
+ saved values.
69
+
70
+ Verified on both Laminator projects through the shipped engine: attach into
71
+ `backups/motionworks/<project>/<stamp>/`, then `workflow_check`, `pous`, `read_text`, `validate`,
72
+ `tasks`, `types`, `globals` and `library` all succeed (8 POUs, 5 tasks, ~170 globals, 297 types,
73
+ 23 blocks each).
74
+
75
+ ### Documentation
76
+
77
+ - `docs/optix-netlogic.md`: how the FactoryTalk Optix NetLogic cheat sheet should be carried — a
78
+ pinned snapshot, a generated index, and one `optix_netlogic` retrieval tool, with the risk flags and
79
+ disclaimers the repository itself requires.
80
+
3
81
  ## [0.4.22] - 2026-10-27
4
82
 
5
83
  ### Fixed
package/README.md CHANGED
@@ -91,7 +91,9 @@ Where each surface actually stands, based on what has been exercised.
91
91
  downloads accepting only a verification id produced by a passed build
92
92
  - Live EtherNet/IP: polling, one-request batch reads of up to 64 tags, controller identity and clock, LAN discovery, and a bounded trend
93
93
  - Supervised live writes: `write_live_tag` and `write_controller_clock` need the row's `allowWrites` switch **and** per-call human approval, and report the value the controller reads back
94
- - FactoryTalk View ME: export reads, structural checks, comparison, staged display and HMI tag edits
94
+ - FactoryTalk View ME: export reads, structural checks, comparison, staged display and HMI tag edits;
95
+ reads verified on a real Studio export (`<gfx>` displays and the comma-delimited tag CSV —
96
+ 18 displays, 361 tags, no parse errors)
95
97
  - MotionWorks IEC: verified live against 3 Pro 1.19 — direct open with a verified pre-edit backup
96
98
  under `.motionworks/attached`, attach-without-reload to a project the IDE already has open, start,
97
99
  worksheet, close, saved-project reads, compile diagnostics, code replacement, declaration add, POU
@@ -118,6 +120,7 @@ the lean profile is the one that turns them off.
118
120
 
119
121
  | Version | What landed |
120
122
  |---|---|
123
+ | **0.5.0** | Commissioned on real files: FactoryTalk View reads genuine Studio exports (`<gfx>` displays, comma CSVs, UTF-16) instead of reporting zero; L5X alias resolution cuts 315 false "unresolved reference" findings to 16 on a 1279-tag project; `add_tag` no longer demands a value; a locked ACD exports from a copy |
121
124
  | **0.4.17** | MotionWorks edits the real project again: the pre-edit backup is written where the bridge and engine verify it, an already-open project is attached instead of reloaded, and `mw_get` returns the body hash the edit takes |
122
125
  | **0.4.15** | Every tool surface ships on by default; the row Configure pages switch them off |
123
126
  | **0.4.4** | One row per product: five rows, optional surfaces moved into each row's config |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-engineering",
3
- "version": "0.4.22",
3
+ "version": "0.5.0",
4
4
  "description": "AryaCode engineering bundle: one install surface for the Studio 5000 L5X, FactoryTalk View ME, MotionWorks IEC and FactoryTalk Optix surfaces, one row per product, with the ladder and display previews.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -13,6 +13,12 @@ A staged edit is written to a NEW export folder; the source is never modified. T
13
13
  a manual import for a human; it does not drive Studio, contact a terminal, or decode MER or MED
14
14
  databases.
15
15
 
16
+ Both export shapes are read: the Studio ME export (`<gfx>` displays, `Gfx-ME17.xsd`, a comma-delimited
17
+ `Tags/*.csv` with its header before the `###002` line and `;` comment rows) and the older
18
+ `<display>`-root files. Staged attribute edits were built for the `<display>` shape; on a `<gfx>`
19
+ export, read and verify with this surface and make the change in Studio, or confirm the edit applied
20
+ before relying on it.
21
+
16
22
  ## Backups
17
23
 
18
24
  Every engineering product backs up into the same tree, so a backup means one place whichever surface
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module dsh-engineering-ftview/model
10
10
  */
11
- import { type FsService } from './core.ts';
11
+ import { type FsService, type FsTarget } from './core.ts';
12
12
  /** One row of the HMI tag CSV, keyed by its canonical columns. */
13
13
  export interface TagRow {
14
14
  readonly name: string;
@@ -51,11 +51,16 @@ export interface TagCsv {
51
51
  readonly rows: readonly TagRow[];
52
52
  readonly errors: readonly string[];
53
53
  }
54
- /** Split one semicolon-separated CSV line, honouring double quotes. */
54
+ /** Split one CSV line, honouring double quotes and the line's own separator. */
55
55
  export declare function splitCsv(line: string): string[];
56
56
  /**
57
57
  * Parse the HMI tag CSV.
58
58
  *
59
+ * The ME export writes an optional header first, then a `###002` version line, then
60
+ * `;` comment/section lines and the data rows. Earlier builds assumed the header
61
+ * followed the version line and treated `;Folders Section` as the header, so a real
62
+ * export parsed as zero tags.
63
+ *
59
64
  * @param text - Whole CSV file.
60
65
  * @returns Rows plus structural errors; a file with no `###002` marker yields no rows.
61
66
  */
@@ -77,4 +82,4 @@ export declare function parseDisplay(file: string, text: string): DisplayFile;
77
82
  * @param maxFiles - Largest number of display/tag files accepted.
78
83
  * @returns The scanned application.
79
84
  */
80
- export declare function scanApplication(fs: FsService, root: string, signal: AbortSignal | undefined, maxFiles: number): Promise<Application>;
85
+ export declare function scanApplication(fs: FsService, root: string | FsTarget, signal: AbortSignal | undefined, maxFiles: number): Promise<Application>;
@@ -14,8 +14,30 @@ import { XMLParser } from 'fast-xml-parser';
14
14
  import { EngineeringError } from "./core.js";
15
15
  /** Attribute prefix the XML parser uses. */
16
16
  const ATTRIBUTE = '@_';
17
- /** Split one semicolon-separated CSV line, honouring double quotes. */
17
+ /**
18
+ * Pick the field separator of one line.
19
+ *
20
+ * A Studio ME tag export is comma-separated and uses a leading `;` as a comment
21
+ * prefix; older/semicolon exports exist too. Splitting on a fixed `;` turned every
22
+ * real row into one cell, so a genuine export parsed as zero tags.
23
+ */
24
+ function delimiterOf(line) {
25
+ let commas = 0;
26
+ let semicolons = 0;
27
+ let quoted = false;
28
+ for (const character of line) {
29
+ if (character === '"')
30
+ quoted = !quoted;
31
+ else if (!quoted && character === ',')
32
+ commas += 1;
33
+ else if (!quoted && character === ';')
34
+ semicolons += 1;
35
+ }
36
+ return commas >= semicolons ? ',' : ';';
37
+ }
38
+ /** Split one CSV line, honouring double quotes and the line's own separator. */
18
39
  export function splitCsv(line) {
40
+ const delimiter = delimiterOf(line);
19
41
  const cells = [];
20
42
  let current = '';
21
43
  let quoted = false;
@@ -38,7 +60,7 @@ export function splitCsv(line) {
38
60
  else if (character === '"') {
39
61
  quoted = true;
40
62
  }
41
- else if (character === ';') {
63
+ else if (character === delimiter) {
42
64
  cells.push(current);
43
65
  current = '';
44
66
  }
@@ -49,32 +71,47 @@ export function splitCsv(line) {
49
71
  cells.push(current);
50
72
  return cells;
51
73
  }
52
- /** Decode bytes as UTF-8, dropping a byte-order mark. */
74
+ /** Decode bytes as UTF-8 or UTF-16, dropping a byte-order mark. */
53
75
  function decode(bytes) {
76
+ // Studio writes most exports as UTF-8, but some generated files (BatchImport) are
77
+ // UTF-16LE. Decoding those as UTF-8 produced a root element of "\0g\0f\0x".
78
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
79
+ return new TextDecoder('utf-16le').decode(bytes.subarray(2));
54
80
  const text = new TextDecoder('utf-8').decode(bytes);
55
81
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
56
82
  }
57
83
  /**
58
84
  * Parse the HMI tag CSV.
59
85
  *
86
+ * The ME export writes an optional header first, then a `###002` version line, then
87
+ * `;` comment/section lines and the data rows. Earlier builds assumed the header
88
+ * followed the version line and treated `;Folders Section` as the header, so a real
89
+ * export parsed as zero tags.
90
+ *
60
91
  * @param text - Whole CSV file.
61
92
  * @returns Rows plus structural errors; a file with no `###002` marker yields no rows.
62
93
  */
63
94
  export function parseTagCsv(text) {
64
95
  const lines = text.split(/\r?\n/);
96
+ const isComment = (line) => line.trimStart().startsWith(';');
65
97
  const versionLine = lines.findIndex(line => line.includes('###002'));
66
98
  if (versionLine < 0) {
67
99
  return { rows: [], errors: ['Unsupported tag CSV: the `###002` version line is missing.'] };
68
100
  }
69
- const headerIndex = lines.findIndex((line, index) => index > versionLine && line.trim() !== '');
101
+ const headerCells = (line) => splitCsv(line).map((cell, index) => {
102
+ const trimmed = cell.trim();
103
+ return index === 0 ? trimmed.replace(/^;/u, '').trim() : trimmed;
104
+ });
105
+ // Prefer a real header before the version line; fall back to the first non-comment
106
+ // line after it for files that put the header last.
107
+ const before = lines.slice(0, versionLine).findIndex(line => headerCells(line).includes('Tag Name'));
108
+ const after = lines.findIndex((line, index) => index > versionLine && line.trim() !== '' && !isComment(line) && headerCells(line).includes('Tag Name'));
109
+ const headerIndex = before >= 0 ? before : after;
70
110
  if (headerIndex < 0) {
71
- return { rows: [], errors: ['Unsupported tag CSV: no header row follows the version line.'] };
111
+ return { rows: [], errors: ['Unsupported tag CSV: no header row with a `Tag Name` column was found.'] };
72
112
  }
73
- const header = splitCsv(lines[headerIndex]).map(cell => cell.trim());
113
+ const header = headerCells(lines[headerIndex]);
74
114
  const nameIndex = header.indexOf('Tag Name');
75
- if (nameIndex < 0) {
76
- return { rows: [], errors: ['Unsupported tag CSV: the header has no `Tag Name` column.'] };
77
- }
78
115
  const descriptionIndex = header.indexOf('Tag Description');
79
116
  const typeIndex = header.indexOf('Tag Type');
80
117
  const rows = [];
@@ -82,11 +119,15 @@ export function parseTagCsv(text) {
82
119
  const seen = new Set();
83
120
  for (let index = headerIndex + 1; index < lines.length; index += 1) {
84
121
  const line = lines[index];
85
- if (line.trim() === '')
122
+ if (line.trim() === '' || isComment(line))
123
+ continue;
124
+ if (index < versionLine)
86
125
  continue;
87
126
  const cells = splitCsv(line);
88
- if (cells.length < header.length) {
89
- errors.push(`Row ${index + 1}: expected ${header.length} columns, found ${cells.length}.`);
127
+ // Studio omits trailing empty columns, so a data row is legitimately shorter
128
+ // than the header; only a longer row is malformed.
129
+ if (cells.length > header.length) {
130
+ errors.push(`Row ${index + 1}: expected at most ${header.length} columns, found ${cells.length}.`);
90
131
  continue;
91
132
  }
92
133
  const raw = {};
@@ -165,7 +206,9 @@ export function parseDisplay(file, text) {
165
206
  if (rootKey === undefined)
166
207
  return { file, name: fallbackName, objects: [], parseError: 'The file has no root element.' };
167
208
  const rootNode = parsed[rootKey];
168
- if (!/display/iu.test(rootKey)) {
209
+ // ME display files use <gfx> (Gfx-ME17.xsd) in a Studio export; some hand-made
210
+ // or older files use a <display> root. Both are display files.
211
+ if (!/display/iu.test(rootKey) && !/^gfx$/iu.test(rootKey)) {
169
212
  return { file, name: fallbackName, objects: [], parseError: `Root element ${rootKey} is not an ME display.` };
170
213
  }
171
214
  const root = (rootNode ?? {});
@@ -233,13 +276,17 @@ async function walk(fs, root, relative, signal, visit, budget) {
233
276
  * @returns The scanned application.
234
277
  */
235
278
  export async function scanApplication(fs, root, signal, maxFiles) {
279
+ // The host's `fs.resolve` returns an FsTarget, while the walk below composes
280
+ // string paths for `listDir`/`readBytes`. Normalise once here: passing the target
281
+ // straight into the walker crashed every open on a host that returns one.
282
+ const base = typeof root === 'string' ? root : root.displayPath;
236
283
  const displays = [];
237
284
  const tags = [];
238
285
  const tagFiles = [];
239
286
  const tagErrors = [];
240
287
  const diagnostics = [];
241
288
  const files = [];
242
- await walk(fs, root, '', signal, async (file, absolute) => {
289
+ await walk(fs, base, '', signal, async (file, absolute) => {
243
290
  const bytes = await fs.readBytes(absolute, signal, 32 * 1024 * 1024);
244
291
  const text = decode(bytes);
245
292
  files.push({ file, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
@@ -262,6 +309,6 @@ export async function scanApplication(fs, root, signal, maxFiles) {
262
309
  }
263
310
  displays.push(display);
264
311
  }, { count: 0, max: maxFiles });
265
- const name = root.replace(/[\\/]$/u, '').split(/[\\/]/u).pop() ?? root;
266
- return { root, name, displays, tags, tagFiles, tagErrors, diagnostics, files };
312
+ const name = base.replace(/[\\/]$/u, '').split(/[\\/]/u).pop() ?? base;
313
+ return { root: base, name, displays, tags, tagFiles, tagErrors, diagnostics, files };
267
314
  }
@@ -15,7 +15,15 @@ export interface Evidence {
15
15
  readonly confidence: 'high' | 'medium' | 'low';
16
16
  readonly context: string;
17
17
  }
18
- /** Resolve only declarations proven by the export. Never guess an absent member. */
18
+ /**
19
+ * Resolve a written symbol to a canonical declaration.
20
+ *
21
+ * The fast path walks the declaration and alias tables. It stops where the
22
+ * operand behind an alias is not a declared `<Tag>` — module I/O such as
23
+ * `FanFold_StraightCut_AENTR:2:I.7` — which is common in a real project: those
24
+ * references are proven by the export, not missing. Fall back to the canonical
25
+ * resolver (the one behind `resolve_tag`) before declaring a symbol unresolved.
26
+ */
19
27
  export declare function resolveSymbol(document: L5xDocument, scope: string, symbol: string): string | undefined;
20
28
  export declare function evidenceOf(project: ProjectSnapshot): readonly Evidence[];
21
29
  export declare function findReferences(project: ProjectSnapshot, symbol: string, scope?: string): readonly Evidence[];
@@ -1,6 +1,7 @@
1
1
  import { controllerElementOf } from "../model/parse.js";
2
2
  import { elementChildren, firstChild } from "../model/xml.js";
3
3
  import { memberOf } from "./details.js";
4
+ import { resolveTag } from "./tag-resolution.js";
4
5
  const TOKEN = /[A-Za-z_][A-Za-z0-9_]*(?::[A-Za-z0-9_]+)*(?:\[[^\]]+\])?(?:\.[A-Za-z0-9_]+(?:\[[^\]]+\])?)*/gu;
5
6
  const LADDER = /([A-Za-z_][A-Za-z0-9_]*)\s*\(([^()]*)\)/gu;
6
7
  const READ_OPS = new Set(['XIC', 'XIO', 'EQU', 'EQ', 'NEQ', 'NE', 'GRT', 'GT', 'GEQ', 'GE', 'LES', 'LT', 'LEQ', 'LE', 'LIM', 'LIMIT', 'CMP']);
@@ -82,8 +83,24 @@ function tableOf(document) {
82
83
  tables.set(document, table);
83
84
  return table;
84
85
  }
85
- /** Resolve only declarations proven by the export. Never guess an absent member. */
86
+ /**
87
+ * Resolve a written symbol to a canonical declaration.
88
+ *
89
+ * The fast path walks the declaration and alias tables. It stops where the
90
+ * operand behind an alias is not a declared `<Tag>` — module I/O such as
91
+ * `FanFold_StraightCut_AENTR:2:I.7` — which is common in a real project: those
92
+ * references are proven by the export, not missing. Fall back to the canonical
93
+ * resolver (the one behind `resolve_tag`) before declaring a symbol unresolved.
94
+ */
86
95
  export function resolveSymbol(document, scope, symbol) {
96
+ const direct = resolveSymbolDirect(document, scope, symbol);
97
+ if (direct !== undefined)
98
+ return direct;
99
+ const resolution = resolveTag(document, scope, symbol);
100
+ return resolution.resolved ? resolution.canonical : undefined;
101
+ }
102
+ /** Resolve only declarations proven by the export. Never guess an absent member. */
103
+ function resolveSymbolDirect(document, scope, symbol) {
87
104
  const table = tableOf(document);
88
105
  const aoi = document.aois.find(entry => entry.name === scope);
89
106
  const parts = symbol.replace(/\[[^\]]+\]/gu, '').split('.');
@@ -96,6 +113,7 @@ export function resolveSymbol(document, scope, symbol) {
96
113
  let tag = table.tags.get(tagKey(scope, base)) ?? table.tags.get(tagKey('controller', base));
97
114
  if (tag === undefined)
98
115
  return undefined;
116
+ const declared = tag;
99
117
  let resolvedBase = tag.name;
100
118
  const seen = new Set();
101
119
  while (tag !== undefined) {
@@ -111,8 +129,12 @@ export function resolveSymbol(document, scope, symbol) {
111
129
  if (aliasBase === undefined)
112
130
  break;
113
131
  tag = table.tags.get(tagKey(tag.scope, aliasBase)) ?? table.tags.get(tagKey('controller', aliasBase));
132
+ // A module I/O operand (`Local:1:I.Data[0].5`) is not a declared <Tag>: the
133
+ // export proves the alias, not the operand behind it. Resolving to the alias
134
+ // is what stops 315 real references being reported as unresolved, while
135
+ // resolveTag still canonicalises the operand for callers that ask to resolve.
114
136
  if (tag === undefined)
115
- return undefined;
137
+ return `${declared.scope}::${declared.name}${parts.length > 0 ? `.${parts.join('.')}` : ''}`;
116
138
  }
117
139
  const path = [...resolvedBase.split('.').slice(1), ...parts];
118
140
  if (path.length > 0) {
@@ -120,7 +142,10 @@ export function resolveSymbol(document, scope, symbol) {
120
142
  for (const [index, part] of path.entries()) {
121
143
  if (/^\d+$/u.test(part)) {
122
144
  // `Alarm[8].12` is bit 12 of an integer element; nothing may follow it.
123
- if (index !== path.length - 1 || !INTEGER_TYPES.has(type?.toUpperCase() ?? ''))
145
+ // Module I/O data carries no builtin DataType in the export, so an empty
146
+ // type with a trailing numeric part is still a bit of that module data.
147
+ const known = type === undefined || type === '' ? undefined : type.toUpperCase();
148
+ if (index !== path.length - 1 || (known !== undefined && !INTEGER_TYPES.has(known)))
124
149
  return undefined;
125
150
  type = 'BOOL';
126
151
  continue;
@@ -137,11 +137,12 @@ function parseOne(value, declaredTypes) {
137
137
  if (dimensions !== undefined && row.value !== undefined)
138
138
  throw new EngineeringError('INVALID_CHANGE_SET', 'An array tag takes Studio default element values; omit value');
139
139
  if (dimensions === undefined && scalar) {
140
- if (row.value === undefined)
141
- throw new EngineeringError('INVALID_CHANGE_SET', 'value is required for a scalar tag; omit value for an array or declared-type tag');
142
- if (!fitsScalar(dataType, row.value))
140
+ // A Logix scalar defaults to its type's zero. Requiring `value` here made
141
+ // the ordinary "declare a REAL" operation invalid, which is what failed
142
+ // two add_tag operations in a live session.
143
+ if (row.value !== undefined && !fitsScalar(dataType, row.value))
143
144
  throw new EngineeringError('INVALID_CHANGE_SET', `value ${String(row.value)} does not fit ${dataType}; BOOL accepts 0/1 and integer types accept their signed/unsigned range`);
144
- return { operation: row.operation, scope: identifier(row, 'scope'), name: identifier(row, 'name'), data_type: dataType, value: row.value, description: text(row, 'description'), ...(access === undefined ? {} : { external_access: access }) };
145
+ return { operation: row.operation, scope: identifier(row, 'scope'), name: identifier(row, 'name'), data_type: dataType, ...(row.value === undefined ? {} : { value: row.value }), description: text(row, 'description'), ...(access === undefined ? {} : { external_access: access }) };
145
146
  }
146
147
  return { operation: row.operation, scope: identifier(row, 'scope'), name: identifier(row, 'name'), data_type: dataType, ...(dimensions === undefined ? {} : { dimensions }), description: text(row, 'description'), ...(access === undefined ? {} : { external_access: access }) };
147
148
  }
@@ -17,6 +17,7 @@ export interface ScalarTag {
17
17
  name: string;
18
18
  program?: string;
19
19
  data_type: Scalar;
20
+ alias_for?: string;
20
21
  }
21
22
  /**
22
23
  * Resolve the controller address and slot both read and write share.
@@ -11,6 +11,7 @@ import { isIPv4 } from 'node:net';
11
11
  import { EngineeringError } from "../session.js";
12
12
  import { aliasesOf } from "./configuration.js";
13
13
  import { findReferences } from "./analysis.js";
14
+ import { resolveTag } from "./tag-resolution.js";
14
15
  import { controllerElementOf } from "../model/parse.js";
15
16
  import { elementChildren, firstChild } from "../model/xml.js";
16
17
  /** Scalar types this surface can read and write. */
@@ -18,6 +19,74 @@ const SCALARS = ['BOOL', 'INT', 'DINT', 'REAL'];
18
19
  /** Integer ranges enforced before a write leaves the machine. */
19
20
  const RANGES = { INT: [-32768, 32767], DINT: [-2147483648, 2147483647] };
20
21
  const isScalar = (value) => typeof value === 'string' && SCALARS.includes(value);
22
+ /** Resolve one entry against the export: name, scope, declaration and alias target. */
23
+ function declaredTag(project, entry, errorCode = 'UNSUPPORTED_WRITE_TAG') {
24
+ if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string'
25
+ || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(entry.name)
26
+ || !Object.keys(entry).every(key => key === 'name' || key === 'scope'))
27
+ throw new EngineeringError(errorCode, 'Each tag needs an exact tag name and optional scope only');
28
+ const scope = 'scope' in entry ? entry.scope : 'controller';
29
+ if (typeof scope !== 'string' || (scope !== 'controller' && !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(scope)))
30
+ throw new EngineeringError('INVALID_POLL', 'scope must be controller or an exact program name');
31
+ const declared = project.document.tags.find(tag => tag.name === entry.name && tag.scope === scope);
32
+ const controller = controllerElementOf(project.document);
33
+ const programs = firstChild(controller, 'Programs');
34
+ const container = scope === 'controller' ? controller : programs === undefined ? undefined
35
+ : elementChildren(programs, 'Program').find(program => program.attrs.Name === scope);
36
+ const tagContainer = container === undefined ? undefined : firstChild(container, 'Tags');
37
+ const declaration = tagContainer === undefined ? undefined : elementChildren(tagContainer, 'Tag').find(tag => tag.attrs.Name === entry.name);
38
+ const dimensions = declaration?.attrs.Dimensions ?? declaration?.attrs.Dimension ?? '';
39
+ if (!declared) {
40
+ throw new EngineeringError(errorCode, `${String(entry.name)} is not a declared tag in this export`, 'Open the export that declares it, or check the name with list_components kind="tag".');
41
+ }
42
+ return {
43
+ name: declared.name,
44
+ scope,
45
+ dataType: declared.dataType,
46
+ ...(declaration?.attrs.AliasFor === undefined ? {} : { aliasFor: declaration.attrs.AliasFor }),
47
+ dimensions,
48
+ ...(declaration?.attrs.ExternalAccess === undefined ? {} : { externalAccess: declaration.attrs.ExternalAccess }),
49
+ };
50
+ }
51
+ /**
52
+ * Resolve one *readable* scalar.
53
+ *
54
+ * Reading is not writing: an alias resolves to the same value the controller
55
+ * serves under either name, and ExternalAccess="None" only stops writes. Refusing
56
+ * aliases here made the ordinary case — a BOOL alias onto a module point — look
57
+ * like a mistyped tag.
58
+ */
59
+ function resolveReadable(project, entry, errorCode = 'UNSUPPORTED_WRITE_TAG') {
60
+ const tag = declaredTag(project, entry, errorCode);
61
+ // An alias carries no DataType of its own. resolveTag follows it to the operand
62
+ // behind it, including module I/O that is not a declared <Tag> — the same
63
+ // resolution the resolve_tag tool reports.
64
+ const resolution = isScalar(tag.dataType) ? undefined : resolveTag(project.document, tag.scope, tag.name);
65
+ const type = isScalar(tag.dataType) ? tag.dataType
66
+ : resolution?.resolved === true && isScalar(resolution.data_type) ? resolution.data_type : undefined;
67
+ if (!isScalar(type)) {
68
+ throw new EngineeringError(errorCode, `${tag.name} is ${tag.dataType || 'an undeclared type'}; this surface reads BOOL, INT, DINT and REAL scalars`, 'Read a scalar tag, or use the export tools for structured values.');
69
+ }
70
+ if (tag.dimensions !== '' && tag.dimensions !== '0') {
71
+ throw new EngineeringError(errorCode, `${tag.name} is an array (${tag.dimensions}); this surface reads one scalar at a time`, 'Read one element with a name the controller declares, or use the export tools for the array.');
72
+ }
73
+ return { name: tag.name, ...(tag.scope === 'controller' ? {} : { program: tag.scope }), data_type: type, ...(tag.aliasFor === undefined ? {} : { alias_for: tag.aliasFor }) };
74
+ }
75
+ /**
76
+ * Resolve one *writable* scalar: no alias, external access, declared range.
77
+ */
78
+ function resolveWritable(project, entry) {
79
+ const tag = declaredTag(project, entry);
80
+ if (aliasesOf(project.document).has(`${tag.scope}::${tag.name}`))
81
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${tag.name} is an alias; write the tag it points at`, 'Read aliases freely; a write must name the declared tag so the controller changes one known operand.');
82
+ if (!isScalar(tag.dataType))
83
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${tag.name} is ${tag.dataType || 'an undeclared type'}; writes accept BOOL, INT, DINT and REAL scalars`, 'Read the value, or use the export tools for structured values.');
84
+ if (tag.dimensions !== '' && tag.dimensions !== '0')
85
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${tag.name} is an array (${tag.dimensions}); write one scalar at a time`, 'Write one element the controller declares, or use the export tools for the array.');
86
+ if (tag.externalAccess === 'None')
87
+ throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${tag.name} is ExternalAccess="None"; the controller refuses external writes`, 'Change ExternalAccess in Studio, or write a different tag.');
88
+ return { name: tag.name, ...(tag.scope === 'controller' ? {} : { program: tag.scope }), data_type: tag.dataType };
89
+ }
21
90
  /**
22
91
  * Resolve the controller address and slot both read and write share.
23
92
  * @param input - Tool arguments.
@@ -48,7 +117,7 @@ export function batchReadRequest(project, input, config, maxBatch) {
48
117
  const raw = typeof input.tags_json === 'string' ? JSON.parse(input.tags_json) : null;
49
118
  if (!Array.isArray(raw) || raw.length < 1 || raw.length > maxBatch)
50
119
  throw new EngineeringError('INVALID_POLL', `tags must contain 1 through ${String(maxBatch)} declared scalar tags`);
51
- const tags = raw.map((entry) => resolveScalar(project, entry));
120
+ const tags = raw.map((entry) => resolveReadable(project, entry));
52
121
  const seen = new Set();
53
122
  for (const tag of tags) {
54
123
  const key = `${tag.program ?? 'controller'}::${tag.name}`.toLowerCase();
@@ -78,7 +147,7 @@ export function writableTagRequest(project, input, config) {
78
147
  const target = controllerTarget(input, config);
79
148
  const name = input.name;
80
149
  const program = input.program;
81
- const tag = resolveScalar(project, { name, ...(program === undefined ? {} : { scope: program }) });
150
+ const tag = resolveWritable(project, { name, ...(program === undefined ? {} : { scope: program }) });
82
151
  const value = input.value;
83
152
  if (tag.data_type === 'BOOL') {
84
153
  if (typeof value !== 'boolean')
@@ -108,27 +177,3 @@ export function discoverRequest(input, config) {
108
177
  throw new EngineeringError('INVALID_POLL', 'duration_seconds must be an integer between 1 and 15');
109
178
  return { request: { op: 'discover', duration: seconds * 1000, timeout: config.requestTimeoutMs } };
110
179
  }
111
- /** Resolve one declared scalar (never an alias, array or unknown tag). */
112
- function resolveScalar(project, entry) {
113
- if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string'
114
- || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(entry.name)
115
- || !Object.keys(entry).every(key => key === 'name' || key === 'scope'))
116
- throw new EngineeringError('UNSUPPORTED_WRITE_TAG', 'Each tag needs an exact scalar name and optional scope only');
117
- const scope = 'scope' in entry ? entry.scope : 'controller';
118
- if (typeof scope !== 'string' || (scope !== 'controller' && !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(scope)))
119
- throw new EngineeringError('INVALID_POLL', 'scope must be controller or an exact program name');
120
- const declared = project.document.tags.find(tag => tag.name === entry.name && tag.scope === scope);
121
- const controller = controllerElementOf(project.document);
122
- const programs = firstChild(controller, 'Programs');
123
- const container = scope === 'controller' ? controller : programs === undefined ? undefined
124
- : elementChildren(programs, 'Program').find(program => program.attrs.Name === scope);
125
- const tagContainer = container === undefined ? undefined : firstChild(container, 'Tags');
126
- const declaration = tagContainer === undefined ? undefined : elementChildren(tagContainer, 'Tag').find(tag => tag.attrs.Name === entry.name);
127
- const dimensions = declaration?.attrs.Dimensions ?? declaration?.attrs.Dimension ?? '';
128
- if (!declared || aliasesOf(project.document).has(`${scope}::${declared.name}`)
129
- || !isScalar(declared.dataType)
130
- || (dimensions !== '' && dimensions !== '0')
131
- || declaration?.attrs.ExternalAccess === 'None')
132
- throw new EngineeringError('UNSUPPORTED_WRITE_TAG', `${String(entry.name)} is not a writable declared scalar BOOL, INT, DINT or REAL`, 'Reads accept the same tags; writes additionally need ExternalAccess and no array dimensions.');
133
- return { name: declared.name, ...(scope === 'controller' ? {} : { program: scope }), data_type: declared.dataType };
134
- }
@@ -2,8 +2,8 @@
2
2
  import { Worker } from 'node:worker_threads';
3
3
  import { isIPv4 } from 'node:net';
4
4
  import { EngineeringError } from "../session.js";
5
- import { aliasesOf } from "./configuration.js";
6
5
  import { findReferences } from "./analysis.js";
6
+ import { resolveTag } from "./tag-resolution.js";
7
7
  import { controllerElementOf } from "../model/parse.js";
8
8
  import { elementChildren, firstChild } from "../model/xml.js";
9
9
  /**
@@ -49,15 +49,21 @@ export function pollingRequest(project, input, config) {
49
49
  const tagContainer = container === undefined ? undefined : firstChild(container, 'Tags');
50
50
  const declaration = tagContainer === undefined ? undefined : elementChildren(tagContainer, 'Tag').find(tag => tag.attrs.Name === entry.name);
51
51
  const dimensions = declaration?.attrs.Dimensions ?? declaration?.attrs.Dimension ?? '';
52
- if (!declared || aliasesOf(project.document).has(`${scope}::${declared.name}`)
53
- || !['BOOL', 'INT', 'DINT', 'REAL'].includes(declared.dataType)
54
- || dimensions !== '' && dimensions !== '0' || declaration?.attrs.ExternalAccess === 'None')
55
- throw new EngineeringError('UNSUPPORTED_POLL_TAG', 'Select a declared non-alias scalar BOOL, INT, DINT, or REAL');
52
+ // A poll is a read: an alias is a normal operand, and ExternalAccess only
53
+ // stops writes. Refusing aliases made every module-point BOOL unmonitorable.
54
+ let type = ['BOOL', 'INT', 'DINT', 'REAL'].includes(declared?.dataType ?? '') ? declared?.dataType ?? '' : '';
55
+ if (type === '' && declared !== undefined) {
56
+ const resolution = resolveTag(project.document, scope, declared.name);
57
+ if (resolution.resolved && resolution.data_type !== undefined && ['BOOL', 'INT', 'DINT', 'REAL'].includes(resolution.data_type))
58
+ type = resolution.data_type;
59
+ }
60
+ if (!declared || type === '' || (dimensions !== '' && dimensions !== '0'))
61
+ throw new EngineeringError('UNSUPPORTED_POLL_TAG', 'Select a declared scalar BOOL, INT, DINT, or REAL (aliases are read through; arrays are refused)');
56
62
  const key = `${scope}::${declared.name}`.toLowerCase();
57
63
  if (seen.has(key))
58
64
  throw new EngineeringError('INVALID_POLL', 'Duplicate watch tag');
59
65
  seen.add(key);
60
- return { name: declared.name, ...(scope === 'controller' ? {} : { program: scope }), data_type: declared.dataType };
66
+ return { name: declared.name, ...(scope === 'controller' ? {} : { program: scope }), data_type: type };
61
67
  });
62
68
  const request = { ip, slot, tags, samples, interval, timeout: config.requestTimeoutMs };
63
69
  return { request, evidence: tags.map(tag => ({ tag: tag.name, scope: tag.program ?? 'controller',
@@ -1,4 +1,4 @@
1
- import { evidenceOf } from "./analysis.js";
1
+ import { evidenceOf, resolveSymbol } from "./analysis.js";
2
2
  import { engineeringTags, moduleConfigurations } from "./configuration.js";
3
3
  function changes(component, before, after, key) {
4
4
  const old = new Map(before.map(value => [key(value), value]));
@@ -71,6 +71,17 @@ export function runChecks(project, baseline) {
71
71
  for (const ref of references) {
72
72
  if (ref.resolved !== undefined || ref.access === 'call' || ref.access === 'unknown')
73
73
  continue;
74
+ // A member of a module-defined or otherwise opaque type (`Drive_MCFG.CommFLT`,
75
+ // `Axis_MAH.PC`) is not in the export's DataTypes, so this model cannot prove
76
+ // it. The base tag is declared, the member is not decodable: that is a coverage
77
+ // limit, not an unresolved reference, and reporting it drowned the real findings
78
+ // — 315 of them on one project that compiles. `S:`/`R:` system flags are the
79
+ // same case: defined by the controller, absent from the export.
80
+ const base = ref.symbol.split('.')[0] ?? ref.symbol;
81
+ const systemFlag = /^[SR]:/iu.test(ref.symbol);
82
+ const baseDeclared = systemFlag ? undefined : resolveSymbol(doc, ref.scope, base);
83
+ if (baseDeclared !== undefined)
84
+ continue;
74
85
  const key = `${ref.scope}::${ref.symbol}`;
75
86
  unresolved.set(key, [...unresolved.get(key) ?? [], ref]);
76
87
  }
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
4
- import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
4
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
- import { dirname, extname, join, resolve } from 'node:path';
6
+ import { basename, dirname, extname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { EngineeringError } from "../session.js";
9
9
  import { documentRoot, firstChild, optionalAttribute, parseXml } from "../model/xml.js";
@@ -135,18 +135,36 @@ export async function exportAcdToL5x(acdPath, outputPath, signal) {
135
135
  throw new EngineeringError('OUTPUT_DIRECTORY', 'Output directory does not exist');
136
136
  const sourceHash = createHash('sha256').update(await readFile(source)).digest('hex');
137
137
  await ensureBridge(availability, signal);
138
- const result = await runBridge([bridgeDll, 'export-l5x', source, output], 240_000, signal);
139
- if (result.code !== 0 || !result.output.includes('EXPORTED_L5X_SUCCEEDED'))
140
- throw new EngineeringError('L5X_EXPORT_FAILED', result.output);
138
+ let result = await runBridge([bridgeDll, 'export-l5x', source, output], 240_000, signal);
139
+ // A project open in Studio 5000 holds an exclusive handle on its ACD, and the
140
+ // SDK opens the file itself, so the ordinary case - the project the user is
141
+ // working on - failed with "being used by another process". Exporting reads a
142
+ // snapshot: copy the ACD and open the copy. Nothing is written to the original.
143
+ let scratch;
144
+ if ((result.code !== 0 || !result.output.includes('EXPORTED_L5X_SUCCEEDED')) && /being used by another process|could not open file/iu.test(result.output)) {
145
+ const scratchDir = join(tmpdir(), `arya-l5x-export-${process.pid}-${Date.now()}`);
146
+ await mkdir(scratchDir, { recursive: true });
147
+ scratch = join(scratchDir, basename(source));
148
+ await copyFile(source, scratch);
149
+ if (createHash('sha256').update(await readFile(scratch)).digest('hex') !== sourceHash) {
150
+ throw new EngineeringError('ACD_CHANGED', 'The ACD changed while it was being copied; save or close it in Studio 5000 and retry');
151
+ }
152
+ result = await runBridge([bridgeDll, 'export-l5x', scratch, output], 240_000, signal);
153
+ }
154
+ if (result.code !== 0 || !result.output.includes('EXPORTED_L5X_SUCCEEDED')) {
155
+ throw new EngineeringError('L5X_EXPORT_FAILED', result.output, 'Close the project in Studio 5000, or retry: a locked ACD is exported from a private copy.');
156
+ }
141
157
  if (createHash('sha256').update(await readFile(source)).digest('hex') !== sourceHash)
142
158
  throw new EngineeringError('ACD_CHANGED', 'Source ACD changed during export; reopen and export an unchanged source copy');
159
+ if (scratch !== undefined)
160
+ await rm(dirname(scratch), { recursive: true, force: true }).catch(() => { });
143
161
  const bytes = await readFile(output);
144
162
  if (bytes.length === 0 || !bytes.toString('utf8', 0, Math.min(bytes.length, 2048)).includes('<RSLogix5000Content')) {
145
163
  throw new EngineeringError('L5X_EXPORT_INVALID', 'SDK export did not produce a recognizable L5X file');
146
164
  }
147
165
  return { source_acd: source, source_sha256: sourceHash, output_path: output, output_bytes: bytes.length,
148
166
  output_sha256: createHash('sha256').update(bytes).digest('hex'), exported_at: new Date().toISOString(),
149
- controller_operation: 'none', studio_review: 'required' };
167
+ ...(scratch === undefined ? {} : { exported_from_copy: true }), controller_operation: 'none', studio_review: 'required' };
150
168
  }
151
169
  function checkProject(project, availability) {
152
170
  if (!availability.available)
@@ -78,7 +78,10 @@ def verb_structure_snapshot(req):
78
78
 
79
79
  def verb_block_interface(req):
80
80
  from motionworks_iec_mcp.block_interfaces import inspect
81
- return _ok(**inspect(Path(req['project']),req['native_libraries'],req.get('name'),req.get('library')))
81
+ # The inventory is optional at this boundary: a read with no live IDE answers
82
+ # from the project's own registry, and a saved vendor reference it cannot match
83
+ # is reported as that, instead of a KeyError the caller cannot act on.
84
+ return _ok(**inspect(Path(req['project']),req.get('native_libraries') or [],req.get('name'),req.get('library')))
82
85
 
83
86
  def verb_installed_help(req):
84
87
  from motionworks_iec_mcp.installed_help import search
@@ -39,7 +39,15 @@ export async function nativeVariableChange(args,{status,saved,snapshot,snapshotS
39
39
  const live=snapshotSaved?combined:await snapshot();
40
40
  const nativeRows=savedProjection(live.variables);
41
41
  const aligned=compare(before.variables,nativeRows);
42
- if(!aligned.accepted)throw new Error('Live declarations disagree with saved baseline; no native action performed');
42
+ // Name the difference. A bare "disagree" left four live attempts with nothing to
43
+ // act on: the caller cannot tell a description trim from a missing declaration.
44
+ const detail=a=>[
45
+ ...(a.missing?.length?['missing '+a.missing.slice(0,5).join(', ')]:[]),
46
+ ...(a.unexpected?.length?['unexpected '+a.unexpected.slice(0,5).join(', ')]:[]),
47
+ ...(a.changed??[]).slice(0,5).map(c=>`${c.name} differs in ${c.fields.join('/')} (live ${JSON.stringify(c.expected)} vs saved ${JSON.stringify(c.saved)})`),
48
+ ...(a.errors??[]).slice(0,3),
49
+ ].join('; ');
50
+ if(!aligned.accepted)throw new Error('Live declarations disagree with saved baseline; no native action performed'+(detail(aligned)?': '+detail(aligned):''));
43
51
  const plan=variableChangePlan(before.variables,args,live.groups.map(g=>g.name));
44
52
  if(args.declaration?.section==='VAR_EXTERNAL') {
45
53
  const global=await globals();
@@ -30,10 +30,16 @@ export declare const GET_KINDS: readonly ["pou", "type", "task", "block", "eip"]
30
30
  /** The saved-project read service. */
31
31
  export declare class ReadService {
32
32
  private readonly engine;
33
+ private readonly nativeLibraries?;
33
34
  /**
34
35
  * @param engine - Code engine over the project files.
36
+ * @param nativeLibraries - Live IDE library inventory, when the host has one.
37
+ * A block interface is decoded against the installed libraries as well as the
38
+ * saved project, so without it a vendor block cannot be resolved. Reads stay
39
+ * usable without an IDE: the provider returns nothing and the engine answers
40
+ * for the project's own registry alone.
35
41
  */
36
- constructor(engine: ReadEngine);
42
+ constructor(engine: ReadEngine, nativeLibraries?: (() => Promise<readonly Record<string, unknown>[]>) | undefined);
37
43
  /** Confirm the engine can read one project. */
38
44
  workflow(project: ProjectRef): Promise<EngineResult>;
39
45
  /**
@@ -15,11 +15,18 @@ export const GET_KINDS = ['pou', 'type', 'task', 'block', 'eip'];
15
15
  /** The saved-project read service. */
16
16
  export class ReadService {
17
17
  engine;
18
+ nativeLibraries;
18
19
  /**
19
20
  * @param engine - Code engine over the project files.
21
+ * @param nativeLibraries - Live IDE library inventory, when the host has one.
22
+ * A block interface is decoded against the installed libraries as well as the
23
+ * saved project, so without it a vendor block cannot be resolved. Reads stay
24
+ * usable without an IDE: the provider returns nothing and the engine answers
25
+ * for the project's own registry alone.
20
26
  */
21
- constructor(engine) {
27
+ constructor(engine, nativeLibraries) {
22
28
  this.engine = engine;
29
+ this.nativeLibraries = nativeLibraries;
23
30
  }
24
31
  /** Confirm the engine can read one project. */
25
32
  async workflow(project) {
@@ -62,9 +69,14 @@ export class ReadService {
62
69
  return { ...text, project: project.directory, ...digest };
63
70
  }
64
71
  if (kind === 'block') {
72
+ // The engine compares the saved library references with the installed
73
+ // inventory, so a missing inventory is a refusal, not a crash: pass what the
74
+ // host could observe (nothing when the IDE is closed).
75
+ const native = this.nativeLibraries === undefined ? [] : await this.nativeLibraries().catch(() => []);
65
76
  return await this.engine.run('block_interface', {
66
77
  project: project.directory,
67
78
  name,
79
+ native_libraries: native,
68
80
  ...(library === undefined ? {} : { library }),
69
81
  }, 60_000);
70
82
  }
@@ -102,7 +102,13 @@ export class PowerShellSessionRuntime {
102
102
  }));
103
103
  this.engine = new CodeEngine({ workspace, stageRoot: stageRootOf(workspace), codeDir: CODE_DIR });
104
104
  this.stages = new StageService(this.engine, workspace);
105
- this.reads = new ReadService(this.engine);
105
+ // A block interface is decoded against the installed libraries as well as the
106
+ // saved project. That inventory is a live IDE read; when no IDE answers, the
107
+ // read service falls back to the project's own registry instead of failing.
108
+ this.reads = new ReadService(this.engine, async () => {
109
+ const snapshot = await this.bridge.run('library_snapshot', {}, 30_000);
110
+ return (snapshot.libraries ?? []).map(row => ({ name: String(row.name ?? ''), full_name: String(row.full_name ?? '') }));
111
+ });
106
112
  }
107
113
  /** @returns The bridge's status document. */
108
114
  async status() {