Skip to content

JavaScript: attribute ambient packages and AMD factory parameters - #8796

Closed
MBoegers wants to merge 5 commits into
mainfrom
missing-types
Closed

MBoegers wants to merge 5 commits into
mainfrom
missing-types

Conversation

@MBoegers

@MBoegers MBoegers commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Type-aware recipes for SAP UI5 were blocked on two separate things, neither of which was an inability to read UI5's declarations.

Ambient packages were never in scope. The parser's types defaults to ["*"], which globs node_modules/@types and nothing else. @openui5/types declares "sap/m/Button" ambiently rather than shipping something resolvable by path, so it installed cleanly and its declarations then sat on disk unreferenced — every Button came out <unknown>. npm() now names the packages a fixture's own manifest declares, keeping "*" alongside so @types reads as before. Declaring the dependency is the whole setup.

AMD factory parameters carried no type. sap.ui.define(["sap/m/Button"], function (Button) {…}) pairs the nth dependency with the nth parameter positionally, at loader runtime, so nothing declares that pairing for a checker to follow. The parser now declares it, generating an overload whose dependencies is the literal tuple the call site passes:

declare namespace sap.ui {
    function define(dependencies: ["sap/m/Button"], factory: (p0: typeof import("sap/m/Button").default) => any): void;
}

It matches that call and no other, and TypeScript's own inference carries the type through the factory body from there — new Button(), the variable it initialises, and the calls on that variable all attribute with no propagation added to the type mapper.

The declarations go in a file beside the source rather than in it. That keeps every position in the parsed text where it was; transpiling the AMD block into an ES module before type-checking, which is what the UI5 linter does, would mean remapping the whole LST. Sitting beside the source also lets a relative specifier resolve exactly as it does for the source. require is left alone deliberately — @types/node declares it globally, and an overload there would catch every CommonJS call in the file.

This is worth closing because the mechanical fix for most UI5 findings is to add a module to the sap.ui.define array and rewrite the reference to the injected parameter, and every one of those rewrites needs the parameter's type.

Tests

type-mapping-ambient-modules.test.ts covers both gaps, alongside a reference-directive control that passed before this change. ui5-setup.test.ts pins what a MethodMatcher can select once the types are present, including two limits worth knowing: a module path does not glob (sap/m/* selects nothing, since package patterns split on dots), and matching follows the receiver's own type rather than walking to the type that declares the member.

Both suites share one manifest verbatim, so they reuse a single cached install.

Full suite: 2255 passing. The three failures in test/rpc/install-recipes.test.ts are npm install @openrewrite/recipes-nodejs@latest exiting 1, and reproduce identically on main.

Two things blocked type-aware recipes for SAP UI5, and neither was about
being unable to read UI5's declarations.

A package that declares its modules ambiently was never in scope. The
parser's `types` defaults to `["*"]`, which globs `node_modules/@types`
and nothing else, so `@openui5/types` — which declares `"sap/m/Button"`
rather than shipping something resolvable by path — installed cleanly and
then sat unreferenced, leaving every `Button` unattributed. `npm()` now
names the packages a fixture's own manifest declares, keeping `"*"`
alongside so `@types` reads as before. A test that declared a dependency
in order to use it gets that dependency's declarations.

AMD factory parameters carried no type at all. `sap.ui.define([...],
function (Button) {…})` pairs the nth dependency with the nth parameter
positionally, at loader runtime, so nothing declares the pairing for a
checker to follow, and everything reached through the parameter went
unattributed — which is most of the UI5 migration surface. The parser now
declares that pairing itself, as an overload whose `dependencies` is the
literal tuple the call site passes, so it matches that call and no other.
The declarations go in a file beside the source rather than in it: no
position in the parsed text moves, a relative specifier resolves as it
does for the source, and the checker's own inference carries the type
through the factory body from there. `require` is left alone, already
being declared globally by `@types/node`.
`main` reworked how programs are built while this branch was open: #8733
reads the project's tsconfig and builds one `ts.Program` per config group
rather than one per parse batch. The AMD overload declarations move onto
that structure — each is added to the root names of the program its own
source belongs to, and the compiler host serves them per group.
`sap.ui.define(["sap/m/Button"], function (Button) {…})` attributed its factory parameter only where `@openui5/types` reached the program through a reference directive. Named in `types`, which is how a project states it and how a `tsconfig` carries it, the loader's own `sap.ui.define` overloads win instead and the parameter stays `any` — so the generated overload did nothing for a UI5 source as one is actually written.

The generated declarations now reference each named type package themselves. The AMD test drops the `/// <reference types>` line its fixture carried, which is what let this through: no fixture reached the overload the way a UI5 source does.
`TemplateOptions.types` passes through to `compilerOptions.types`, where an explicit list replaces the default rather than extending it. A template naming an ambient package — the documented use, `types: ['@sapui5/types']` — therefore lost every `@types/*` global along with it: `process.cwd()` came back unattributed in a workspace that installs `@types/node`.

The engine keeps `*` alongside whatever the caller names, which is what the option's own documentation describes. An empty list still names nothing and loads nothing, which is the one way to state that.
…ackages

`npm()` names the declaration packages a fixture's manifest declares, as the parser's default. A project config merges over that default and carries `types` only where it states one, so a fixture writing a `tsconfig.json` that says nothing about `types` was still given its manifest's packages, with `"types": ["*"]` the only way to decline.

Which ambient declarations are in scope is among the options a project decides, so the manifest names them only for a fixture carrying no config. A fixture that carries one is left to say what it means to say — and a spec helper for `tsconfig.json`, when there is one, then composes rather than collides.
@MBoegers

MBoegers commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

split into #8806 and #8807

@MBoegers MBoegers closed this Sep 8, 2026
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants