Skip to content

Commit 98d4c58

Browse files
grypezclaude
andcommitted
test(sheaves): add failing tests for prototype-chain bugs in decomposeMetadata
Two bugs in decomposeMetadata: 1. `key in constraints` matches prototype-inherited names (e.g. 'constructor') on an empty {} object, causing distinguishing metadata keys to be silently dropped from stripped candidates. 2. `key in meta` matches prototype-inherited names, and when the inherited value happens to equal Object.is the candidate value, the key is wrongly treated as shared and placed in constraints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cc25c7f commit 98d4c58

1 file changed

Lines changed: 100 additions & 0 deletions

File tree

packages/sheaves/src/sheafify.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,106 @@ describe('sheafify', () => {
688688
(section as Record<string, unknown>)[GET_DESCRIPTION],
689689
).toBeUndefined();
690690
});
691+
692+
it('does not drop prototype-named distinguishing metadata keys from stripped candidates', async () => {
693+
// 'constructor' matches Object.prototype.constructor. Naive `key in constraints`
694+
// returns true on an empty {} because of the prototype chain, causing the key to be
695+
// silently dropped from every stripped candidate even though it was never a constraint.
696+
type Meta = Record<string, unknown>;
697+
let capturedCandidates: Candidate<Partial<Meta>>[] = [];
698+
let capturedContext: PolicyContext<Meta> | undefined;
699+
700+
const spy: Policy<Meta> = async function* (candidates, context) {
701+
capturedCandidates = candidates;
702+
capturedContext = context;
703+
yield candidates[0]!;
704+
};
705+
706+
const providers: Provider<Meta>[] = [
707+
{
708+
handler: makeHandler(
709+
'Wallet:0',
710+
M.interface('Wallet:0', {
711+
getBalance: M.call(M.string()).returns(M.number()),
712+
}),
713+
{ getBalance: (_acct: string) => 100 },
714+
),
715+
metadata: constant({ constructor: 'typeA', cost: 100 }),
716+
},
717+
{
718+
handler: makeHandler(
719+
'Wallet:1',
720+
M.interface('Wallet:1', {
721+
getBalance: M.call(M.string()).returns(M.number()),
722+
}),
723+
{ getBalance: (_acct: string) => 42 },
724+
),
725+
metadata: constant({ constructor: 'typeB', cost: 1 }),
726+
},
727+
];
728+
729+
const wallet = sheafify({ name: 'Wallet', providers }).getGlobalSection({
730+
lift: spy,
731+
});
732+
await E(wallet).getBalance('alice');
733+
734+
expect(capturedContext).toStrictEqual({
735+
method: 'getBalance',
736+
args: ['alice'],
737+
constraints: {},
738+
});
739+
expect(
740+
capturedCandidates.map((candidate) => candidate.metadata),
741+
).toStrictEqual([
742+
{ constructor: 'typeA', cost: 100 },
743+
{ constructor: 'typeB', cost: 1 },
744+
]);
745+
});
746+
747+
it('does not treat prototype-inherited value as shared when key is absent from some candidates', async () => {
748+
// Provider A has { constructor: Object, cost: 100 }. Provider B has { cost: 1 }.
749+
// Naive `key in meta` finds 'constructor' in B via Object.prototype, and
750+
// Object.is(meta_B['constructor'], Object) is true ({}.constructor === Object),
751+
// so the key is wrongly counted as shared and moved into constraints.
752+
type Meta = Record<string, unknown>;
753+
let capturedContext: PolicyContext<Meta> | undefined;
754+
755+
const spy: Policy<Meta> = async function* (candidates, context) {
756+
capturedContext = context;
757+
yield candidates[0]!;
758+
};
759+
760+
const providers: Provider<Meta>[] = [
761+
{
762+
handler: makeHandler(
763+
'Wallet:0',
764+
M.interface('Wallet:0', {
765+
getBalance: M.call(M.string()).returns(M.number()),
766+
}),
767+
{ getBalance: (_acct: string) => 100 },
768+
),
769+
metadata: constant({ constructor: Object, cost: 100 }),
770+
},
771+
{
772+
handler: makeHandler(
773+
'Wallet:1',
774+
M.interface('Wallet:1', {
775+
getBalance: M.call(M.string()).returns(M.number()),
776+
}),
777+
{ getBalance: (_acct: string) => 42 },
778+
),
779+
metadata: constant({ cost: 1 }),
780+
},
781+
];
782+
783+
const wallet = sheafify({ name: 'Wallet', providers }).getGlobalSection({
784+
lift: spy,
785+
});
786+
await E(wallet).getBalance('alice');
787+
788+
// 'constructor' is only owned by provider A — must not appear in constraints
789+
expect(capturedContext?.constraints).not.toHaveProperty('constructor');
790+
});
691791
});
692792

693793
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)