@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 +32 -0
- package/README.md +4 -3
- package/package.json +10 -11
- package/vendor/l5x/SKILL.md +27 -7
- package/vendor/l5x/lib/engine/change-set.d.ts +9 -1
- package/vendor/l5x/lib/engine/change-set.js +32 -7
- package/vendor/l5x/lib/engine/documentation.d.ts +58 -0
- package/vendor/l5x/lib/engine/documentation.js +237 -0
- package/vendor/l5x/lib/engine/instructions.d.ts +50 -0
- package/vendor/l5x/lib/engine/instructions.js +107 -0
- package/vendor/l5x/lib/engine/ladder.d.ts +74 -0
- package/vendor/l5x/lib/engine/ladder.js +209 -0
- package/vendor/l5x/lib/index.d.ts +1 -0
- package/vendor/l5x/lib/index.js +3 -1
- package/vendor/l5x/lib/model/routines.js +6 -1
- package/vendor/l5x/lib/session.d.ts +20 -0
- package/vendor/l5x/lib/session.js +36 -0
- package/vendor/l5x/lib/tools/document.d.ts +18 -0
- package/vendor/l5x/lib/tools/document.js +119 -0
- package/vendor/l5x/lib/tools/drafts.js +109 -3
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
/** Scalar data types the composer treats as numbers. */
|
|
16
|
+
export const NUMERIC_TYPES = new Set(['SINT', 'INT', 'DINT', 'LINT', 'USINT', 'UINT', 'UDINT', 'ULINT', 'REAL', 'LREAL']);
|
|
17
|
+
/** Timer data types. */
|
|
18
|
+
export const TIMER_TYPES = new Set(['TIMER', 'TON', 'TOF', 'RTO']);
|
|
19
|
+
/** Counter data types. */
|
|
20
|
+
export const COUNTER_TYPES = new Set(['COUNTER', 'CTU', 'CTD', 'CTUD']);
|
|
21
|
+
/** Slot helpers kept terse: the table reads as data, not code. */
|
|
22
|
+
const bool = (name = 'Tag') => ({ name, kind: 'BOOL' });
|
|
23
|
+
const num = (name = 'Source') => ({ name, kind: 'NUMBER' });
|
|
24
|
+
/** The catalog, in category order. */
|
|
25
|
+
export const INSTRUCTIONS = [
|
|
26
|
+
// Contacts and coils
|
|
27
|
+
{ name: 'XIC', category: 'Contacts and coils', summary: 'Examine if closed: true when the BOOL tag is true.', operands: [bool()] },
|
|
28
|
+
{ name: 'XIO', category: 'Contacts and coils', summary: 'Examine if open: true when the BOOL tag is false.', operands: [bool()] },
|
|
29
|
+
{ name: 'OTE', category: 'Contacts and coils', summary: 'Output energize: sets the BOOL tag to the rung condition.', operands: [bool()] },
|
|
30
|
+
{ name: 'OTL', category: 'Contacts and coils', summary: 'Output latch: sets the BOOL tag when the rung is true and holds it.', operands: [bool()] },
|
|
31
|
+
{ name: 'OTU', category: 'Contacts and coils', summary: 'Output unlatch: clears a latched BOOL tag when the rung is true.', operands: [bool()] },
|
|
32
|
+
{ name: 'ONS', category: 'Contacts and coils', summary: 'One shot: true for one scan when the rung goes true.', operands: [{ name: 'Storage', kind: 'BOOL' }] },
|
|
33
|
+
{ name: 'AFI', category: 'Contacts and coils', summary: 'Always false instruction: disables the rung and its outputs.', operands: [] },
|
|
34
|
+
// Comparison
|
|
35
|
+
{ name: 'EQU', category: 'Comparison', summary: 'Equal: true when source A equals source B.', operands: [num('Source A'), num('Source B')] },
|
|
36
|
+
{ name: 'NEQ', category: 'Comparison', summary: 'Not equal.', operands: [num('Source A'), num('Source B')] },
|
|
37
|
+
{ name: 'GRT', category: 'Comparison', summary: 'Greater than.', operands: [num('Source A'), num('Source B')] },
|
|
38
|
+
{ name: 'GEQ', category: 'Comparison', summary: 'Greater than or equal.', operands: [num('Source A'), num('Source B')] },
|
|
39
|
+
{ name: 'LES', category: 'Comparison', summary: 'Less than.', operands: [num('Source A'), num('Source B')] },
|
|
40
|
+
{ name: 'LEQ', category: 'Comparison', summary: 'Less than or equal.', operands: [num('Source A'), num('Source B')] },
|
|
41
|
+
{ name: 'LIM', category: 'Comparison', summary: 'Limit test: true when the test value lies between the limits.', operands: [num('Low limit'), num('Test'), num('High limit')] },
|
|
42
|
+
{ name: 'CMP', category: 'Comparison', summary: 'Compare: true when the expression is true.', operands: [{ name: 'Expression', kind: 'ANY' }] },
|
|
43
|
+
// Math
|
|
44
|
+
{ name: 'ADD', category: 'Math', summary: 'Add source A and source B into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
45
|
+
{ name: 'SUB', category: 'Math', summary: 'Subtract source B from source A into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
46
|
+
{ name: 'MUL', category: 'Math', summary: 'Multiply source A by source B into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
47
|
+
{ name: 'DIV', category: 'Math', summary: 'Divide source A by source B into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
48
|
+
{ name: 'MOD', category: 'Math', summary: 'Remainder of source A divided by source B.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
49
|
+
{ name: 'SQR', category: 'Math', summary: 'Square root of the source into the destination.', operands: [num('Source'), num('Destination')] },
|
|
50
|
+
{ name: 'CPT', category: 'Math', summary: 'Compute an expression into the destination.', operands: [{ name: 'Expression', kind: 'ANY' }, num('Destination')] },
|
|
51
|
+
// Move
|
|
52
|
+
{ name: 'MOV', category: 'Move', summary: 'Move the source value into the destination.', operands: [num('Source'), num('Destination')] },
|
|
53
|
+
{ name: 'COP', category: 'Move', summary: 'Copy elements from the source to the destination.', operands: [{ name: 'Source', kind: 'ANY' }, { name: 'Destination', kind: 'ANY' }, num('Length')] },
|
|
54
|
+
{ name: 'CLR', category: 'Move', summary: 'Clear the destination to zero.', operands: [num('Destination')] },
|
|
55
|
+
// Timer and counter
|
|
56
|
+
{ name: 'TON', category: 'Timer and counter', summary: 'Timer on delay: accumulates while the rung is true.', operands: [{ name: 'Timer', kind: 'TIMER' }, num('Preset')] },
|
|
57
|
+
{ name: 'TOF', category: 'Timer and counter', summary: 'Timer off delay: accumulates after the rung goes false.', operands: [{ name: 'Timer', kind: 'TIMER' }, num('Preset')] },
|
|
58
|
+
{ name: 'RTO', category: 'Timer and counter', summary: 'Retentive timer on: accumulates and holds its value.', operands: [{ name: 'Timer', kind: 'TIMER' }, num('Preset')] },
|
|
59
|
+
{ name: 'RES', category: 'Timer and counter', summary: 'Reset a timer or counter structure.', operands: [{ name: 'Structure', kind: 'ANY' }] },
|
|
60
|
+
{ name: 'CTU', category: 'Timer and counter', summary: 'Count up on each false-to-true rung transition.', operands: [{ name: 'Counter', kind: 'COUNTER' }, num('Preset')] },
|
|
61
|
+
{ name: 'CTD', category: 'Timer and counter', summary: 'Count down on each false-to-true rung transition.', operands: [{ name: 'Counter', kind: 'COUNTER' }, num('Preset')] },
|
|
62
|
+
// Program control
|
|
63
|
+
{ name: 'JSR', category: 'Program control', summary: 'Jump to a subroutine; parameters follow the routine name.', operands: [{ name: 'Routine', kind: 'ANY' }, { name: 'Parameter', kind: 'ANY', optional: true }] },
|
|
64
|
+
{ name: 'RET', category: 'Program control', summary: 'Return from a subroutine.', operands: [] },
|
|
65
|
+
{ name: 'JMP', category: 'Program control', summary: 'Jump to a label in the same routine.', operands: [{ name: 'Label', kind: 'ANY' }] },
|
|
66
|
+
{ name: 'LBL', category: 'Program control', summary: 'Label target for a JMP.', operands: [{ name: 'Label', kind: 'ANY' }] },
|
|
67
|
+
{ name: 'MCR', category: 'Program control', summary: 'Master control relay zone boundary.', operands: [] },
|
|
68
|
+
// Bit
|
|
69
|
+
{ name: 'AND', category: 'Bit', summary: 'Bitwise AND of two sources into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
70
|
+
{ name: 'OR', category: 'Bit', summary: 'Bitwise OR of two sources into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
71
|
+
{ name: 'XOR', category: 'Bit', summary: 'Bitwise exclusive OR of two sources into the destination.', operands: [num('Source A'), num('Source B'), num('Destination')] },
|
|
72
|
+
{ name: 'NOT', category: 'Bit', summary: 'Bitwise complement of the source into the destination.', operands: [num('Source'), num('Destination')] },
|
|
73
|
+
{ name: 'BSL', category: 'Bit', summary: 'Bit shift left through an array.', operands: [{ name: 'Array', kind: 'ANY' }, { name: 'Control', kind: 'ANY' }, num('Length')] },
|
|
74
|
+
{ name: 'BSR', category: 'Bit', summary: 'Bit shift right through an array.', operands: [{ name: 'Array', kind: 'ANY' }, { name: 'Control', kind: 'ANY' }, num('Length')] },
|
|
75
|
+
];
|
|
76
|
+
/** Alias spellings accepted for a catalog name. */
|
|
77
|
+
const ALIASES = { EQ: 'EQU', NE: 'NEQ', GT: 'GRT', GE: 'GEQ', LT: 'LES', LE: 'LEQ', LIMIT: 'LIM', MOVE: 'MOV' };
|
|
78
|
+
/** The catalog entry for one mnemonic, following the alias table. */
|
|
79
|
+
export function instructionByName(name) {
|
|
80
|
+
const upper = name.toUpperCase();
|
|
81
|
+
const canonical = ALIASES[upper] ?? upper;
|
|
82
|
+
return INSTRUCTIONS.find(instruction => instruction.name === canonical);
|
|
83
|
+
}
|
|
84
|
+
/** The catalog name a caller should use for a mnemonic. */
|
|
85
|
+
export function canonicalInstruction(name) {
|
|
86
|
+
const upper = name.toUpperCase();
|
|
87
|
+
return ALIASES[upper] ?? upper;
|
|
88
|
+
}
|
|
89
|
+
/** Up to `limit` catalog names closest to one unknown mnemonic. */
|
|
90
|
+
export function nearestInstructions(name, limit = 5) {
|
|
91
|
+
const upper = name.toUpperCase();
|
|
92
|
+
const scored = INSTRUCTIONS.map(instruction => {
|
|
93
|
+
let score = 0;
|
|
94
|
+
const candidate = instruction.name;
|
|
95
|
+
for (let index = 0; index < Math.min(candidate.length, upper.length); index += 1) {
|
|
96
|
+
if (candidate[index] === upper[index])
|
|
97
|
+
score += 1;
|
|
98
|
+
else
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
for (const letter of new Set(upper))
|
|
102
|
+
if (candidate.includes(letter))
|
|
103
|
+
score += 0.1;
|
|
104
|
+
return { name: candidate, score };
|
|
105
|
+
});
|
|
106
|
+
return scored.sort((left, right) => right.score - left.score).slice(0, limit).map(row => row.name);
|
|
107
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose one ladder rung from a structure, never from prose.
|
|
3
|
+
*
|
|
4
|
+
* The composer is the generation half of the pair: the caller decides the logic,
|
|
5
|
+
* this module decides the NeutralText. Every instruction is checked against the
|
|
6
|
+
* catalog (or a declared AOI), every operand against the export, and the emitted
|
|
7
|
+
* text is re-parsed by the same parser `draft_rung` uses. A rung that fails any
|
|
8
|
+
* of those never reaches a draft.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-l5x/engine/ladder
|
|
11
|
+
*/
|
|
12
|
+
import type { ProjectSnapshot } from '../session.ts';
|
|
13
|
+
/** One instruction node. */
|
|
14
|
+
export interface InstructionNode {
|
|
15
|
+
/** Instruction mnemonic, for example `XIC`. */
|
|
16
|
+
readonly instr: string;
|
|
17
|
+
/** Operands in written order. */
|
|
18
|
+
readonly args?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/** A left-to-right sequence of nodes. */
|
|
21
|
+
export interface SeriesNode {
|
|
22
|
+
/** Nodes evaluated in series. */
|
|
23
|
+
readonly series: readonly LadderNodeSpec[];
|
|
24
|
+
}
|
|
25
|
+
/** Parallel paths, each a left-to-right sequence. */
|
|
26
|
+
export interface ParallelNode {
|
|
27
|
+
/** One entry per path. */
|
|
28
|
+
readonly parallel: readonly (readonly LadderNodeSpec[])[];
|
|
29
|
+
}
|
|
30
|
+
/** One node of the composition tree. */
|
|
31
|
+
export type LadderNodeSpec = InstructionNode | SeriesNode | ParallelNode;
|
|
32
|
+
/** One operand, with what the export says about it. */
|
|
33
|
+
export interface ComposedOperand {
|
|
34
|
+
/** Operand text as emitted. */
|
|
35
|
+
readonly text: string;
|
|
36
|
+
/** Whether the export declares it (false for numeric literals). */
|
|
37
|
+
readonly resolved: boolean;
|
|
38
|
+
/** Canonical declaration when it resolves. */
|
|
39
|
+
readonly canonical?: string;
|
|
40
|
+
/** Declared data type when it resolves. */
|
|
41
|
+
readonly data_type?: string;
|
|
42
|
+
}
|
|
43
|
+
/** One composed rung. */
|
|
44
|
+
export interface ComposedRung {
|
|
45
|
+
/** NeutralText, ready for `draft_rung`. */
|
|
46
|
+
readonly neutral_text: string;
|
|
47
|
+
/** Instruction count. */
|
|
48
|
+
readonly instructions: number;
|
|
49
|
+
/** Operands in written order. */
|
|
50
|
+
readonly operands: readonly ComposedOperand[];
|
|
51
|
+
/** Where the instruction grammar came from. */
|
|
52
|
+
readonly grounding: 'catalog' | 'aoi' | 'mixed';
|
|
53
|
+
}
|
|
54
|
+
/** A composition result: one rung, or a pattern's pair. */
|
|
55
|
+
export interface ComposedRungs {
|
|
56
|
+
/** Rungs in evaluation order. */
|
|
57
|
+
readonly rungs: readonly ComposedRung[];
|
|
58
|
+
/** Pattern name, when a pattern produced them. */
|
|
59
|
+
readonly pattern?: string;
|
|
60
|
+
}
|
|
61
|
+
/** Named patterns the composer can expand. */
|
|
62
|
+
export declare const PATTERNS: readonly ["start_stop_latch", "fault_latch", "ton_delay", "interlock"];
|
|
63
|
+
/** One pattern name. */
|
|
64
|
+
export type PatternName = (typeof PATTERNS)[number];
|
|
65
|
+
/**
|
|
66
|
+
* Compose one rung from a structure tree.
|
|
67
|
+
*
|
|
68
|
+
* @param project - Fresh export snapshot the operands are resolved against.
|
|
69
|
+
* @param spec - The composition tree.
|
|
70
|
+
* @returns The validated rung.
|
|
71
|
+
*/
|
|
72
|
+
export declare function composeRung(project: ProjectSnapshot, spec: LadderNodeSpec): ComposedRung;
|
|
73
|
+
/** A pattern's named tags, expanded to one or two rungs. */
|
|
74
|
+
export declare function composePattern(project: ProjectSnapshot, pattern: PatternName, args: Readonly<Record<string, unknown>>): ComposedRungs;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose one ladder rung from a structure, never from prose.
|
|
3
|
+
*
|
|
4
|
+
* The composer is the generation half of the pair: the caller decides the logic,
|
|
5
|
+
* this module decides the NeutralText. Every instruction is checked against the
|
|
6
|
+
* catalog (or a declared AOI), every operand against the export, and the emitted
|
|
7
|
+
* text is re-parsed by the same parser `draft_rung` uses. A rung that fails any
|
|
8
|
+
* of those never reaches a draft.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-l5x/engine/ladder
|
|
11
|
+
*/
|
|
12
|
+
import { L5xError } from "../core/errors.js";
|
|
13
|
+
import { resolveTag } from "./tag-resolution.js";
|
|
14
|
+
import { instructionByName, nearestInstructions, NUMERIC_TYPES, TIMER_TYPES } from "./instructions.js";
|
|
15
|
+
import { parseLadder } from "./logic-syntax.js";
|
|
16
|
+
/** Named patterns the composer can expand. */
|
|
17
|
+
export const PATTERNS = ['start_stop_latch', 'fault_latch', 'ton_delay', 'interlock'];
|
|
18
|
+
const MAX_INSTRUCTIONS = 64;
|
|
19
|
+
const MAX_DEPTH = 16;
|
|
20
|
+
const LITERAL = /^-?(?:\d+\.?\d*|\.\d+)$/u;
|
|
21
|
+
const TAG_TEXT = /^[A-Za-z_][A-Za-z0-9_:.[\]]*$/u;
|
|
22
|
+
/** A numeric literal, which is not a declaration. */
|
|
23
|
+
const isLiteral = (text) => LITERAL.test(text);
|
|
24
|
+
/** A BOOL-ish check for the slot validation below uses this resolved operand. */
|
|
25
|
+
function resolveOperand(document, text) {
|
|
26
|
+
const direct = resolveTag(document, 'controller', text);
|
|
27
|
+
if (direct.resolved)
|
|
28
|
+
return direct;
|
|
29
|
+
// A rung is written inside a program, so an unqualified program tag is the
|
|
30
|
+
// ordinary case. Resolve it when exactly one program declares the base name;
|
|
31
|
+
// two programs declaring it is an ambiguity the caller must settle.
|
|
32
|
+
const base = text.split(/[.[:]/u)[0] ?? text;
|
|
33
|
+
const declared = document.tags.filter(tag => tag.name.toUpperCase() === base.toUpperCase());
|
|
34
|
+
if (declared.length === 1)
|
|
35
|
+
return resolveTag(document, declared[0].scope, text);
|
|
36
|
+
if (declared.length > 1) {
|
|
37
|
+
throw new L5xError('AMBIGUOUS_TAG', `${base} is declared in ${declared.length} scopes.`, 'Declare the tag once, or stage the rung through a routine in the program that owns it.', declared.map(tag => tag.scope));
|
|
38
|
+
}
|
|
39
|
+
return direct;
|
|
40
|
+
}
|
|
41
|
+
/** One tag or literal operand, checked against its slot kind. */
|
|
42
|
+
function checkOperand(project, text, kind, seen) {
|
|
43
|
+
if (isLiteral(text)) {
|
|
44
|
+
// A literal is a value, not an address: only the slots that take a value accept it.
|
|
45
|
+
if (kind === 'BOOL')
|
|
46
|
+
throw new L5xError('INVALID_OPERAND', `${text} is a number where a BOOL tag is required.`, 'Pass a declared BOOL tag.');
|
|
47
|
+
if (kind === 'TIMER')
|
|
48
|
+
throw new L5xError('INVALID_OPERAND', `${text} is a number where a timer is required.`, 'Pass a declared TIMER tag.');
|
|
49
|
+
const operand = { text, resolved: false };
|
|
50
|
+
seen.push(operand);
|
|
51
|
+
return operand;
|
|
52
|
+
}
|
|
53
|
+
if (!TAG_TEXT.test(text)) {
|
|
54
|
+
throw new L5xError('INVALID_OPERAND', `"${text}" is not a tag or a number.`, 'Pass a declared tag name, a member path, or a numeric literal; no spaces or quotes.');
|
|
55
|
+
}
|
|
56
|
+
const resolution = resolveOperand(project.document, text);
|
|
57
|
+
if (!resolution.resolved) {
|
|
58
|
+
const near = project.document.tags
|
|
59
|
+
.map(tag => tag.name)
|
|
60
|
+
.filter(name => name.toLowerCase().includes(text.split('.')[0].toLowerCase()))
|
|
61
|
+
.slice(0, 5);
|
|
62
|
+
throw new L5xError('TAG_NOT_FOUND', `No tag named "${text}" resolves in this export.`, near.length === 0 ? 'Call list_components kind="tag" to see the declared names.' : 'Retry with one of the candidates, or declare the tag first.', near);
|
|
63
|
+
}
|
|
64
|
+
const type = (resolution.data_type ?? '').toUpperCase();
|
|
65
|
+
const operand = { text, resolved: true, canonical: resolution.canonical ?? text, data_type: type === '' ? undefined : type };
|
|
66
|
+
seen.push(operand);
|
|
67
|
+
if (kind === 'BOOL' && type !== 'BOOL') {
|
|
68
|
+
throw new L5xError('INVALID_OPERAND', `${text} is ${type || 'an unknown type'}; this slot needs a BOOL tag.`, 'Use a BOOL tag, or an alias that resolves to BOOL.', []);
|
|
69
|
+
}
|
|
70
|
+
if (kind === 'NUMBER' && !NUMERIC_TYPES.has(type)) {
|
|
71
|
+
throw new L5xError('INVALID_OPERAND', `${text} is ${type || 'an unknown type'}; this slot needs a numeric value or tag.`, 'Pass a numeric literal or an INT, DINT, REAL or similar tag.', []);
|
|
72
|
+
}
|
|
73
|
+
if (kind === 'TIMER' && !TIMER_TYPES.has(type)) {
|
|
74
|
+
throw new L5xError('INVALID_OPERAND', `${text} is ${type || 'an unknown type'}; this slot needs a timer.`, 'Pass a declared TIMER tag (TON, TOF or RTO instance).', []);
|
|
75
|
+
}
|
|
76
|
+
return operand;
|
|
77
|
+
}
|
|
78
|
+
/** Resolve one instruction node and emit it. */
|
|
79
|
+
function composeInstruction(project, node, operands, counter) {
|
|
80
|
+
const args = node.args ?? [];
|
|
81
|
+
counter.instructions += 1;
|
|
82
|
+
if (counter.instructions > MAX_INSTRUCTIONS) {
|
|
83
|
+
throw new L5xError('LADDER_TOO_LARGE', `One rung may hold at most ${MAX_INSTRUCTIONS} instructions.`, 'Split the logic across rungs.');
|
|
84
|
+
}
|
|
85
|
+
const instruction = instructionByName(node.instr);
|
|
86
|
+
if (instruction !== undefined) {
|
|
87
|
+
const required = instruction.operands.filter(slot => slot.optional !== true).length;
|
|
88
|
+
if (args.length < required || args.length > instruction.operands.length) {
|
|
89
|
+
const shape = instruction.operands.map(slot => `${slot.name}${slot.optional === true ? '?' : ''}`).join(', ');
|
|
90
|
+
throw new L5xError('INVALID_OPERAND', `${instruction.name} takes ${required === instruction.operands.length ? String(required) : `${required}-${instruction.operands.length}`} operand(s): ${shape}; ${args.length} were supplied.`, `Call list_instructions with filter "${instruction.name}" for the exact operand order.`);
|
|
91
|
+
}
|
|
92
|
+
for (const [index, argument] of args.entries())
|
|
93
|
+
checkOperand(project, argument, instruction.operands[index].kind, operands);
|
|
94
|
+
return { text: `${instruction.name}(${args.join(',')})`, grounding: 'catalog' };
|
|
95
|
+
}
|
|
96
|
+
const aoi = project.document.aois.find(entry => entry.name.toUpperCase() === node.instr.toUpperCase());
|
|
97
|
+
if (aoi === undefined) {
|
|
98
|
+
throw new L5xError('UNKNOWN_INSTRUCTION', `"${node.instr}" is neither a catalogued instruction nor an AOI declared in this export.`, 'Call list_instructions for the catalog; an AOI must be declared in the project.', nearestInstructions(node.instr));
|
|
99
|
+
}
|
|
100
|
+
// AOI pins are declared by the AOI, not by this catalog; the operand text is
|
|
101
|
+
// still resolved so a typo in a tag cannot reach the draft.
|
|
102
|
+
for (const argument of args)
|
|
103
|
+
checkOperand(project, argument, 'ANY', operands);
|
|
104
|
+
return { text: `${aoi.name}(${args.join(',')})`, grounding: 'aoi' };
|
|
105
|
+
}
|
|
106
|
+
/** Emit one node tree, validating as it goes. */
|
|
107
|
+
function composeNode(project, node, operands, counter, depth, grounds) {
|
|
108
|
+
if (depth > MAX_DEPTH)
|
|
109
|
+
throw new L5xError('LADDER_TOO_LARGE', `Branch nesting exceeds ${MAX_DEPTH}.`, 'Flatten the branches.');
|
|
110
|
+
if ('parallel' in node) {
|
|
111
|
+
if (!Array.isArray(node.parallel) || node.parallel.length < 2) {
|
|
112
|
+
throw new L5xError('INVALID_LADDER_SPEC', 'A parallel branch needs at least two paths.', 'Wrap the alternative paths in "parallel": [[...],[...]].');
|
|
113
|
+
}
|
|
114
|
+
const paths = node.parallel.map(path => {
|
|
115
|
+
if (!Array.isArray(path) || path.length === 0)
|
|
116
|
+
throw new L5xError('INVALID_LADDER_SPEC', 'A parallel path cannot be empty.', 'Give every path at least one instruction.');
|
|
117
|
+
return path.map(entry => composeNode(project, entry, operands, counter, depth + 1, grounds)).join('');
|
|
118
|
+
});
|
|
119
|
+
return `[${paths.join(' ,')} ]`;
|
|
120
|
+
}
|
|
121
|
+
if ('series' in node) {
|
|
122
|
+
if (!Array.isArray(node.series) || node.series.length === 0) {
|
|
123
|
+
throw new L5xError('INVALID_LADDER_SPEC', 'A series cannot be empty.', 'Give the series at least one instruction.');
|
|
124
|
+
}
|
|
125
|
+
return node.series.map(entry => composeNode(project, entry, operands, counter, depth + 1, grounds)).join('');
|
|
126
|
+
}
|
|
127
|
+
const composed = composeInstruction(project, node, operands, counter);
|
|
128
|
+
grounds.add(composed.grounding);
|
|
129
|
+
return composed.text;
|
|
130
|
+
}
|
|
131
|
+
/** Validate the rung we just emitted with the parser the draft path uses. */
|
|
132
|
+
function verify(project, text, operands, instructions, grounds) {
|
|
133
|
+
const parsed = parseLadder(text);
|
|
134
|
+
if (parsed.issues.length > 0) {
|
|
135
|
+
// The composer produced text its own parser rejects: a defect, never a draft.
|
|
136
|
+
throw new L5xError('COMPOSER_DEFECT', `The composed rung does not parse: ${parsed.issues[0]?.message ?? 'unknown issue'}`, 'This is a plugin defect; report the spec that produced it.');
|
|
137
|
+
}
|
|
138
|
+
const grounding = grounds.size === 2 ? 'mixed' : grounds.has('aoi') ? 'aoi' : 'catalog';
|
|
139
|
+
return { neutral_text: text, instructions, operands, grounding };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Compose one rung from a structure tree.
|
|
143
|
+
*
|
|
144
|
+
* @param project - Fresh export snapshot the operands are resolved against.
|
|
145
|
+
* @param spec - The composition tree.
|
|
146
|
+
* @returns The validated rung.
|
|
147
|
+
*/
|
|
148
|
+
export function composeRung(project, spec) {
|
|
149
|
+
const operands = [];
|
|
150
|
+
const counter = { instructions: 0 };
|
|
151
|
+
const grounds = new Set();
|
|
152
|
+
const text = `${composeNode(project, spec, operands, counter, 0, grounds)};`;
|
|
153
|
+
if (counter.instructions === 0)
|
|
154
|
+
throw new L5xError('INVALID_LADDER_SPEC', 'The specification contains no instruction.', 'Give the rung at least one instruction.');
|
|
155
|
+
return verify(project, text, operands, counter.instructions, grounds);
|
|
156
|
+
}
|
|
157
|
+
/** A required pattern argument. */
|
|
158
|
+
function arg(args, name) {
|
|
159
|
+
const value = args[name];
|
|
160
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
161
|
+
throw new L5xError('INVALID_ARGUMENT', `Pattern argument ${name} must be a non-empty string.`, `Pass ${name} as a declared tag name.`);
|
|
162
|
+
}
|
|
163
|
+
return value.trim();
|
|
164
|
+
}
|
|
165
|
+
/** A pattern's named tags, expanded to one or two rungs. */
|
|
166
|
+
export function composePattern(project, pattern, args) {
|
|
167
|
+
const one = (spec) => ({ rungs: [composeRung(project, spec)], pattern });
|
|
168
|
+
if (pattern === 'start_stop_latch') {
|
|
169
|
+
const start = arg(args, 'start');
|
|
170
|
+
const stop = arg(args, 'stop');
|
|
171
|
+
const run = arg(args, 'run');
|
|
172
|
+
return one({ series: [
|
|
173
|
+
{ parallel: [[{ instr: 'XIC', args: [start] }], [{ instr: 'XIC', args: [run] }]] },
|
|
174
|
+
{ instr: 'XIO', args: [stop] },
|
|
175
|
+
{ instr: 'OTE', args: [run] },
|
|
176
|
+
] });
|
|
177
|
+
}
|
|
178
|
+
if (pattern === 'interlock') {
|
|
179
|
+
const conditions = args.conditions;
|
|
180
|
+
if (!Array.isArray(conditions) || conditions.length === 0) {
|
|
181
|
+
throw new L5xError('INVALID_ARGUMENT', 'interlock needs conditions: at least one tag.', 'Pass conditions as an array of declared BOOL tag names.');
|
|
182
|
+
}
|
|
183
|
+
const output = arg(args, 'output');
|
|
184
|
+
return one({ series: [
|
|
185
|
+
...conditions.map(condition => ({ instr: 'XIC', args: [String(condition)] })),
|
|
186
|
+
{ instr: 'OTE', args: [output] },
|
|
187
|
+
] });
|
|
188
|
+
}
|
|
189
|
+
if (pattern === 'ton_delay') {
|
|
190
|
+
const enable = arg(args, 'enable');
|
|
191
|
+
const timer = arg(args, 'timer');
|
|
192
|
+
const preset = args.preset_ms;
|
|
193
|
+
if (typeof preset !== 'number' || !Number.isFinite(preset) || preset < 0) {
|
|
194
|
+
throw new L5xError('INVALID_ARGUMENT', 'ton_delay needs preset_ms as a non-negative number.', 'Pass the preset in milliseconds, for example 1500.');
|
|
195
|
+
}
|
|
196
|
+
return one({ series: [{ instr: 'XIC', args: [enable] }, { instr: 'TON', args: [timer, String(preset)] }] });
|
|
197
|
+
}
|
|
198
|
+
// fault_latch: set on the fault, clear on the reset — two rungs, never one.
|
|
199
|
+
const fault = arg(args, 'fault');
|
|
200
|
+
const reset = arg(args, 'reset');
|
|
201
|
+
const latched = arg(args, 'latched');
|
|
202
|
+
return {
|
|
203
|
+
pattern,
|
|
204
|
+
rungs: [
|
|
205
|
+
composeRung(project, { series: [{ instr: 'XIC', args: [fault] }, { instr: 'OTL', args: [latched] }] }),
|
|
206
|
+
composeRung(project, { series: [{ instr: 'XIC', args: [reset] }, { instr: 'OTU', args: [latched] }] }),
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -31,6 +31,7 @@ export { ProjectSession } from './session.ts';
|
|
|
31
31
|
export { readTools } from './tools/read.ts';
|
|
32
32
|
export { referenceTools } from './tools/references.ts';
|
|
33
33
|
export { checkTools } from './tools/checks.ts';
|
|
34
|
+
export { documentTools } from './tools/document.ts';
|
|
34
35
|
export { L5xError } from './core/errors.ts';
|
|
35
36
|
export type { NextStep, Coverage, Success } from './core/result.ts';
|
|
36
37
|
export type { ToolDefinition } from './core/tool.ts';
|
package/vendor/l5x/lib/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ProjectSession } from "./session.js";
|
|
|
12
12
|
import { readTools } from "./tools/read.js";
|
|
13
13
|
import { referenceTools } from "./tools/references.js";
|
|
14
14
|
import { checkTools } from "./tools/checks.js";
|
|
15
|
+
import { documentTools } from "./tools/document.js";
|
|
15
16
|
/** Plugin name in a composition. */
|
|
16
17
|
export const name = 'l5x-read';
|
|
17
18
|
/** Services this plugin cannot register without. */
|
|
@@ -31,7 +32,7 @@ export function apply(ctx, config = {}) {
|
|
|
31
32
|
}
|
|
32
33
|
const session = new ProjectSession(ctx.fs, maxFileBytes);
|
|
33
34
|
ctx.provide?.(PROJECTS_SERVICE, session);
|
|
34
|
-
for (const definition of [...readTools(session), ...referenceTools(session), ...checkTools(session)]) {
|
|
35
|
+
for (const definition of [...readTools(session), ...referenceTools(session), ...checkTools(session), ...documentTools(session)]) {
|
|
35
36
|
ctx.tools.register(definition);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -39,4 +40,5 @@ export { ProjectSession } from "./session.js";
|
|
|
39
40
|
export { readTools } from "./tools/read.js";
|
|
40
41
|
export { referenceTools } from "./tools/references.js";
|
|
41
42
|
export { checkTools } from "./tools/checks.js";
|
|
43
|
+
export { documentTools } from "./tools/document.js";
|
|
42
44
|
export { L5xError } from "./core/errors.js";
|
|
@@ -175,7 +175,12 @@ function decodeLines(routine) {
|
|
|
175
175
|
function decodeRung(rung) {
|
|
176
176
|
const textElement = firstChild(rung, 'Text');
|
|
177
177
|
const text = textElement === undefined ? '' : (nodeText(textElement) ?? '').trim();
|
|
178
|
-
|
|
178
|
+
// Current exports write the comment as a <Comment> child (CDATA); older ones
|
|
179
|
+
// used the attribute. Reading only the attribute dropped 572 real comments in
|
|
180
|
+
// one project, which is what a documentation pass needs most.
|
|
181
|
+
const commentElement = firstChild(rung, 'Comment');
|
|
182
|
+
const fromChild = commentElement === undefined ? undefined : nodeText(commentElement);
|
|
183
|
+
const comment = (fromChild ?? optionalAttribute(rung, 'Comment', '')).trim();
|
|
179
184
|
const number = Number.parseInt(attribute(rung, 'Number'), 10);
|
|
180
185
|
if (comment === '')
|
|
181
186
|
return { number, text };
|
|
@@ -101,6 +101,26 @@ export declare class ProjectSession {
|
|
|
101
101
|
* @returns The resolved path.
|
|
102
102
|
*/
|
|
103
103
|
resolvePath(requestedPath: string, exec?: ExecContext): Promise<string>;
|
|
104
|
+
/**
|
|
105
|
+
* Whether one path already exists.
|
|
106
|
+
*
|
|
107
|
+
* @param requestedPath - Path as the caller supplied it.
|
|
108
|
+
* @param exec - Calling context: cancellation and workspace root.
|
|
109
|
+
* @returns True when the provider reports a file or folder there.
|
|
110
|
+
*/
|
|
111
|
+
exists(requestedPath: string, exec?: ExecContext): Promise<boolean>;
|
|
112
|
+
/**
|
|
113
|
+
* Write one text file at an exact path, without the `.L5X` restriction.
|
|
114
|
+
*
|
|
115
|
+
* Used by the documentation bundle: the provider creates missing parent
|
|
116
|
+
* folders, so a new `output_root` needs no separate mkdir.
|
|
117
|
+
*
|
|
118
|
+
* @param requestedPath - Destination as the caller supplied it.
|
|
119
|
+
* @param text - File contents.
|
|
120
|
+
* @param exec - Calling context: cancellation and workspace root.
|
|
121
|
+
* @returns The resolved destination.
|
|
122
|
+
*/
|
|
123
|
+
writeTextAt(requestedPath: string, text: string, exec?: ExecContext): Promise<string>;
|
|
104
124
|
/**
|
|
105
125
|
* Write one prepared draft to a new path, never overwriting.
|
|
106
126
|
*
|
|
@@ -144,6 +144,42 @@ export class ProjectSession {
|
|
|
144
144
|
});
|
|
145
145
|
return target.displayPath;
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether one path already exists.
|
|
149
|
+
*
|
|
150
|
+
* @param requestedPath - Path as the caller supplied it.
|
|
151
|
+
* @param exec - Calling context: cancellation and workspace root.
|
|
152
|
+
* @returns True when the provider reports a file or folder there.
|
|
153
|
+
*/
|
|
154
|
+
async exists(requestedPath, exec) {
|
|
155
|
+
const target = await this.fs.resolve(requestedPath, {
|
|
156
|
+
...(exec?.cwd === undefined ? {} : { cwd: exec.cwd }),
|
|
157
|
+
...(exec?.signal === undefined ? {} : { signal: exec.signal }),
|
|
158
|
+
});
|
|
159
|
+
return (await this.fs.stat(target, exec?.signal)) !== undefined;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Write one text file at an exact path, without the `.L5X` restriction.
|
|
163
|
+
*
|
|
164
|
+
* Used by the documentation bundle: the provider creates missing parent
|
|
165
|
+
* folders, so a new `output_root` needs no separate mkdir.
|
|
166
|
+
*
|
|
167
|
+
* @param requestedPath - Destination as the caller supplied it.
|
|
168
|
+
* @param text - File contents.
|
|
169
|
+
* @param exec - Calling context: cancellation and workspace root.
|
|
170
|
+
* @returns The resolved destination.
|
|
171
|
+
*/
|
|
172
|
+
async writeTextAt(requestedPath, text, exec) {
|
|
173
|
+
if (this.fs.writeText === undefined) {
|
|
174
|
+
throw new L5xError('OUTPUT_UNAVAILABLE', 'The filesystem provider in this profile cannot write files.', 'Use a profile whose filesystem provider allows writes.');
|
|
175
|
+
}
|
|
176
|
+
const target = await this.fs.resolve(requestedPath, {
|
|
177
|
+
...(exec?.cwd === undefined ? {} : { cwd: exec.cwd }),
|
|
178
|
+
...(exec?.signal === undefined ? {} : { signal: exec.signal }),
|
|
179
|
+
});
|
|
180
|
+
await this.fs.writeText(target, text, { kind: 'createIfAbsent' }, exec?.signal);
|
|
181
|
+
return target.displayPath;
|
|
182
|
+
}
|
|
147
183
|
/**
|
|
148
184
|
* Write one prepared draft to a new path, never overwriting.
|
|
149
185
|
*
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documentation and generation-reference tools.
|
|
3
|
+
*
|
|
4
|
+
* `list_instructions` is the catalog the composer validates against, exposed so a
|
|
5
|
+
* caller can read operand order instead of guessing it. `document_project` builds
|
|
6
|
+
* the handover bundle into a new folder: it writes documents, never the project.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-engineering-l5x/tools/document
|
|
9
|
+
*/
|
|
10
|
+
import type { ToolDefinition } from '../core/tool.ts';
|
|
11
|
+
import type { ProjectSession } from '../session.ts';
|
|
12
|
+
/**
|
|
13
|
+
* The documentation tools.
|
|
14
|
+
*
|
|
15
|
+
* @param session - Project handles for the calling chat.
|
|
16
|
+
* @returns Tool definitions in registration order.
|
|
17
|
+
*/
|
|
18
|
+
export declare function documentTools(session: ProjectSession): readonly ToolDefinition[];
|