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:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user