Skip to content

Commit 56e6c7a

Browse files
committed
test(compat-eslint): bottom-up parity sweep + phantom-types invariant
Hardens the test suite against the bug class fixed earlier in this PR (JSXAttribute.parent corruption via phantom TSJsxAttributes). Master's tests had three blind spots: 1. lazy-estree.test's `compare()` walks tree TOP-DOWN through child getters, where parent is set correctly even on master. Bottom-up `materialise(tsNode)` (what tsScanTraverse does on selector match) was never exercised. The function also explicitly skips the `parent` field — so even top-down parent-chain corruption would not have failed parity. 2. compat-pipeline's JSXAttribute listener pushed `attr:${n.name}` events but never read `n.parent`, leaving the listener-API parent contract unverified. 3. Nothing checked the invariant "no node has a 'TS<KindName>' type for kinds typescript-estree elides" — the property the TSJsxAttributes regression directly violated. Three new test blocks address each: Bottom-up parity sweep (lazy-estree.test): 9 JSX-attribute fixtures (self-closing, non-self-closing, spread, sibling attrs, multi-attr mixed, fragment, nested attr value, deeply nested), each walks every TS node, runs `lazy.materialise(tsNode, ctx)`, and compares the resulting type / parent.type / parent.parent.type against eager's `astMaps.tsNodeToESTreeNodeMap` lookup. Eager parents are stitched ourselves (typescript-estree's astConverter doesn't set parent — ESLint's SourceCode does that downstream). 117 type asserts + 108 parent asserts on the fix branch; 7 unique mismatches on master. Phantom-types invariant (lazy-estree.test): 5 comprehensive fixtures (jsx-attrs-everything, ts-everything, imports-everything, patterns- everything, jsx-fragment-mix), each walks via visitor-keys and ALSO bottom-up materialises every TS node that has an eager counterpart. Asserts no produced type starts with 'TS' AND isn't in typescript- estree's published TS-* type list. The published list is hard-coded inline (~75 entries) so the test fails immediately if a future PR-added GenericTSNode fallback emits a name not in eager's spec. JSX listener parent assertion (compat-pipeline): test #7's JSXAttribute / JSXSpreadAttribute listeners now also assert `parents[0] === 'JSXOpeningElement'` — the contract any jsx-a11y / react/jsx-* rule relies on. The contract is correct in spec; whether each listener path independently fails on master depends on whether sibling listeners pre-warm the cache top-down, but the assert still locks the contract from inside the actual ESLint listener API surface. Out of scope (real but unrelated impedance with eager that the broad sweep also surfaces — left as separate follow-ups): - export wrapper identity (lazy maps the TS node to ExportNamed/ DefaultWrapper, eager maps to the inner declaration) - chain-expression wrapping for `a?.b?.c` - destructuring with defaults - CatchClause param lifted from ts.VariableDeclaration shim - TSStringKeyword / TSVoidKeyword direct-on-Signature
1 parent c1ccffe commit 56e6c7a

2 files changed

Lines changed: 394 additions & 2 deletions

File tree

packages/compat-eslint/test/compat-pipeline.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,10 +1981,36 @@ type D = Awaited<Promise<number>>;
19811981
'JSX: JSXAttribute listener fires on `id="root"`',
19821982
calls.some(c => c.selector === 'JSXAttribute'),
19831983
);
1984+
// Parent chain via bottom-up materialise: the master regression had
1985+
// JSXAttribute.parent = 'TSJsxAttributes' for the non-self-closing case
1986+
// and = 'JSXElement' for self-closing. The contract is that the parent
1987+
// is JSXOpeningElement (synthetic for self-closing) — typescript-estree
1988+
// shape that JSX rules rely on. Pin it from inside the listener path
1989+
// so any future regression to the parent chain fires here too.
1990+
{
1991+
// Fixture has exactly one JSXAttribute (`id="root"`), so first hit.
1992+
const attrCall = calls.find(c => c.selector === 'JSXAttribute');
1993+
check(
1994+
'JSX: JSXAttribute.parent === JSXOpeningElement (bottom-up via listener)',
1995+
attrCall?.parents[0] === 'JSXOpeningElement',
1996+
`got parents: ${JSON.stringify(attrCall?.parents.slice(0, 3))}`,
1997+
);
1998+
}
19841999
check(
19852000
'JSX: JSXSpreadAttribute listener fires on `{...rest}`',
19862001
calls.filter(c => c.selector === 'JSXSpreadAttribute').length === 1,
19872002
);
2003+
// Same parent-chain pin for spread-attribute — different lazy-estree
2004+
// path (JsxSpreadAttribute is a TS kind, not a wrapped expression),
2005+
// so verify independently.
2006+
{
2007+
const spread = calls.find(c => c.selector === 'JSXSpreadAttribute');
2008+
check(
2009+
'JSX: JSXSpreadAttribute.parent === JSXOpeningElement',
2010+
spread?.parents[0] === 'JSXOpeningElement',
2011+
`got: ${JSON.stringify(spread?.parents.slice(0, 3))}`,
2012+
);
2013+
}
19882014
check(
19892015
'JSX: JSXExpressionContainer listener fires on `{count && ...}`',
19902016
calls.some(c => c.selector === 'JSXExpressionContainer'),
@@ -2052,7 +2078,13 @@ type D = Awaited<Promise<number>>;
20522078
events.push(`open:${n.name?.name ?? '?'}`);
20532079
},
20542080
JSXAttribute(n: any) {
2055-
events.push(`attr:${n.name?.name ?? '?'}`);
2081+
// Read parent.type — the field that the TSJsxAttributes
2082+
// regression silently corrupted. Real ESLint plugins
2083+
// (jsx-a11y, react/jsx-*) read this constantly to scope
2084+
// rules to the enclosing tag. Asserting it here exercises
2085+
// the bottom-up materialise path with parent semantics
2086+
// from inside the actual ESLint API surface.
2087+
events.push(`attr:${n.name?.name ?? '?'}:parent=${n.parent?.type ?? 'NONE'}`);
20562088
},
20572089
};
20582090
},
@@ -2071,7 +2103,11 @@ type D = Awaited<Promise<number>>;
20712103
events.includes('open:div') && events.includes('open:span'),
20722104
`events: ${events.filter(e => e.startsWith('open:')).join(',')}`,
20732105
);
2074-
check('JSX+CPA: JSXAttribute fires on `id="x"`', events.includes('attr:id'));
2106+
check(
2107+
'JSX+CPA: JSXAttribute fires on `id="x"` with parent=JSXOpeningElement',
2108+
events.includes('attr:id:parent=JSXOpeningElement'),
2109+
`events: ${events.filter(e => e.startsWith('attr:')).join(',')}`,
2110+
);
20752111
}
20762112

20772113
// 9. JSX rule with real esquery selector + report mechanism — mirrors

0 commit comments

Comments
 (0)