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 + * `', + ' {flag && }', + ' ', + ' ', '
', ' );', '}', @@ -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 },