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:
v-openrig-buildandClaude Fable 5 committed 2026-08-11 21:25:53 +00:00
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);
});
});
+12 -4
View File
@@ -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 () => {