feat(core): backward-compat parsing for legacy reflexes array

- Merge top-level reflexes into sense-level interval/on fields
- Emit deprecation warning when legacy reflexes detected
- Deduplicate on[] triggers, detect interval conflicts
- Add tests for compat parsing, merge, and conflict cases

Fixes #193
This commit is contained in:
2026-04-27 10:40:46 +00:00
parent 453294a465
commit d0cc8c0840
2 changed files with 172 additions and 25 deletions
+70 -3
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseNerveConfig } from "../parse-nerve-config.js";
function testConsole(): { warn: (...args: unknown[]) => void } {
return (globalThis as unknown as { console: { warn: (...args: unknown[]) => void } }).console;
}
const VALID_CONFIG = `
senses:
cpu:
@@ -27,11 +31,21 @@ workflows:
`;
describe("parseNerveConfig", () => {
beforeEach(() => {
vi.spyOn(testConsole(), "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("valid configs", () => {
it("parses a full valid config", () => {
const result = parseNerveConfig(VALID_CONFIG);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(testConsole().warn).toHaveBeenCalledWith(
expect.stringMatching(/top-level `reflexes`.*deprecated/i),
);
expect(result.value.senses.cpu).toEqual({
group: "system",
@@ -39,6 +53,8 @@ describe("parseNerveConfig", () => {
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: 30_000,
on: [],
});
expect(result.value.senses.memory).toEqual({
group: "system",
@@ -46,6 +62,8 @@ describe("parseNerveConfig", () => {
timeout: 10_000,
gracePeriod: 3000,
retention: 10_000,
interval: null,
on: ["high_usage"],
});
expect(result.value.reflexes).toHaveLength(2);
expect(result.value.reflexes[0]).toEqual({
@@ -79,6 +97,7 @@ reflexes: []
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.reflexes).toEqual([]);
expect(testConsole().warn).not.toHaveBeenCalled();
});
it("parses config without workflows section", () => {
@@ -112,6 +131,8 @@ reflexes: []
timeout: null,
gracePeriod: null,
retention: 10_000,
interval: null,
on: [],
});
});
@@ -456,16 +477,62 @@ reflexes: []
expect(result.error.message).toMatch(/senses/);
});
it("returns error when reflexes is missing", () => {
it("accepts config without reflexes key (inline triggers only)", () => {
const yaml = `
senses:
cpu:
group: system
interval: 5s
on:
- memory
memory:
group: system
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.senses.cpu.interval).toBe(5000);
expect(result.value.senses.cpu.on).toEqual(["memory"]);
expect(result.value.reflexes).toEqual([
{ kind: "sense", sense: "cpu", interval: 5000, on: ["memory"] },
]);
});
it("returns error when legacy reflex interval conflicts with sense interval", () => {
const yaml = `
senses:
cpu:
group: system
interval: 10s
reflexes:
- sense: cpu
interval: 5s
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error.message).toMatch(/reflexes/);
expect(result.error.message).toMatch(/conflicting interval/);
});
it("merges legacy reflex on[] into sense on and dedupes", () => {
const yaml = `
senses:
cpu:
group: system
on:
- a
reflexes:
- sense: cpu
interval: 1s
on:
- a
- b
`;
const result = parseNerveConfig(yaml);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.senses.cpu.on).toEqual(["a", "b"]);
expect(result.value.senses.cpu.interval).toBe(1000);
});
it("returns error for invalid group name", () => {
+102 -22
View File
@@ -113,6 +113,102 @@ function validateSenseConfig(name: string, raw: unknown): Result<SenseConfig> {
});
}
const LEGACY_REFLEXES_DEPRECATION =
"[nerve] Deprecated: top-level `reflexes` in nerve.yaml is deprecated. Move each reflex's `interval` and `on` fields onto the corresponding sense under `senses.<name>`.";
/** Uses `globalThis` so declaration emit works without DOM / Node `lib` typings for `console`. */
function warnToConsole(message: string): void {
const c = (globalThis as unknown as { console: { warn: (m: string) => void } }).console;
c.warn(message);
}
function mergeOnLists(existing: readonly string[], extra: readonly string[]): string[] {
const seen = new Set(existing);
const out = [...existing];
for (const name of extra) {
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
return out;
}
function mergeSenseIntervalFromLegacyReflex(
senseName: string,
senseInterval: number | null,
reflexInterval: number | null,
reflexIndex: number,
): Result<number | null> {
if (reflexInterval === null) return ok(senseInterval);
if (senseInterval === null) return ok(reflexInterval);
if (senseInterval === reflexInterval) return ok(senseInterval);
return err(
new Error(
`reflexes[${reflexIndex}].sense "${senseName}": conflicting interval (sense has ${senseInterval} ms, reflex has ${reflexInterval} ms); use a single interval source`,
),
);
}
function buildReflexesFromSenses(senses: Record<string, SenseConfig>): ReflexConfig[] {
const reflexes: ReflexConfig[] = [];
for (const [senseName, sc] of Object.entries(senses)) {
if (sc.interval !== null || sc.on.length > 0) {
reflexes.push({
kind: "sense" as const,
sense: senseName,
interval: sc.interval,
on: sc.on,
});
}
}
return reflexes;
}
/**
* If `reflexes` is present and non-empty, parse legacy entries and merge `interval` / `on`
* into the corresponding sense. Emits a deprecation warning once when any legacy row exists.
*/
function mergeLegacyReflexesIntoSenses(
obj: Record<string, unknown>,
senses: Record<string, SenseConfig>,
senseNames: Set<string>,
): Result<Record<string, SenseConfig>> {
if (!Object.hasOwn(obj, "reflexes") || obj.reflexes === undefined || obj.reflexes === null) {
return ok(senses);
}
if (!Array.isArray(obj.reflexes)) {
return err(new Error("reflexes: must be an array if provided"));
}
if (obj.reflexes.length > 0) {
warnToConsole(LEGACY_REFLEXES_DEPRECATION);
}
let merged: Record<string, SenseConfig> = senses;
for (let i = 0; i < obj.reflexes.length; i++) {
const reflexResult = validateReflexConfig(i, obj.reflexes[i], senseNames);
if (!reflexResult.ok) return reflexResult;
const ref = reflexResult.value;
const senseName = ref.sense;
const sense = merged[senseName];
const intervalResult = mergeSenseIntervalFromLegacyReflex(
senseName,
sense.interval,
ref.interval,
i,
);
if (!intervalResult.ok) return intervalResult;
const next: SenseConfig = {
...sense,
interval: intervalResult.value,
on: mergeOnLists(sense.on, ref.on),
};
merged = { ...merged, [senseName]: next };
}
return ok(merged);
}
function parseOnField(index: number, obj: Record<string, unknown>): Result<string[]> {
if (obj.on === undefined || obj.on === null) return ok([]);
if (!Array.isArray(obj.on) || !obj.on.every((item): item is string => typeof item === "string")) {
@@ -264,24 +360,6 @@ function parseSenses(
return ok({ senses, senseNames });
}
function parseReflexes(
obj: Record<string, unknown>,
senseNames: Set<string>,
): Result<ReflexConfig[]> {
if (!Array.isArray(obj.reflexes)) {
return err(new Error("reflexes: required array"));
}
const reflexes: ReflexConfig[] = [];
for (let i = 0; i < obj.reflexes.length; i++) {
const result = validateReflexConfig(i, obj.reflexes[i], senseNames);
if (!result.ok) return result;
reflexes.push(result.value);
}
return ok(reflexes);
}
const DEFAULT_API_BIND_HOST = "127.0.0.1";
/** Hosts that may bind the HTTP API without `api.token` (loopback-only). */
@@ -389,8 +467,10 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
if (!sensesResult.ok) return sensesResult;
const { senses, senseNames } = sensesResult.value;
const reflexesResult = parseReflexes(obj, senseNames);
if (!reflexesResult.ok) return reflexesResult;
const mergedSensesResult = mergeLegacyReflexesIntoSenses(obj, senses, senseNames);
if (!mergedSensesResult.ok) return mergedSensesResult;
const mergedSenses = mergedSensesResult.value;
const reflexes = buildReflexesFromSenses(mergedSenses);
const workflowsResult = parseWorkflows(obj);
if (!workflowsResult.ok) return workflowsResult;
@@ -403,8 +483,8 @@ export function parseNerveConfig(raw: string): Result<NerveConfig> {
return ok({
maxRounds: maxRoundsResult.value,
senses,
reflexes: reflexesResult.value,
senses: mergedSenses,
reflexes,
workflows: workflowsResult.value,
api: apiResult.value,
});