Agent IA planificateur EVCS en MODE OMBRE (Advisor Shadow V1)

- Workflow n8n tdfInZns7cqo8fU2 actif (15 min): snapshot HA compact -> prompt FR
  -> LLM local Spark (modèle dynamique via /v1/models) avec fallback OpenRouter
  claude-haiku-4.5 (credential chiffré, clé hors git) -> validation stricte
  (enums, bornes, rejet total si invalide) -> helpers advisor_* + divergence
  + compteurs accord/divergence + logbook.
- GARANTIE OMBRE structurelle: écritures limitées à advisor_*/logbook,
  vérifiée par tests/advisor/run.mjs (16 tests, bloquants au push).
- Kill-switch input_boolean.evcs_advisor_enabled.
- Extension R2 multi-workflows (lib-node-map WORKFLOWS[], build/extract/push
  par fichier, n8n-create.mjs, gen-advisor-workflow.mjs).
- Helpers HA via package NFS + carte dashboard EVCS Master (backup fait).
- 1re reco réelle: auto_solar_plus, cible 35%, accord avec le réel.
- Fix sécurité n8n-push: plus de fallback d'id implicite vers le workflow EVCS.
This commit is contained in:
Tallagrand Thierry
2026-08-07 22:42:06 +02:00
parent 1ae8129658
commit 63d56bf284
13 changed files with 1282 additions and 69 deletions

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
// Génère workflows/evcs-advisor-shadow-v1.json (squelette : nodes, connexions,
// credentials, positions). Le CODE des nodes est ensuite injecté depuis
// src/advisor/ par tools/n8n-build.mjs (source de vérité R2).
// Rejouable : écrase le fichier local (pas le serveur).
import fs from 'fs';
const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' };
const OPENROUTER_CRED = { id: 'eOM9xofDHT0KMoeY', name: 'OpenRouter Advisor' };
const SPARK_URL = 'http://10.10.10.8:8000';
const CLOUD_MODEL = 'anthropic/claude-haiku-4.5';
const HA_URL = 'http://10.10.10.17:8123';
const nodes = [
{
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: [0, 0],
parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 15 }] } },
},
{
name: 'HA Snapshot Advisor',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [200, 0],
parameters: {
method: 'POST',
url: `${HA_URL}/api/template`,
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Accept', value: 'text/plain' },
],
},
sendBody: true,
bodyParameters: { parameters: [{ name: 'template', value: '(injecté depuis src/advisor)' }] },
options: { timeout: 15000 },
},
credentials: { httpBearerAuth: HA_CRED },
},
{
name: 'BUILD_PROMPT',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [400, 0],
parameters: { jsCode: '// injecté depuis src/advisor/BUILD_PROMPT.js' },
},
{
name: 'IF_ENABLED',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [600, 0],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose', version: 2 },
combinator: 'and',
conditions: [
{
leftValue: '={{ $json.enabled }}',
rightValue: '',
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
},
},
},
{
name: 'LLM_LOCAL_MODELS',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [800, -100],
parameters: {
method: 'GET',
url: `${SPARK_URL}/v1/models`,
options: { timeout: 5000 },
},
onError: 'continueRegularOutput',
alwaysOutputData: true,
},
{
name: 'LLM_LOCAL',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [1000, -100],
parameters: {
method: 'POST',
url: `${SPARK_URL}/v1/chat/completions`,
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody:
"={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}",
options: { timeout: 120000 },
},
onError: 'continueRegularOutput',
alwaysOutputData: true,
},
{
name: 'IF_LOCAL_OK',
type: 'n8n-nodes-base.if',
typeVersion: 2.2,
position: [1200, -100],
parameters: {
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose', version: 2 },
combinator: 'and',
conditions: [
{
leftValue: "={{ $json.choices !== undefined && $json.choices.length > 0 }}",
rightValue: '',
operator: { type: 'boolean', operation: 'true', singleValue: true },
},
],
},
},
},
{
name: 'LLM_CLOUD',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [1400, 40],
parameters: {
method: 'POST',
url: 'https://openrouter.ai/api/v1/chat/completions',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody:
`={{ JSON.stringify({ model: '${CLOUD_MODEL}', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}`,
options: { timeout: 60000 },
},
credentials: { httpHeaderAuth: OPENROUTER_CRED },
onError: 'continueRegularOutput',
alwaysOutputData: true,
},
{
name: 'VALIDATE_PARSE',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [1600, -30],
parameters: { jsCode: '// injecté depuis src/advisor/VALIDATE_PARSE.js' },
},
{
name: 'BUILD_HA_CALLS',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [1800, -30],
parameters: { jsCode: '// injecté depuis src/advisor/BUILD_HA_CALLS.js' },
},
{
name: 'Execute HA Service Advisor',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [2000, -30],
parameters: {
method: 'POST',
url: "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}",
authentication: 'genericCredentialType',
genericAuthType: 'httpBearerAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'accept', value: 'application/json' },
],
},
sendBody: true,
specifyBody: 'json',
jsonBody: '={{ $json.data }}',
options: { timeout: 15000 },
},
credentials: { httpBearerAuth: HA_CRED },
onError: 'continueRegularOutput',
},
];
const connections = {
'Schedule Trigger': { main: [[{ node: 'HA Snapshot Advisor', type: 'main', index: 0 }]] },
'HA Snapshot Advisor': { main: [[{ node: 'BUILD_PROMPT', type: 'main', index: 0 }]] },
BUILD_PROMPT: { main: [[{ node: 'IF_ENABLED', type: 'main', index: 0 }]] },
IF_ENABLED: { main: [[{ node: 'LLM_LOCAL_MODELS', type: 'main', index: 0 }], []] },
LLM_LOCAL_MODELS: { main: [[{ node: 'LLM_LOCAL', type: 'main', index: 0 }]] },
LLM_LOCAL: { main: [[{ node: 'IF_LOCAL_OK', type: 'main', index: 0 }]] },
IF_LOCAL_OK: {
main: [
[{ node: 'VALIDATE_PARSE', type: 'main', index: 0 }],
[{ node: 'LLM_CLOUD', type: 'main', index: 0 }],
],
},
LLM_CLOUD: { main: [[{ node: 'VALIDATE_PARSE', type: 'main', index: 0 }]] },
VALIDATE_PARSE: { main: [[{ node: 'BUILD_HA_CALLS', type: 'main', index: 0 }]] },
BUILD_HA_CALLS: { main: [[{ node: 'Execute HA Service Advisor', type: 'main', index: 0 }]] },
};
const workflow = {
name: 'EVCS Advisor Shadow V1',
nodes,
connections,
settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' },
staticData: null,
};
fs.writeFileSync('workflows/evcs-advisor-shadow-v1.json', JSON.stringify(workflow, null, 2) + '\n');
console.log(`Squelette écrit: workflows/evcs-advisor-shadow-v1.json (${nodes.length} nodes)`);
console.log('→ lancer: node tools/n8n-build.mjs workflows/evcs-advisor-shadow-v1.json');

View File

@@ -1,19 +1,40 @@
// R2 — Cartographie src/nodes/ ↔ workflow JSON (source de vérité : src/nodes/).
// R2 — Cartographie src/ ↔ workflows JSON (source de vérité : src/).
// Multi-workflows depuis l'Advisor (08/2026).
// Utilisé par tools/n8n-extract.mjs (JSON → src) et tools/n8n-build.mjs (src → JSON).
export const WORKFLOW_FILE = 'workflows/evcs-surplus-controller-v2.6.json';
export const SRC_DIR = 'src/nodes';
export const NODE_MAP = [
{ file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' },
{ file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' },
{ file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' },
{ file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' },
{ file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' },
{ file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' },
export const WORKFLOWS = [
{
id: 'evcs',
workflowFile: 'workflows/evcs-surplus-controller-v2.6.json',
srcDir: 'src/nodes',
nodes: [
{ file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' },
{ file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' },
{ file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' },
{ file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' },
{ file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' },
{ file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' },
],
},
{
id: 'advisor',
workflowFile: 'workflows/evcs-advisor-shadow-v1.json',
srcDir: 'src/advisor',
nodes: [
{ file: 'BUILD_PROMPT.js', node: 'BUILD_PROMPT', kind: 'jsCode' },
{ file: 'VALIDATE_PARSE.js', node: 'VALIDATE_PARSE', kind: 'jsCode' },
{ file: 'BUILD_HA_CALLS.js', node: 'BUILD_HA_CALLS', kind: 'jsCode' },
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' },
],
},
];
// Rétro-compatibilité (outils historiques mono-workflow)
export const WORKFLOW_FILE = WORKFLOWS[0].workflowFile;
export const SRC_DIR = WORKFLOWS[0].srcDir;
export const NODE_MAP = WORKFLOWS[0].nodes;
export function getCode(workflowNode, kind) {
if (kind === 'jsCode') return workflowNode.parameters.jsCode;
if (kind === 'template') {
@@ -30,3 +51,8 @@ export function setCode(workflowNode, kind, code) {
}
throw new Error(`kind inconnu: ${kind}`);
}
export function findWorkflowByFile(inputPath) {
const base = inputPath.replace(/\\/g, '/').split('/').pop();
return WORKFLOWS.find(w => w.workflowFile.endsWith(base));
}

View File

@@ -1,38 +1,46 @@
#!/usr/bin/env node
// R2 — BUILD : src/nodes/*.js → workflow JSON (src/ = SOURCE DE VÉRITÉ, src gagne).
// Usage : node tools/n8n-build.mjs [--check]
// --check : vérifie que le JSON est déjà synchronisé avec src/ (exit 1 sinon),
// sans rien écrire.
// R2 — BUILD : src/ → workflows JSON (src = SOURCE DE VÉRITÉ, src gagne).
// Usage : node tools/n8n-build.mjs [--check] [fichierWorkflow]
// --check : vérifie la synchro sans écrire (exit 1 si désynchronisé)
// fichierWorkflow : limite à un workflow (sinon tous ceux du node-map)
import fs from 'fs';
import path from 'path';
import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode, setCode } from './lib-node-map.mjs';
import { WORKFLOWS, getCode, setCode, findWorkflowByFile } from './lib-node-map.mjs';
const CHECK = process.argv.includes('--check');
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
const fileArg = process.argv.slice(2).find(a => !a.startsWith('--'));
const targets = fileArg ? [findWorkflowByFile(fileArg)].filter(Boolean) : WORKFLOWS;
if (fileArg && !targets.length) {
console.log(`(${fileArg} non enregistré dans le node-map : rien à builder)`);
process.exit(0);
}
let changes = 0;
for (const { file, node, kind } of NODE_MAP) {
const srcPath = path.join(SRC_DIR, file);
if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`);
const srcCode = fs.readFileSync(srcPath, 'utf8');
const n = wf.nodes.find(x => x.name === node);
if (!n) throw new Error(`Node introuvable dans le JSON: ${node}`);
if (getCode(n, kind) !== srcCode) {
changes++;
console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${file} → "${node}"`);
if (!CHECK) setCode(n, kind, srcCode);
let totalChanges = 0;
for (const w of targets) {
if (!fs.existsSync(w.workflowFile)) {
console.log(`(skip ${w.id}: ${w.workflowFile} absent)`);
continue;
}
const wf = JSON.parse(fs.readFileSync(w.workflowFile, 'utf8'));
let changes = 0;
for (const { file, node, kind } of w.nodes) {
const srcPath = path.join(w.srcDir, file);
if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`);
const srcCode = fs.readFileSync(srcPath, 'utf8');
const n = wf.nodes.find(x => x.name === node);
if (!n) throw new Error(`Node introuvable dans ${w.workflowFile}: ${node}`);
if (getCode(n, kind) !== srcCode) {
changes++;
console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${w.srcDir}/${file} → "${node}"`);
if (!CHECK) setCode(n, kind, srcCode);
}
}
if (!CHECK && changes) {
fs.writeFileSync(w.workflowFile, JSON.stringify(wf, null, 2) + '\n');
}
totalChanges += changes;
console.log(`${w.id}: ${changes ? changes + ' node(s) ' + (CHECK ? 'désynchronisé(s)' : 'injecté(s)') : 'synchronisé'}`);
}
if (CHECK) {
console.log(changes ? `\n${changes} node(s) désynchronisé(s) : lancer node tools/n8n-build.mjs` : 'JSON synchronisé avec src/nodes/');
process.exit(changes ? 1 : 0);
}
if (changes) {
fs.writeFileSync(WORKFLOW_FILE, JSON.stringify(wf, null, 2) + '\n');
console.log(`\nBuild OK: ${changes} node(s) injecté(s) dans ${WORKFLOW_FILE}`);
} else {
console.log('Build: aucun changement (déjà synchronisé)');
}
if (CHECK) process.exit(totalChanges ? 1 : 0);

37
tools/n8n-create.mjs Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Crée un workflow n8n depuis un fichier JSON local (POST /api/v1/workflows).
// Usage: node tools/n8n-create.mjs <fichier.json>
import fs from 'fs';
import os from 'os';
import path from 'path';
const INPUT = process.argv[2];
if (!INPUT) { console.error('Usage: node tools/n8n-create.mjs <fichier.json>'); process.exit(1); }
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8'));
const env = cfg.mcp['n8n-mcp'].environment;
const url = env.N8N_API_URL.replace(/\/api\/v1\/?$/, '');
const headers = { 'X-N8N-API-KEY': env.N8N_API_KEY, 'Content-Type': 'application/json' };
const body = {
name: local.name,
nodes: local.nodes,
connections: local.connections,
settings: {
executionOrder: local.settings?.executionOrder || 'v1',
callerPolicy: local.settings?.callerPolicy || 'workflowsFromSameOwner',
},
};
const res = await fetch(`${url}/api/v1/workflows`, { method: 'POST', headers, body: JSON.stringify(body) });
const text = await res.text();
if (!res.ok) {
console.error(`CRÉATION REFUSÉE: ${res.status} ${res.statusText}`);
console.error(text.slice(0, 1000));
process.exit(1);
}
const created = JSON.parse(text);
console.log(`Créé: "${created.name}" id=${created.id} (active=${created.active})`);
console.log(`→ reporter l'id dans le fichier local si besoin: ${INPUT}`);

View File

@@ -1,21 +1,27 @@
#!/usr/bin/env node
// R2 — EXTRACT : workflow JSON → src/nodes/*.js (+ template snapshot).
// À utiliser UNIQUEMENT pour (re)synchroniser src/ après une modification faite
// directement dans l'UI n8n (à éviter). En temps normal : éditer src/nodes/,
// puis tools/n8n-push.mjs reconstruit le JSON automatiquement.
// R2 — EXTRACT : workflows JSON → src/ (resynchronisation après édition UI n8n — à éviter).
// Usage : node tools/n8n-extract.mjs [fichierWorkflow]
import fs from 'fs';
import path from 'path';
import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode } from './lib-node-map.mjs';
import { WORKFLOWS, getCode, findWorkflowByFile } from './lib-node-map.mjs';
const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8'));
fs.mkdirSync(SRC_DIR, { recursive: true });
const fileArg = process.argv.slice(2).find(a => !a.startsWith('--'));
const targets = fileArg ? [findWorkflowByFile(fileArg)].filter(Boolean) : WORKFLOWS;
for (const { file, node, kind } of NODE_MAP) {
const n = wf.nodes.find(x => x.name === node);
if (!n) throw new Error(`Node introuvable: ${node}`);
const code = getCode(n, kind);
fs.writeFileSync(path.join(SRC_DIR, file), code);
console.log(`${file} ← "${node}" (${code.length} chars)`);
for (const w of targets) {
if (!fs.existsSync(w.workflowFile)) {
console.log(`(skip ${w.id}: ${w.workflowFile} absent)`);
continue;
}
const wf = JSON.parse(fs.readFileSync(w.workflowFile, 'utf8'));
fs.mkdirSync(w.srcDir, { recursive: true });
for (const { file, node, kind } of w.nodes) {
const n = wf.nodes.find(x => x.name === node);
if (!n) throw new Error(`Node introuvable: ${node} (${w.workflowFile})`);
const code = getCode(n, kind);
fs.writeFileSync(path.join(w.srcDir, file), code);
console.log(`${w.srcDir}/${file} ← "${node}" (${code.length} chars)`);
}
}
console.log(`\nExtraction OK dans ${SRC_DIR}/`);
console.log('\nExtraction OK');

View File

@@ -15,14 +15,14 @@ if (!INPUT) {
process.exit(1);
}
// R2 : src/nodes/ = SOURCE DE VÉRITÉ pour le workflow EVCS.
// Le build injecte src/nodes/*.js dans le JSON AVANT toute vérification :
// toute édition faite directement dans le JSON (ou l'UI n8n non ré-extraite)
// est écrasée par src/. Workflow EVCS uniquement.
if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSync('src/nodes')) {
console.log('Build src/nodes/ → JSON (source de vérité : src/)...');
// R2 : src/ = SOURCE DE VÉRITÉ pour les workflows enregistrés dans le node-map.
// Le build injecte src/**/*.js dans le JSON AVANT toute vérification.
const { findWorkflowByFile } = await import('./lib-node-map.mjs');
const registered = findWorkflowByFile(INPUT);
if (registered) {
console.log(`Build ${registered.srcDir}/ → JSON (source de vérité : src/)...`);
const { spawnSync } = await import('child_process');
const b = spawnSync('node', ['tools/n8n-build.mjs'], { stdio: 'inherit' });
const b = spawnSync('node', ['tools/n8n-build.mjs', INPUT], { stdio: 'inherit' });
if (b.status !== 0) {
console.error('ABANDON: échec du build src → JSON.');
process.exit(1);
@@ -30,7 +30,11 @@ if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSyn
}
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K';
const WORKFLOW_ID = process.argv[3] || local.id;
if (!WORKFLOW_ID) {
console.error('ABANDON: aucun id de workflow (ni argument, ni champ id dans le JSON).');
process.exit(1);
}
function loadApiConfig() {
if (process.env.N8N_API_KEY) {
@@ -101,20 +105,25 @@ if (syntaxErrors) {
}
console.log('Syntaxe OK sur tous les nodes jsCode.');
// 3bis. Suite golden (R3) : obligatoire pour le workflow EVCS, sauf --skip-golden
// 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json';
const skipGolden = process.argv.includes('--skip-golden');
if (isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs')) {
const testSuite =
isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' :
isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' :
null;
if (testSuite) {
if (skipGolden) {
console.log('⚠️ Suite golden SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.');
console.log('⚠️ Suite de tests SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.');
} else {
console.log('Exécution de la suite golden (tests/golden/run.mjs)...');
console.log(`Exécution de la suite de tests (${testSuite})...`);
const { spawnSync } = await import('child_process');
const g = spawnSync('node', ['tests/golden/run.mjs'], { stdio: 'inherit' });
const g = spawnSync('node', [testSuite], { stdio: 'inherit' });
if (g.status !== 0) {
console.error('ABANDON: la suite golden échoue. Rien n\'a été poussé.');
console.error('ABANDON: la suite de tests échoue. Rien n\'a été poussé.');
console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus');
console.error(' dans tests/golden/scenarios.mjs dans le même commit, puis re-pousser.)');
console.error(' de la suite dans le même commit, puis re-pousser.)');
process.exit(1);
}
}