mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
test(51-03): close the vacuous-equality holes guard found — empty projection is a defect, not evidence
Guard found and the desk reproduced two false greens on fc3f766e4: both sides
plucking a missing field normalized to [] and compared EQUAL (3/3 green on two
distinct non-empty surfaces), and a single-surface equals also passed. My own
falsification could not have caught either — I broke ONE side and got a correct
FAIL, which proves the comparison notices a DIFFERENCE but says nothing about
whether it notices EMPTINESS. A falsification only covers the axis it perturbs.
A. projectSurface now THROWS ProjectionError when a declared projection extracts
nothing from NON-EMPTY input. Empty-from-empty stays legal (a surface may
honestly hold nothing); empty-from-real-data means the declaration does not
match the surface, and it must never become equality evidence. Seeding a real
qitem cannot defend this — the PROJECTION is what empties the set.
B. Load-time refusals that teach, so a TypeError is never the first signal:
the declarative mapping is required (legacy list refused, naming the
declarative form), at least TWO surfaces are required (one-sided equality
passes regardless of the data), and path must be a string (it previously
passed validation and threw "path.split is not a function" at runtime).
C. Re-falsified on every axis, not just the original one: symmetric-empty now
ERRORs loud end-to-end with the field named, single-surface is refused at
load, and the one-sided difference case still FAILs. All three are unit
checks now.
Also isolated R4's pre-effect measurement to a private TMPDIR — it counted
scaffolds in the SHARED /tmp and flaked under the parallel matrix, the same
self-induced contention the D5 pin hit. Caught by an A/B run, not assumed.
3/3 scenarios PASS. Matrix 178/178 across 21 files, daemon tsc clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
fc3f766e44
commit
dcddb8a6ee
4 files changed
+145
-8
No files matched your search
@@ -25,6 +25,23 @@
|
||||
|
||||
import type { ExpectSurface } from "./scenario-schema.js";
|
||||
|
||||
/**
|
||||
* Thrown when a declared projection extracts NOTHING from a NON-EMPTY surface.
|
||||
*
|
||||
* Guard finding on a7b6b7c85: two surfaces both plucking a field neither has both
|
||||
* normalize to [], and `[] === []` made a vacuous comparison GREEN. Seeding a real
|
||||
* qitem cannot defend against this, because the PROJECTION is what empties the
|
||||
* set. Empty-from-empty is legal (a surface really can hold nothing);
|
||||
* empty-from-NON-empty means the declaration does not match the surface, and that
|
||||
* must fail visibly instead of becoming equality evidence.
|
||||
*/
|
||||
export class ProjectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ProjectionError";
|
||||
}
|
||||
}
|
||||
|
||||
/** One surface's declared projection onto the shared comparison form. */
|
||||
export interface SurfaceProjection {
|
||||
/** Field to take from each element of an array surface (e.g. `name`). */
|
||||
@@ -57,14 +74,29 @@ export function rigOf(session: string): string {
|
||||
}
|
||||
|
||||
/** Apply one surface's declared projection. Total: never throws on a shape it did not expect. */
|
||||
export function projectSurface(value: unknown, spec: SurfaceProjection): unknown {
|
||||
export function projectSurface(
|
||||
value: unknown,
|
||||
spec: SurfaceProjection,
|
||||
surface = "surface",
|
||||
): unknown {
|
||||
const base = spec.path ? readPath(value, spec.path) : value;
|
||||
if (!spec.pluck) return base;
|
||||
const items = Array.isArray(base) ? base : [base];
|
||||
const nonEmptyInput = items.some((i) => i !== undefined && i !== null);
|
||||
const plucked = items
|
||||
.map((item) => (item && typeof item === "object" ? (item as Record<string, unknown>)[spec.pluck!] : undefined))
|
||||
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
||||
.map((v) => (spec.rig ? rigOf(v) : v));
|
||||
// A declaration that matches nothing on a surface that HAS data is a broken
|
||||
// declaration, not evidence of agreement. Fail loud; never return [] here.
|
||||
if (plucked.length === 0 && nonEmptyInput) {
|
||||
throw new ProjectionError(
|
||||
`${surface}: projection { pluck: "${spec.pluck}"${spec.path ? `, path: "${spec.path}"` : ""} } extracted NOTHING ` +
|
||||
`from a non-empty surface (${items.length} item(s)). An empty projection from real data cannot be equality ` +
|
||||
`evidence — two surfaces both extracting nothing compare equal and prove nothing. Check the declared field ` +
|
||||
`against the surface's actual shape.`,
|
||||
);
|
||||
}
|
||||
// Set semantics: dedupe + sort, so agreement on the SET is not defeated by order
|
||||
// or by one surface listing the same rig twice (N qitems, one rig).
|
||||
return [...new Set(plucked)].sort();
|
||||
@@ -80,6 +112,6 @@ export function buildDeclarativeNormalizer(
|
||||
): (surface: ExpectSurface, value: unknown) => unknown {
|
||||
return (surface, value) => {
|
||||
const spec = mapping[surface];
|
||||
return spec ? projectSurface(value, spec) : value;
|
||||
return spec ? projectSurface(value, spec, surface) : value;
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export type ValidationErrorCode =
|
||||
| "WITHIN_NOT_A_DURATION"
|
||||
| "EQUALS_PROJECTION_INVALID"
|
||||
| "EQUALS_SURFACE_UNKNOWN"
|
||||
| "EQUALS_NOT_DECLARATIVE"
|
||||
| "EQUALS_TOO_FEW_SURFACES"
|
||||
| "EMIT_NOT_OBJECT"
|
||||
| "UNKNOWN_EMIT_BEHAVIOR"
|
||||
| "USAGE_LIMIT_IN_STUB_TOPOLOGY"
|
||||
@@ -322,7 +324,32 @@ function validateExpect(
|
||||
// 51-03: the declarative `equals` mapping (surface -> projection). Validated
|
||||
// here so an authoring error is a load-time teaching failure, not a scenario
|
||||
// that runs and compares nothing.
|
||||
if (value.equals !== undefined && !isPlainObject(value.equals)) {
|
||||
// Guard finding: the legacy list form still parsed, so a scenario could name
|
||||
// surfaces without declaring HOW they compare — which is what left the
|
||||
// comparison to an injected placeholder. A-N1 makes the declarative mapping
|
||||
// the only scenario-facing form; refuse anything else at load and teach it.
|
||||
push(
|
||||
"EQUALS_NOT_DECLARATIVE",
|
||||
`${path}.equals: must be the DECLARATIVE mapping of surface -> projection, e.g. ` +
|
||||
`{ ps: { pluck: name }, queue: { pluck: destinationSession, rig: true } }. ` +
|
||||
`A bare list of surfaces names what to compare without declaring HOW, so the comparison cannot be honest.`,
|
||||
`${path}.equals`,
|
||||
);
|
||||
}
|
||||
if (isPlainObject(value.equals)) {
|
||||
// A comparison needs at least TWO sides. One surface (or none) is vacuous by
|
||||
// construction — it passes whatever the data is.
|
||||
const declaredSurfaces = Object.keys(value.equals);
|
||||
if (declaredSurfaces.length < 2) {
|
||||
push(
|
||||
"EQUALS_TOO_FEW_SURFACES",
|
||||
`${path}.equals: needs at least TWO surfaces to compare, found ${declaredSurfaces.length}` +
|
||||
`${declaredSurfaces.length ? ` (${declaredSurfaces.join(", ")})` : ""} — a one-sided equality passes ` +
|
||||
`regardless of the data and proves nothing.`,
|
||||
`${path}.equals`,
|
||||
);
|
||||
}
|
||||
for (const [surf, spec] of Object.entries(value.equals)) {
|
||||
if (!(EXPECT_SURFACES as readonly string[]).includes(surf)) {
|
||||
push(
|
||||
@@ -344,6 +371,11 @@ function validateExpect(
|
||||
if (spec.pluck !== undefined && typeof spec.pluck !== "string") {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.pluck: must be a field name string`, `${path}.equals.${surf}.pluck`);
|
||||
}
|
||||
if (spec.path !== undefined && typeof spec.path !== "string") {
|
||||
// was accepted at load and then threw `path.split is not a function` at
|
||||
// runtime — a TypeError must never be the first signal.
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.path: must be a dot-path string`, `${path}.equals.${surf}.path`);
|
||||
}
|
||||
if (spec.rig !== undefined && typeof spec.rig !== "boolean") {
|
||||
push("EQUALS_PROJECTION_INVALID", `${path}.equals.${surf}.rig: must be a boolean`, `${path}.equals.${surf}.rig`);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
buildDeclarativeNormalizer,
|
||||
isEqualsMapping,
|
||||
rigOf,
|
||||
ProjectionError,
|
||||
} from "./helpers/scenario-normalizer.js";
|
||||
import { validateScenario } from "./helpers/scenario-schema.js";
|
||||
|
||||
// 51-03 — the declarative cross-surface mapping. A-N1: this is the only
|
||||
// scenario-facing form and it LOWERS to the runner-internal seam.
|
||||
@@ -23,10 +25,11 @@ describe("projectSurface — declares which field carries the shared truth", ()
|
||||
expect(projectSurface(queue, { pluck: "destinationSession", rig: true })).toEqual(["scn-baton"]);
|
||||
});
|
||||
|
||||
it("is total on shapes it did not expect (no throw, no fabricated value)", () => {
|
||||
it("returns empty for genuinely empty input, but THROWS when real data yields nothing", () => {
|
||||
expect(projectSurface([], { pluck: "name" })).toEqual([]);
|
||||
expect(projectSurface(null, { pluck: "name" })).toEqual([]);
|
||||
expect(projectSurface([{ other: 1 }], { pluck: "name" })).toEqual([]);
|
||||
// non-empty input + zero extracted = broken declaration, not agreement
|
||||
expect(() => projectSurface([{ other: 1 }], { pluck: "name" })).toThrow(ProjectionError);
|
||||
});
|
||||
|
||||
it("reads a nested path on an object surface before projecting", () => {
|
||||
@@ -61,3 +64,65 @@ describe("buildDeclarativeNormalizer — lowering onto the seam", () => {
|
||||
expect(n("ps", [{ name: "scn-baton" }])).not.toEqual(n("queue", [{ destinationSession: "w@other-rig" }]));
|
||||
});
|
||||
});
|
||||
|
||||
// Guard finding on a7b6b7c85, reproduced by the desk: my own falsification broke
|
||||
// ONE side (name -> rigId) and got a correct FAIL, so I called the comparison
|
||||
// value-sensitive. It is — on the DIFFERENCE axis. It could not detect EMPTINESS.
|
||||
// A falsification only covers the axis it perturbs; these cover the others.
|
||||
describe("the axes my one-sided falsification could not reach", () => {
|
||||
it("SYMMETRIC EMPTY: both sides plucking a missing field must FAIL LOUD, never compare equal", () => {
|
||||
const n = buildDeclarativeNormalizer({
|
||||
ps: { pluck: "nosuchfield" },
|
||||
queue: { pluck: "alsomissing" },
|
||||
});
|
||||
// two DISTINCT non-empty surfaces; before the fix both became [] and passed
|
||||
expect(() => n("ps", [{ name: "scn-baton" }])).toThrow(ProjectionError);
|
||||
expect(() => n("queue", [{ destinationSession: "w@scn-baton" }])).toThrow(ProjectionError);
|
||||
let msg = "";
|
||||
try { n("ps", [{ name: "scn-baton" }]); } catch (e) { msg = (e as Error).message; }
|
||||
expect(msg).toContain("extracted NOTHING");
|
||||
expect(msg).toContain("nosuchfield");
|
||||
});
|
||||
|
||||
it("empty-from-EMPTY stays legal — a surface may honestly hold nothing", () => {
|
||||
const n = buildDeclarativeNormalizer({ queue: { pluck: "destinationSession", rig: true } });
|
||||
expect(n("queue", [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("a partially-matching projection does NOT throw (some data extracted is a real answer)", () => {
|
||||
const n = buildDeclarativeNormalizer({ ps: { pluck: "name" } });
|
||||
expect(n("ps", [{ name: "a" }, { other: 1 }])).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load-time refusals (a TypeError must never be the first signal)", () => {
|
||||
const base = (equals: unknown) => ({
|
||||
scenario: "s", topology: "t.yaml",
|
||||
steps: [{ expect: { surface: "ps", equals } }],
|
||||
});
|
||||
const codes = (doc: unknown) => {
|
||||
const r = validateScenario(doc);
|
||||
return r.ok ? [] : r.errors.map((e) => e.code);
|
||||
};
|
||||
|
||||
it("refuses a SINGLE-surface equals — one-sided comparison is vacuous by construction", () => {
|
||||
expect(codes(base({ ps: { pluck: "name" } }))).toContain("EQUALS_TOO_FEW_SURFACES");
|
||||
});
|
||||
|
||||
it("refuses an EMPTY equals mapping", () => {
|
||||
expect(codes(base({}))).toContain("EQUALS_TOO_FEW_SURFACES");
|
||||
});
|
||||
|
||||
it("refuses the LEGACY list form, naming the declarative form", () => {
|
||||
expect(codes(base(["ps", "queue"]))).toContain("EQUALS_NOT_DECLARATIVE");
|
||||
});
|
||||
|
||||
it("refuses a non-string path at LOAD instead of throwing at runtime", () => {
|
||||
expect(codes(base({ ps: { path: 123 }, queue: { pluck: "x" } }))).toContain("EQUALS_PROJECTION_INVALID");
|
||||
});
|
||||
|
||||
it("accepts a well-formed two-surface declarative mapping", () => {
|
||||
const r = validateScenario(base({ ps: { pluck: "name" }, queue: { pluck: "destinationSession", rig: true } }));
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,14 @@ describe("D1 R4/R5 — pipeline-level fences", () => {
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const before = readdirSync(tmpdir()).filter((n) => n.startsWith("openrig-scenario-"));
|
||||
// Measure in a PRIVATE temp root: scaffolds land under os.tmpdir(), which reads
|
||||
// TMPDIR per call, so counting the SHARED /tmp made this flake whenever another
|
||||
// suite created a scaffold concurrently (same self-induced contention the D5
|
||||
// pre-effect pin hit).
|
||||
const priv = mkdtempSync(join(tmpdir(), "r4-preeffect-"));
|
||||
dirs.push(priv);
|
||||
const savedTmp = process.env.TMPDIR;
|
||||
process.env.TMPDIR = priv;
|
||||
let spawned = false;
|
||||
await expect(
|
||||
runScenarioFile(scenarioPath, {
|
||||
@@ -207,9 +214,10 @@ describe("D1 R4/R5 — pipeline-level fences", () => {
|
||||
}),
|
||||
).rejects.toThrow(StubScriptTargetError);
|
||||
expect(spawned).toBe(false);
|
||||
// no scaffold was created either
|
||||
const after = readdirSync(tmpdir()).filter((n) => n.startsWith("openrig-scenario-"));
|
||||
expect(after.length).toBe(before.length);
|
||||
// no scaffold was created either — the private root is still empty
|
||||
expect(readdirSync(priv)).toEqual([]);
|
||||
if (savedTmp === undefined) delete process.env.TMPDIR;
|
||||
else process.env.TMPDIR = savedTmp;
|
||||
});
|
||||
|
||||
it("R5: container mode REFUSES scripted scenarios by name, and is untouched without scripts", async () => {
|
||||
|
||||
Reference in new issue
Block a user