fix: address Copilot review round 23 — inherited constants, branch null forms, test cast
- matchesConstants checks own-property presence before reading a supplied
value, so an absent argument legally named `constructor` no longer reads the
inherited one and rules out every branch pinning that name.
- anyOfAdmitsNull asks `admitsNull` about each composition-free branch instead
of testing `type` alone, so a branch spelling its nullability as
`{ const: null }` or `{ nullable: true }` is recognized. Refusing composed
branches is what bounds the recursion.
- Drop a double cast from the malformed-applicator fixture.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0be450a911
commit
4cea4e1985
5 changed files with 57 additions and 11 deletions
|
|
@ -181,6 +181,36 @@ describe("JSON Utils", () => {
|
||||||
).toEqual({ a: "x", value: 3 });
|
).toEqual({ a: "x", value: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not read an inherited property as a supplied constant (#2123)", () => {
|
||||||
|
const pinnedOnInherited: Tool = {
|
||||||
|
name: "pinned-on-inherited",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: Object.fromEntries([
|
||||||
|
["constructor", { const: "a" }],
|
||||||
|
["count", { type: "number" }],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: Object.fromEntries([
|
||||||
|
["constructor", { const: "b" }],
|
||||||
|
["other", { type: "string" }],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// `constructor` was not supplied, so it rules nothing out — `count`
|
||||||
|
// belongs to the first branch alone and settles it.
|
||||||
|
expect(convertToolParameters(pinnedOnInherited, { count: "3" })).toEqual({
|
||||||
|
count: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
|
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
|
||||||
const ambiguous: Tool = {
|
const ambiguous: Tool = {
|
||||||
name: "ambiguous",
|
name: "ambiguous",
|
||||||
|
|
|
||||||
|
|
@ -751,6 +751,14 @@ describe("admitsNull", () => {
|
||||||
expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true);
|
expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a branch that spells its nullability another way", () => {
|
||||||
|
// The branch admits null through `const`, not through `type`.
|
||||||
|
expect(admitsNull({ const: null, anyOf: [{ const: null }] })).toBe(true);
|
||||||
|
expect(admitsNull({ const: null, anyOf: [{ nullable: true }] })).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not override a sibling that rejects null", () => {
|
it("does not override a sibling that rejects null", () => {
|
||||||
// `const` is conjunctive with its siblings, not an override: both of
|
// `const` is conjunctive with its siblings, not an override: both of
|
||||||
// these reject every value, so claiming nullability would let the
|
// these reject every value, so claiming nullability would let the
|
||||||
|
|
|
||||||
|
|
@ -668,10 +668,13 @@ describe("resolveRootUnion", () => {
|
||||||
// These schemas describe the wire, and every member arrives as `unknown`
|
// These schemas describe the wire, and every member arrives as `unknown`
|
||||||
// — reading `anyOf: {}` as a list would throw and take all three clients
|
// — reading `anyOf: {}` as a list would throw and take all three clients
|
||||||
// down rather than declining one malformed tool.
|
// down rather than declining one malformed tool.
|
||||||
|
// A single assertion, from an `unknown`-typed value: the point is a wire
|
||||||
|
// shape TypeScript would never produce, not a cast chain.
|
||||||
|
const malformed: unknown = {};
|
||||||
const { base, branches } = resolveRootUnion({
|
const { base, branches } = resolveRootUnion({
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { a: { type: "string" } },
|
properties: { a: { type: "string" } },
|
||||||
anyOf: {} as unknown as unknown[],
|
anyOf: malformed as unknown[],
|
||||||
});
|
});
|
||||||
expect(branches).toEqual([]);
|
expect(branches).toEqual([]);
|
||||||
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
|
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,9 @@ function matchesConstants(
|
||||||
if (typeof schema !== "object" || schema === null) return true;
|
if (typeof schema !== "object" || schema === null) return true;
|
||||||
const constValue = (schema as { const?: unknown }).const;
|
const constValue = (schema as { const?: unknown }).const;
|
||||||
if (constValue === undefined) return true;
|
if (constValue === undefined) return true;
|
||||||
|
// `hasOwn`: an absent argument legally named `constructor` would otherwise
|
||||||
|
// read the inherited one and rule out every branch that pins that name.
|
||||||
|
if (!Object.hasOwn(params, name)) return true;
|
||||||
const supplied = params[name];
|
const supplied = params[name];
|
||||||
return supplied === undefined || supplied === String(constValue);
|
return supplied === undefined || supplied === String(constValue);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -433,17 +433,19 @@ function anyOfAdmitsNull(schema: NullableUnionSchema): boolean {
|
||||||
if (!Array.isArray(branches)) return false;
|
if (!Array.isArray(branches)) return false;
|
||||||
return branches.some((entry) => {
|
return branches.some((entry) => {
|
||||||
const branch = toBranch(entry);
|
const branch = toBranch(entry);
|
||||||
if (branch === null || !typeNamesNull(branch.type)) {
|
if (branch === null) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// A branch that names null can still admit nothing: `{ type: "null",
|
|
||||||
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
|
|
||||||
// it is as opaque here as one on the wrapper.
|
|
||||||
const branchSchema = branch as NullableUnionSchema;
|
const branchSchema = branch as NullableUnionSchema;
|
||||||
return (
|
// A nested union or applicator inside a branch is as opaque here as one on
|
||||||
!nullExcludedBySiblings(branchSchema) &&
|
// the wrapper, and refusing it also bounds this recursion: what remains is
|
||||||
!hasUnevaluatedComposition(branchSchema)
|
// a composition-free schema, which `admitsNull` answers without reaching
|
||||||
);
|
// back into this function.
|
||||||
|
if (hasUnevaluatedComposition(branchSchema)) return false;
|
||||||
|
// Asked through `admitsNull` rather than by testing `type` alone, so a
|
||||||
|
// branch spelling its nullability another way — `{ const: null }`,
|
||||||
|
// `{ nullable: true }` — is recognized the same way the wrapper's own
|
||||||
|
// forms are, and an unsatisfiable `{ type: "null", const: "x" }` is still
|
||||||
|
// rejected by the sibling check inside it.
|
||||||
|
return admitsNull(branchSchema);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue