fix(tools): widen the Unicode-skew obligation past isBareIdentifier

The predicate is not the only reader of the engine's XID tables. camelCase
reads them through its split set and its head test, and the class name it
derives is emitted for EVERY tool -- including one the predicate rejected,
whose TypedDict is still declared and named. A tool named `zz-` + U+1E4D0
never reaches the skew in the predicate, since the `-` rejects it outright,
yet still emits `class Zz<U+1E4D0>xArgs`, which CPython 3.9.6 refuses the
same way. A backend PR executing "pin the predicate against tables for the
floor" literally would leave that path open, so the note and the docstring
now name all three read points.

Two corrections in the same paragraph. The failing direction is a character
added to XID_Start OR XID_Continue -- one added only to the latter passes
the trailing `\p{XID_Continue}*` in a tail position and fails identically.
And the safe direction routes a name to the subscript/`dict[str, Any]`
path: a rejected FIELD name degrades its whole enclosing object rather than
just itself, which the predicate's opening paragraph already said.

Also qualify the module header's "ONLY source" claim, which holds under
`mode: 'code'` but not `both`, where wireSchemas ships every native schema
alongside the SDK section; record the measured str.isidentifier()
equivalence (21 samples, zero divergence, Node 22.23.1 vs CPython 3.9.6)
where the versions it is relative to already live; and attribute the
`FInd` spelling in the ligature test to full case mapping rather than to
the NFKC step, which is the identity there.

Two tests. The fold-collision half of the childClassName fix: sibling joins
that are byte-distinct before NFKC and equal after, so `usedClassNames`
dedupes by raw bytes and the counter only sees the collision because the
join is normalized. And the argument-side oneOf-of-objects branch naming,
which reaches the same childClassName path the output side already pins.
This commit is contained in:
Chinesezjc
2026-08-05 21:08:24 +08:00
parent af51e68875
commit 2914a87eda
5 changed files with 110 additions and 29 deletions

View File

@@ -453,8 +453,11 @@ describe('renderToolsSdkPy', () => {
// ligature name cannot, because `async def find` would define `find`. The
// subscript comment quotes the name, so its exact bytes survive, and its
// TypedDict is still named and referenced — the name is only unusable as a
// method, not as a class-name source (`camelCase` normalizes what it
// derives, since a generated name is never matched against a JSON key).
// method, not as a class-name source. The `FInd` spelling comes from `fi`'s
// multi-character full case mapping (`'fi'.toUpperCase()` is `'FI'`), not
// from `camelCase`'s NFKC step, which is the identity on `FInd`: the
// ligature is XID_Start, so the split set keeps it and only the
// capitalization of the head transforms it.
const of = (name: string): ToolSdkSchema => ({
name,
description: `Tool ${name}.`,
@@ -559,6 +562,64 @@ describe('renderToolsSdkPy', () => {
expect(text).toContain('class XArgs\uAC00\u1100(TypedDict):')
})
it('routes a fold collision through the counter that raw-byte dedup would miss', () => {
// The other half of the `childClassName` normalization: two joins that are
// byte-distinct before NFKC and identical after. Field `\uAC00` allocates
// `XArgs\uAC00`; the sibling `\u1100` allocates `XArgs\u1100`, and ITS child
// `\u1161` joins to `XArgs\u1100\u1161` — the same `XArgs\uAC00` once composed.
// Normalizing at the join is what lets `usedClassNames`, which dedupes by raw
// bytes, see the collision at all; unnormalized, both would be declared and
// CPython would compile the second as a shadow of the first.
const text = renderToolsSdkPy([
{
name: 'x',
description: 'Colliding jamo joins.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['\uAC00', '\u1100'],
properties: {
'\uAC00': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
'\u1100': {
type: 'object',
additionalProperties: false,
required: ['\u1161'],
properties: {
'\u1161': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
},
},
},
},
output: { type: 'string' },
},
])
expect(text).toContain('class XArgs\uAC00(TypedDict):')
expect(text).toContain('class XArgs\uAC002(TypedDict):')
expect(text).toContain(' \u1161: XArgs\uAC002')
})
it('names both branches of a oneOf of objects on the argument side', () => {
// The output side is pinned elsewhere; arguments reach the same
// `childClassName(frame.className, index + 1)` path, and the annotation is
// the union of the two derived names rather than a degraded dict.
const text = renderToolsSdkPy([
{
name: 'x',
description: 'Union arguments.',
parameters: {
oneOf: [
{ type: 'object', additionalProperties: false, required: ['a'], properties: { a: { type: 'string' } } },
{ type: 'object', additionalProperties: false, required: ['b'], properties: { b: { type: 'number' } } },
],
},
output: { type: 'string' },
},
])
expect(text).toContain('class XArgs1(TypedDict):')
expect(text).toContain('class XArgs2(TypedDict):')
expect(text).toContain('async def x(self, args: XArgs1 | XArgs2) -> str:')
})
it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => {
// `{ type: 'object', additionalProperties: false }` with no `properties`
// is a closed empty object — no key accepted — exactly as the validator