@o-lang/olang 1.4.0 → 1.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-lang/olang",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "author": "Olalekan Ogundipe <info@olang.cloud>",
5
5
  "description": "O-Lang: A governance language for user-directed, rule-enforced agent workflows with native African language PII protection.",
6
6
  "main": "./src/runtime/index.js",
@@ -77,6 +77,11 @@ function parseWorkflowLines(lines, filename) {
77
77
  let escalationLevels = [];
78
78
  let currentLevel = null;
79
79
 
80
+ // ✅ ENHANCED: Track labels for blocks
81
+ let currentIfLabel = null;
82
+ let currentParallelLabel = null;
83
+ let currentEscalationLabel = null;
84
+
80
85
  const flushCurrentStep = () => {
81
86
  if (currentStep) {
82
87
  workflow.steps.push(currentStep);
@@ -88,6 +93,15 @@ function parseWorkflowLines(lines, filename) {
88
93
  let line = lines[i++].trim();
89
94
  if (line === '' || line.startsWith('#')) continue;
90
95
 
96
+ // ✅ ENHANCED: Extract "Step N:" label if present
97
+ let stepLabel = null;
98
+ let strippedLine = line;
99
+ const labelMatch = line.match(/^Step\s+(\d+)\s*:\s*(.+)$/i);
100
+ if (labelMatch) {
101
+ stepLabel = `Step ${labelMatch[1]}`;
102
+ strippedLine = labelMatch[2].trim();
103
+ }
104
+
91
105
  // --- 1. Workflow Declaration ---
92
106
  if (line.startsWith('Workflow ')) {
93
107
  const match = line.match(/^Workflow\s+"([^"]+)"(?:\s+with\s+(.+))?$/i);
@@ -134,11 +148,13 @@ function parseWorkflowLines(lines, filename) {
134
148
  }
135
149
 
136
150
  // --- 4. Block: Escalation ---
137
- if (line.match(/^Run in parallel with escalation:$/i)) {
151
+ // ✅ ENHANCED: Use strippedLine to match "Step N: Run in parallel with escalation:"
152
+ if (strippedLine.match(/^Run in parallel with escalation:$/i)) {
138
153
  flushCurrentStep();
139
154
  inEscalationBlock = true;
140
155
  escalationLevels = [];
141
156
  currentLevel = null;
157
+ currentEscalationLabel = stepLabel; // ✅ Save label
142
158
  continue;
143
159
  }
144
160
 
@@ -148,8 +164,10 @@ function parseWorkflowLines(lines, filename) {
148
164
  workflow.steps.push({
149
165
  type: 'escalation',
150
166
  levels: escalationLevels,
151
- stepNumber: workflow.steps.length + 1
167
+ stepNumber: workflow.steps.length + 1,
168
+ label: currentEscalationLabel // ✅ ENHANCED: Add label
152
169
  });
170
+ currentEscalationLabel = null; // ✅ Reset
153
171
  inEscalationBlock = false;
154
172
  continue;
155
173
  } else if (line.match(/^Level \d+:/i)) {
@@ -185,7 +203,8 @@ function parseWorkflowLines(lines, filename) {
185
203
  }
186
204
 
187
205
  // --- 5. Block: Parallel ---
188
- const timedParMatch = line.match(/^Run in parallel for (\d+)\s*([smhd])$/i);
206
+ // ENHANCED: Use strippedLine to match "Step N: Run in parallel for Xs"
207
+ const timedParMatch = strippedLine.match(/^Run in parallel for (\d+)\s*([smhd])$/i);
189
208
  if (timedParMatch) {
190
209
  flushCurrentStep();
191
210
  const value = parseInt(timedParMatch[1]);
@@ -195,14 +214,17 @@ function parseWorkflowLines(lines, filename) {
195
214
 
196
215
  inParallelBlock = true;
197
216
  parallelSteps = [];
217
+ currentParallelLabel = stepLabel; // ✅ Save label
198
218
  continue;
199
219
  }
200
220
 
201
- if (line.match(/^Run in parallel$/i)) {
221
+ // ✅ ENHANCED: Use strippedLine to match "Step N: Run in parallel"
222
+ if (strippedLine.match(/^Run in parallel$/i)) {
202
223
  flushCurrentStep();
203
224
  inParallelBlock = true;
204
225
  parallelSteps = [];
205
226
  parallelTimeout = null;
227
+ currentParallelLabel = stepLabel; // ✅ Save label
206
228
  continue;
207
229
  }
208
230
 
@@ -214,8 +236,10 @@ function parseWorkflowLines(lines, filename) {
214
236
  type: 'parallel',
215
237
  steps: parsedParallel,
216
238
  timeout: parallelTimeout,
217
- stepNumber: workflow.steps.length + 1
239
+ stepNumber: workflow.steps.length + 1,
240
+ label: currentParallelLabel // ✅ ENHANCED: Add label
218
241
  });
242
+ currentParallelLabel = null; // ✅ Reset
219
243
  inParallelBlock = false;
220
244
  parallelTimeout = null;
221
245
  continue;
@@ -227,12 +251,13 @@ function parseWorkflowLines(lines, filename) {
227
251
 
228
252
  // --- 6. Block: If / Else If / Else (FIXED STATE MACHINE) ---
229
253
 
230
- // Start If
231
- if (line.match(/^(?:If|When)\s+(.+)$/i)) {
254
+ // ENHANCED: Use strippedLine to match "Step N: If ..."
255
+ if (strippedLine.match(/^(?:If|When)\s+(.+)$/i)) {
232
256
  flushCurrentStep();
233
- const ifMatch = line.match(/^(?:If|When)\s+(.+)$/i);
257
+ const ifMatch = strippedLine.match(/^(?:If|When)\s+(.+)$/i);
234
258
  ifCondition = ifMatch[1].trim();
235
259
  inIfBlock = true;
260
+ currentIfLabel = stepLabel; // ✅ Save label
236
261
 
237
262
  // Reset accumulators
238
263
  mainIfBody = [];
@@ -291,8 +316,10 @@ function parseWorkflowLines(lines, filename) {
291
316
  body: parseBlock(mainIfBody), // Main body correctly isolated
292
317
  elseIf: elseIfChain, // Else-if chain
293
318
  elseBranch: inElseBlock ? parseBlock(currentBranchBody) : [], // Else body
294
- stepNumber: workflow.steps.length + 1
319
+ stepNumber: workflow.steps.length + 1,
320
+ label: currentIfLabel // ✅ ENHANCED: Add label
295
321
  });
322
+ currentIfLabel = null; // ✅ Reset
296
323
 
297
324
  // Reset State
298
325
  inIfBlock = false;
@@ -315,8 +342,25 @@ function parseWorkflowLines(lines, filename) {
315
342
 
316
343
  // --- 7. Standard Steps & Keywords ---
317
344
 
345
+ // Calculate (NEW v1.4.0 — math expression evaluation)
346
+ // ✅ ENHANCED: Use strippedLine
347
+ const calcMatch = strippedLine.match(/^Calculate\s+(.+)$/i);
348
+ if (calcMatch) {
349
+ flushCurrentStep();
350
+ workflow.steps.push({
351
+ type: 'calculate',
352
+ expression: calcMatch[1].trim(),
353
+ stepNumber: workflow.steps.length + 1,
354
+ saveAs: null,
355
+ constraints: {},
356
+ label: stepLabel // ✅ ENHANCED: Add label
357
+ });
358
+ continue;
359
+ }
360
+
318
361
  // Connect: Connect "name" to url "..." OR Connect "name" to resolver "..."
319
- const connectMatch = line.match(/^Connect\s+"([^"]+)"\s+to\s+(url|resolver)\s+"([^"]+)"$/i);
362
+ // ENHANCED: Use strippedLine
363
+ const connectMatch = strippedLine.match(/^Connect\s+"([^"]+)"\s+to\s+(url|resolver)\s+"([^"]+)"$/i);
320
364
  if (connectMatch) {
321
365
  flushCurrentStep();
322
366
  workflow.steps.push({
@@ -324,20 +368,23 @@ function parseWorkflowLines(lines, filename) {
324
368
  resource: connectMatch[1],
325
369
  endpoint: connectMatch[3],
326
370
  targetType: connectMatch[2].toLowerCase(),
327
- stepNumber: workflow.steps.length + 1
371
+ stepNumber: workflow.steps.length + 1,
372
+ label: stepLabel // ✅ ENHANCED: Add label
328
373
  });
329
374
  continue;
330
375
  }
331
376
 
332
377
  // Use: Use "logicalName" as "resource"
333
- const useMatch = line.match(/^Use\s+"([^"]+)"\s+as\s+"([^"]+)"$/i);
378
+ // ENHANCED: Use strippedLine
379
+ const useMatch = strippedLine.match(/^Use\s+"([^"]+)"\s+as\s+"([^"]+)"$/i);
334
380
  if (useMatch) {
335
381
  flushCurrentStep();
336
382
  workflow.steps.push({
337
383
  type: 'agent_use',
338
384
  logicalName: useMatch[1],
339
385
  resource: useMatch[2],
340
- stepNumber: workflow.steps.length + 1
386
+ stepNumber: workflow.steps.length + 1,
387
+ label: stepLabel // ✅ ENHANCED: Add label
341
388
  });
342
389
  continue;
343
390
  }
@@ -351,7 +398,8 @@ function parseWorkflowLines(lines, filename) {
351
398
  stepNumber: parseInt(stepMatch[1], 10),
352
399
  actionRaw: stepMatch[2].trim(),
353
400
  saveAs: null,
354
- constraints: {}
401
+ constraints: {},
402
+ label: `Step ${stepMatch[1]}` // ✅ ENHANCED: Add label
355
403
  };
356
404
  continue;
357
405
  }
@@ -364,59 +412,68 @@ function parseWorkflowLines(lines, filename) {
364
412
  }
365
413
 
366
414
  // Debrief
367
- const debriefMatch = line.match(/^Debrief\s+([^\s]+)\s+with\s+"([^"]*)"$/i);
415
+ // ENHANCED: Use strippedLine
416
+ const debriefMatch = strippedLine.match(/^Debrief\s+([^\s]+)\s+with\s+"([^"]*)"$/i);
368
417
  if (debriefMatch) {
369
418
  flushCurrentStep();
370
419
  workflow.steps.push({
371
420
  type: 'debrief',
372
421
  agent: debriefMatch[1].trim(),
373
422
  message: debriefMatch[2],
374
- stepNumber: workflow.steps.length + 1
423
+ stepNumber: workflow.steps.length + 1,
424
+ label: stepLabel // ✅ ENHANCED: Add label
375
425
  });
376
426
  continue;
377
427
  }
378
428
 
379
429
  // Prompt
380
- const promptMatch = line.match(/^Prompt user to\s+"([^"]*)"$/i);
430
+ // ENHANCED: Use strippedLine
431
+ const promptMatch = strippedLine.match(/^Prompt user to\s+"([^"]*)"$/i);
381
432
  if (promptMatch) {
382
433
  flushCurrentStep();
383
434
  workflow.steps.push({
384
435
  type: 'prompt',
385
436
  question: promptMatch[1],
386
437
  stepNumber: workflow.steps.length + 1,
387
- saveAs: null
438
+ saveAs: null,
439
+ label: stepLabel // ✅ ENHANCED: Add label
388
440
  });
389
441
  continue;
390
442
  }
391
443
 
392
444
  // Persist
393
- const persistMatch = line.match(/^Persist\s+([^\s]+)\s+to\s+"([^"]*)"$/i);
445
+ // ENHANCED: Use strippedLine
446
+ const persistMatch = strippedLine.match(/^Persist\s+([^\s]+)\s+to\s+"([^"]*)"$/i);
394
447
  if (persistMatch) {
395
448
  flushCurrentStep();
396
449
  workflow.steps.push({
397
450
  type: 'persist',
398
451
  variable: persistMatch[1].trim(),
399
452
  target: persistMatch[2],
400
- stepNumber: workflow.steps.length + 1
453
+ stepNumber: workflow.steps.length + 1,
454
+ label: stepLabel // ✅ ENHANCED: Add label
401
455
  });
402
456
  continue;
403
457
  }
404
458
 
405
459
  // Emit
406
- const emitMatch = line.match(/^Emit\s+"([^"]+)"\s+with\s+(.+)$/i);
460
+ // ENHANCED: Use strippedLine
461
+ const emitMatch = strippedLine.match(/^Emit\s+"([^"]+)"\s+with\s+(.+)$/i);
407
462
  if (emitMatch) {
408
463
  flushCurrentStep();
409
464
  workflow.steps.push({
410
465
  type: 'emit',
411
466
  event: emitMatch[1],
412
467
  payload: emitMatch[2].trim(),
413
- stepNumber: workflow.steps.length + 1
468
+ stepNumber: workflow.steps.length + 1,
469
+ label: stepLabel // ✅ ENHANCED: Add label
414
470
  });
415
471
  continue;
416
472
  }
417
473
 
418
474
  // Ask (Multiline support)
419
- const askMatch = line.match(/^Ask\s+(.+)$/i);
475
+ // ENHANCED: Use strippedLine
476
+ const askMatch = strippedLine.match(/^Ask\s+(.+)$/i);
420
477
  if (askMatch) {
421
478
  flushCurrentStep();
422
479
  let actionContent = askMatch[1].trim();
@@ -434,7 +491,8 @@ function parseWorkflowLines(lines, filename) {
434
491
  actionRaw: `Action ${actionContent}`,
435
492
  stepNumber: workflow.steps.length + 1,
436
493
  saveAs: null,
437
- constraints: {}
494
+ constraints: {},
495
+ label: stepLabel // ✅ ENHANCED: Add label
438
496
  });
439
497
  continue;
440
498
  }
@@ -451,24 +509,26 @@ function parseWorkflowLines(lines, filename) {
451
509
  }
452
510
 
453
511
  // Fallback: Treat as action
454
- if (line.trim() !== '') {
512
+ // ENHANCED: Use strippedLine for actionRaw
513
+ if (strippedLine.trim() !== '') {
455
514
  if (!currentStep) {
456
515
  currentStep = {
457
516
  type: 'action',
458
517
  stepNumber: workflow.steps.length + 1,
459
- actionRaw: line,
518
+ actionRaw: strippedLine,
460
519
  saveAs: null,
461
- constraints: {}
520
+ constraints: {},
521
+ label: stepLabel // ✅ ENHANCED: Add label
462
522
  };
463
523
  } else {
464
- currentStep.actionRaw += ' ' + line;
524
+ currentStep.actionRaw += ' ' + strippedLine;
465
525
  }
466
526
  }
467
527
  }
468
528
 
469
529
  flushCurrentStep();
470
530
 
471
- // Post-process Save as in actionRaw
531
+ // Post-process Save as in actionRaw AND expression (calculate steps)
472
532
  workflow.steps.forEach(step => {
473
533
  if (step.actionRaw && step.saveAs === null) {
474
534
  const saveInAction = step.actionRaw.match(/(.+?)\s+Save as\s+(.+)$/i);
@@ -477,6 +537,14 @@ function parseWorkflowLines(lines, filename) {
477
537
  step.saveAs = normalizeSymbol(saveInAction[2].trim());
478
538
  }
479
539
  }
540
+ // ✅ NEW: Handle Calculate steps with inline Save as
541
+ if (step.type === 'calculate' && step.expression && step.saveAs === null) {
542
+ const saveInExpr = step.expression.match(/(.+?)\s+Save as\s+(.+)$/i);
543
+ if (saveInExpr) {
544
+ step.expression = saveInExpr[1].trim();
545
+ step.saveAs = normalizeSymbol(saveInExpr[2].trim());
546
+ }
547
+ }
480
548
  if (step.saveAs) {
481
549
  step.saveAs = normalizeSymbol(step.saveAs);
482
550
  }
@@ -504,6 +572,19 @@ function parseBlock(lines) {
504
572
  line = line.trim();
505
573
  if (!line || line.startsWith('#')) continue;
506
574
 
575
+ // Calculate in Block (NEW v1.4.0)
576
+ const calcMatch = line.match(/^Calculate\s+(.+)$/i);
577
+ if (calcMatch) {
578
+ flush();
579
+ steps.push({
580
+ type: 'calculate',
581
+ expression: calcMatch[1].trim(),
582
+ saveAs: null,
583
+ constraints: {}
584
+ });
585
+ continue;
586
+ }
587
+
507
588
  // Connect in Block
508
589
  const connectMatch = line.match(/^Connect\s+"([^"]+)"\s+to\s+(url|resolver)\s+"([^"]+)"$/i);
509
590
  if (connectMatch) {
@@ -538,7 +619,8 @@ function parseBlock(lines) {
538
619
  stepNumber: parseInt(stepMatch[1], 10),
539
620
  actionRaw: stepMatch[2].trim(),
540
621
  saveAs: null,
541
- constraints: {}
622
+ constraints: {},
623
+ label: `Step ${stepMatch[1]}` // ✅ ENHANCED: Add label
542
624
  };
543
625
  continue;
544
626
  }
@@ -604,6 +686,14 @@ function parseBlock(lines) {
604
686
  step.saveAs = normalizeSymbol(saveInAction[2].trim());
605
687
  }
606
688
  }
689
+ // ✅ NEW: Handle Calculate steps with inline Save as in blocks
690
+ if (step.type === 'calculate' && step.expression && step.saveAs === null) {
691
+ const saveInExpr = step.expression.match(/(.+?)\s+Save as\s+(.+)$/i);
692
+ if (saveInExpr) {
693
+ step.expression = saveInExpr[1].trim();
694
+ step.saveAs = normalizeSymbol(saveInExpr[2].trim());
695
+ }
696
+ }
607
697
  if (step.saveAs) {
608
698
  step.saveAs = normalizeSymbol(step.saveAs);
609
699
  }
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto'); // ✅ CRYPTOGRAPHIC AUDIT LOGS
4
4
 
5
5
  // ✅ O-Lang Kernel Version (Safety Logic & Governance Rules)
6
- const KERNEL_VERSION = '1.4.0'; // 🔁 Bumped: PII redaction engine added
6
+ const KERNEL_VERSION = '1.4.1'; // 🔁 Bumped: PII redaction engine added
7
7
 
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
9
  // ✅ NEW v1.3.0 — SEPARATED PATTERN SETS
@@ -1624,8 +1624,15 @@ class RuntimeAPI {
1624
1624
  }
1625
1625
  }
1626
1626
 
1627
- let errorMessage = `[O-Lang SAFETY] No resolver handled action: "${action}\n`;
1627
+ // ENHANCED: Extract step location context for debugging
1628
+ const stepLabel = step.label || `unnamed_${step.type}_step`;
1629
+ const branchContext = step._parentLabel ? ` [Inside ${step._parentLabel} → ${step._branch} branch]` : '';
1630
+
1631
+ let errorMessage = `[O-Lang SAFETY] No resolver handled action: "${action}"\n`;
1632
+ errorMessage += ` → Location: ${stepLabel}${branchContext}\n`;
1633
+ errorMessage += ` → Workflow: ${this.context.workflow_name || 'unknown'}\n`;
1628
1634
  errorMessage += `Attempted resolvers:\n`;
1635
+
1629
1636
  resolverAttempts.forEach((attempt, i) => {
1630
1637
  const namePad = attempt.name.padEnd(30);
1631
1638
  if (attempt.status === 'skipped') {
@@ -1682,6 +1689,16 @@ class RuntimeAPI {
1682
1689
  errorMessage += ` → Visit https://www.npmjs.com/search?q=%40o-lang for resolver packages\n`;
1683
1690
  }
1684
1691
  errorMessage += `\n🛑 Workflow halted to prevent unsafe data propagation to LLMs.`;
1692
+
1693
+ // ✅ ENHANCED: Log the failure with full context before throwing
1694
+ this._createAuditEntry('resolver_execution_failed', {
1695
+ step_label: stepLabel,
1696
+ branch_context: step._parentLabel ? `${step._parentLabel} (${step._branch})` : null,
1697
+ action: action,
1698
+ attempted_resolvers: resolverAttempts.map(a => a.name),
1699
+ severity: 'high'
1700
+ });
1701
+
1685
1702
  throw new Error(errorMessage);
1686
1703
  };
1687
1704
 
@@ -1859,7 +1876,9 @@ class RuntimeAPI {
1859
1876
  // 3. Auditability: Logs which condition was evaluated and which branch fired.
1860
1877
  // ─────────────────────────────────────────────────────────────────────────────
1861
1878
 
1862
- case 'if': {
1879
+ case 'if': {
1880
+ const stepLabel = step.label || 'unnamed_if_block';
1881
+
1863
1882
  // 1. Validate all symbols referenced in the main condition
1864
1883
  const condSymbols = step.condition ? step.condition.match(/\{([^\}]+)\}/g) || [] : [];
1865
1884
  let symbolsValid = true;
@@ -1873,6 +1892,7 @@ class RuntimeAPI {
1873
1892
 
1874
1893
  if (!symbolsValid) {
1875
1894
  this._createAuditEntry('condition_skipped', {
1895
+ step_label: stepLabel,
1876
1896
  condition: step.condition,
1877
1897
  reason: 'One or more symbols missing in context',
1878
1898
  severity: 'warn'
@@ -1884,6 +1904,7 @@ class RuntimeAPI {
1884
1904
  const mainPassed = this.evaluateCondition(step.condition, this.context);
1885
1905
 
1886
1906
  this._createAuditEntry('condition_evaluated', {
1907
+ step_label: stepLabel,
1887
1908
  condition: step.condition,
1888
1909
  passed: mainPassed,
1889
1910
  branch: 'if',
@@ -1892,7 +1913,12 @@ class RuntimeAPI {
1892
1913
 
1893
1914
  if (mainPassed) {
1894
1915
  if (step.body && Array.isArray(step.body)) {
1895
- for (const s of step.body) await this.executeStep(s, agentResolver);
1916
+ // ENHANCED: Tag child steps with parent context for debugging
1917
+ for (const s of step.body) {
1918
+ s._parentLabel = stepLabel;
1919
+ s._branch = 'if';
1920
+ await this.executeStep(s, agentResolver);
1921
+ }
1896
1922
  }
1897
1923
  break; // Exit after successful if
1898
1924
  }
@@ -1913,6 +1939,7 @@ class RuntimeAPI {
1913
1939
 
1914
1940
  if (!branchSymbolsValid) {
1915
1941
  this._createAuditEntry('condition_skipped', {
1942
+ step_label: stepLabel,
1916
1943
  condition: branch.condition,
1917
1944
  reason: 'One or more symbols missing in context',
1918
1945
  severity: 'warn'
@@ -1923,6 +1950,7 @@ class RuntimeAPI {
1923
1950
  const branchPassed = this.evaluateCondition(branch.condition, this.context);
1924
1951
 
1925
1952
  this._createAuditEntry('condition_evaluated', {
1953
+ step_label: stepLabel,
1926
1954
  condition: branch.condition,
1927
1955
  passed: branchPassed,
1928
1956
  branch: 'else-if',
@@ -1931,7 +1959,12 @@ class RuntimeAPI {
1931
1959
 
1932
1960
  if (branchPassed) {
1933
1961
  if (branch.body && Array.isArray(branch.body)) {
1934
- for (const s of branch.body) await this.executeStep(s, agentResolver);
1962
+ // ENHANCED: Tag child steps with parent context
1963
+ for (const s of branch.body) {
1964
+ s._parentLabel = stepLabel;
1965
+ s._branch = 'else-if';
1966
+ await this.executeStep(s, agentResolver);
1967
+ }
1935
1968
  }
1936
1969
  elseIfFired = true;
1937
1970
  break;
@@ -1943,17 +1976,23 @@ class RuntimeAPI {
1943
1976
  // 4. else fallback
1944
1977
  if (step.elseBranch && Array.isArray(step.elseBranch)) {
1945
1978
  this._createAuditEntry('condition_evaluated', {
1979
+ step_label: stepLabel,
1946
1980
  condition: 'else',
1947
1981
  passed: true,
1948
1982
  branch: 'else',
1949
1983
  severity: 'info'
1950
1984
  });
1951
- for (const s of step.elseBranch) await this.executeStep(s, agentResolver);
1985
+ // ENHANCED: Tag child steps with parent context
1986
+ for (const s of step.elseBranch) {
1987
+ s._parentLabel = stepLabel;
1988
+ s._branch = 'else';
1989
+ await this.executeStep(s, agentResolver);
1990
+ }
1952
1991
  }
1953
1992
 
1954
1993
  break;
1955
1994
  }
1956
-
1995
+
1957
1996
  // ─────────────────────────────────────────────────────────────────────────────
1958
1997
  // PARALLEL
1959
1998
  //