docs: standards cleanup — single front-door CONTRACTS.md + archive stale plan artifacts

Establish CONTRACTS.md at the EVOLV root as the canonical map of where every
contract, rule, and standard lives. Surface it from CLAUDE.md so every fresh
agent or colleague lands there first.

Reshape .claude/refactor/ to reflect that the platform refactor is done:
live standards stay at the top level; the plan artifacts (CONTINUE_HERE.md,
TASKS.md) move into Archive/ with WARNING banners.

Drop content that drifted out of date or duplicated the new standards stack:
- docs/DEVELOPER_GUIDE.md (pre-refactor walkthrough; superseded by
  wiki/Architecture, wiki/Getting-Started, .claude/rules/node-architecture,
  .claude/refactor/MODULE_SPLIT + per-node CONTRACT.md + src/commands/).
- .agents/decisions/ (15 DECISION files): load-bearing decisions belong in
  commit messages and PR descriptions; live open items in OPEN_QUESTIONS.md.
- .agents/improvements/TOP10_*.md: moved to Archive/.

Bump generalFunctions to 49c77f2 — adds CONTRACT.md inside the library:
different shape from per-node CONTRACT.md files (library API, not msg.topic),
with stability tags and pointers to .claude/refactor/CONTRACTS.md §N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-18 15:48:46 +02:00
parent 560cc2f39a
commit 253ac93896
26 changed files with 210 additions and 1278 deletions

View File

@@ -1,602 +0,0 @@
# EVOLV Developer Guide: Creating a New Node
This guide walks through creating a new EVOLV node from scratch, following the project's three-layer architecture pattern.
## Prerequisites
- **Node.js** (v18+)
- **Node-RED** installed globally or as a dev dependency
- Clone the repo and run `npm install` in the root (no build step required)
- The `generalFunctions` submodule must be initialized (`git submodule update --init`)
## Architecture Overview
Every EVOLV node follows a **three-layer pattern**:
| Layer | File | Responsibility |
|-------|------|---------------|
| 1 - Wrapper | `<name>.js` | Registers the node type with Node-RED, sets up HTTP endpoints for menus/config |
| 2 - Node Adapter | `src/nodeClass.js` | Bridges Node-RED with domain logic: config loading, tick loop, input routing, lifecycle |
| 3 - Domain Logic | `src/specificClass.js` | Pure business logic with no Node-RED dependencies |
Plus a UI definition: `<name>.html` for the Node-RED editor.
## Step-by-Step: Creating a New Node
We will create a hypothetical `flowMeter` node as an example.
### Step 1: Create Directory Structure
```
nodes/flowMeter/
flowMeter.js # Layer 1 - wrapper
flowMeter.html # UI definition
src/
nodeClass.js # Layer 2 - node adapter
specificClass.js # Layer 3 - domain logic
test/
specificClass.test.js
```
### Step 2: Write the Wrapper (`flowMeter.js`)
The wrapper registers the node type with Node-RED and exposes HTTP endpoints for dynamic menus and config data.
```js
const nameOfNode = 'flowMeter';
const nodeClass = require('./src/nodeClass.js');
const { MenuManager, configManager } = require('generalFunctions');
module.exports = function(RED) {
// Register the node type
RED.nodes.registerType(nameOfNode, function(config) {
RED.nodes.createNode(this, config);
this.nodeClass = new nodeClass(config, RED, this, nameOfNode);
});
// Menu endpoint (dynamic dropdowns in the editor UI)
const menuMgr = new MenuManager();
RED.httpAdmin.get(`/${nameOfNode}/menu.js`, (req, res) => {
try {
const script = menuMgr.createEndpoint(nameOfNode, ['asset', 'logger', 'position']);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating menu: ${err.message}`);
}
});
// Config data endpoint (exposes JSON config to the editor)
const cfgMgr = new configManager();
RED.httpAdmin.get(`/${nameOfNode}/configData.js`, (req, res) => {
try {
const script = cfgMgr.createEndpoint(nameOfNode);
res.type('application/javascript').send(script);
} catch (err) {
res.status(500).send(`// Error generating configData: ${err.message}`);
}
});
};
```
Key points:
- `nameOfNode` must match the file name, the `registerType` name, and the HTML `data-template-name`.
- Menu categories (`['asset', 'logger', 'position']`) control which shared UI sections appear.
- The config endpoint is optional if you do not need dynamic config in the editor.
### Step 3: Write `nodeClass.js` (Node Adapter)
This class bridges Node-RED's API with your domain logic.
```js
const { outputUtils, configManager } = require('generalFunctions');
const Specific = require('./specificClass');
class nodeClass {
constructor(uiConfig, RED, nodeInstance, nameOfNode) {
this.node = nodeInstance;
this.RED = RED;
this.name = nameOfNode;
this._loadConfig(uiConfig);
this._setupSpecificClass();
this._bindEvents();
this._registerChild();
this._startTickLoop();
this._attachInputHandler();
this._attachCloseHandler();
}
_loadConfig(uiConfig) {
const cfgMgr = new configManager();
this.defaultConfig = cfgMgr.getConfig(this.name);
// buildConfig merges base sections (general, asset, functionality)
// with node-specific domain config from the UI
this.config = cfgMgr.buildConfig(this.name, uiConfig, this.node.id, {
// Add domain-specific config sections here:
flowSettings: {
maxFlow: uiConfig.maxFlow,
pipeSize: uiConfig.pipeSize,
},
});
this._output = new outputUtils();
}
_setupSpecificClass() {
this.source = new Specific(this.config);
this.node.source = this.source;
}
_bindEvents() {
// Subscribe to domain events for Node-RED status display
this.source.emitter.on('flowUpdate', (val) => {
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
});
}
_registerChild() {
// Delayed to avoid Node-RED startup race conditions
setTimeout(() => {
this.node.send([
null,
null,
{
topic: 'registerChild',
payload: this.node.id,
positionVsParent: this.config?.functionality?.positionVsParent || 'atEquipment',
distance: this.config?.functionality?.distance || null,
},
]);
}, 100);
}
_startTickLoop() {
setTimeout(() => {
this._tickInterval = setInterval(() => this._tick(), 1000);
}, 1000);
}
_tick() {
this.source.tick();
const raw = this.source.getOutput();
const processMsg = this._output.formatMsg(raw, this.config, 'process');
const influxMsg = this._output.formatMsg(raw, this.config, 'influxdb');
this.node.send([processMsg, influxMsg]);
}
_attachInputHandler() {
this.node.on('input', (msg, send, done) => {
switch (msg.topic) {
case 'measurement':
if (typeof msg.payload === 'number') {
this.source.inputValue = parseFloat(msg.payload);
}
break;
// Add more input topics as needed
}
done();
});
}
_attachCloseHandler() {
this.node.on('close', (done) => {
clearInterval(this._tickInterval);
done();
});
}
}
module.exports = nodeClass;
```
Essential methods every `nodeClass` must implement:
- `_loadConfig()` -- merges default JSON config with UI config via `configManager.buildConfig()`
- `_setupSpecificClass()` -- instantiates the domain class
- `_registerChild()` -- sends a `registerChild` message on output port 2 (parent)
- `_startTickLoop()` -- drives periodic output at 1-second intervals
- `_tick()` -- calls `source.getOutput()` and formats via `outputUtils.formatMsg()`
- `_attachInputHandler()` -- routes incoming `msg.topic` to domain methods
- `_attachCloseHandler()` -- clears timers on node removal
### Step 4: Write `specificClass.js` (Domain Logic)
This is pure JavaScript with no Node-RED dependencies.
```js
const EventEmitter = require('events');
const { logger, configUtils, configManager, MeasurementContainer } = require('generalFunctions');
class FlowMeter {
constructor(config = {}) {
this.emitter = new EventEmitter();
this.configManager = new configManager();
this.defaultConfig = this.configManager.getConfig('flowMeter');
this.configUtils = new configUtils(this.defaultConfig);
this.config = this.configUtils.initConfig(config);
this.logger = new logger(
this.config.general.logging.enabled,
this.config.general.logging.logLevel,
this.config.general.name
);
// MeasurementContainer stores typed/positioned values
this.measurements = new MeasurementContainer({
autoConvert: true,
windowSize: this.config.smoothing?.smoothWindow || 10,
});
this.measurements.setChildId(this.config.general.id);
this.measurements.setChildName(this.config.general.name);
// Domain state
this.currentFlow = 0;
}
tick() {
// Called every 1 second by nodeClass._tick()
this.calculateFlow();
}
calculateFlow() {
// Your domain logic here
const flow = this.currentFlow;
// Store in MeasurementContainer using the chainable API:
// .type(measType).variant(variant).position(pos).value(val, timestamp, unit)
this.measurements
.type(this.config.asset.type)
.variant('measured')
.position(this.config.functionality.positionVsParent)
.value(flow, Date.now(), this.config.asset.unit);
this.emitter.emit('flowUpdate', flow);
}
getOutput() {
return {
flow: this.currentFlow,
};
}
}
module.exports = FlowMeter;
```
Key patterns:
- Always create an `emitter` (EventEmitter) -- parents subscribe to child events through it.
- Use `MeasurementContainer` for storing measurements. The chainable API follows the pattern: `measurements.type(t).variant(v).position(p).value(val, timestamp, unit)`.
- Expose `tick()` and `getOutput()` for the node adapter to call.
- Use `logger` instead of `console.log`.
### Step 5: Write the HTML (UI Definition)
```html
<script src="/flowMeter/menu.js"></script>
<script src="/flowMeter/configData.js"></script>
<script>
RED.nodes.registerType("flowMeter", {
category: "EVOLV",
color: "#a9daee", // S88 Control Module color
defaults: {
name: { value: "flowMeter" },
maxFlow: { value: 100, required: true },
pipeSize: { value: 0.1, required: true },
// Standard fields (asset, logger, position)
uuid: { value: "" },
supplier: { value: "" },
category: { value: "" },
assetType: { value: "" },
model: { value: "" },
unit: { value: "" },
enableLog: { value: false },
logLevel: { value: "error" },
positionVsParent: { value: "" },
positionIcon: { value: "" },
},
inputs: 1,
outputs: 3,
outputLabels: ["process", "dbase", "parent"],
icon: "font-awesome/fa-tachometer",
label: function() {
return this.name || "flowMeter";
},
oneditprepare: function() {
// Wait for shared menu system to initialize
const waitForMenuData = () => {
if (window.EVOLV?.nodes?.flowMeter?.initEditor) {
window.EVOLV.nodes.flowMeter.initEditor(this);
} else {
setTimeout(waitForMenuData, 50);
}
};
waitForMenuData();
},
oneditsave: function() {
if (window.EVOLV?.nodes?.flowMeter?.assetMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.assetMenu.saveEditor(this);
}
if (window.EVOLV?.nodes?.flowMeter?.loggerMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.loggerMenu.saveEditor(this);
}
if (window.EVOLV?.nodes?.flowMeter?.positionMenu?.saveEditor) {
window.EVOLV.nodes.flowMeter.positionMenu.saveEditor(this);
}
},
});
</script>
<script type="text/html" data-template-name="flowMeter">
<div class="form-row">
<label for="node-input-maxFlow"><i class="fa fa-arrows-v"></i> Max Flow</label>
<input type="number" id="node-input-maxFlow" placeholder="100" />
</div>
<div class="form-row">
<label for="node-input-pipeSize"><i class="fa fa-circle-o"></i> Pipe Size (m)</label>
<input type="number" id="node-input-pipeSize" placeholder="0.1" step="0.01" />
</div>
<!-- Shared UI sections injected by MenuManager -->
<div id="asset-fields-placeholder"></div>
<div id="logger-fields-placeholder"></div>
<div id="position-fields-placeholder"></div>
</script>
<script type="text/html" data-help-name="flowMeter">
<p><b>Flow Meter Node</b>: Measures and processes flow data.</p>
</script>
```
**S88 color scheme** (pick based on your node's hierarchy level):
| S88 Level | Color | Text Color |
|-----------|-------|-----------|
| Area | `#0f52a5` | white |
| Process Cell | `#0c99d9` | white |
| Unit | `#50a8d9` | black |
| Equipment | `#86bbdd` | black |
| Control Module | `#a9daee` | black |
All nodes must have **3 outputs**: `[process, dbase, parent]`.
### Step 6: Create Config JSON Schema
Create `nodes/generalFunctions/src/configs/flowMeter.json`. This defines defaults and validation rules for every config property. The `configManager` reads this file by node name.
```json
{
"general": {
"name": {
"default": "FlowMeter",
"rules": { "type": "string", "description": "Human-readable name." }
},
"id": {
"default": null,
"rules": { "type": "string", "nullable": true }
},
"unit": {
"default": "m3/h",
"rules": { "type": "string" }
},
"logging": {
"logLevel": {
"default": "info",
"rules": {
"type": "enum",
"values": [
{ "value": "debug" }, { "value": "info" },
{ "value": "warn" }, { "value": "error" }
]
}
},
"enabled": { "default": true, "rules": { "type": "boolean" } }
}
},
"functionality": {
"softwareType": { "default": "flowMeter", "rules": { "type": "string" } },
"role": { "default": "Sensor", "rules": { "type": "string" } },
"positionVsParent": {
"default": "atEquipment",
"rules": {
"type": "enum",
"values": [
{ "value": "atEquipment" }, { "value": "upstream" }, { "value": "downstream" }
]
}
}
},
"asset": {
"supplier": { "default": "Unknown", "rules": { "type": "string" } },
"category": { "default": "sensor", "rules": { "type": "string" } },
"type": { "default": "flow", "rules": { "type": "string" } },
"model": { "default": "Unknown", "rules": { "type": "string" } },
"unit": { "default": "m3/h", "rules": { "type": "string" } }
}
}
```
Each property has a `default` value and a `rules` object specifying the type (`string`, `number`, `boolean`, `enum`, `object`), optional constraints (`min`, `max`, `nullable`), and a description.
### Step 7: Register with `package.json`
Add your node to the root `package.json` under `node-red.nodes`:
```json
{
"node-red": {
"nodes": {
"flowMeter": "nodes/flowMeter/flowMeter.js"
}
}
}
```
Restart Node-RED to pick up the new node.
### Step 8: Add Tests
Create `nodes/flowMeter/test/specificClass.test.js`. Tests target Layer 3 (domain logic) directly, without Node-RED.
```js
const FlowMeter = require('../src/specificClass');
function makeConfig(overrides = {}) {
const base = {
general: { name: 'TestFlow', id: 'test-1', logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'flowMeter', role: 'sensor', positionVsParent: 'atEquipment' },
asset: { category: 'sensor', type: 'flow', model: 'test', supplier: 'Test', unit: 'm3/h' },
};
for (const key of Object.keys(overrides)) {
base[key] = typeof overrides[key] === 'object' ? { ...base[key], ...overrides[key] } : overrides[key];
}
return base;
}
describe('FlowMeter specificClass', () => {
it('should create an instance', () => {
const fm = new FlowMeter(makeConfig());
expect(fm).toBeDefined();
});
it('should return output with expected keys', () => {
const fm = new FlowMeter(makeConfig());
const out = fm.getOutput();
expect(out).toHaveProperty('flow');
});
it('tick() should not throw', () => {
const fm = new FlowMeter(makeConfig());
expect(() => fm.tick()).not.toThrow();
});
});
```
Run tests with: `npm test` (uses Jest). The project also supports `node:test` for basic smoke tests.
**Test organization conventions** (based on existing nodes):
- `test/specificClass.test.js` -- unit tests for domain logic
- `test/basic/*.test.js` -- structural/smoke tests (module loads, exports exist)
- `test/edge/*.test.js` -- edge case and boundary tests
- `test/integration/*.test.js` -- multi-component integration tests
## Key APIs Reference
### MeasurementContainer
Chainable storage for typed, positioned measurements. Used by every domain class.
```js
const { MeasurementContainer } = require('generalFunctions');
const mc = new MeasurementContainer({ autoConvert: true, windowSize: 10 });
mc.setChildId('node-id');
mc.setChildName('PT-001');
// Store a value
mc.type('pressure').variant('measured').position('upstream').value(3.5, Date.now(), 'bar');
// Parents subscribe to events via mc.emitter
mc.emitter.on('pressure.measured.upstream', (data) => { /* { value, unit, ... } */ });
```
The event name follows the pattern: `{type}.{variant}.{position}`.
### configManager.buildConfig()
Merges the JSON config schema defaults with UI-provided values. Called in `nodeClass._loadConfig()`.
```js
const { configManager } = require('generalFunctions');
const cfgMgr = new configManager();
const defaults = cfgMgr.getConfig('myNode'); // loads myNode.json
const config = cfgMgr.buildConfig('myNode', uiConfig, nodeId, domainOverrides);
```
### POSITIONS
Canonical position constants. Use these instead of hardcoded strings.
```js
const { POSITIONS } = require('generalFunctions');
// POSITIONS.UPSTREAM = 'upstream'
// POSITIONS.DOWNSTREAM = 'downstream'
// POSITIONS.AT_EQUIPMENT = 'atEquipment'
// POSITIONS.DELTA = 'delta'
```
### outputUtils.formatMsg()
Formats raw output data into either `process` or `influxdb` messages. Only sends changed fields.
```js
const { outputUtils } = require('generalFunctions');
const out = new outputUtils();
const processMsg = out.formatMsg(rawData, config, 'process');
const influxMsg = out.formatMsg(rawData, config, 'influxdb');
node.send([processMsg, influxMsg]);
```
### childRegistrationUtils
Manages parent-child node relationships. Parents use this to accept child registrations.
```js
const { childRegistrationUtils } = require('generalFunctions');
const regUtils = new childRegistrationUtils(this); // 'this' is the parent specificClass
// Called when a child's registerChild message arrives:
regUtils.registerChild(childSource, positionVsParent, distance);
```
The parent's `registerChild()` method subscribes to the child's `measurements.emitter` events for data propagation.
## Common Patterns
### Parent-Child Registration
1. Child sends `{ topic: 'registerChild', payload: nodeId, positionVsParent }` on output port 2.
2. Parent's `nodeClass._attachInputHandler()` catches `msg.topic === 'registerChild'`.
3. Parent calls `childRegistrationUtils.registerChild(child, position)`.
4. Parent subscribes to child's `measurements.emitter` events (e.g., `'flow.measured.downstream'`).
5. When the child updates a measurement, the parent's listener fires and updates its own state.
### Tick Loop
Every node runs a 1-second tick loop that:
1. Calls `source.tick()` to advance domain logic.
2. Calls `source.getOutput()` for current state.
3. Formats into `process` and `influxdb` messages via `outputUtils.formatMsg()`.
4. Sends on ports 0 (process) and 1 (dbase).
The tick loop starts with a 1-second delay to allow child registration to complete.
### Three-Output Format
All nodes send on three ports: `node.send([processMsg, influxMsg, parentMsg])`.
| Port | Purpose | When |
|------|---------|------|
| 0 | Process data for downstream nodes | Every tick (if changed) |
| 1 | InfluxDB line protocol for persistence | Every tick (if changed) |
| 2 | Parent registration/control messages | On startup; on parent commands |
### Event-Driven Communication
Nodes communicate via `EventEmitter`, not Node-RED wires:
- `measurements.emitter` fires `{type}.{variant}.{position}` events.
- Parents listen to children's emitters after registration.
- The `emitter` on the specificClass itself is used for internal state changes (e.g., updating Node-RED node status display).
## Checklist
Before submitting a new node, verify:
- [ ] Three-layer structure: wrapper, nodeClass, specificClass
- [ ] Config JSON in `generalFunctions/src/configs/<name>.json`
- [ ] Registered in root `package.json` under `node-red.nodes`
- [ ] HTML registers under category `'EVOLV'` with correct S88 color
- [ ] Three outputs: `[process, dbase, parent]`
- [ ] Uses `logger` (not `console.log`)
- [ ] Uses `MeasurementContainer` for measurement storage
- [ ] Uses `outputUtils.formatMsg()` for output formatting
- [ ] Tick loop cleans up in `_attachCloseHandler()`
- [ ] Tests exist for specificClass domain logic
- [ ] Node-specific UI fields plus shared placeholders (asset, logger, position)