@aryacode/dsh-engineering 0.6.0 → 0.7.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-10-27
4
+
5
+ ### Added
6
+
7
+ - **Ladder generation that cannot emit invalid NeutralText.** Three pieces:
8
+ - `list_instructions` — the instruction catalog the composer validates: mnemonic, category, summary
9
+ and operand slots with their kinds (BOOL, NUMBER, TIMER, …), filterable. 41 instructions, covering
10
+ every mnemonic the Laminator project uses (45 distinct across 860 rungs).
11
+ - `compose_rung` — builds one rung from a structure tree (`spec_json`) or a named pattern
12
+ (`start_stop_latch`, `fault_latch`, `ton_delay`, `interlock`), resolves every operand against the
13
+ export (including unqualified program tags and aliases), type-checks each slot, emits the export's
14
+ own bracket style, and re-parses its output with the same parser `draft_rung` uses. Unknown
15
+ instructions name the nearest catalog entries; a wrong operand count, a non-BOOL contact, a
16
+ non-numeric value slot, an undeclared tag or an undeclared AOI is refused before anything is staged.
17
+ Passing `program`, `routine`, `mode` and `rung` stages the rung in the draft; `fault_latch` stages
18
+ its two rungs consecutively.
19
+ - **Rung comments are read and written.** The model read the `Comment` **attribute** only, so a real
20
+ Studio export's 572 child `<Comment>` elements were invisible. `decodeRung` now supports both shapes,
21
+ `draft_rung` accepts a `comment`, and `draft_rung_comment` sets or clears one on an existing rung.
22
+ CDATA terminators in a comment round-trip through the existing escaping.
23
+ - **Documentation dimension.** `document_project` writes a handover bundle into a NEW folder and never
24
+ touches the export: `PROJECT.md` (controller, revision, tasks, programs, counts), `io.md`/`io.csv`,
25
+ `tags.csv` and `tags-studio.csv` (the `Tag Name,Description` shape Studio's Tags and Comments import
26
+ expects), and `rungs/<program>.md` with each rung's number, comment, NeutralText, instructions and
27
+ resolved tags. Filters: `include`, `programs`, `routines`, `rungs_per_file`.
28
+
29
+ ### Documentation
30
+
31
+ - `packages/l5x/SKILL.md`: a Generate section (catalog → compose → validate → apply) and a
32
+ Documentation section (bundle contents, the comment round trip, what the bundle cannot prove).
33
+ - README: 89 tools, L5X 44, and the 0.7.0 milestone.
34
+
3
35
  ## [0.6.0] - 2026-10-27
4
36
 
5
37
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  MotionWorks IEC and FactoryTalk Optix sit behind a single install, one row per surface. A row is the
5
5
  switch: disable it and that plugin unloads, so its tool schemas leave the request.
6
6
 
7
- `@aryacode/dsh-engineering` · 85 tools · 5 rows · 4 products · MIT
7
+ `@aryacode/dsh-engineering` · 89 tools · 5 rows · 4 products · MIT
8
8
 
9
9
  ---
10
10
 
@@ -36,14 +36,14 @@ bundle.
36
36
 
37
37
  | Product | Tools | Default | Needs |
38
38
  |---|---|---|---|
39
- | **Studio 5000 L5X** | 40 | all on | Node for offline reads; Studio 5000 + Logix Designer SDK for SDK work; a controller for live |
39
+ | **Studio 5000 L5X** | 44 | all on | Node for offline reads; Studio 5000 + Logix Designer SDK for SDK work; a controller for live |
40
40
  | **FactoryTalk View ME** | 14 | all on | Node for an export folder; FactoryTalk View Studio ME for discovery and the handoff |
41
41
  | **MotionWorks IEC** | 24 | all on | MotionWorks IEC 3 Pro installed; verified against 1.19 |
42
42
  | **FactoryTalk Optix** | 10 (3 need preview) | all on | Optix Studio installed; Tesseract for OCR |
43
43
 
44
44
  Tool groups, by product:
45
45
 
46
- - **L5X** — 11 read (`open_project` … `compare_projects`), 9 staged drafts (`draft_*`), 6 Logix
46
+ - **L5X** — 13 read (`open_project` … `document_project`), 11 staged drafts (`draft_*`, `compose_rung`), 6 Logix
47
47
  Designer SDK, 7 supervised controller, 7 live EtherNet/IP (2 reads, 2 identity/discovery, 1 batch read, 2 approval-gated writes)
48
48
  - **FactoryTalk View** — 6 read, 5 staged edits, 3 Studio handoff
49
49
  - **MotionWorks IEC** — 7 session, 7 saved-project reads, 3 verify, 7 direct edits
@@ -120,6 +120,7 @@ the lean profile is the one that turns them off.
120
120
 
121
121
  | Version | What landed |
122
122
  |---|---|
123
+ | **0.7.0** | Ladder generation that cannot emit invalid NeutralText: an instruction catalog, a pattern/tree composer that resolves and type-checks every operand, rung comments read and written, and a documentation bundle (`document_project`) with the Studio tags-and-comments CSV |
123
124
  | **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 |
124
125
  | **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 |
125
126
  | **0.4.15** | Every tool surface ships on by default; the row Configure pages switch them off |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-engineering",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -55,15 +55,6 @@
55
55
  ]
56
56
  }
57
57
  },
58
- "scripts": {
59
- "test": "node --import tsx --test test/patch.spec.mjs test/docs.spec.mjs test/tool-reference.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs test/config.spec.mjs test/netlogic.spec.mjs",
60
- "docs:tools": "tsx scripts/gen-tool-reference.ts",
61
- "docs:check": "tsx scripts/gen-tool-reference.ts --check",
62
- "netlogic:sync": "node scripts/sync-netlogic.mjs",
63
- "build:client": "node scripts/build-client.mjs",
64
- "assemble": "node scripts/assemble.mjs",
65
- "prepack": "node scripts/build-client.mjs && node scripts/assemble.mjs"
66
- },
67
58
  "dependencies": {
68
59
  "fast-xml-parser": "5.11.1",
69
60
  "fast-xml-validator": "1.4.2",
@@ -103,5 +94,13 @@
103
94
  "./mwi/skill": "./vendor/mwi/lib/skill.js",
104
95
  "./mwi/verify": "./vendor/mwi/lib/verify.js",
105
96
  "./mwi/edit": "./vendor/mwi/lib/edit.js"
97
+ },
98
+ "scripts": {
99
+ "test": "node --import tsx --test test/patch.spec.mjs test/docs.spec.mjs test/tool-reference.spec.mjs test/manifests.spec.mjs test/client-face.spec.mjs test/config.spec.mjs test/netlogic.spec.mjs",
100
+ "docs:tools": "tsx scripts/gen-tool-reference.ts",
101
+ "docs:check": "tsx scripts/gen-tool-reference.ts --check",
102
+ "netlogic:sync": "node scripts/sync-netlogic.mjs",
103
+ "build:client": "node scripts/build-client.mjs",
104
+ "assemble": "node scripts/assemble.mjs"
106
105
  }
107
- }
106
+ }
@@ -56,12 +56,12 @@ this is a backup on request ("back up this project"). Build it as a copy plus
56
56
 
57
57
  ## Live reads
58
58
 
59
- The `l5x-live` entry reads declared scalar tags straight from a controller over EtherNet/IP, without Studio 5000, FactoryTalk Linx or the SDK. It may be disabled; if `poll_live_tags` is unavailable, say live reads are not enabled rather than trying another tool.
59
+ `l5x-live` reads declared scalar tags over EtherNet/IP with no Studio, FactoryTalk Linx or SDK. If `poll_live_tags` is unavailable, say live reads are not enabled here.
60
60
 
61
- - `poll_live_tags` takes an explicit IPv4 address, a CPU slot and 1 to 16 declared scalar tags (`BOOL`, `INT`, `DINT`, `REAL`), and returns timestamped sweeps, quality and the controller identity.
61
+ - `poll_live_tags` takes an explicit IPv4 address, a CPU slot and 1 to 16 declared scalars, and returns timestamped sweeps, quality and controller identity.
62
62
  - `start_plc_trend` samples the same way as a session-owned job the chart can follow; stop it through the job controls.
63
63
  - These are read-only. Never write controller data, force tags, toggle bits or edit online.
64
- - Report `live_logic_matches_export: "unverified"`: the read proves a value at a moment, not that the controller runs the exported logic, and sequential reads can miss transitions.
64
+ - Report `live_logic_matches_export: "unverified"`: a read proves a value at a moment, not that the controller runs the export.
65
65
 
66
66
  ## Reporting
67
67
 
@@ -81,18 +81,38 @@ The `l5x-live` entry reads declared scalar tags straight from a controller over
81
81
  The `l5x-studio` entry adds offline SDK work; it may be disabled. Call `studio_sdk_status` before using it.
82
82
 
83
83
  - `export_acd_to_l5x` writes one new full L5X from an ACD. No controller contact.
84
- - `verify_offline_l5x` imports a full L5X into a new offline ACD and builds it. Build needs project revision 37 or later; `probe: true` attempts a lower revision and reports the SDK error. A Studio-exported L5X builds; an L5X produced by `export_acd_to_l5x` may be refused by the SDK as "no changes".
85
- - `build_acd_from_draft` imports one changed RLL or ST routine into a new ACD copy, builds it and exports it. The baseline must be an L5X exported from that ACD in this chat, and neither file may change afterwards.
84
+ - `verify_offline_l5x` imports a full L5X into a new offline ACD and builds it. Build needs project revision 37+; `probe: true` tries a lower one and reports the SDK error. An L5X from `export_acd_to_l5x` may be refused as "no changes".
85
+ - `build_acd_from_draft` imports one changed RLL or ST routine into a new ACD copy, builds and exports it. The baseline must be an L5X exported from that ACD in this chat; neither file may change.
86
86
  - `studio_review_evidence` checks program, routine and rung or line names and verbatim quotes before you report findings.
87
- - `controller_approval_status` reports whether this session can ask a human. `capture_controller_snapshot`, `change_controller_mode` and `download_project` each require per-call human approval of the exact controller, route and target, and proceed only on `allowed-once`; `download_project` accepts only a `verification_id` from a passed build in this chat.
87
+ - `controller_approval_status` says whether this session can ask a human. `capture_controller_snapshot`, `change_controller_mode` and `download_project` need per-call approval of the exact target and proceed only on `allowed-once`; `download_project` takes a `verification_id` from a passed build in this chat.
88
88
  - `go_online`, `read_controller_mode` and `read_live_tag` are read-only and need no approval. Never write controller data, force tags, toggle bits or edit online.
89
89
 
90
90
  ## Editing
91
91
 
92
92
  Editing uses the `draft_*` family. It may be disabled in this profile; if a draft tool is unavailable, say editing is not enabled here rather than trying another tool.
93
93
 
94
- - One operation per call: `draft_rung`, `draft_st`, `draft_routine`, `draft_tag`, `draft_tag_description`, `draft_udt_member`, `draft_udt_description`. Each returns the draft id and the operation count.
94
+ - One operation per call: `draft_rung`, `draft_rung_comment`, `draft_st`, `draft_routine`, `draft_tag`, `draft_tag_description`, `draft_udt_member`, `draft_udt_description`.
95
95
  - `draft_validate` prepares the accumulated draft against the project and returns its hash, diff summary and check findings. Read the findings before applying.
96
96
  - `draft_apply` writes the draft to ONE new `.L5X` path. It never overwrites an existing file.
97
97
  - After applying, reopen the written file with `open_project` to review it.
98
98
  - Drafts belong to this chat. Another chat starts with none, and an applied draft is cleared.
99
+
100
+ ## Generate
101
+
102
+ Write ladder through the composer, not by inventing NeutralText.
103
+
104
+ - `list_instructions` gives the mnemonic, category and operand slots with their kinds; read it before composing.
105
+ - `compose_rung` builds one rung from a tree (`spec_json`) or a pattern (`start_stop_latch`, `fault_latch`, `ton_delay`, `interlock`), resolves every operand, type-checks each slot and parses its own output; anything unknown or mistyped is refused before staging.
106
+ - Pass `program`, `routine`, `mode` and `rung` to stage it (plus `comment`); without them it returns text only.
107
+ - `fault_latch` stages two rungs at `rung` and `rung + 1`; rung numbers are not renumbered, so pick free ones.
108
+
109
+ ## Documentation
110
+
111
+ `document_project` writes a handover bundle into a NEW folder and never touches the export:
112
+
113
+ - `PROJECT.md` — controller, revision, tasks, programs, counts;
114
+ - `io.md` / `io.csv` — modules and module-data tags;
115
+ - `tags.csv` and `tags-studio.csv` — every declared tag, and the `Tag Name,Description` shape Studio imports;
116
+ - `rungs/<program>.md` — each rung's number, comment, NeutralText, instructions and resolved tags.
117
+
118
+ The bundle is structure and citations, not prose: write the narrative yourself and use `draft_rung_comment` / `draft_tag_description` when it belongs in the project. No C&E, FAT/SAT or runtime proof.
@@ -5,6 +5,7 @@ type Operation = {
5
5
  readonly routine: string;
6
6
  readonly rung: number;
7
7
  readonly text: string;
8
+ readonly comment?: string;
8
9
  } | {
9
10
  readonly operation: 'delete_rung';
10
11
  readonly program: string;
@@ -17,6 +18,13 @@ type Operation = {
17
18
  readonly rung: number;
18
19
  readonly text: string;
19
20
  readonly before_rung?: number;
21
+ readonly comment?: string;
22
+ } | {
23
+ readonly operation: 'set_rung_comment';
24
+ readonly program: string;
25
+ readonly routine: string;
26
+ readonly rung: number;
27
+ readonly comment: string;
20
28
  } | {
21
29
  readonly operation: 'replace_st';
22
30
  readonly program: string;
@@ -59,7 +67,7 @@ type Operation = {
59
67
  readonly description: string;
60
68
  };
61
69
  /** Compact contract returned with validation errors and embedded in tool descriptions. */
62
- export declare const CHANGE_SET_HELP = "Operations (each also needs \"operation\"):\nreplace_rung {program,routine,rung,text}\ninsert_rung {program,routine,rung,text,before_rung?}\ndelete_rung {program,routine,rung}\nreplace_st {program,routine,code}\nadd_routine {program,name,language:\"RLL\"|\"ST\",code}\nadd_tag {scope,name,data_type,description,value? scalar, dimensions? \"4 2\", external_access? \"Read/Write\"|\"Read Only\"|\"None\"}\nset_tag_description {scope,name,description}\nadd_udt_member {udt,name,data_type,description}\nset_udt_description {udt,description}\nset_udt_member_description {udt,member,description}\nIdentifiers are at most 40 characters. Example: {\"version\":1,\"operations\":[{\"operation\":\"add_tag\",\"scope\":\"controller\",\"name\":\"SP_Start\",\"data_type\":\"REAL\",\"value\":80,\"description\":\"Start threshold\"}]}";
70
+ export declare const CHANGE_SET_HELP = "Operations (each also needs \"operation\"):\nreplace_rung {program,routine,rung,text,comment?}\ninsert_rung {program,routine,rung,text,before_rung?,comment?}\ndelete_rung {program,routine,rung}\nset_rung_comment {program,routine,rung,comment}\nreplace_st {program,routine,code}\nadd_routine {program,name,language:\"RLL\"|\"ST\",code}\nadd_tag {scope,name,data_type,description,value? scalar, dimensions? \"4 2\", external_access? \"Read/Write\"|\"Read Only\"|\"None\"}\nset_tag_description {scope,name,description}\nadd_udt_member {udt,name,data_type,description}\nset_udt_description {udt,description}\nset_udt_member_description {udt,member,description}\nIdentifiers are at most 40 characters. Example: {\"version\":1,\"operations\":[{\"operation\":\"add_tag\",\"scope\":\"controller\",\"name\":\"SP_Start\",\"data_type\":\"REAL\",\"value\":80,\"description\":\"Start threshold\"}]}";
63
71
  /** Limits are resolved by the plugin's configuration before tools call this module. */
64
72
  export interface ChangeSetLimits {
65
73
  readonly operations: number;
@@ -9,12 +9,13 @@ import { uniqueRegion, xmlRegions } from "./xml-regions.js";
9
9
  /** Scalar types the plugin can create with a literal initial value. */
10
10
  const SCALAR_TYPES = new Set(['BOOL', 'SINT', 'INT', 'DINT', 'LINT', 'USINT', 'UINT', 'UDINT', 'ULINT', 'REAL', 'LREAL']);
11
11
  /** Operation names accepted by a version-1 change set. */
12
- const SUPPORTED_OPERATIONS = ['replace_rung', 'insert_rung', 'delete_rung', 'replace_st', 'add_routine', 'add_tag', 'set_tag_description', 'add_udt_member', 'set_udt_description', 'set_udt_member_description'];
12
+ const SUPPORTED_OPERATIONS = ['replace_rung', 'insert_rung', 'delete_rung', 'set_rung_comment', 'replace_st', 'add_routine', 'add_tag', 'set_tag_description', 'add_udt_member', 'set_udt_description', 'set_udt_member_description'];
13
13
  /** Compact contract returned with validation errors and embedded in tool descriptions. */
14
14
  export const CHANGE_SET_HELP = `Operations (each also needs "operation"):
15
- replace_rung {program,routine,rung,text}
16
- insert_rung {program,routine,rung,text,before_rung?}
15
+ replace_rung {program,routine,rung,text,comment?}
16
+ insert_rung {program,routine,rung,text,before_rung?,comment?}
17
17
  delete_rung {program,routine,rung}
18
+ set_rung_comment {program,routine,rung,comment}
18
19
  replace_st {program,routine,code}
19
20
  add_routine {program,name,language:"RLL"|"ST",code}
20
21
  add_tag {scope,name,data_type,description,value? scalar, dimensions? "4 2", external_access? "Read/Write"|"Read Only"|"None"}
@@ -104,17 +105,21 @@ function parseOne(value, declaredTypes) {
104
105
  case 'replace_rung':
105
106
  case 'insert_rung':
106
107
  case 'delete_rung': {
107
- fields(row, row.operation === 'delete_rung' ? ['operation', 'program', 'routine', 'rung'] : row.operation === 'insert_rung' ? ['operation', 'program', 'routine', 'rung', 'text', 'before_rung'] : ['operation', 'program', 'routine', 'rung', 'text']);
108
+ fields(row, row.operation === 'delete_rung' ? ['operation', 'program', 'routine', 'rung'] : row.operation === 'insert_rung' ? ['operation', 'program', 'routine', 'rung', 'text', 'before_rung', 'comment'] : ['operation', 'program', 'routine', 'rung', 'text', 'comment']);
108
109
  const selected = { program: identifier(row, 'program'), routine: identifier(row, 'routine'), rung: integer(row, 'rung') };
109
110
  if (row.operation === 'delete_rung')
110
111
  return { operation: row.operation, ...selected };
111
112
  const source = text(row, 'text');
112
113
  if (parseLadder(source).issues.length > 0)
113
114
  throw new EngineeringError('INVALID_LADDER', 'Rung contains malformed or unsupported NeutralText structure');
115
+ const comment = row.comment === undefined ? {} : { comment: text(row, 'comment') };
114
116
  if (row.operation === 'replace_rung')
115
- return { operation: row.operation, ...selected, text: source };
116
- return { operation: row.operation, ...selected, text: source, ...(row.before_rung === undefined ? {} : { before_rung: integer(row, 'before_rung') }) };
117
+ return { operation: row.operation, ...selected, text: source, ...comment };
118
+ return { operation: row.operation, ...selected, text: source, ...comment, ...(row.before_rung === undefined ? {} : { before_rung: integer(row, 'before_rung') }) };
117
119
  }
120
+ case 'set_rung_comment':
121
+ fields(row, ['operation', 'program', 'routine', 'rung', 'comment']);
122
+ return { operation: row.operation, program: identifier(row, 'program'), routine: identifier(row, 'routine'), rung: integer(row, 'rung'), comment: text(row, 'comment') };
118
123
  case 'replace_st':
119
124
  fields(row, ['operation', 'program', 'routine', 'code']);
120
125
  return { operation: row.operation, program: identifier(row, 'program'), routine: identifier(row, 'routine'), code: text(row, 'code') };
@@ -314,7 +319,7 @@ function applyOperation(xml, operation, context) {
314
319
  if (operation.operation === 'insert_rung') {
315
320
  if (targets.length !== 0)
316
321
  throw new EngineeringError('NAME_COLLISION', 'Rung number already exists; no automatic renumbering is performed');
317
- const newRung = `<Rung Number="${operation.rung}" Type="N"><Text>${cdata(operation.text)}</Text></Rung>`;
322
+ const newRung = `<Rung Number="${operation.rung}" Type="N">${commentXml(operation.comment)}<Text>${cdata(operation.text)}</Text></Rung>`;
318
323
  if (operation.before_rung === undefined)
319
324
  return insert(xml, content, newRung);
320
325
  const before = rungs.filter(rung => Number(rung.attributes.Number) === operation.before_rung);
@@ -327,9 +332,29 @@ function applyOperation(xml, operation, context) {
327
332
  const target = targets[0];
328
333
  if (operation.operation === 'delete_rung')
329
334
  return replace(xml, target.start, target.end, '');
335
+ if (operation.operation === 'set_rung_comment') {
336
+ return replace(xml, target.start, target.end, rungWith(target, retainedText(xml, target), operation.comment));
337
+ }
338
+ if (operation.comment !== undefined) {
339
+ return replace(xml, target.start, target.end, rungWith(target, `<Text>${cdata(operation.text)}</Text>`, operation.comment));
340
+ }
330
341
  const contentText = uniqueRegion(target.children, 'Text');
331
342
  return replace(xml, contentText.start, contentText.end, `<Text>${cdata(operation.text)}</Text>`);
332
343
  }
344
+ /** A comment element, or nothing when the comment is cleared. */
345
+ function commentXml(comment) {
346
+ return comment === undefined || comment === '' ? '' : `<Comment>${cdata(comment)}</Comment>`;
347
+ }
348
+ /** The rung's existing `<Text>` element, verbatim, for a comment-only edit. */
349
+ function retainedText(xml, rung) {
350
+ const text = rung.children.find(child => child.kind === 'Text');
351
+ return text === undefined ? '<Text><![CDATA[]]></Text>' : xml.slice(text.start, text.end);
352
+ }
353
+ /** Rebuild one rung element with a comment and a text element. */
354
+ function rungWith(rung, text, comment) {
355
+ const type = rung.attributes.Type ?? 'N';
356
+ return `<Rung Number="${rung.attributes.Number}" Type="${type}">${commentXml(comment)}${text}</Rung>`;
357
+ }
333
358
  /** Every type name a change may reference: scalars plus declared types. */
334
359
  function declaredTypeNames(project) {
335
360
  const names = new Set([...SCALAR_TYPES].map(name => name.toLowerCase()));
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Build the handover documentation bundle for one export.
3
+ *
4
+ * Everything here is derived from the parsed project: the generator provides the
5
+ * structure and the citations, and the narrative prose stays the caller's job.
6
+ * That split is deliberate — a generated narrative would be an unsourced claim,
7
+ * while a rung with its number, comment, text and referenced tags is evidence a
8
+ * reviewer can check in one click.
9
+ *
10
+ * The builder is pure: it returns a map of relative path to text and writes
11
+ * nothing. The tool decides where the folder goes.
12
+ *
13
+ * @module dsh-engineering-l5x/engine/documentation
14
+ */
15
+ import type { ProjectSnapshot } from '../session.ts';
16
+ /** One documentation part a caller can ask for. */
17
+ export type DocumentPart = 'project' | 'io' | 'tags' | 'rungs';
18
+ /** Every part, in the order the files are produced. */
19
+ export declare const DOCUMENT_PARTS: readonly DocumentPart[];
20
+ /** What one generation produced. */
21
+ export interface DocumentBundle {
22
+ /** Relative path to file text. */
23
+ readonly files: ReadonlyMap<string, string>;
24
+ /** Counts a caller can compare with project_overview. */
25
+ counts: {
26
+ programs: number;
27
+ tasks: number;
28
+ routines: number;
29
+ rungs: number;
30
+ comments: number;
31
+ tags: number;
32
+ modules: number;
33
+ };
34
+ /** Programs and routines the filters excluded. */
35
+ readonly excluded: {
36
+ readonly programs: readonly string[];
37
+ readonly routines: readonly string[];
38
+ };
39
+ }
40
+ /** Options for one generation. */
41
+ export interface DocumentOptions {
42
+ /** Parts to produce; defaults to all. */
43
+ readonly include?: readonly DocumentPart[];
44
+ /** Restrict to these programs. */
45
+ readonly programs?: readonly string[];
46
+ /** Restrict to these routine names. */
47
+ readonly routines?: readonly string[];
48
+ /** Most rungs per generated file before a continuation file is written. */
49
+ readonly rungsPerFile?: number;
50
+ }
51
+ /**
52
+ * Build the documentation bundle.
53
+ *
54
+ * @param project - Open export snapshot.
55
+ * @param options - Parts, filters and file split.
56
+ * @returns Files to write, counts, and what the filters excluded.
57
+ */
58
+ export declare function buildDocumentation(project: ProjectSnapshot, options?: DocumentOptions): DocumentBundle;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Build the handover documentation bundle for one export.
3
+ *
4
+ * Everything here is derived from the parsed project: the generator provides the
5
+ * structure and the citations, and the narrative prose stays the caller's job.
6
+ * That split is deliberate — a generated narrative would be an unsourced claim,
7
+ * while a rung with its number, comment, text and referenced tags is evidence a
8
+ * reviewer can check in one click.
9
+ *
10
+ * The builder is pure: it returns a map of relative path to text and writes
11
+ * nothing. The tool decides where the folder goes.
12
+ *
13
+ * @module dsh-engineering-l5x/engine/documentation
14
+ */
15
+ import { L5xError } from "../core/errors.js";
16
+ import { getRoutine, listRoutines } from "../model/routines.js";
17
+ import { aliasesOf } from "./configuration.js";
18
+ import { parseLadder } from "./logic-syntax.js";
19
+ import { resolveTag } from "./tag-resolution.js";
20
+ /** Every part, in the order the files are produced. */
21
+ export const DOCUMENT_PARTS = ['project', 'io', 'tags', 'rungs'];
22
+ /** CSV cell with quoting when the value needs it. */
23
+ function cell(value) {
24
+ const text = value === undefined || value === null ? '' : String(value);
25
+ return /[",\n\r]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
26
+ }
27
+ /** One CSV line. */
28
+ function line(...values) {
29
+ return `${values.map(cell).join(',')}\n`;
30
+ }
31
+ /** A markdown table row. */
32
+ function mdRow(...values) {
33
+ return `| ${values.map(value => String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', ' ')).join(' | ')} |\n`;
34
+ }
35
+ /** Every instruction name in one rung, branches included. */
36
+ function instructionsOf(nodes, out = new Set()) {
37
+ for (const node of nodes) {
38
+ if (node.kind === 'instruction')
39
+ out.add(node.name.toUpperCase());
40
+ else
41
+ for (const path of node.paths)
42
+ instructionsOf(path, out);
43
+ }
44
+ return out;
45
+ }
46
+ /** Tags a rung mentions, resolved through the export; instruction names excluded. */
47
+ function tagsOf(document, rung) {
48
+ const parsed = parseLadder(rung.text);
49
+ const instructions = instructionsOf(parsed.nodes);
50
+ for (const aoi of document.aois)
51
+ instructions.add(aoi.name.toUpperCase());
52
+ const found = new Set();
53
+ for (const match of rung.text.matchAll(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+|\[[^\]]*\])*/gu)) {
54
+ const text = match[0];
55
+ if (instructions.has(text.toUpperCase()))
56
+ continue;
57
+ const resolution = resolveTag(document, 'controller', text);
58
+ if (resolution.resolved)
59
+ found.add(resolution.canonical ?? text);
60
+ }
61
+ return [...found];
62
+ }
63
+ /** A tag's alias target, for the CSV only. */
64
+ function aliasTarget(document, tag) {
65
+ return aliasesOf(document).get(`${tag.scope}::${tag.name}`) ?? '';
66
+ }
67
+ /** The tags CSV every declared tag appears in. */
68
+ function tagsCsv(document) {
69
+ let text = line('scope', 'name', 'data_type', 'description', 'alias_for', 'dimensions', 'external_access');
70
+ for (const tag of document.tags) {
71
+ const record = tag;
72
+ text += line(tag.scope, tag.name, tag.dataType, tag.description ?? '', aliasTarget(document, tag), record.dimensions ?? '', tag.externalAccess ?? '');
73
+ }
74
+ return text;
75
+ }
76
+ /**
77
+ * The Studio import shape: `Tag Name,Description`.
78
+ *
79
+ * A program-scoped tag is written `Program:Name` so a duplicate name across two
80
+ * programs cannot silently overwrite one of them on import. Verify the template
81
+ * against the installed Studio help before relying on it for a bulk import.
82
+ */
83
+ function tagsStudioCsv(document) {
84
+ let text = line('Tag Name', 'Description');
85
+ for (const tag of document.tags) {
86
+ const name = tag.scope === 'controller' ? tag.name : `${tag.scope}:${tag.name}`;
87
+ text += line(name, tag.description ?? '');
88
+ }
89
+ return text;
90
+ }
91
+ /** The I/O list: modules and the tags that look like module data. */
92
+ function ioFiles(document) {
93
+ const io = document.tags.filter(tag => tag.name.includes(':'));
94
+ let markdown = '# I/O list\n\n';
95
+ markdown += `${document.modules.length} module(s); ${io.length} tag(s) look like module data (a colon in the name).\n\n`;
96
+ markdown += '| Module | Catalog | Slot | Parent | Inhibited | Description |\n|---|---|---|---|---|---|\n';
97
+ for (const module of document.modules) {
98
+ markdown += mdRow(module.name, module.catalogNumber, module.slot ?? '', module.parentModule, module.inhibited === true, module.description ?? '');
99
+ }
100
+ markdown += `\nModule-data tags: ${io.map(tag => tag.name).join(', ') || 'none'}.\n`;
101
+ let csv = line('name', 'catalog_number', 'slot', 'parent_module', 'inhibited', 'description');
102
+ for (const module of document.modules)
103
+ csv += line(module.name, module.catalogNumber, module.slot ?? '', module.parentModule, module.inhibited === true, module.description ?? '');
104
+ return { markdown, csv };
105
+ }
106
+ /** The project summary: identity, tasks, programs, counts, coverage. */
107
+ function projectMarkdown(project, counts) {
108
+ const document = project.document;
109
+ const { controller } = document;
110
+ let text = `# ${controller.name} project documentation\n\n`;
111
+ text += `Generated from \`${project.path}\` (sha256 \`${project.sha256.slice(0, 16)}…\`) at ${new Date().toISOString()}.\n\n`;
112
+ text += `- Processor: ${controller.processorType}\n- Revision: ${document.controller.majorRev}.${document.controller.minorRev}\n`;
113
+ text += `- Export: ${project.exportKind}; parser verification: ${project.verification}\n`;
114
+ text += `- Programs: ${counts.programs}; tasks: ${counts.tasks}; routines: ${counts.routines}; rungs: ${counts.rungs} (${counts.comments} with comments); tags: ${counts.tags}; modules: ${counts.modules}\n\n`;
115
+ text += '## Tasks\n\n| Task | Type | Rate (ms) | Priority | Programs |\n|---|---|---|---|---|\n';
116
+ for (const task of document.tasks)
117
+ text += mdRow(task.name, task.type, task.rate, task.priority, task.programs.join(', '));
118
+ text += '\n## Programs\n\n| Program | Main routine | Routines | Description |\n|---|---|---|---|\n';
119
+ for (const program of document.programs) {
120
+ const routines = listRoutines(document, program.name);
121
+ text += mdRow(program.name, program.mainRoutine, routines.length, program.description);
122
+ }
123
+ text += '\n## Coverage\n\nThis document is generated from the saved export. It does not prove construction of the program,';
124
+ text += ' compiled behaviour, runtime behaviour, or anything outside the export (HMI, produced/consumed tags, driver consumers).\n';
125
+ return text;
126
+ }
127
+ /** Rung skeletons, one markdown file per program (and continuation). */
128
+ function rungFiles(project, options) {
129
+ const files = new Map();
130
+ const document = project.document;
131
+ const programs = options.programs ?? document.programs.map(program => program.name);
132
+ for (const programName of programs) {
133
+ const program = document.programs.find(entry => entry.name === programName);
134
+ if (program === undefined)
135
+ continue;
136
+ let section = `# ${programName} — rung documentation\n\nProgram main routine: ${program.mainRoutine || 'not declared'}. ${program.description || 'No program description.'}\n`;
137
+ let part = 1;
138
+ let inFile = 0;
139
+ let headingWritten = false;
140
+ for (const info of listRoutines(document, programName)) {
141
+ if (options.routines !== undefined && !options.routines.includes(info.name))
142
+ continue;
143
+ const routine = getRoutine(document, programName, info.name);
144
+ if (routine === undefined || routine.body.type !== 'RLL')
145
+ continue;
146
+ section += `\n## ${info.name}\n\n${info.description || 'No routine description.'}\n`;
147
+ for (const rung of routine.body.rungs) {
148
+ const instructions = [...instructionsOf(parseLadder(rung.text).nodes)].join(', ');
149
+ const tags = tagsOf(document, rung);
150
+ section += `\n### Rung ${rung.number}\n\n`;
151
+ if (rung.comment !== undefined)
152
+ section += `**Comment:** ${rung.comment.replaceAll('\n', '\n> ')}\n\n`;
153
+ section += `\`\`\`\n${rung.text}\n\`\`\`\n\n`;
154
+ section += `Instructions: ${instructions || 'none parsed'}.\n`;
155
+ if (tags.length > 0)
156
+ section += `Tags: ${tags.join(', ')}.\n`;
157
+ inFile += 1;
158
+ if (inFile >= options.perFile) {
159
+ files.set(part === 1 ? `rungs/${programName}.md` : `rungs/${programName}-${part}.md`, section);
160
+ part += 1;
161
+ inFile = 0;
162
+ headingWritten = true;
163
+ section = `# ${programName} — rung documentation (continued)\n`;
164
+ }
165
+ }
166
+ }
167
+ if (inFile > 0 || !headingWritten)
168
+ files.set(part === 1 ? `rungs/${programName}.md` : `rungs/${programName}-${part}.md`, section);
169
+ }
170
+ return files;
171
+ }
172
+ /**
173
+ * Build the documentation bundle.
174
+ *
175
+ * @param project - Open export snapshot.
176
+ * @param options - Parts, filters and file split.
177
+ * @returns Files to write, counts, and what the filters excluded.
178
+ */
179
+ export function buildDocumentation(project, options = {}) {
180
+ const document = project.document;
181
+ const include = new Set(options.include ?? DOCUMENT_PARTS);
182
+ const programs = options.programs === undefined ? undefined : [...options.programs];
183
+ const routines = options.routines === undefined ? undefined : [...options.routines];
184
+ if (programs !== undefined) {
185
+ const unknown = programs.filter(name => !document.programs.some(program => program.name === name));
186
+ if (unknown.length > 0)
187
+ throw new L5xError('COMPONENT_NOT_FOUND', `No program named ${unknown.join(', ')}.`, 'Call list_components kind="program" for the declared names.', document.programs.map(program => program.name).slice(0, 5));
188
+ }
189
+ const counts = {
190
+ programs: programs?.length ?? document.programs.length,
191
+ tasks: document.tasks.length,
192
+ routines: 0,
193
+ rungs: 0,
194
+ comments: 0,
195
+ tags: document.tags.length,
196
+ modules: document.modules.length,
197
+ };
198
+ for (const program of document.programs) {
199
+ if (programs !== undefined && !programs.includes(program.name))
200
+ continue;
201
+ for (const routine of listRoutines(document, program.name)) {
202
+ if (routines !== undefined && !routines.includes(routine.name))
203
+ continue;
204
+ counts.routines += 1;
205
+ const body = getRoutine(document, program.name, routine.name);
206
+ if (body !== undefined && body.body.type === 'RLL') {
207
+ counts.rungs += body.body.rungs.length;
208
+ counts.comments += body.body.rungs.filter(rung => rung.comment !== undefined).length;
209
+ }
210
+ }
211
+ }
212
+ const files = new Map();
213
+ if (include.has('project'))
214
+ files.set('PROJECT.md', projectMarkdown(project, counts));
215
+ if (include.has('io')) {
216
+ const io = ioFiles(document);
217
+ files.set('io.md', io.markdown);
218
+ files.set('io.csv', io.csv);
219
+ }
220
+ if (include.has('tags')) {
221
+ files.set('tags.csv', tagsCsv(document));
222
+ files.set('tags-studio.csv', tagsStudioCsv(document));
223
+ }
224
+ if (include.has('rungs')) {
225
+ const perFile = Math.max(1, options.rungsPerFile ?? 200);
226
+ for (const [path, text] of rungFiles(project, { programs, routines, perFile }))
227
+ files.set(path, text);
228
+ }
229
+ return {
230
+ files,
231
+ counts,
232
+ excluded: {
233
+ programs: programs === undefined ? [] : document.programs.map(program => program.name).filter(name => !programs.includes(name)),
234
+ routines: document.routines.map(routine => routine.name).filter(name => routines !== undefined && !routines.includes(name)),
235
+ },
236
+ };
237
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The instruction catalog the ladder composer validates against.
3
+ *
4
+ * Logix has no instruction dictionary inside an L5X export, so a generated rung
5
+ * could previously name any identifier and still parse. This table is the
6
+ * contract: an instruction, its operand slots and the kind each slot accepts.
7
+ *
8
+ * The set covers what engineering programs actually use — measured against a
9
+ * 1279-tag, 860-rung project — rather than every instruction the vendor ships.
10
+ * An unknown name is refused with the nearest catalog names; an AOI declared by
11
+ * the project is accepted separately, because its pins live in the export.
12
+ *
13
+ * @module dsh-engineering-l5x/engine/instructions
14
+ */
15
+ /** What one operand slot accepts. */
16
+ export type OperandKind = 'BOOL' | 'NUMBER' | 'ANY' | 'TIMER' | 'COUNTER';
17
+ /** One operand slot. */
18
+ export interface InstructionOperand {
19
+ /** Slot name as the programming manual presents it. */
20
+ readonly name: string;
21
+ /** What the slot accepts. */
22
+ readonly kind: OperandKind;
23
+ /** True when the instruction accepts the slot but does not require it. */
24
+ readonly optional?: boolean;
25
+ }
26
+ /** One catalogued instruction. */
27
+ export interface InstructionInfo {
28
+ /** Instruction mnemonic, upper case. */
29
+ readonly name: string;
30
+ /** Grouping shown by the listing tool. */
31
+ readonly category: string;
32
+ /** One line on what the instruction does. */
33
+ readonly summary: string;
34
+ /** Operand slots in written order. */
35
+ readonly operands: readonly InstructionOperand[];
36
+ }
37
+ /** Scalar data types the composer treats as numbers. */
38
+ export declare const NUMERIC_TYPES: ReadonlySet<string>;
39
+ /** Timer data types. */
40
+ export declare const TIMER_TYPES: ReadonlySet<string>;
41
+ /** Counter data types. */
42
+ export declare const COUNTER_TYPES: ReadonlySet<string>;
43
+ /** The catalog, in category order. */
44
+ export declare const INSTRUCTIONS: readonly InstructionInfo[];
45
+ /** The catalog entry for one mnemonic, following the alias table. */
46
+ export declare function instructionByName(name: string): InstructionInfo | undefined;
47
+ /** The catalog name a caller should use for a mnemonic. */
48
+ export declare function canonicalInstruction(name: string): string;
49
+ /** Up to `limit` catalog names closest to one unknown mnemonic. */
50
+ export declare function nearestInstructions(name: string, limit?: number): readonly string[];