diff --git a/parser/src/analysis-types/typescript/TsExpressionRegistry.ts b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
index fafe44dad..56d406ebc 100644
--- a/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
+++ b/parser/src/analysis-types/typescript/TsExpressionRegistry.ts
@@ -172,6 +172,17 @@ export class TsExpressionRegistry implements EntityIdentifiable {
this.potentialQualifiedName = potentialQualifiedName;
}
+ /**
+ * The tag of an intrinsic element (`div`, `svg:circle`, `my-el`), written as in the source.
+ *
+ * A component tag is a METHOD_NAME child that resolves; an intrinsic one references nothing, so its
+ * name has no child to live on and goes in this slot, with `referencedEntityKind` left empty. The
+ * engine's projections never read this column, so it cannot reach name resolution.
+ */
+ setJsxTagName(tag: string): void {
+ this.potentialQualifiedName = tag;
+ }
+
getReferencedEntityHash(): string {
return this.referencedEntityHash;
}
diff --git a/parser/src/enums/typescript/expressions/TsExpressionKind.ts b/parser/src/enums/typescript/expressions/TsExpressionKind.ts
index 6d42c2088..c0ada80a2 100644
--- a/parser/src/enums/typescript/expressions/TsExpressionKind.ts
+++ b/parser/src/enums/typescript/expressions/TsExpressionKind.ts
@@ -33,8 +33,10 @@
* A JSX element whose tag names a component IS a call to it, with the whole
* props object as argument 0: JSX_ELEMENT / JSX_SELF_CLOSING, 1:1 with a call
* site, with the tag as its METHOD_NAME child. Attribute values and children are
- * rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element emits
- * no row.
+ * rooted as their own JSX_EMBEDDED_EXPRESSION trees. An intrinsic element
+ * (`
`, ``, ``) is JSX_INTRINSIC_ELEMENT: a row with no
+ * children and no call site, so it exists only as the element its attributes
+ * belong to.
*
* Schema §4.14 c0, §4.15.1.
*/
@@ -99,6 +101,15 @@ export enum TsExpressionKind {
JSX_ELEMENT = 'JSX_ELEMENT',
/** `` naming a component. 1:1 with a call site. */
JSX_SELF_CLOSING = 'JSX_SELF_CLOSING',
+ /**
+ * `
`, ``, `` -- a tag that names no
+ * declaration. NOT a call: no call site, no METHOD_NAME child, so nothing can
+ * resolve it into `JSX.IntrinsicElements`. The row exists so that a plain
+ * tag's attributes (an event handler, a `ref` callback) have an element to
+ * belong to, as the JavaScript parser's JSX_INTRINSIC_ELEMENT does. The tag,
+ * as written (`div`, `svg:circle`, `my-el`), is its `potentialQualifiedName`.
+ */
+ JSX_INTRINSIC_ELEMENT = 'JSX_INTRINSIC_ELEMENT',
/** `delete a`, `typeof a`, `void a` — `operatorString` says which. */
DELETE_TYPEOF_VOID = 'DELETE_TYPEOF_VOID',
/** `a, b` — the comma operator. */
diff --git a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
index f4183b369..94096b266 100644
--- a/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
+++ b/parser/src/parsers/typescript/extractors/ts-expression-extractor.ts
@@ -47,7 +47,9 @@ import { isVueTemplateTag } from '@/utils/vue-sfc';
*
* A JSX element naming a component is a call (§4.15.1): a JSX_ELEMENT or
* JSX_SELF_CLOSING row with its tag as the METHOD_NAME child, and a
- * JSX_COMPONENT_CALL site. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
+ * JSX_COMPONENT_CALL site. An intrinsic element (`
`) is a
+ * JSX_INTRINSIC_ELEMENT row with no child and no call site; its tag (`div`)
+ * is the row's `potentialQualifiedName`. `JSX_ATTRIBUTE_VALUE` and `JSX_CHILD` stay reserved:
* attribute values and children are rooted as their own trees by the walker, and
* the gate asserts the emptiness so that switching them on shows up as a failure.
*/
@@ -174,6 +176,9 @@ export class TsExpressionExtractor {
isTypeOnlyReachable: false,
serviceVersionLinkHash: this.options.serviceVersionLinkHash,
});
+ if (kind === TsExpressionKind.JSX_INTRINSIC_ELEMENT) {
+ row.setJsxTagName(jsxTagText(item.node, this.sf));
+ }
this.expressions.push(row);
this.rowByNode.set(nodeId(item.node, this.sf), row);
this.emitted.push({ node: item.node, row });
@@ -453,10 +458,12 @@ export function expressionKindOf(node: ts.Node): TsExpressionKind | undefined {
case ts.SyntaxKind.JsxElement:
case ts.SyntaxKind.JsxSelfClosingElement: {
// `` IS a call to Badge. `
` is an intrinsic element that no
- // project declaration can bind, so it produces no row -- a call site that
- // can only ever resolve into `JSX.IntrinsicElements` would be noise.
+ // project declaration can bind: a call site that can only ever resolve
+ // into `JSX.IntrinsicElements` would be noise, so it is its own kind,
+ // which is never a call (see `emit`) and carries no tag child. The row is
+ // what a plain tag's attributes -- `onClick={h}`, `ref={r}` -- belong to.
if (jsxTagReference(node) === undefined) {
- return undefined;
+ return TsExpressionKind.JSX_INTRINSIC_ELEMENT;
}
return ts.isJsxElement(node) ? TsExpressionKind.JSX_ELEMENT : TsExpressionKind.JSX_SELF_CLOSING;
}
@@ -909,6 +916,16 @@ export function jsxTagReference(node: ts.Node): ts.Expression | undefined {
return undefined;
}
+/** A JSX element's tag as written: `div`, `svg:circle`, `my-el`, `ui.Card`. */
+export function jsxTagText(node: ts.Node, sf: ts.SourceFile): string {
+ const tagName = ts.isJsxElement(node)
+ ? node.openingElement.tagName
+ : ts.isJsxSelfClosingElement(node)
+ ? node.tagName
+ : undefined;
+ return tagName === undefined ? '' : tagName.getText(sf);
+}
+
/** The receiver of a callee, or `undefined` for an unqualified call. */
export function receiverNodeOf(callee: ts.Node | undefined): ts.Node | undefined {
if (!callee) {
diff --git a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
index 1b82d1018..5fda834bf 100644
--- a/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
+++ b/parser/src/parsers/typescript/extractors/ts-expression-walker.ts
@@ -2,7 +2,6 @@ import * as ts from 'typescript';
import {
ExpressionOwner,
- jsxTagReference,
TsExpressionExtractor,
} from '@/parsers/typescript/extractors/ts-expression-extractor';
import { TsExpressionOwnerKind, TsRootContext } from '@/enums/typescript/expressions';
@@ -513,11 +512,12 @@ export class TsExpressionWalker {
// attribute values, children, and spreads at once, wherever the JSX sits.
//
// A component ELEMENT is a call in its own right: `` calls Badge.
- // One that is already a row -- `cond ? : `, `x && ` -- was
- // reached by the worklist as an operand; any other is rooted here, which
- // is what reaches an element nested in another element's children.
+ // An intrinsic one (`
`) is a JSX_INTRINSIC_ELEMENT row and no call,
+ // the element its attributes belong to. One that is already a row --
+ // `cond ? : `, `x && ` -- was reached by the worklist as an
+ // operand; any other is rooted here, which is what reaches an element
+ // nested in another element's children.
if ((ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
- && jsxTagReference(child) !== undefined
&& !this.options.extractor.rowByNode.has(nodeId(child, this.sf))) {
this.root(child, TsRootContext.JSX_EMBEDDED_EXPRESSION);
return;
diff --git a/parser/src/schema/typescript/schema.json b/parser/src/schema/typescript/schema.json
index 590c68a26..b91264822 100644
--- a/parser/src/schema/typescript/schema.json
+++ b/parser/src/schema/typescript/schema.json
@@ -829,6 +829,7 @@
"FUNCTION_EXPRESSION",
"IDENTIFIER_REFERENCE",
"JSX_ELEMENT",
+ "JSX_INTRINSIC_ELEMENT",
"JSX_SELF_CLOSING",
"LITERAL",
"NEW_EXPRESSION",
diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts
index fd295003d..0a0ee9aa3 100644
--- a/parser/src/test/typescript-tests.ts
+++ b/parser/src/test/typescript-tests.ts
@@ -31,6 +31,7 @@ import * as os from 'os';
import * as ts from 'typescript';
import * as path from 'path';
+import { jsxTagReference } from '@/parsers/typescript/extractors/ts-expression-extractor';
import { IrCompletenessReport } from '@/parsers/typescript/extractors/ts-ir-completeness';
import { verifyRelationFileStreaming } from '@/workflows/typescript/ts-relation-writer';
import { TypeScriptProjectAnalyzer } from '@/workflows/typescript/typescript-project-analyzer';
@@ -1414,7 +1415,9 @@ function irCompleteness(): number {
* The component element is a call too: `` is `Badge({...})`, a
* JSX_COMPONENT_CALL site, wherever it sits -- a child, a ternary branch, the
* body of a `.map` arrow. The CONTROL is the intrinsic element: `
` and
- * `` name no declaration and must produce no call site at all.
+ * `` name no declaration and must produce no call site at all. It is
+ * still an element, a childless JSX_INTRINSIC_ELEMENT row, so that
+ * `
',
' );',
'}',
@@ -1493,6 +1502,15 @@ async function jsxBraceExpressionsWalked(): Promise {
}
}
let expected = 0;
+ // Expression rows by position, so an element's row can be asserted.
+ const exprRows = relation(outputDir, 'all-typescript-expressions.csv')
+ .filter((r) => (modulePath.get(r.tsModuleLinkHash ?? '') ?? '').endsWith('panel.tsx'));
+ const exprKindsAt = new Map();
+ for (const r of exprRows) {
+ const key = `panel.tsx:${r.startLine}:${r.startColumn}`;
+ exprKindsAt.set(key, [...(exprKindsAt.get(key) ?? []), r.kind ?? '']);
+ }
+ const parents = new Set(exprRows.map((r) => r.parentExpressionHash ?? ''));
let components = 0;
let intrinsics = 0;
const walk = (node: ts.Node): void => {
@@ -1507,12 +1525,43 @@ async function jsxBraceExpressionsWalked(): Promise {
failures.push(`no JSX_COMPONENT_CALL at ${key} for \`${node.getText(sf).slice(0, 44)}\``
+ ` (found ${kindAt.get(key) ?? 'nothing'})`);
}
+ // The control: a component keeps its call-shaped row, never the intrinsic kind.
+ const want = ts.isJsxElement(node) ? 'JSX_ELEMENT' : 'JSX_SELF_CLOSING';
+ const kinds = exprKindsAt.get(key) ?? [];
+ if (!kinds.includes(want) || kinds.includes('JSX_INTRINSIC_ELEMENT')) {
+ failures.push(`component at ${key} has expression kind(s) [${kinds.join(', ')}], `
+ + `expected ${want}`);
+ }
} else {
intrinsics += 1;
if (emitted.has(key)) {
failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} became a call site `
+ `(${kindAt.get(key)}) -- it names no declaration`);
}
+ // ...but it IS an element: one JSX_INTRINSIC_ELEMENT row, with no child
+ // (no tag reference for anything to resolve), so its attributes have an
+ // element to belong to, as in the JavaScript parser.
+ const rows = exprRows.filter((r) => `panel.tsx:${r.startLine}:${r.startColumn}` === key
+ && r.kind === 'JSX_INTRINSIC_ELEMENT');
+ if (rows.length !== 1) {
+ failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} has ${rows.length} `
+ + 'JSX_INTRINSIC_ELEMENT row(s), expected 1');
+ } else if (parents.has(rows[0]!.tsExpressionUniqueHash ?? '')) {
+ failures.push(`intrinsic \`<${tag.getText(sf)}>\` at ${key} has a child row -- `
+ + 'its tag references nothing, so nothing hangs off it');
+ }
+ // An attribute's value is still its own rooted tree.
+ const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : node.attributes;
+ for (const attribute of attributes.properties) {
+ if (ts.isJsxAttribute(attribute) && attribute.initializer
+ && ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression) {
+ const v = sf.getLineAndCharacterOfPosition(attribute.initializer.expression.getStart(sf));
+ const vkey = `panel.tsx:${v.line + 1}:${v.character + 1}`;
+ if ((exprKindsAt.get(vkey) ?? []).length === 0) {
+ failures.push(`attribute \`${attribute.getText(sf)}\` at ${vkey} has no value row`);
+ }
+ }
+ }
}
}
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
@@ -1528,6 +1577,33 @@ async function jsxBraceExpressionsWalked(): Promise {
};
ts.forEachChild(sf, walk);
+ // The tag of a plain element is on its row, as written (`div`, `my-el`, `svg:circle`). The CONTROL is
+ // the component element, whose row keeps an empty potentialQualifiedName: its tag is the METHOD_NAME
+ // child that resolves, unchanged. The expectation is read off the AST.
+ let tagged = 0;
+ const walkTags = (node: ts.Node): void => {
+ if (ts.isJsxSelfClosingElement(node) || ts.isJsxElement(node)) {
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
+ const p = sf.getLineAndCharacterOfPosition(node.getStart(sf));
+ const at = `${p.line + 1}:${p.character + 1}`;
+ const row = exprRows.find((r) => `${r.startLine}:${r.startColumn}` === at && (r.kind ?? '').startsWith('JSX_'));
+ const tag = opening.tagName.getText(sf);
+ const want = jsxTagReference(node) === undefined ? tag : '';
+ if (row === undefined) {
+ failures.push(`no element row at ${at} for <${tag}>`);
+ } else if ((row.potentialQualifiedName ?? '') !== want) {
+ failures.push(`<${tag}> at ${at}: potentialQualifiedName "${row.potentialQualifiedName}", expected "${want}"`);
+ } else if (want !== '') {
+ tagged += 1;
+ }
+ }
+ ts.forEachChild(node, walkTags);
+ };
+ ts.forEachChild(sf, walkTags);
+ if (tagged !== intrinsics) {
+ failures.push(`${tagged} of ${intrinsics} intrinsic element(s) carry their tag`);
+ }
+
// The edge roles that stay reserved: attributes and children are rooted trees.
for (const [file, names] of [
['all-typescript-call-sites.csv', ['callKind']],
@@ -1545,7 +1621,9 @@ async function jsxBraceExpressionsWalked(): Promise {
console.log(` ${expected} call/new node(s) in JSX attributes, children, spreads and a ` +
`nested map; ${expected - missingCalls} emitted; ${components} component element(s) as ` +
- `calls, ${intrinsics} intrinsic element(s) as none; ${RESERVED_TSX_VALUES.size} reserved ` +
+ `calls, ${intrinsics} intrinsic element(s) as JSX_INTRINSIC_ELEMENT rows and no call ` +
+ `(${tagged} with their tag); ` +
+ `${RESERVED_TSX_VALUES.size} reserved ` +
'value(s) still empty');
for (const f of failures.slice(0, 10)) console.log(` ${f}`);
fs.rmSync(root, { recursive: true, force: true });
@@ -4721,7 +4799,7 @@ const CHECKS: Check[] = [
{ name: 'tsc-adjudicated resolution', proves: 'every resolved call target equals getResolvedSignature', run: tscAdjudicatedResolution },
{ name: 'type-only isolation', proves: 'no type-only construct reaches the call graph', run: typeOnlyIsolation },
{ name: 'TSX reserved but empty', proves: 'the JSX attribute and child edge roles carry no rows', run: tsxReservedButEmpty },
- { name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site, a component element is a JSX_COMPONENT_CALL, an intrinsic element is none', run: jsxBraceExpressionsWalked },
+ { name: 'JSX brace expressions are walked', proves: 'a call inside a JSX brace is an ordinary call site, a component element is a JSX_COMPONENT_CALL, an intrinsic element is a childless JSX_INTRINSIC_ELEMENT row carrying its tag and no call', run: jsxBraceExpressionsWalked },
{ name: 'destructuring records its source', proves: 'a bound name carries the property or index it binds, so a renamed or positional binding is recoverable', run: destructuringRecordsItsSource },
{ name: 'no emitted value can split a row', proves: 'no value contains a character a consumer treats as a line break, so a row cannot tear', run: noValueCanSplitARow },
{ name: 'signatures link their return type', proves: 'a call through any callable shape reaches a result type, so a chain does not stop at it', run: signaturesLinkTheirReturnType },