diff --git a/.changeset/script-types-fixes.md b/.changeset/script-types-fixes.md new file mode 100644 index 000000000..6720d674a --- /dev/null +++ b/.changeset/script-types-fixes.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API IntelliSense fixes: `this` in SFRA middleware is now typed as the route, requests are faster in large cartridge workspaces, and the tsserver plugin no longer resolves `require()` paths outside the bundled types and cartridge roots of a malicious repository. diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md new file mode 100644 index 000000000..426960eb9 --- /dev/null +++ b/.changeset/script-types-infer-usage.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'b2c-vs-extension': minor +--- + +Script API IntelliSense can now infer types for undocumented cartridge helpers from how they are used across the project, so hover and completions keep working where they used to fall back to `any`. Off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the tsserver plugin config for other editors. See the IDE integration guide for details. diff --git a/.changeset/vs-extension-untrusted-workspaces.md b/.changeset/vs-extension-untrusted-workspaces.md new file mode 100644 index 000000000..8612832b6 --- /dev/null +++ b/.changeset/vs-extension-untrusted-workspaces.md @@ -0,0 +1,5 @@ +--- +'b2c-vs-extension': minor +--- + +The VS Code extension is now disabled in untrusted (Restricted Mode) workspaces, because it reads instance settings and credentials from workspace files; trust the workspace to use it. diff --git a/.github/workflows/ci-vs-extension.yml b/.github/workflows/ci-vs-extension.yml index cddb5bf9c..c3e32089a 100644 --- a/.github/workflows/ci-vs-extension.yml +++ b/.github/workflows/ci-vs-extension.yml @@ -7,6 +7,7 @@ on: - develop paths: - 'packages/b2c-vs-extension/**' + - 'packages/b2c-script-types/**' - 'packages/b2c-tooling-sdk/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' @@ -17,6 +18,7 @@ on: - develop paths: - 'packages/b2c-vs-extension/**' + - 'packages/b2c-script-types/**' - 'packages/b2c-tooling-sdk/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' diff --git a/.gitignore b/.gitignore index 2499eeb92..55a5afb92 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist-deploy /tmp node_modules /coverage +coverage/ oclif.manifest.json *.tsbuildinfo diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 333fac2b0..09be2caa9 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -24,6 +24,8 @@ If you have the Salesforce B2C Commerce VS Code extension installed, IntelliSens You can disable the feature with the `b2c-dx.features.scriptTypes` setting (default: `true`). +The extension only runs in trusted workspaces. It reads instance settings and credentials from workspace files such as `dw.json`, so VS Code keeps it disabled in Restricted Mode; trust the workspace to use it. + The plugin also resolves SFCC cartridge-style requires, matching runtime semantics: - `require('~/cartridge/scripts/foo')` — resolves only within the cartridge that contains the current file (the SFCC `~` shortcut for "current cartridge"). If `foo` doesn't exist there, IntelliSense reports it unresolved — same as runtime. @@ -125,6 +127,53 @@ require('lspconfig').ts_ls.setup({ If your editor's LSP client is launched outside the repo root (for example, opening a single cartridge subdirectory), point it at the project root so the plugin's auto-discovery walks the right tree. +### Inferring types for undocumented helpers (experimental) + +JSDoc-documented functions get full hover/completion support when the annotation names a real Script API type (`@param {dw.customer.Customer}` / `@param {Customer}`), because TypeScript reads those directly — the same happy path the IntelliJ SFCC plugin relies on. Plain, undocumented helpers don't, and neither do the placeholder SFRA annotations that show up constantly in real cartridges (`@param {Object}`, `{obj}`, `{*}`, `{}`, and arrays of those such as `{Array}`): those widen to an uninformative type and silence completion for everything downstream. + +Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or pass `inferUsage: true` in the plugin config (`init_options.plugins` for other LSP hosts) to have the plugin infer a plausible type for these cases the way IntelliJ does: from how the value is actually used across the project, not from what it is called. Deliberate `@param {any}` / `: any` annotations are still respected and never second-guessed; real `dw.*` JSDoc is left alone too. + +The evidence it weighs: + +- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, `this` passed from inside a constructor or class (`parameterToArray(this, key, value)` passes the model being built), calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). A function handed to a documented API rather than to a project helper takes the parameter types of the callback that API declares: `basket.getProductLineItems().toArray().forEach(keep)` types `keep`'s parameter as a `ProductLineItem`. +- **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. +- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), a union when unrelated values were (`(Product | Basket)[]`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. Members a constructor defines with `Object.defineProperty(this, 'locale', {...})`, as SFRA's request model does, count as members of the model even though TypeScript leaves them off its type. +- **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. +- **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. +- **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. +- **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; a member the body only tests for or falls back from (`if (res.setStatusCode)`, `lineItemCtnr.orderNo || lineItemCtnr.UUID`) is not relied on. With no call site at all, the members are matched against every Script API class. + +`module.superModule` is understood too: in an overlay cartridge that extends a base module (`var base = module.superModule;`), hover and completions on `base` and on values derived from it resolve against the same-path module in the next cartridge down the cartridge path — including recursing into the base module's own undocumented helpers, and across multi-cartridge plugin stacks where intermediate levels re-export the base and add members (`module.exports = base; module.exports.extra = extra;`). + +Two more SFRA idioms are covered: + +- **Iteration callbacks** — a callback's parameters are what the helper it is passed to calls it with, for that call's arguments: `collections.forEach(product.getVariants(), function (variant) {...})` types `variant` as a `Variant`, and `collections.reduce(basket.productLineItems, function (total, lineItem) {...}, 0)` types `lineItem`, the second parameter, as a `ProductLineItem`. This reads the helper's own `callback(item)` / `callback.call(scope, item)` calls, so a project's own helpers (`eachShipment(basket, function (shipment) {...})`) and wrappers that hand the callback on to another helper work the same way; an accumulator the callback's result is fed back into stays unknown, as with a native `reduce`. A helper whose source isn't available falls back to its name: `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` take an element of the collection travelling alongside the callback (anything with `iterator()`/`next()`, i.e. `dw.util.Collection` and friends). Manual iterator loops (`var iter = coll.iterator(); while (iter.hasNext()) { var item = iter.next(); }`) and ternary returns like stock `collections.first` (`return it.hasNext() ? it.next() : null`) resolve through the same chain machinery. +- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature, and `this` as the route, so the `req`/`res` of a `this.on('route:BeforeComplete', function (req, res) {...})` listener are typed as well. Inference deliberately stays out of the way there. A named middleware function that lives in its own module (`server.get('Show', cache.applyDefaultCache, ...)`, `consentTracking.consent`) has no contextual type of its own; with inference on, it takes `req`/`res`/`next` from the routes it is handed to. So does the middleware a factory returns (`server.append('Confirm', jsonResponse.responseHandler(fieldMap))`, where `responseHandler` returns `function (req, res, next) {...}`). + +Cross-file inference (call sites in other files, `module.superModule`) needs those files in the same TypeScript project. A `jsconfig.json` that includes all cartridge sources — like the one `b2c setup ide vscode-types` generates — provides that; without one, each open file gets its own inferred project and only same-file usage is visible. + +Inference stays cheap on large cartridge stacks. A request reads only the files that can name the value it follows: a helper local to one file is looked up in that file, and a module's exports in that module and the files that `require()` it. Each hover or completion runs a bounded number of such searches, and is dropped as soon as the editor cancels it (for example, when you keep typing). + +Inferred results are heuristic and clearly labeled: + +- Hover text gets an appended `Inferred from usage: ` line. +- Member completions synthesized this way are still offered alongside (not instead of) whatever TypeScript already resolved. +- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Different element types of one collection class read as that class (`Collection` and `Collection` show `Collection`). An object literal with only members of a class another call site passes, such as a unit test's `{httpHeaders: {get: ...}}` stand-in for a `Request`, isn't counted as a type of its own. Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. +- Usage that a JavaScript built-in fits as well (`value.replace(...)` could be a `String`) and the TopLevel classes describing `module` and `arguments` never produce a Script API guess on their own. +- Names never add a type of their own; they only break a tie when the usage fits several classes equally well. A `profile` parameter picks `dw.customer.Profile` over `ProductListRegistrant`, `lineItem` / `pli` pick `ProductLineItem`, and qualified names such as `apiProduct`, `currentBasket` or `resettingCustomer` resolve to the class they end in. + +This won't recover types TypeScript genuinely can't infer — for example, values that are never called with a consistent, well-typed argument anywhere in the project — and it's off by default because it's new and heuristic. + +**Known limitations** — intentionally deferred patterns: + +- Destructured function parameters (`function f({a, b})`) +- Destructured return values (`var {a, b} = undocumentedFn()`) +- Constructor inheritance via `Foo.prototype = Base.prototype` +- Hooks the platform itself invokes (OCAPI/SCAPI `dw.ocapi.*` hooks, system extension points such as `dw.order.calculate` when no cartridge calls it): with no `HookMgr.callHook` call there is no call site, so only the hook's own body is evidence. Extension points without a literal prefix (`HookMgr.callHook(hookName, ...)` where `hookName` is a parameter or a variable assigned more than once) aren't matched either +- Typed Script API uses through a value that is itself inferred (`paymentInstrument.paymentTransaction.setPaymentProcessor(processor)` where `paymentInstrument` is undocumented) don't count as usage of the argument +- Values ISML templates read from `pdict` +- Guessing individual custom attribute names on `.custom` (only `.custom` itself is usage evidence) + ### Notes - The bundle is version-locked to a Script API release (currently 26.9). Re-run `b2c setup ide vscode-types` after upgrading the CLI to refresh the vendored copy; use `--force` to overwrite existing files if they were previously created. The plugin path returned by `b2c setup ide tsserver-plugin` always points at the bundle shipped with your installed CLI. diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index faf68093b..a7359b328 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -56,4 +56,46 @@ in by the host extension via `tsApi.configurePlugin(...)`. Files outside the cartridge layout fall straight through to the unwrapped service — non-cartridge JavaScript and TypeScript in the same workspace see no behavior change. -See [plugin/index.ts](./plugin/index.ts) for the implementation. +See [src/index.ts](./src/index.ts) for the implementation. + +### Usage-based type inference (experimental, opt-in) + +An undocumented helper function (no JSDoc) gets its parameters and return +value widened to `any` by plain TypeScript inference, and that `any` +propagates to every caller. Passing `inferUsage: true` in the plugin config +(off by default) makes the plugin infer a plausible type for these cases the +way IntelliJ does: from call-site arguments (including `new`, `.call`/`.apply` +and callbacks), the callback types of the declared APIs a function, or a +factory's result, is handed to (`server.get('Show', cache.applyDefaultCache)`), +return values, the typed Script API calls a value is passed to, and the members +and `instanceof`/`typeof` checks in the helper's own body. +Values are followed where they go — into object members, `this.x`, pushed +arrays, factory returns and `module.exports` — and a generic helper such as +`collections.find(basket.shipments, ...)` is inferred again for the call at +hand, together with the callback it is passed +(`collections.map(basket.shipments, fn)` is an array of what `fn` returns, +and `fn`'s parameter is what `collections.map` calls it with: a `Shipment`). +Hook scripts registered in a cartridge's `hooks.json` take the arguments of +the `HookMgr.callHook(...)` calls that reach them, literal or prefixed +(`'app.payment.processor.' + id`), and those calls return what the hooks return +([src/resolver/hook-registry.ts](./src/resolver/hook-registry.ts) reads the +registrations, as JSON only). +The result is surfaced as an "Inferred from usage" hover note plus synthesized +member completions; call sites that disagree show a union of up to three +types, not counting object literals that only stand in for one of them (a +unit test's `{httpHeaders: ...}` passed where production code passes a +`Request`). It only kicks in where the checker has already given up (`any`, or a +placeholder SFRA JSDoc such as `@param {Object}`), never overriding a real +type from TypeScript or JSDoc. + +The engine gathers evidence ([src/inference/core.ts](./src/inference/core.ts)) +and applies one decision policy ([src/inference/policy.ts](./src/inference/policy.ts)) +everywhere, so parameter, member and chain hovers and completions always +agree. [src/usage-inference.ts](./src/usage-inference.ts) is the barrel and +lists the modules in reading order. + +A request reads only the files that can name the value it follows +([src/inference/reference-search.ts](./src/inference/reference-search.ts)): +the declaring file for a file-local helper, and the module plus the files that +`require()` it for an export. It runs a bounded number of searches and stops +when the editor cancels it. diff --git a/packages/b2c-script-types/eslint.config.mjs b/packages/b2c-script-types/eslint.config.mjs index c5336a271..9baff8f82 100644 --- a/packages/b2c-script-types/eslint.config.mjs +++ b/packages/b2c-script-types/eslint.config.mjs @@ -33,4 +33,13 @@ export default [ files: ['test/**/*.ts'], rules: chaiTestRules, }, + { + // The usage-inference suites are plain CommonJS .js files that exercise + // the compiled plugin/ output directly, so they use require() rather than + // the src/ package's ESM-style import syntax. + files: ['test/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, ]; diff --git a/packages/b2c-script-types/package.json b/packages/b2c-script-types/package.json index 075111f90..92d82eca5 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -29,11 +29,12 @@ "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src test", "format:check": "prettier --check src test", - "pretest": "tsc --noEmit -p test", - "test": "c8 mocha --forbid-only \"test/**/*.test.ts\"", - "test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/**/*.test.ts\"", - "test:agent": "mocha --forbid-only --reporter min \"test/**/*.test.ts\"", - "test:watch": "mocha --watch \"test/**/*.test.ts\"", + "pretest": "tsc --noEmit -p test && pnpm run build", + "test": "c8 mocha --forbid-only \"test/**/*.test.{ts,js}\"", + "test:ci": "c8 mocha --forbid-only --reporter json --reporter-option output=test-results.json \"test/**/*.test.{ts,js}\"", + "test:agent": "pnpm run build && mocha --forbid-only --reporter min \"test/**/*.test.{ts,js}\"", + "test:unit": "pnpm run test", + "test:watch": "pnpm run build && mocha --watch \"test/**/*.test.{ts,js}\"", "coverage": "c8 report", "posttest": "pnpm run lint" }, diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 490c6b3f4..956e0cd0f 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -8,7 +8,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) { * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ const node_path_1 = __importDefault(require("node:path")); -const PLUGIN_NAME = '@salesforce/b2c-script-types'; +const usage_inference_1 = require("./usage-inference"); +const constants_1 = require("./resolver/constants"); +const cartridge_discovery_1 = require("./resolver/cartridge-discovery"); +const hook_registry_1 = require("./resolver/hook-registry"); +const module_resolution_1 = require("./resolver/module-resolution"); const TYPES_DIR = node_path_1.default.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, // `customer`, `empty(...)`, the `dw.*` namespace alias, etc.). The plugin @@ -19,62 +23,37 @@ const GLOBAL_DTS = node_path_1.default.join(TYPES_DIR, 'global.d.ts').replace(/\ // and friends so cartridge code works under `checkJs: true` despite the dynamic // property assignments in modules/server.js that TS can't infer. const SFRA_SERVER_DTS = node_path_1.default.join(TYPES_DIR, 'sfra', 'server.d.ts').replace(/\\/g, '/'); -// Bare-name requires that the SFRA server.d.ts ambient declaration covers. -// We deliberately do NOT redirect these to modules/.js, so TS uses the -// ambient declaration's types instead of the inferred .js types (which can't -// see the dynamic `server.middleware = ...` assignments in modules/server.js). -const SFRA_AMBIENT_MODULES = new Set([ - 'server', - 'server/server', - 'server/middleware', - 'server/render', - 'server/route', - 'server/request', - 'server/response', - 'server/queryString', - 'server/forms', - 'server/forms/forms', -]); -// Candidate suffixes appended when resolving a SFCC-style relative require to -// a cartridge file. SFRA convention is to omit the .js extension, so .js wins -// first; .json captures the occasional resource bundle import; .ds covers -// legacy pipeline-era scripts, which the platform also resolves after .js. -const CANDIDATE_EXTENSIONS = ['.js', '.json', '.ds', '/index.js', '/index.ds']; -// Cartridges that conventionally sit at the bottom of the cartridge path when -// the user hasn't told us otherwise (no `cartridges` in dw.json/SFCC_CARTRIDGES). -// Higher rank = lower in the cartridge path. SFRA's runtime path ends with -// `app_storefront_base:modules`, so `modules` sorts strictly last. -// Mirrors BASE_CARTRIDGE_RANK in packages/b2c-vs-extension/src/cartridges/cartridge-service.ts. -const BASE_CARTRIDGE_RANK = { - app_storefront_base: 1, - modules: 2, -}; -// Directories skipped during recursive .project discovery. Mirrors the ignore -// list in @salesforce/b2c-tooling-sdk's findCartridges() so plain LSP usage -// matches CLI/extension discovery. -const DISCOVERY_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.cache', 'tmp', 'temp']); -const DISCOVERY_MAX_DEPTH = 8; +/** True when two file lists name the same files in the same order. */ +function sameFileNames(a, b) { + return a.length === b.length && a.every((name, i) => name === b[i]); +} function init({ typescript: ts }) { - // Module-scoped state shared across all projects in the TS server. The host - // calls onConfigurationChanged() on this module when configurePlugin() runs; - // each project's wrapped resolver reads from these variables. + // tsserver calls this factory function fresh for every project that loads + // the plugin (once per tsconfig/jsconfig root), so these variables are a + // private closure per project, not shared state across a multi-root + // workspace. configurePlugin() broadcasts the same config to every open + // project, but each project's own onConfigurationChanged() call only + // updates its own copy of these variables. let cartridges = []; let enabled = true; let autoDiscoverEnabled = true; + let inferUsageEnabled = false; // Whether the most recent applyConfig() received an explicit cartridges list. // When true, we skip auto-discovery; when false, create() may auto-populate. let cartridgesFromHost = false; + // The cache resets of every language service create() decorated. + const usageInferenceResets = new Set(); // tsserver internally canonicalizes file paths to forward slashes regardless of // platform (so containingFile is "C:/proj/..." on Windows). The cartridge roots // we receive from the extension come from Node's path.resolve(), which returns // backslashes on Windows — we have to normalize to match. We also fold case on // case-insensitive filesystems (Windows + default macOS HFS+/APFS) so a path // like "C:/Proj" matches a cartridge root of "c:/proj". - const caseSensitive = ts.sys.useCaseSensitiveFileNames; - const normalize = (p) => { - const slashed = p.replace(/\\/g, '/'); - return caseSensitive ? slashed : slashed.toLowerCase(); - }; + // + // isWithinRoot is the trust boundary for every resolver below — see its + // doc comment in resolver/module-resolution.ts for the full rationale and + // known limitations. + const { normalize, isWithinRoot } = (0, module_resolution_1.createPathContainment)(ts, ts.sys.useCaseSensitiveFileNames); const setCartridges = (list) => { cartridges = list.map(({ name, src }) => { const n = normalize(src); @@ -82,7 +61,7 @@ function init({ typescript: ts }) { return { name, root: n.endsWith('/') ? n : n + '/', - srcRoot: raw.endsWith('/') ? raw : raw + '/', + rawRoot: raw.endsWith('/') ? raw : raw + '/', }; }); }; @@ -90,6 +69,7 @@ function init({ typescript: ts }) { const c = (config ?? {}); enabled = c.enabled !== false; autoDiscoverEnabled = c.autoDiscover !== false; + inferUsageEnabled = c.inferUsage === true; // Only touch the cartridge list if the host explicitly provided one. // This lets onConfigurationChanged() update flags (enabled, autoDiscover) // without wiping a previously auto-discovered list. @@ -110,105 +90,6 @@ function init({ typescript: ts }) { cartridgesFromHost = list.length > 0; setCartridges(list); }; - // Recursively walk projectRoot for `.project` markers. Stops descending into - // a cartridge once found (cartridges don't nest). Depth-limited to keep - // tsserver startup snappy on huge monorepos. - const discoverCartridgesOnDisk = (projectRoot) => { - const found = []; - const stack = [{ dir: projectRoot, depth: 0 }]; - while (stack.length > 0) { - const { dir, depth } = stack.pop(); - if (fileExists(node_path_1.default.join(dir, '.project'))) { - found.push({ name: node_path_1.default.basename(dir), src: dir }); - continue; - } - if (depth >= DISCOVERY_MAX_DEPTH) - continue; - let subdirs = []; - try { - subdirs = ts.sys.getDirectories(dir); - } - catch { - subdirs = []; - } - for (const sub of subdirs) { - if (DISCOVERY_IGNORE.has(sub)) - continue; - stack.push({ dir: node_path_1.default.join(dir, sub), depth: depth + 1 }); - } - } - // Stable ordering for deterministic auto-discovery output. - found.sort((a, b) => a.src.localeCompare(b.src)); - return found; - }; - // Read the top-level dw.json `cartridges` field (string with comma/colon - // separators OR array of names) for an explicit cartridge-path order. - // Mirrors what the b2c CLI's resolved config exposes; we don't try to honor - // SFCC_CARTRIDGES / .env / plugins here — hosts that need that complexity - // should push the resolved list in via configurePlugin(). - const readDwJsonCartridges = (projectRoot) => { - const dwJsonPath = node_path_1.default.join(projectRoot, 'dw.json'); - if (!fileExists(dwJsonPath)) - return undefined; - let content; - try { - content = ts.sys.readFile(dwJsonPath); - } - catch { - return undefined; - } - if (!content) - return undefined; - let parsed; - try { - parsed = JSON.parse(content); - } - catch { - return undefined; - } - const value = parsed?.cartridges; - if (typeof value === 'string') { - return value - .split(/[,:]/) - .map((s) => s.trim()) - .filter(Boolean); - } - if (Array.isArray(value)) { - return value.filter((s) => typeof s === 'string' && s.length > 0); - } - return undefined; - }; - // Apply cartridge ordering: if `configured` is set, named-first then any - // remaining discovered cartridges in their original order; otherwise - // discovery order with KNOWN_BASE_CARTRIDGES sorted last. - const orderCartridges = (discovered, configured) => { - if (configured && configured.length > 0) { - const byName = new Map(discovered.map((c) => [c.name, c])); - const ordered = []; - const seen = new Set(); - for (const name of configured) { - const found = byName.get(name); - if (found && !seen.has(name)) { - ordered.push(found); - seen.add(name); - } - } - for (const c of discovered) { - if (!seen.has(c.name)) - ordered.push(c); - } - return ordered; - } - const indexed = discovered.map((c, i) => ({ c, i })); - indexed.sort((a, b) => { - const ar = BASE_CARTRIDGE_RANK[a.c.name] ?? 0; - const br = BASE_CARTRIDGE_RANK[b.c.name] ?? 0; - if (ar !== br) - return ar - br; - return a.i - b.i; - }); - return indexed.map((x) => x.c); - }; const isCartridgeFile = (filePath) => { if (!enabled || cartridges.length === 0) return false; @@ -224,7 +105,12 @@ function init({ typescript: ts }) { // tsserver keys its internal file map on forward-slash paths, so normalize // the return value here — path.join produces backslashes on Windows. if (moduleName.startsWith('dw/')) { - return node_path_1.default.join(TYPES_DIR, moduleName + '.d.ts').replace(/\\/g, '/'); + const resolved = node_path_1.default.join(TYPES_DIR, moduleName + '.d.ts').replace(/\\/g, '/'); + // A crafted name like `dw/../../../etc/passwd` would otherwise join to a + // path outside the bundled types dir. Reject anything that escapes it. + if (!isWithinRoot(resolved, TYPES_DIR)) + return undefined; + return resolved; } return undefined; }; @@ -236,114 +122,35 @@ function init({ typescript: ts }) { return false; } }; - // Resolve a SFCC cartridge-style require relative to the configured cartridge - // path. Returns the absolute path to the resolved JS file, or undefined if no - // cartridge contains the target. - // - // ~/cartridge/scripts/foo -> only the cartridge that owns containingFile - // * /cartridge/scripts/foo -> walks the cartridge path, owner-first - // bar/cartridge/scripts/foo -> only the cartridge named "bar" - const resolveCartridgeModule = (moduleName, containingFile) => { - if (cartridges.length === 0) - return undefined; - let subpath; - let order; - if (moduleName.startsWith('~/')) { - // ~ is the current cartridge — restrict to the cartridge that owns the - // calling file. If the containing file isn't inside any known cartridge, - // there is no current cartridge, so the require can't be resolved. - subpath = moduleName.slice(2); - const owner = ownerCartridge(containingFile); - if (!owner) - return undefined; - order = [owner]; - } - else if (moduleName.startsWith('*/')) { - // * walks the cartridge path. Owner-first matches SFRA-style overrides - // (the requesting cartridge wins before falling through to others). - subpath = moduleName.slice(2); - order = reorderForContainingFile(cartridges, containingFile); - } - else { - // /cartridge/... — only treat as a cartridge require if the - // first segment matches a known cartridge name. Otherwise pass through so - // node_modules and other resolutions still work. - const slash = moduleName.indexOf('/'); - if (slash <= 0) - return undefined; - const head = moduleName.slice(0, slash); - const known = cartridges.find((c) => c.name === head); - if (!known) - return undefined; - subpath = moduleName.slice(slash + 1); - order = [known]; - } - if (!subpath) - return undefined; - for (const c of order) { - const baseAbs = c.srcRoot + subpath; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return { resolved: candidate, source: c.name }; - } - } - } - return undefined; - }; - // Resolve a bare `require('server')`-style import against the SFRA `modules` - // cartridge. Unlike normal cartridges (which expose files under - // `cartridge/scripts/...`), the `modules` cartridge exposes its entire tree - // at the root, so `require('server')` -> `/server[.js|/index.js]` - // and `require('server/middleware')` -> `/server/middleware[.js]`. - // Falls through unless a cartridge literally named `modules` is in the list. - const resolveModulesCartridge = (moduleName) => { - if (cartridges.length === 0) - return undefined; - if (moduleName.startsWith('.') || moduleName.startsWith('/')) - return undefined; - if (moduleName.startsWith('~/') || moduleName.startsWith('*/') || moduleName.startsWith('dw/')) - return undefined; - // Let the bundled SFRA ambient declarations win for these names. If we - // resolved them to the .js file here, TS would infer types from the JS - // (which misses dynamic property assignments in modules/server.js) and - // ignore the ambient `declare module 'server' { ... }` shape. - if (SFRA_AMBIENT_MODULES.has(moduleName)) - return undefined; - const modulesCart = cartridges.find((c) => c.name === 'modules'); - if (!modulesCart) - return undefined; - const baseAbs = modulesCart.srcRoot + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return { resolved: candidate, source: modulesCart.name }; - } + const ownerCartridge = (containingFile) => (0, module_resolution_1.ownerCartridge)(cartridges, normalize, containingFile); + // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. + // Neovim with typescript-language-server). Walks the project root for + // `.project` markers and honors dw.json's `cartridges` field for ordering. + const needsAutoDiscovery = () => enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0; + const autoDiscoverCartridges = (projectRoot, log) => { + if (!projectRoot) + return; + try { + const discovered = (0, cartridge_discovery_1.discoverCartridgesOnDisk)(ts, projectRoot, fileExists); + const configured = (0, cartridge_discovery_1.readDwJsonCartridges)(ts, projectRoot, fileExists); + setCartridges((0, cartridge_discovery_1.orderCartridges)(discovered, configured)); + log(`auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + + (configured ? ` (ordered by dw.json cartridges)` : '')); } - // package.json `main` fallback for directories without an index.js. - const pkgPath = baseAbs + '/package.json'; - if (fileExists(pkgPath)) { - try { - const content = ts.sys.readFile(pkgPath); - if (content) { - const main = JSON.parse(content).main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.srcRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - if (fileExists(resolved)) { - return { resolved, source: modulesCart.name }; - } - } - } - } - catch { - // best-effort - } + catch (e) { + log(`auto-discovery failed: ${e.message}`); } - return undefined; }; - const ownerCartridge = (containingFile) => { - const f = normalize(containingFile); - return cartridges.find((c) => f.startsWith(c.root)); + // The ambient declarations a project with cartridge files needs and its + // file list doesn't already include (dedup by normalized path): + // - global.d.ts: SFCC platform globals (session, request, response, + // customer, empty(), the ambient `dw` namespace). + // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, + // middleware, etc.) — only when a `modules` cartridge is configured. + const missingAmbientDeclarations = (list) => { + const present = new Set(list.map((f) => normalize(f))); + const wanted = cartridges.some((c) => c.name === 'modules') ? [GLOBAL_DTS, SFRA_SERVER_DTS] : [GLOBAL_DTS]; + return wanted.filter((dts) => fileExists(dts) && !present.has(normalize(dts))); }; // Cached map of byte ranges in types/sfra/server.d.ts to the SFRA module // declared by their enclosing `declare module 'X' { ... }` block. Used to @@ -352,7 +159,7 @@ function init({ typescript: ts }) { const sfraModuleAtOffset = (offset) => { if (!sfraDtsRanges) { const content = fileExists(SFRA_SERVER_DTS) ? ts.sys.readFile(SFRA_SERVER_DTS) : undefined; - sfraDtsRanges = content ? parseDeclareModuleRanges(content) : []; + sfraDtsRanges = content ? (0, cartridge_discovery_1.parseDeclareModuleRanges)(content) : []; } for (const r of sfraDtsRanges) { if (offset >= r.start && offset <= r.end) @@ -360,81 +167,118 @@ function init({ typescript: ts }) { } return undefined; }; - const parseDeclareModuleRanges = (content) => { - const ranges = []; - const re = /declare module ['"]([^'"]+)['"]\s*\{/g; - let m; - while ((m = re.exec(content)) !== null) { - const start = m.index; - // Walk forward from the opening brace to find the matching close. - let depth = 1; - let i = m.index + m[0].length; - while (i < content.length && depth > 0) { - const ch = content[i]; - if (ch === '{') - depth++; - else if (ch === '}') - depth--; - i++; - } - ranges.push({ start, end: i, module: m[1] }); - } - return ranges; - }; - const reorderForContainingFile = (list, containingFile) => { - const owner = ownerCartridge(containingFile); - if (!owner) - return list; - return [owner, ...list.filter((c) => c !== owner)]; - }; function create(info) { - const log = (msg) => info.project.projectService.logger.info(`[${PLUGIN_NAME}] ${msg}`); + const log = (msg) => info.project.projectService.logger.info(`[${constants_1.PLUGIN_NAME}] ${msg}`); applyConfig(info.config); - // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. - // Neovim with typescript-language-server). Walks the project root for - // `.project` markers and honors dw.json's `cartridges` field for ordering. - if (enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0) { - const projectRoot = info.project.getCurrentDirectory(); - if (projectRoot) { - try { - const discovered = discoverCartridgesOnDisk(projectRoot); - const configured = readDwJsonCartridges(projectRoot); - const ordered = orderCartridges(discovered, configured); - setCartridges(ordered); - log(`auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + - (configured ? ` (ordered by dw.json cartridges)` : '')); - } - catch (e) { - log(`auto-discovery failed: ${e.message}`); - } - } - } + if (needsAutoDiscovery()) + autoDiscoverCartridges(info.project.getCurrentDirectory(), log); const host = info.languageServiceHost; - // Inject ambient declarations into the TS program when the project - // contains at least one cartridge file: - // - global.d.ts: SFCC platform globals (session, request, response, - // customer, empty(), the ambient `dw` namespace). - // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, - // middleware, etc.) — only injected when a `modules` cartridge is - // configured. Configured projects that already include either via a - // jsconfig include glob are unaffected (dedup by normalized path). + // What `module.superModule` refers to at runtime: the same-subpath file + // in the next cartridge down the cartridge path that has one. Powers the + // usage-inference engine's handling of SFRA overlay modules. Probes + // existence through the language-service host (not ts.sys) so it sees + // the same filesystem view as the rest of this project. + const hostFileExists = (p) => { + try { + return host.fileExists ? host.fileExists(p) : ts.sys.fileExists(p); + } + catch { + return false; + } + }; + // Prefer the language-service host's view of the filesystem for require() + // resolution (not ts.sys): in-memory / virtualized hosts (tests, some LSP + // setups) otherwise never see cartridge files, and `~/` / `*/` requires + // silently stay unresolved. Auto-discovery above still uses ts.sys because + // it walks the real project root on disk. + const resolveCartridgeModuleOnHost = (moduleName, containingFile) => (0, module_resolution_1.resolveCartridgeModule)(cartridges, moduleName, containingFile, { + normalize, + isWithinRoot, + fileExists: hostFileExists, + }); + const resolveModulesCartridgeOnHost = (moduleName) => (0, module_resolution_1.resolveModulesCartridge)(ts, cartridges, moduleName, { + isWithinRoot, + fileExists: hostFileExists, + }); + const resolveSuperModulePath = (containingFile) => { + const owner = ownerCartridge(containingFile); + if (!owner) + return undefined; + // Slice from the slash-normalized-but-original-case form (not + // normalize()'s case-folded one) so the candidate built below from + // rawRoot doesn't get a folded-case tail spliced onto a real-case + // root — case folding never changes string length, so `owner.root`'s + // length is safe to reuse here. + const rawSubpath = containingFile.replace(/\\/g, '/').slice(owner.root.length); + for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { + const candidate = cartridges[i].rawRoot + rawSubpath; + // `subpath` is derived from an editor-supplied file path; contain the + // next-cartridge-down candidate so a crafted path or an overlapping + // cartridge root can't point it at a file outside that cartridge. + if (hostFileExists(candidate) && isWithinRoot(candidate, cartridges[i].root)) + return candidate; + } + return undefined; + }; + // Which scripts the cartridges' hooks.json files register for which + // extension points, so usage inference can follow + // `HookMgr.callHook(extensionPoint, functionName, ...args)` into them. + // Read through the language-service host, like require() resolution; a + // read that throws declares nothing. + const hookRegistrations = () => (0, hook_registry_1.readHookRegistrations)(cartridges, { + readFile: (p) => (host.readFile ? host.readFile(p) : ts.sys.readFile(p)), + fileExists: hostFileExists, + isWithinRoot, + }); + // Inject the ambient declarations into the TS program when the project + // contains at least one cartridge file (isCartridgeFile is false while the + // plugin is disabled or no cartridge is known). Projects that already + // include them via a jsconfig include glob are unaffected. + // + // tsserver asks for this list on every language-service request (each + // hover, completion and diagnostics pass), building a new array each time + // from the same names while the project's files stay put. The answer is + // kept until a name or the configuration changes, so a request costs one + // name-by-name comparison rather than normalizing every path in the + // project and probing the disk. const origGetScriptFileNames = host.getScriptFileNames.bind(host); - host.getScriptFileNames = () => { - const list = origGetScriptFileNames(); - if (!enabled || cartridges.length === 0) - return list; + const withAmbientDeclarations = (list) => { if (!list.some((f) => isCartridgeFile(f))) return list; - const additions = []; - const present = new Set(list.map((f) => normalize(f))); - if (fileExists(GLOBAL_DTS) && !present.has(normalize(GLOBAL_DTS))) { - additions.push(GLOBAL_DTS); + const additions = missingAmbientDeclarations(list); + return additions.length > 0 ? [...list, ...additions] : list; + }; + let lastFileNames; + host.getScriptFileNames = () => { + const list = origGetScriptFileNames(); + const last = lastFileNames; + if (last?.cartridges === cartridges && last.enabled === enabled && sameFileNames(last.list, list)) { + return last.result; } - const hasModules = cartridges.some((c) => c.name === 'modules'); - if (hasModules && fileExists(SFRA_SERVER_DTS) && !present.has(normalize(SFRA_SERVER_DTS))) { - additions.push(SFRA_SERVER_DTS); + const result = withAmbientDeclarations(list); + lastFileNames = { list, cartridges, enabled, result }; + return result; + }; + // Shared by both host resolution hooks below (the modern + // resolveModuleNameLiterals and the legacy TS 4.x resolveModuleNames): + // tries dw/* types, then SFCC cartridge-relative requires, then the SFRA + // `modules` cartridge, in that priority order. Each hook only differs in + // the shape TS expects the result wrapped in. + const resolveOne = (text, containingFile) => { + const dw = resolveDwModule(text); + // Bundled dw/* types live on the real disk next to the plugin — ts.sys + // (via fileExists) is the right probe there, not the project host. + if (dw && fileExists(dw)) { + return { resolvedFileName: dw, extension: ts.Extension.Dts, isExternalLibraryImport: true }; } - return additions.length > 0 ? [...list, ...additions] : list; + const local = resolveCartridgeModuleOnHost(text, containingFile) ?? resolveModulesCartridgeOnHost(text); + if (!local) + return undefined; + return { + resolvedFileName: local.resolved, + extension: local.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; }; // TS derives a file's ScriptKind from its extension and falls back to TS // for anything it doesn't recognize, which would parse legacy .ds scripts @@ -456,41 +300,12 @@ function init({ typescript: ts }) { return original.map((res, i) => { if (res.resolvedModule) return res; - const text = moduleLiterals[i].text; - const dw = resolveDwModule(text); - if (dw && fileExists(dw)) { - return { - resolvedModule: { - resolvedFileName: dw, - extension: ts.Extension.Dts, - isExternalLibraryImport: true, - packageId: undefined, - }, - }; - } - const cart = resolveCartridgeModule(text, containingFile); - if (cart) { - return { - resolvedModule: { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - packageId: undefined, - }, - }; - } - const mod = resolveModulesCartridge(text); - if (mod) { - return { - resolvedModule: { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - packageId: undefined, - }, - }; - } - return res; + const resolved = resolveOne(moduleLiterals[i].text, containingFile); + if (!resolved) + return res; + return { + resolvedModule: { ...resolved, packageId: undefined }, + }; }); }; } @@ -504,32 +319,8 @@ function init({ typescript: ts }) { return original.map((res, i) => { if (res) return res; - const text = moduleNames[i]; - const dw = resolveDwModule(text); - if (dw && fileExists(dw)) { - return { - resolvedFileName: dw, - extension: ts.Extension.Dts, - isExternalLibraryImport: true, - }; - } - const cart = resolveCartridgeModule(text, containingFile); - if (cart) { - return { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - const mod = resolveModulesCartridge(text); - if (mod) { - return { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - return res; + const resolved = resolveOne(moduleNames[i], containingFile); + return resolved ? resolved : res; }); }; } @@ -555,7 +346,7 @@ function init({ typescript: ts }) { const moduleName = sfraModuleAtOffset(def.textSpan.start); if (!moduleName) return def; - const candidates = [modulesCart.srcRoot + moduleName + '.js', modulesCart.srcRoot + moduleName + '/index.js']; + const candidates = [modulesCart.rawRoot + moduleName + '.js', modulesCart.rawRoot + moduleName + '/index.js']; for (const candidate of candidates) { if (fileExists(candidate)) { return { ...def, fileName: candidate, textSpan: { start: 0, length: 0 } }; @@ -581,11 +372,39 @@ function init({ typescript: ts }) { const result = info.languageService.getImplementationAtPosition(fileName, position); return result?.map(remapDefinition); }; + // Usage-based inference (opt-in, `inferUsage`): when hover or member + // completion hits a value the checker has given up on (typically a + // parameter of an undocumented helper), add what call sites and the + // value's own usage say about it. See inference/editor-hooks. + const usageInference = (0, usage_inference_1.createUsageInferenceHooks)({ + ts, + languageService: info.languageService, + resolveSuperModulePath, + hookRegistrations, + // The project's own token: tsserver flags it when the editor cancels + // the request this hover or completion belongs to. + isCancellationRequested: () => host.getCancellationToken?.().isCancellationRequested() ?? false, + log, + }); + usageInferenceResets.add(usageInference.reset); + const inferenceActive = (fileName) => inferUsageEnabled && isCartridgeFile(fileName); + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + return inferenceActive(fileName) ? usageInference.decorateQuickInfo(fileName, position, original) : original; + }; + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + return inferenceActive(fileName) ? usageInference.decorateCompletions(fileName, position, original) : original; + }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } function onConfigurationChanged(config) { applyConfig(config); + // A new configuration can change what inference sees (cartridge order + // decides `module.superModule`), so drop every cached hover and list. + for (const reset of usageInferenceResets) + reset(); } return { create, onConfigurationChanged }; } diff --git a/packages/b2c-script-types/plugin/inference/ambient-index.js b/packages/b2c-script-types/plugin/inference/ambient-index.js new file mode 100644 index 000000000..44b551691 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/ambient-index.js @@ -0,0 +1,82 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAmbientClasses = getAmbientClasses; +exports.ambientClassesWithMembers = ambientClassesWithMembers; +exports.ambientClassType = ambientClassType; +exports.findAmbientClassType = findAmbientClassType; +exports.builtinValueTypes = builtinValueTypes; +const constants_1 = require("./constants"); +const classesByFile = new WeakMap(); +const classesByProgram = new WeakMap(); +function indexDeclaration(ctx, statement) { + const { ts, checker } = ctx; + if (!(ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) || !statement.name) + return undefined; + const symbol = checker.getSymbolAtLocation(statement.name); + if (!symbol) + return undefined; + const memberNames = new Set(checker.getPropertiesOfType(checker.getDeclaredTypeOfSymbol(symbol)).map((m) => m.name)); + if (memberNames.size === 0) + return undefined; + return { name: statement.name.text, memberNames, declaration: statement }; +} +function indexFile(ctx, sourceFile) { + const cached = classesByFile.get(sourceFile); + if (cached) + return cached; + const classes = sourceFile.statements.flatMap((statement) => indexDeclaration(ctx, statement) ?? []); + classesByFile.set(sourceFile, classes); + return classes; +} +/** + * Every top-level class and interface in the program's declaration files, + * except TypeScript's own default library (DOM and ES classes such as + * `HTMLDataElement` would otherwise match ordinary SFCC usage). + */ +function getAmbientClasses(ctx) { + const { program } = ctx; + const cached = classesByProgram.get(program); + if (cached) + return cached; + const classes = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.isDeclarationFile && !program.isSourceFileDefaultLibrary(sourceFile)) + .flatMap((sourceFile) => indexFile(ctx, sourceFile)); + classesByProgram.set(program, classes); + return classes; +} +/** The indexed classes that have every member in `memberNames`, in index order. */ +function ambientClassesWithMembers(ctx, memberNames) { + const names = [...memberNames]; + return getAmbientClasses(ctx).filter((ambientClass) => names.every((name) => ambientClass.memberNames.has(name))); +} +/** The class's declared (uninstantiated) type, resolved with the current checker. */ +function ambientClassType(ctx, ambientClass) { + const { checker } = ctx; + const name = ambientClass.declaration.name; + const symbol = name && checker.getSymbolAtLocation(name); + return symbol ? checker.getDeclaredTypeOfSymbol(symbol) : undefined; +} +/** + * The declared type of the ambient class called `name`, preferring the + * vendored Script API's declaration (under a `/dw/` directory) when a + * project declares another class of the same name. + */ +function findAmbientClassType(ctx, name) { + const named = getAmbientClasses(ctx).filter((ambientClass) => ambientClass.name === name); + const preferred = named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; + return preferred && ambientClassType(ctx, preferred); +} +/** The declared types of the JavaScript built-ins in BUILTIN_VALUE_TYPES (`String`, `Array`, ...). */ +function builtinValueTypes(ctx) { + const { ts, checker } = ctx; + return constants_1.BUILTIN_VALUE_TYPES.flatMap((name) => { + const symbol = checker.resolveName(name, undefined, ts.SymbolFlags.Type, false); + return symbol ? [checker.getDeclaredTypeOfSymbol(symbol)] : []; + }); +} diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js new file mode 100644 index 000000000..2f1818bad --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -0,0 +1,299 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getNodeAtPosition = getNodeAtPosition; +exports.spellingFilter = spellingFilter; +exports.propertyAccessNamedBy = propertyAccessNamedBy; +exports.memberCompletionAccess = memberCompletionAccess; +exports.hasExplicitParameterType = hasExplicitParameterType; +exports.hasExplicitReturnType = hasExplicitReturnType; +exports.hasExplicitVariableType = hasExplicitVariableType; +exports.memberAssignmentOf = memberAssignmentOf; +exports.isExportsObject = isExportsObject; +exports.isModuleExports = isModuleExports; +exports.exportsObjectWrittenBy = exportsObjectWrittenBy; +exports.invocationOf = invocationOf; +exports.isTestedOrAlternative = isTestedOrAlternative; +exports.collectReturnExpressions = collectReturnExpressions; +/** + * Finds the most specific node whose span contains `pos`. Standard technique + * built only on public Node/forEachChild APIs — deliberately avoids TS's + * internal (unversioned) getTokenAtPosition helper. + * + * The walk stops scanning a sibling list as soon as it passes `pos` + * (forEachChild aborts when the callback returns truthy, and siblings are + * ordered and non-overlapping), and skips the siblings before `pos` by their + * end alone, so a node's start (which means scanning its leading comments) + * is computed only for the one sibling that reaches `pos`. Without that, a + * hover in a file whose top-level (or any enclosing) node has thousands of + * children — a generated data file with an 8,000-element array literal, say + * — pays for the full child list. + */ +function getNodeAtPosition(sourceFile, ts, pos) { + let result; + const visit = (node) => { + if (pos >= node.getEnd()) + return undefined; // before pos — keep scanning this sibling list + if (pos < node.getStart(sourceFile)) + return true; // walked past pos — later siblings can't contain it + result = node; + ts.forEachChild(node, visit); + return true; // containing child handled — siblings don't overlap + }; + visit(sourceFile); + return result; +} +/** + * A pre-test for walks of `scope` looking for identifiers named one of + * `names`: true when a node's text spells one of them. An identifier's text + * starts with its name, so a subtree the test is false for holds none of + * them and the walk can skip it, reading only the few paths that lead to + * one. An identifier written with a unicode escape sequence spells its + * name in other characters, so a subtree holding an escape is always read. + */ +function spellingFilter(scope, names) { + const { text } = scope.getSourceFile(); + const offsets = []; + for (const spelling of new Set([...names, '\\u'])) { + let at = text.indexOf(spelling, scope.pos); + for (; at >= 0 && at < scope.end; at = text.indexOf(spelling, at + spelling.length)) + offsets.push(at); + } + offsets.sort((a, b) => a - b); + return (node) => { + const next = firstAtOrAfter(offsets, node.pos); + return next < offsets.length && offsets[next] < node.end; + }; +} +/** The index of the first of the ascending `offsets` that is at or after `pos`. */ +function firstAtOrAfter(offsets, pos) { + let low = 0; + let high = offsets.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (offsets[middle] < pos) + low = middle + 1; + else + high = middle; + } + return low; +} +/** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ +function propertyAccessNamedBy(node, ts) { + const { parent } = node; + return parent && ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : undefined; +} +/** + * The property access a member completion at `position` is completing: the + * cursor sits right after its dot (`shipment.|`) or inside its member name + * (`shipment.pro|`). Anywhere else — inside the receiver, or inside an + * argument list further along the chain (`basket.getShipment(|).ID`) — the + * enclosing access's members are not what is being typed, so this returns + * `undefined`. + */ +function memberCompletionAccess(sourceFile, ts, position) { + const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) + return undefined; + // The node is the access itself only when position - 1 falls on a token it + // owns directly (its dot), never inside the receiver or the name. + if (ts.isPropertyAccessExpression(node)) + return position >= node.name.pos ? node : undefined; + return propertyAccessNamedBy(node, ts); +} +/** + * SFRA helpers are often "documented" with a placeholder type that carries no + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`), and + * arrays of those (`{Array}`, `{Object[]}`, `{Array.<*>}`), which say nothing + * about their elements. Those are ubiquitous in real cartridges (and IntelliJ + * mainly helps when authors write a real `dw.*` JSDoc), so treating them as + * deliberate annotations would permanently silence usage inference on the + * exact helpers that need it most. + * + * Deliberate `{any}` / `: any` is *not* weak: that is an author saying "do not + * pretend you know this type", and we still respect it (`{any[]}` too). + */ +function isWeakTypeNode(typeNode, ts) { + const node = unwrapTypeNode(typeNode, ts); + // `{Object|null}`, `{Object|undefined}`: a placeholder that may be missing is still a placeholder. + if (ts.isUnionTypeNode(node)) { + const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); + return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); + } + if (ts.isArrayTypeNode(node)) + return isWeakTypeNode(node.elementType, ts); + if (isArrayReference(node, ts)) + return node.typeArguments?.every((element) => isWeakTypeNode(element, ts)) ?? true; + return isPlaceholderTypeNode(node, ts); +} +/** One placeholder on its own: `{*}`, `{}`, `{object}`, `{Object}` or `{obj}`. */ +function isPlaceholderTypeNode(node, ts) { + // JSDoc `{*}` — "any value", not a real shape. + if (node.kind === ts.SyntaxKind.JSDocAllType) + return true; + // Empty object literal type `{}`. + if (ts.isTypeLiteralNode(node) && node.members.length === 0) + return true; + // Lowercase `object` keyword (TS/JSDoc) — non-primitive bag, not a dw.* class. + if (node.kind === ts.SyntaxKind.ObjectKeyword) + return true; + const lower = typeReferenceName(node, ts)?.toLowerCase(); + // `Object` / `object` / the SFRA-conventional misspelling `obj`. + return lower === 'object' || lower === 'obj'; +} +/** The type `(T)`, `?T`, `!T` and `T=` wrap: parentheses and JSDoc nullability or optionality change no shape. */ +function unwrapTypeNode(typeNode, ts) { + let node = typeNode; + while (ts.isParenthesizedTypeNode(node) || + ts.isJSDocNullableType(node) || + ts.isJSDocNonNullableType(node) || + ts.isJSDocOptionalType(node)) { + node = node.type; + } + return node; +} +/** `Array`, `Array` or JSDoc's `Array.`. */ +function isArrayReference(node, ts) { + return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Array'; +} +/** `null` or `undefined` as a type. */ +function isNullishTypeNode(node, ts) { + if (node.kind === ts.SyntaxKind.UndefinedKeyword) + return true; + return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword; +} +/** The name a type reference names: `Object` in `{Object}` or `extends Object`. */ +function typeReferenceName(node, ts) { + if (ts.isTypeReferenceNode(node)) + return node.typeName.getText(); + return ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression) ? node.expression.text : undefined; +} +/** True when `typeNode` is a real annotation we must not second-guess (including deliberate `any`). */ +function isStrongTypeNode(typeNode, ts) { + return typeNode !== undefined && !isWeakTypeNode(typeNode, ts); +} +/** + * True when the developer already gave this parameter an explicit, meaningful + * type — TS syntax or JSDoc — even if that type is literally `any`. In that + * case the checker's type reflects a deliberate choice, not an inference + * failure, so usage inference must never second-guess it. Placeholder SFRA + * annotations (`Object` / `obj` / `*` / `{}`) do **not** count; see + * {@link isWeakTypeNode}. + */ +function hasExplicitParameterType(param, ts) { + return isStrongTypeNode(param.type, ts) || isStrongTypeNode(ts.getJSDocType(param), ts); +} +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ +function hasExplicitReturnType(fn, ts) { + return isStrongTypeNode(fn.type, ts) || isStrongTypeNode(ts.getJSDocReturnType(fn), ts); +} +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +function hasExplicitVariableType(decl, ts) { + return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); +} +/** The `x.name = value` assignment a statement is, if any. */ +function memberAssignmentOf(stmt, ts) { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? { binary, target: binary.left } : undefined; +} +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +function isExportsObject(expr, ts) { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} +/** `module.exports`, identified structurally. */ +function isModuleExports(expr, ts) { + return (ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports'); +} +/** The `exports` / `module.exports` object a top-level statement assigns to or adds a member to. */ +function exportsObjectWrittenBy(stmt, ts) { + const target = memberAssignmentOf(stmt, ts)?.target; + if (!target) + return undefined; + if (isExportsObject(target.expression, ts)) + return target.expression; + return isModuleExports(target, ts) ? target : undefined; +} +/** + * `call` as an invocation of the function its callee names: `fn(a)`, + * `fn.call(scope, a)` without its `this` argument, or `fn.apply(scope, args)`. + * Any other method call (`list.push(a)`) invokes a member, not its receiver. + */ +function invocationOf(call, ts) { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) + return { callee, args: call.arguments }; + if (callee.name.text === 'call') + return { callee: callee.expression, args: call.arguments.slice(1) }; + return callee.name.text === 'apply' ? { callee: callee.expression, args: [] } : undefined; +} +/** True when `node` is the right side of `a && node`, which evaluates to it whenever it is reached. */ +function isRightOfAnd(node, ts) { + const parent = node.parent; + return (ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parent.right === node); +} +/** True when `operator` offers its operands as alternatives: `a || b`, `a ?? b`. */ +function isAlternativeOperator(operator, ts) { + return operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.QuestionQuestionToken; +} +/** + * True when the value `expr` evaluates to is only tested, or offered as one + * of several alternatives: the condition of an `if`, a loop or a `?:`, the + * operand of `!` or `typeof`, the left side of `&&`, or either side of `||` / + * `??` (`if (res.setStatusCode)`, `(res && res.statusCode) || res.errorCode`). + * Code that reads a member only this way expects values that lack it. + */ +function isTestedOrAlternative(expr, ts) { + let value = expr; + while (ts.isParenthesizedExpression(value.parent) || isRightOfAnd(value, ts)) + value = value.parent; + const parent = value.parent; + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + return (isAlternativeOperator(operator, ts) || + (operator === ts.SyntaxKind.AmpersandAmpersandToken && parent.left === value)); + } + if (ts.isPrefixUnaryExpression(parent)) + return parent.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isConditionalExpression(parent)) + return parent.condition === value; + const tests = ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent); + return ts.isTypeOfExpression(parent) || (tests && parent.expression === value); +} +/** + * Recursively walks a function body collecting `return` expressions, without + * descending into nested function-like boundaries (their returns belong to + * them, not to `fn`). + */ +function collectReturnExpressions(fn, ts) { + if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) { + return [fn.body]; + } + const body = fn.body; + const out = []; + if (!body) + return out; + const visit = (n) => { + if (ts.isFunctionLike(n) && n !== fn) + return; + if (ts.isReturnStatement(n) && n.expression) { + out.push(n.expression); + return; + } + ts.forEachChild(n, visit); + }; + visit(body); + return out; +} diff --git a/packages/b2c-script-types/plugin/inference/bindings.js b/packages/b2c-script-types/plugin/inference/bindings.js new file mode 100644 index 000000000..3f36d129f --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/bindings.js @@ -0,0 +1,114 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NO_BINDINGS = void 0; +exports.withBindings = withBindings; +exports.argumentBindings = argumentBindings; +exports.boundArgument = boundArgument; +exports.boundCallback = boundCallback; +exports.hasAnyTypeArgument = hasAnyTypeArgument; +exports.narrows = narrows; +const ast_helpers_1 = require("./ast-helpers"); +const member_values_1 = require("./member-values"); +const signatures_1 = require("./signatures"); +const type_helpers_1 = require("./type-helpers"); +/** No parameter bound: what every inference outside a call-specific retry runs with. */ +exports.NO_BINDINGS = new Map(); +/** Runs `compute` with `bindings` in force, restoring the previous ones afterwards. */ +function withBindings(ctx, bindings, compute) { + const previous = ctx.bindings; + ctx.bindings = bindings; + try { + return compute(); + } + finally { + ctx.bindings = previous; + } +} +/** + * `fn`'s plain parameters bound to the arguments `args` of one call, on top of + * the bindings already in force (a helper nested in a bound function still + * sees its parameters). Arguments after a spread, rest parameters and + * destructured parameters are left unbound; `undefined` when nothing binds. + */ +function argumentBindings(ctx, fn, args, depth) { + const { ts } = ctx; + const outer = ctx.bindings; + const bindings = new Map(outer); + for (const [index, parameter] of fn.parameters.entries()) { + const argument = args[index]; + if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) + break; + if (ts.isIdentifier(parameter.name)) + bindings.set(parameter, { argument, depth, outer }); + } + return bindings.size > outer.size ? bindings : undefined; +} +/** The binding the parameter `expr` names has in the bindings in force. */ +function boundArgument(ctx, expr) { + const { ts } = ctx; + if (ctx.bindings.size === 0 || !ts.isIdentifier(expr)) + return undefined; + const declaration = (0, member_values_1.valueDeclarationOf)(ctx, expr); + return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; +} +/** + * The function the bound parameter `expr` holds: the function its argument + * is or names, followed through parameters each enclosing call passes on as + * is (`function mapAll(items, fn) { return collections.map(items, fn); }`). + * Each step moves to the bindings in force where that call was written, + * which hold fewer parameters, so the walk ends. + */ +function boundFunction(ctx, expr) { + let binding = boundArgument(ctx, expr); + while (binding) { + const fn = (0, signatures_1.functionOf)(ctx, binding.argument); + if (fn) + return fn; + const { argument, outer } = binding; + binding = withBindings(ctx, outer, () => boundArgument(ctx, argument)); + } + return undefined; +} +/** + * `call` read as a call of the function a bound parameter holds, with the + * arguments that function receives: `callback(item)`, or `callback.call(scope, + * item)` without its `this` argument. A `callback.apply(scope, args)` call + * binds none of them. + */ +function boundCallback(ctx, call) { + const invocation = (0, ast_helpers_1.invocationOf)(call, ctx.ts); + const fn = invocation && boundFunction(ctx, invocation.callee); + return fn && { fn, args: invocation.args }; +} +/** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ +function hasAnyTypeArgument(ctx, type) { + const { ts, checker } = ctx; + if (!(type.flags & ts.TypeFlags.Object)) + return false; + if (!(type.objectFlags & ts.ObjectFlags.Reference)) + return false; + return checker.getTypeArguments(type).some((argument) => (0, type_helpers_1.isAnyType)(ts, argument)); +} +/** + * True when `types`, recovered for one call, may stand in for the `general` + * types the checker gives the same expression without it: when those say + * nothing, or when every recovered type is assignable to one of them + * (`Collection` for a parameter declared `{dw.util.Collection}`). + * Anything else is left alone, and TypeScript builds without the public + * `isTypeAssignableTo` never narrow a type that says something. + */ +function narrows(ctx, general, types) { + const { checker } = ctx; + if (types.length === 0) + return false; + if (general.length === 0) + return true; + if (typeof checker.isTypeAssignableTo !== 'function') + return false; + return types.every((type) => general.some((part) => checker.isTypeAssignableTo(type, part))); +} diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js new file mode 100644 index 000000000..3f0402476 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -0,0 +1,135 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.collectCallSites = collectCallSites; +exports.forwardedParameter = forwardedParameter; +const constants_1 = require("./constants"); +const ast_helpers_1 = require("./ast-helpers"); +const bindings_1 = require("./bindings"); +const member_values_1 = require("./member-values"); +const reference_search_1 = require("./reference-search"); +const type_helpers_1 = require("./type-helpers"); +const value_flow_1 = require("./value-flow"); +/** + * True when `name` declares a parameter or a variable inside a function. + * Such a search reads that function only (see ./reference-search) and can be + * reached only through a hop from a search that was charged, so it is not + * charged to ctx.searchBudget, though each of its hits still spends + * ctx.referenceBudget. Every other search spends one unit, even one confined + * to its own file: the budget bounds how far a request follows values from + * helper to helper, not only how many files it reads. + */ +function isFunctionLocal(ts, name) { + const declaration = name.parent; + if (ts.isParameter(declaration)) + return true; + const isVariable = ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration); + return isVariable && (0, value_flow_1.enclosingFunction)(declaration, ts) !== undefined; +} +function holderKey({ name, role }) { + const sourceFile = name.getSourceFile(); + return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; +} +/** + * True when `next` is searched now: it was not searched before in this walk + * and the request can still pay for it. Records the search and spends its + * unit of ctx.searchBudget, when it costs one. + */ +function claimSearch(ctx, next, searched) { + const key = holderKey(next); + const charged = !isFunctionLocal(ctx.ts, next.name); + if (searched.has(key) || (charged && ctx.searchBudget <= 0)) + return false; + searched.add(key); + if (charged) + ctx.searchBudget--; + return true; +} +/** + * Finds the actual call sites of the function `holder` holds (see + * ./value-flow's holderOf), and the handoffs to declared callees, following up to MAX_REFERENCE_HOPS names the function value flows + * into (see ./value-flow: require() bindings, exports, aliases, factories + * returning it, parameters it is passed to) when a reference doesn't sit + * directly in callee position. Stops early once ctx.referenceBudget (result + * count) or ctx.searchBudget (searches) runs out, returning whatever call + * sites were already found rather than continuing to fan out — an + * under-inferred (but still heuristic, clearly-labeled) result beats hanging + * on a widely-referenced helper. Results are memoized per name node for the + * duration of the request. + */ +function collectCallSites(ctx, holder) { + const key = holderKey(holder); + const memoized = ctx.callSiteMemo.get(key); + if (memoized) + return memoized; + const found = { calls: [], handoffs: [] }; + const searched = new Set(); + let frontier = [holder]; + let localBudget = Math.min(constants_1.MAX_REFERENCES_PER_CALL, ctx.referenceBudget); + for (let hop = 0; hop <= constants_1.MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { + const nextFrontier = []; + for (const next of frontier) { + if (localBudget <= 0) + break; + if (claimSearch(ctx, next, searched)) { + localBudget = collectCallsFromName(ctx, next, found, nextFrontier, localBudget); + } + } + frontier = nextFrontier; + } + ctx.callSiteMemo.set(key, found); + return found; +} +/** + * Runs one reference search for `next.name` and sorts each hit into a + * resolved call site or handoff (recorded in `found`, once per call) or a + * further name to chase on the next hop (pushed to `nextFrontier`). Consumes + * up to `localBudget` result slots, returning the remaining local budget so + * the caller can stop fanning out once it's exhausted. + */ +function collectCallsFromName(ctx, next, found, nextFrontier, localBudget) { + for (const reference of (0, reference_search_1.searchReferences)(ctx, next.name)) { + if (localBudget <= 0) + break; + localBudget--; + ctx.referenceBudget--; + const target = (0, value_flow_1.valueTarget)(ctx, reference, next.role); + if (target?.kind === 'name') + nextFrontier.push(target); + else if (target) + record(found, target); + } + return localBudget; +} +/** Records a call site or handoff in `found`, unless a reference found earlier already led to the same call. */ +function record(found, target) { + if (target.kind === 'call') { + if (!found.calls.some((site) => site.node === target.call.node)) + found.calls.push(target.call); + } + else if (!found.handoffs.some((handoff) => handoff.call === target.handoff.call)) { + found.handoffs.push(target.handoff); + } +} +/** + * The caller's own parameter an argument passes on as is (`items` in + * `getMatchingProducts(productId, items)` inside a function taking `items`), + * when only that parameter's call sites can say what it holds: the checker + * has no type for it, it has no default value and no type of its own, and it + * is not bound to a call being resolved (see ./bindings). + */ +function forwardedParameter(ctx, argument) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(argument) || (0, bindings_1.boundArgument)(ctx, argument)) + return undefined; + const declaration = (0, member_values_1.valueDeclarationOf)(ctx, argument); + if (!declaration || !ts.isParameter(declaration) || declaration.initializer) + return undefined; + if ((0, ast_helpers_1.hasExplicitParameterType)(declaration, ts)) + return undefined; + return (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(argument)).length === 0 ? declaration : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/callback-arguments.js b/packages/b2c-script-types/plugin/inference/callback-arguments.js new file mode 100644 index 000000000..a9d52d6dd --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/callback-arguments.js @@ -0,0 +1,75 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NO_CALLBACK_USES = void 0; +exports.callbackUses = callbackUses; +const ast_helpers_1 = require("./ast-helpers"); +/** A helper that never calls or hands on its callback. */ +exports.NO_CALLBACK_USES = { passed: [], forwarded: [] }; +/** + * True when `call`'s result is assigned back to the variable `argument` + * names: an accumulator (`value = callback(value, item)`), which holds an + * initial value, an element or the callback's own result, by call. Like the + * accumulator of a native `reduce`, it stays unknown. + */ +function isFedBack(ctx, call, argument) { + const { ts, checker } = ctx; + const assignment = call.parent; + const assigns = ts.isBinaryExpression(assignment) && + assignment.right === call && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(assignment.left); + return (assigns && + ts.isIdentifier(argument) && + checker.getSymbolAtLocation(assignment.left) === checker.getSymbolAtLocation(argument)); +} +/** + * How `helper` uses its parameter `callbackIndex`: the argument each call of + * it (`callback(item)`, `callback.call(scope, item)`) passes as parameter + * `paramIndex`, and the calls it is handed on to. Arguments after a spread + * are not known by position and are left out, and so is an accumulator (see + * {@link isFedBack}). + */ +function callbackUses(ctx, helper, callbackIndex, paramIndex) { + const { ts, checker } = ctx; + const parameter = helper.parameters[callbackIndex]; + const body = helper.body; + if (!body || !parameter || parameter.dotDotDotToken || !ts.isIdentifier(parameter.name)) + return exports.NO_CALLBACK_USES; + const { text } = parameter.name; + const symbol = checker.getSymbolAtLocation(parameter.name); + const namesCallback = (node) => ts.isIdentifier(node) && node.text === text && checker.getSymbolAtLocation(node) === symbol; + const passed = []; + const forwarded = []; + const record = (call) => { + const invocation = (0, ast_helpers_1.invocationOf)(call, ts); + if (invocation && namesCallback(invocation.callee)) { + const argument = argumentAt(ts, invocation.args, paramIndex); + if (argument && !isFedBack(ctx, call, argument)) + passed.push(argument); + return; + } + const argIndex = call.arguments.findIndex(namesCallback); + if (argIndex >= 0) + forwarded.push({ call, argIndex }); + }; + const spellsCallback = (0, ast_helpers_1.spellingFilter)(body, [text]); + const visit = (node) => { + if (!spellsCallback(node)) + return; + if (ts.isCallExpression(node)) + record(node); + ts.forEachChild(node, visit); + }; + visit(body); + return { passed, forwarded }; +} +/** The argument at `index`, unless a spread at or before it leaves its position unknown. */ +function argumentAt(ts, args, index) { + const known = args.slice(0, index + 1); + return known.length > index && !known.some((arg) => ts.isSpreadElement(arg)) ? known[index] : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js new file mode 100644 index 000000000..f1013d301 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -0,0 +1,139 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.BUILTIN_VALUE_TYPES = exports.GLOBAL_OBJECT_CLASSES = exports.WEAK_USAGE_MEMBERS = exports.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_USAGE_FORWARDING_HOPS = exports.MAX_INFERENCE_DEPTH = void 0; +// Tunable limits for the usage-inference engine. They exist so a crafted (or +// merely huge) cartridge can't make a single hover/completion do unbounded +// work — every recursive walk and reference search is capped by one of these. +// Grouping them here keeps the "how hard will this try?" knobs in one place. +// How far we chase an undocumented call chain (helper calls helper calls +// helper...) before giving up. Keeps worst-case cost predictable regardless of +// how deep a cartridge's helper stack goes. +exports.MAX_INFERENCE_DEPTH = 3; +// How many functions a parameter's usage is followed into when the body +// passes the parameter on unchanged (`new BooleanAttributeValue(search, +// definition, refinementValue)`): what the receiving parameter's function +// does with it is usage of this one too. Each hop is one walk of a function +// (or of its file, for a value stored on `this`) and no reference search. +exports.MAX_USAGE_FORWARDING_HOPS = 3; +// How many names a function value is followed through (see ./value-flow) +// before collectCallSites() gives up on finding an actual call site. SFRA's +// refinement models need four: the model -> `module.exports` (its require() +// calls) -> the factory returning it -> `var Model = factory(...)` -> the +// `Model` parameter it is passed on to, where `new Model(...)` finally runs. +// Hops into function-local names cost no project-wide search. +exports.MAX_REFERENCE_HOPS = 4; +// Hard cap on how many reference-search hits collectCallSites() will process +// across a single top-level inference request (not just one call site) — +// bounds worst-case cost for a helper referenced from dozens of places, +// complementing MAX_INFERENCE_DEPTH's cap on recursion depth. Generous enough +// to cover realistic cartridge helper usage without being effectively +// unlimited. Note what this does and doesn't bound: it caps how many results +// get processed and how far the search fans out, but a single search still +// checks every occurrence of the name in the files that can refer to it — +// on a large project the dominant cost is that first search, and the real +// bound on it is cancellation: the search polls the host's cancellation +// token between files and throws TypeScript's OperationCanceledException, +// which the plugin's `guarded` wrapper rethrows, never swallows. +exports.MAX_REFERENCES_PER_REQUEST = 200; +// Caps how much of that shared request-wide budget a *single* collectCallSites +// call can spend, so one widely-referenced sub-helper (e.g. reached from the +// first of several sibling return statements or call-site arguments) can't +// exhaust the whole budget and starve the others processed later in the same +// request. +exports.MAX_REFERENCES_PER_CALL = 50; +// How many `.method()` hops resolveExpressionTypes() will chase within a +// single static method-chain expression (e.g. `a.b().c().d()`). This is +// separate from MAX_INFERENCE_DEPTH, which only bounds crossing into another +// undocumented helper's own return-type inference — an in-expression chain +// never crosses a function boundary, so without its own cap it would be +// bounded only by how long an expression a cartridge author (or a generated +// file) happens to write, not by a predictable cost. +exports.MAX_CHAIN_HOPS = 10; +// How many cartridge levels the superModule member walk descends (top overlay +// -> mid overlay -> ... -> base). Real cartridge paths rarely stack more than +// three or four overlays of the same module. +exports.MAX_SUPERMODULE_HOPS = 8; +// Hard cap on how many reference SEARCHES one top-level request may issue +// (one for a name declared inside a function is not counted: it reads that +// function only). This is a different axis from MAX_REFERENCES_PER_REQUEST, +// which only bounds how many search *results* get processed: every search +// costs its reads and checks even when it returns almost nothing, so a +// helper whose call sites feed it results of many DISTINCT sub-helpers (each +// searched once, each contributing only 2-3 results) drains the result +// budget at ~2-3 per search — measured at 76 searches ≈ 115ms for a single +// hover on an SFRA-sized program (~1,900 cartridge files) before this cap +// existed. Legitimate scenarios in the perf baseline suite need at most 6 +// searches; 12 doubles that headroom while keeping the worst case at ~12 +// searches per request. +exports.MAX_SEARCHES_PER_REQUEST = 12; +// Bounds the editor's per-Program cache of finished hovers and completion +// lists during a long session without edits (hours of hovering around one +// Program). Entries are small plain data, so this is belt-and-braces, and a +// wholesale clear is honest: no LRU bookkeeping for a cache this cheap to +// refill. +exports.MAX_DISPLAY_CACHE_ENTRIES = 512; +// Marks the completion entries this plugin synthesizes (as opposed to ones the +// TypeScript language service produced itself), so the editor can tell them +// apart. Purely a label — it carries no path or other data. +exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; +// Ambient usage matching (see ./ambient-index): a usage signature with fewer +// distinct member names than this is only trusted when exactly one ambient +// class declares it (`.addresses` is AddressBook's alone) or when the +// identifier name picks one of the matches (`customer` + `.profile`). +exports.MIN_USAGE_SIGNATURE_MEMBERS = 2; +// Most distinct types a hover or completion list shows as a union, IntelliJ +// style (`Product | Order`). More than this collapses to the candidates' +// closest shared ancestor class when one fits, and otherwise stays silent: a +// longer union is noise rather than a hint. +exports.MAX_UNION_TYPES = 3; +// Root classes nearly every Script API class inherits from. Offering them as +// the "common ancestor" of unrelated candidates tells the reader nothing. +exports.UNINFORMATIVE_ANCESTORS = new Set(['Object', 'ExtensibleObject', 'PersistentObject']); +// Member names so common across dw.* that they barely discriminate a class +// on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). +// They still narrow an ambient match, but a signature made only of these +// never picks one class out of several. +exports.WEAK_USAGE_MEMBERS = new Set(['custom', 'UUID', 'toString', 'valueOf']); +// Script API TopLevel classes that describe one global object (`module`, +// `arguments`) rather than a kind of value code passes around. Usage matching +// skips them: `regionDefinition.id` is not a Module. +exports.GLOBAL_OBJECT_CLASSES = new Set(['Module', 'arguments']); +// JavaScript built-ins whose members ordinary code uses all the time. A usage +// signature one of them also satisfies (`msg.replace(...)`, `x.length`) is +// ambiguous: the value may well be a string or an array, so ambient matching +// stays silent rather than naming the Script API class that happens to fit. +exports.BUILTIN_VALUE_TYPES = [ + 'String', + 'Number', + 'Boolean', + 'Array', + 'Function', + 'Date', + 'RegExp', +]; +// Callee names whose callbacks lead with the collection element +// (`collections.forEach(coll, function (item) {...})`). A project helper's +// callbacks are typed from what its body passes them; this list only covers +// helpers whose body can't be read, and for those only these names get the +// sibling-collection element-type heuristic. `reduce` (accumulator first) +// and other unknown helpers stay out. +exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ + 'forEach', + 'map', + 'filter', + 'every', + 'some', + // SFRA `collections.find(coll, function (item) {...})` — same element-first + // shape; used heavily for address-book and line-item lookups. + 'find', + // Stock SFRA `collections.first` takes only the collection, but several + // storefronts (and common calculate.js ports) call it with a predicate + // the same shape as `find`. Treat that second-arg callback as element-first + // when present so the predicate parameter still gets a type. + 'first', +]); diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js new file mode 100644 index 000000000..de1f5bd25 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -0,0 +1,103 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createInferenceContext = createInferenceContext; +exports.contextForProgram = contextForProgram; +exports.withCycleGuard = withCycleGuard; +exports.withInferenceGuards = withInferenceGuards; +const bindings_1 = require("./bindings"); +const constants_1 = require("./constants"); +/** + * Builds a fresh inference context for one top-level hover/completion + * request, or `undefined` if the language service has no program yet. + */ +function createInferenceContext(ts, languageService, host = {}, triggerPosition) { + const program = languageService.getProgram(); + return program && contextForProgram(ts, program, host, triggerPosition); +} +/** + * Like {@link createInferenceContext}, for a caller that already holds the + * request's Program: asking the language service for it again brings it up + * to date with the host again, which reads the version of every file. + */ +function contextForProgram(ts, program, host = {}, triggerPosition) { + return { + ts, + program, + checker: program.getTypeChecker(), + visiting: new Set(), + memo: new Map(), + referenceBudget: constants_1.MAX_REFERENCES_PER_REQUEST, + searchBudget: constants_1.MAX_SEARCHES_PER_REQUEST, + callSiteMemo: new Map(), + typeDisplayStrings: new Map(), + profiles: new Map(), + cycleHits: 0, + bindings: bindings_1.NO_BINDINGS, + host, + triggerPosition, + }; +} +/** + * Runs `compute` with `node` marked as in progress, so a cycle that leads + * back to the same node (`var a = b; var b = a;`, or two helpers returning + * each other's result) gets `fallback` instead of recursing forever. Each hit + * is counted in ctx.cycleHits; see {@link withInferenceGuards}. + */ +function withCycleGuard(ctx, node, fallback, compute) { + if (ctx.visiting.has(node)) { + ctx.cycleHits++; + return fallback; + } + ctx.visiting.add(node); + try { + return compute(); + } + finally { + ctx.visiting.delete(node); + } +} +/** + * The shared preamble for every memoized inference entry point: serve a memo + * hit, enforce MAX_INFERENCE_DEPTH, break cycles, and memoize the result. + * + * The memo is consulted before the depth cap: a result computed at an equal + * or shallower depth had at least as much budget as this call would, so it is + * reusable however deep the current path is. A result whose computation hit + * a cycle guard is not memoized: it was cut short by what happened to be on + * the current call stack, and the same node reached later from outside the + * cycle could resolve more. While call-specific bindings are in force + * nothing is memoized, since a result for one call's arguments is not the + * node's general result; and inside a bound function the memo is not read + * either, since its general result is what the bindings are there to + * improve on. Anywhere else the general result still holds. + */ +function withInferenceGuards(ctx, node, depth, compute) { + const unbound = ctx.bindings.size === 0; + const cached = unbound || !isInBoundFunction(ctx, node) ? ctx.memo.get(node) : undefined; + if (cached && cached.atDepth <= depth) + return cached.types; + if (depth > constants_1.MAX_INFERENCE_DEPTH) + return []; + return withCycleGuard(ctx, node, [], () => { + const cycleHitsBefore = ctx.cycleHits; + const types = compute(); + if (unbound && ctx.cycleHits === cycleHitsBefore) + ctx.memo.set(node, { atDepth: depth, types }); + return types; + }); +} +/** True when `node` lies inside a function whose parameters are bound to one call's arguments. */ +function isInBoundFunction(ctx, node) { + const file = node.getSourceFile(); + for (const parameter of ctx.bindings.keys()) { + const fn = parameter.parent; + if (fn.getSourceFile() === file && node.pos >= fn.pos && node.end <= fn.end) + return true; + } + return false; +} diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js new file mode 100644 index 000000000..97df80cd9 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -0,0 +1,475 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.inferParameterType = inferParameterType; +exports.inferReturnType = inferReturnType; +exports.inferTypeForNode = inferTypeForNode; +exports.inferTypeForExpression = inferTypeForExpression; +const constants_1 = require("./constants"); +const context_1 = require("./context"); +const ast_helpers_1 = require("./ast-helpers"); +const bindings_1 = require("./bindings"); +const callback_arguments_1 = require("./callback-arguments"); +const call_sites_1 = require("./call-sites"); +const value_flow_1 = require("./value-flow"); +const framework_contracts_1 = require("./framework-contracts"); +const generic_calls_1 = require("./generic-calls"); +const hook_calls_1 = require("./hook-calls"); +const member_values_1 = require("./member-values"); +const policy_1 = require("./policy"); +const signatures_1 = require("./signatures"); +const super_module_1 = require("./super-module"); +const type_helpers_1 = require("./type-helpers"); +const usage_profile_1 = require("./usage-profile"); +/** + * Everything a local variable can hold: its initializer plus every later + * `x = value` assignment (`var result = null; if (a) result = x; else result + * = y;`), decided as one candidate set. Covers the idiomatic SFCC style of + * splitting a chain across intermediate variables (`var priceModel = + * product.getPriceModel(); return priceModel.getPrice();`), which would + * otherwise dead-end at the variable even though the inline expression + * resolves fine. An explicit annotation means the variable's `any` is + * deliberate (same rule as parameters and returns). Following a variable + * never crosses a function boundary, so it is charged to `chainHops`, not to + * the recursion depth. + */ +function resolveVariableTypes(ctx, decl, depth, chainHops) { + if ((0, ast_helpers_1.hasExplicitVariableType)(decl, ctx.ts)) + return []; + return (0, context_1.withCycleGuard)(ctx, decl, [], () => { + const profile = (0, usage_profile_1.usageProfileOf)(ctx, decl); + const initial = decl.initializer + ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) + : []; + const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; + return (0, policy_1.decideType)(ctx, evidence, profile, decl.name, false); + }); +} +/** A variable's initializer; an array literal later filled by `items.push(x)` is an array of what it holds. */ +function resolveInitializerTypes(ctx, initializer, pushedValues, depth, chainHops) { + if (pushedValues.length === 0 || !ctx.ts.isArrayLiteralExpression(initializer)) { + return resolveExpressionTypes(ctx, initializer, depth, chainHops); + } + return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); +} +/** Resolves values one hop further along the expression `depth` and `chainHops` describe. */ +function nextHop(ctx, depth, chainHops) { + return (value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1); +} +/** The types of several values the same thing can hold, each one hop further along the expression. */ +function resolveValues(ctx, values, depth, chainHops) { + return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); +} +/** + * Infers a callback parameter's type from the call the callback is passed + * to. A method on a known receiver declares it (`value: T` of + * `items.filter(function (item) {...})` once `items` is inferred as a + * `ProductLineItem[]`). A project helper says it by what it passes the + * callback (see {@link invokedCallbackTypes}). A helper that can't be read, + * when it is an element-first one by name (`collections.forEach(coll, fn)`, + * see {@link isElementFirstCallbackCall}), hands the first parameter an + * element of the collection travelling alongside it. Other unknown helpers + * are left alone: guessing their shape would be wrong more often than not. + */ +function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { + const call = fn.parent; + if (!call || !ctx.ts.isCallExpression(call)) + return []; + const argIndex = call.arguments.indexOf(fn); + const access = call.expression; + const declared = ctx.ts.isPropertyAccessExpression(access) + ? (0, signatures_1.callbackParameterTypes)(ctx, memberTypesOfReceiver(ctx, access.expression, access.name, depth, 0), call, argIndex, paramIndex) + : []; + if (declared.length > 0) + return declared; + const invoked = invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth); + if (invoked.length > 0 || paramIndex !== 0 || !(0, signatures_1.isElementFirstCallbackCall)(ctx, call)) + return invoked; + return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); +} +/** + * What the project helper `call` invokes passes parameter `paramIndex` of + * the callback it receives as argument `argIndex`, resolved with the + * helper's parameters bound to this call's arguments: an element of the + * collection this call passes, for `collections.reduce(lineItems, function + * (total, lineItem) {...})` as for a project's own `eachShipment(basket, + * fn)`. A helper handing the callback on to another one is followed into + * that one, a level deeper. + */ +function invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth) { + const helper = depth < constants_1.MAX_INFERENCE_DEPTH ? (0, signatures_1.resolveCalleeDeclaration)(ctx, call) : undefined; + const { passed, forwarded } = helper ? (0, callback_arguments_1.callbackUses)(ctx, helper, argIndex, paramIndex) : callback_arguments_1.NO_CALLBACK_USES; + if (!helper || (passed.length === 0 && forwarded.length === 0)) + return []; + const bindings = (0, bindings_1.argumentBindings)(ctx, helper, call.arguments, depth) ?? ctx.bindings; + return (0, bindings_1.withBindings)(ctx, bindings, () => [ + ...passed.flatMap((value) => resolveExpressionTypes(ctx, value, depth + 1)), + ...forwarded.flatMap((use) => invokedCallbackTypes(ctx, use.call, use.argIndex, paramIndex, depth + 1)), + ]); +} +/** + * The checker's type of `expr`. Inside a class or a constructor function the + * checker types `this` as an open `this` type; what it holds is the instance + * that type stands for (`helper(this)` in `function Basket() {…}` passes a + * `Basket`). + */ +function checkerTypeOf(ctx, expr) { + const { ts, checker } = ctx; + const type = checker.getTypeAtLocation(expr); + if (expr.kind !== ts.SyntaxKind.ThisKeyword || !(type.flags & ts.TypeFlags.TypeParameter)) + return type; + return checker.getBaseConstraintOfType(type) ?? type; +} +/** + * Resolves the candidate type(s) of `expr`: the checker's own type when it is + * informative, otherwise a type recovered through whatever `expr` is built + * from (an undocumented callee's returns, a receiver's real members, a + * variable's values, a parameter's call sites). + * + * @param chainHops - how many `.method()`/`.prop` hops within the *same* + * static expression have already been chased (e.g. the `2` in + * `a.b().c().d()` when resolving `d`'s receiver `a.b().c()`). This is + * distinct from `depth`, which only advances when crossing into another + * undocumented helper's own inference — chain-hopping never crosses a + * function boundary, so it needs its own bound (`MAX_CHAIN_HOPS`) to keep the + * cost of a very long inline chain predictable. + * @returns An array (rather than a single unioned Type) because the public + * TypeChecker API has no way to synthesize a union Type. + */ +function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { + const { ts, checker } = ctx; + // module.superModule (or a `var base = module.superModule` alias) first: + // TS knows nothing about SFCC overlay semantics, and its type for these + // expressions is never meaningful — sometimes `any`, sometimes an opaque + // circular `typeof base` that would pass for informative. + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); + if (superAccess) + return (0, super_module_1.resolveSuperModuleTypes)(ctx, superAccess, nextHop(ctx, depth, chainHops)); + const direct = (0, type_helpers_1.informativeParts)(ctx, checkerTypeOf(ctx, expr)); + const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; + if (bound) + return bound; + if (direct.length > 0) + return direct; + return chainHops < constants_1.MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; +} +/** + * While a call's arguments are bound (see ./bindings): a bound parameter is + * what its argument is, and an expression the checker types as an `any` + * instantiation (`collection.iterator()` on a bare `{dw.util.Collection}`) + * is recovered through its parts, which may reach a bound parameter. Either + * stands in for the checker's type only where it narrows it. A bound + * parameter whose argument says nothing keeps its general type, resolved + * without the bindings (and so served from the memo). + */ +function resolveBoundTypes(ctx, expr, declared, depth, chainHops) { + const binding = (0, bindings_1.boundArgument)(ctx, expr); + if (binding) { + const types = argumentTypes(ctx, binding); + if ((0, bindings_1.narrows)(ctx, declared, types)) + return types; + return (0, bindings_1.withBindings)(ctx, bindings_1.NO_BINDINGS, () => resolveExpressionTypes(ctx, expr, depth, chainHops)); + } + if (chainHops >= constants_1.MAX_CHAIN_HOPS || !declared.some((type) => (0, bindings_1.hasAnyTypeArgument)(ctx, type))) + return undefined; + const narrowed = resolveFromParts(ctx, expr, depth, chainHops); + return (0, bindings_1.narrows)(ctx, declared, narrowed) ? narrowed : undefined; +} +/** What a bound argument evaluates to where its call is written, resolved once per binding. */ +function argumentTypes(ctx, binding) { + binding.resolved ?? (binding.resolved = (0, bindings_1.withBindings)(ctx, binding.outer, () => (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, resolveExpressionTypes(ctx, binding.argument, binding.depth))))); + return binding.resolved; +} +/** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ +function resolveFromParts(ctx, expr, depth, chainHops) { + const { ts } = ctx; + if (ts.isCallExpression(expr)) + return resolveCallResultTypes(ctx, expr, depth, chainHops); + if (ts.isPropertyAccessExpression(expr)) + return resolvePropertyTypes(ctx, expr, depth, chainHops); + if (ts.isIdentifier(expr)) + return resolveIdentifierTypes(ctx, expr, depth, chainHops); + if (ts.isParenthesizedExpression(expr)) + return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + if (ts.isElementAccessExpression(expr)) + return resolveElementAccessTypes(ctx, expr, depth, chainHops); + if (ts.isArrayLiteralExpression(expr)) + return resolveArrayTypes(ctx, expr.elements, depth, chainHops); + // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) + // and `a || b` / `a ?? b` can each evaluate to either side. + if (ts.isConditionalExpression(expr)) { + return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); + } + return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; +} +/** The element types of whatever arrays or collections `collection` holds. */ +function resolveElementTypes(ctx, collection, depth, chainHops) { + return resolveExpressionTypes(ctx, collection, depth, chainHops + 1).flatMap((type) => (0, type_helpers_1.elementTypeOf)(ctx, type, collection) ?? []); +} +/** `x[i]` is an element of `x` (a Script API collection has no index signature to say so); `x['m']` reads member `m`. */ +function resolveElementAccessTypes(ctx, expr, depth, chainHops) { + const index = expr.argumentExpression; + if (ctx.ts.isStringLiteralLike(index)) { + return (0, type_helpers_1.dedupeKnownTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + } + return (0, type_helpers_1.dedupeTypes)(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); +} +/** `[a, ...rest]`: an array of what its elements are, decided like any other choice of types (see ./policy). */ +function resolveArrayTypes(ctx, elements, depth, chainHops) { + const { ts } = ctx; + const elementTypes = elements.flatMap((element) => ts.isSpreadElement(element) + ? resolveElementTypes(ctx, element.expression, depth, chainHops) + : resolveExpressionTypes(ctx, element, depth, chainHops + 1)); + const array = (0, type_helpers_1.arrayTypeOf)(ctx, (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, elementTypes))); + return array ? [array] : []; +} +function resolveAlternatives(ctx, alternatives, depth, chainHops) { + return (0, type_helpers_1.dedupeTypes)(ctx, alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1))); +} +/** `a || b` and `a ?? b` evaluate to either side; `a && b` to `b` when it's worth showing at all. */ +function resolveLogicalTypes(ctx, expr, depth, chainHops) { + const { SyntaxKind } = ctx.ts; + switch (expr.operatorToken.kind) { + case SyntaxKind.BarBarToken: + case SyntaxKind.QuestionQuestionToken: + return resolveAlternatives(ctx, [expr.left, expr.right], depth, chainHops); + case SyntaxKind.AmpersandAmpersandToken: + return resolveExpressionTypes(ctx, expr.right, depth, chainHops + 1); + default: + return []; + } +} +/** + * The types of member `name` on each candidate type of `receiver` + * (`x.getPriceModel` when `x` is undocumented but inferable). When no + * receiver type carries the member and the receiver is (an alias of) + * `module.superModule`, the member may be an export augmentation added by a + * pass-through overlay level, which no export type can carry. A member the + * checker binds on a receiver it types as nothing worth showing (`this` in a + * prototype method) is the member it binds; on an untyped local receiver, it + * is whatever the code writes to it (`productData.apiProduct = ...`). + */ +function memberTypesOfReceiver(ctx, receiver, name, depth, chainHops) { + const { ts, checker } = ctx; + const memberName = name.text; + const types = resolveExpressionTypes(ctx, receiver, depth, chainHops + 1).flatMap((receiverType) => { + const member = (0, type_helpers_1.getMemberOfType)(checker, receiverType, memberName); + return member ? memberTypes(ctx, member, name, depth, chainHops) : []; + }); + if (types.length > 0) + return types; + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, receiver); + if (superAccess) { + return (0, super_module_1.resolveSuperModuleMemberTypes)(ctx, superAccess, memberName, nextHop(ctx, depth, chainHops)); + } + const bound = checker.getSymbolAtLocation(name); + if (bound) + return memberTypes(ctx, bound, name, depth, chainHops); + return resolveValues(ctx, (0, member_values_1.localMemberValues)(ctx, receiver, memberName), depth, chainHops); +} +/** + * A member's declared type, or — when that says nothing, as for most + * undocumented members (`{apiProduct: apiProduct}`, `this.productSearch = + * productSearch`) — the types of the values it is declared with. + */ +function memberTypes(ctx, member, location, depth, chainHops) { + const declared = ctx.checker.getTypeOfSymbolAtLocation(member, location); + const declaration = member.valueDeclaration ?? member.declarations?.[0]; + if ((0, type_helpers_1.informativeParts)(ctx, declared).length > 0 || !declaration) + return [declared]; + return (0, context_1.withCycleGuard)(ctx, declaration, [], () => resolveValues(ctx, (0, member_values_1.memberValueExpressions)(ctx, member), depth, chainHops)); +} +/** + * The return types of `methodType`'s call signatures. A signature returning + * `any` (an undocumented function reached through a superModule export type) + * is inferred from its own declaration instead. + */ +function signatureReturnTypes(ctx, methodType, depth) { + const { ts, checker } = ctx; + return methodType.getCallSignatures().flatMap((signature) => { + const returnType = checker.getReturnTypeOfSignature(signature); + if (!(0, type_helpers_1.isAnyType)(ts, returnType)) + return [returnType]; + const declaration = signature.declaration; + return declaration && ts.isFunctionLike(declaration) ? inferReturnType(ctx, declaration, depth + 1) : []; + }); +} +/** + * What `fn` returns for this `call`. Its return across all callers, unless + * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a + * helper handed either collection): then what it returns for the arguments + * this call passes it (see ./bindings), when that says anything. A choice + * across callers is the union of what each of them gets, and this call is + * one of them, so its own answer is the more precise one, also where the + * union missed it (a caller the depth or reference budget left out). + */ +function calleeReturnTypes(ctx, fn, call, depth) { + const general = inferReturnType(ctx, fn, depth + 1); + if (general.length === 1 || (0, ast_helpers_1.hasExplicitReturnType)(fn, ctx.ts)) + return general; + const bindings = (0, bindings_1.argumentBindings)(ctx, fn, call.arguments, depth); + const specific = bindings ? (0, bindings_1.withBindings)(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; + return specific.length > 0 ? specific : general; +} +/** + * What a call of a bound parameter returns (`callback(item)` inside + * `collections.map`, inferred for one call): what the function the call + * being resolved passes returns, with its parameters bound to these + * arguments. + */ +function boundCallbackReturnTypes(ctx, { fn, args }, depth) { + const bindings = (0, bindings_1.argumentBindings)(ctx, fn, args, depth) ?? ctx.bindings; + return (0, bindings_1.withBindings)(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)); +} +/** + * Resolves an `any` call expression: first by inferring the callee's own + * return type, then — for a method call on an undocumented receiver + * (`x.getPriceModel().getPrice()`) — from the method's real signature(s) on + * the receiver's inferred type(s). + */ +function resolveCallResultTypes(ctx, expr, depth, chainHops) { + const callback = ctx.bindings.size > 0 ? (0, bindings_1.boundCallback)(ctx, expr) : undefined; + if (callback) + return boundCallbackReturnTypes(ctx, callback, depth); + const calleeFn = (0, signatures_1.resolveCalleeDeclaration)(ctx, expr); + const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; + if (inferred.length > 0) + return inferred; + const hookResults = (0, hook_calls_1.hookImplementations)(ctx, expr).flatMap((hook) => inferReturnType(ctx, hook, depth + 1)); + if (hookResults.length > 0) + return (0, type_helpers_1.dedupeTypes)(ctx, hookResults); + const generic = (0, generic_calls_1.genericResultSource)(ctx, expr); + if (generic) { + return generic.kind === 'return' + ? inferReturnType(ctx, generic.fn, depth + 1) + : resolveExpressionTypes(ctx, generic.argument, depth, chainHops + 1); + } + const callee = expr.expression; + if (!ctx.ts.isPropertyAccessExpression(callee)) + return []; + return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, callee.expression, callee.name, depth, chainHops).flatMap((methodType) => signatureReturnTypes(ctx, methodType, depth))); +} +/** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ +function resolvePropertyTypes(ctx, expr, depth, chainHops) { + return (0, type_helpers_1.dedupeKnownTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); +} +/** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ +function resolveIdentifierTypes(ctx, expr, depth, chainHops) { + const { ts } = ctx; + const decl = (0, member_values_1.valueDeclarationOf)(ctx, expr); + // Only a search of another function's call sites costs depth: an anonymous + // callback's parameter is read off the call it is passed to, in this body. + if (decl && ts.isParameter(decl)) { + return inferParameterType(ctx, decl, (0, value_flow_1.holderOf)(ctx, decl.parent) ? depth + 1 : depth); + } + if (decl && ts.isVariableDeclaration(decl)) + return resolveVariableTypes(ctx, decl, depth, chainHops + 1); + return []; +} +/** + * The argument types a parameter receives: at every call site of its + * function across the project (`helper(x)`, `new Helper(x)`, + * `Helper.call(this, x)`, and for a hook script's export, the + * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it + * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an + * anonymous callback with no name to search for — from the collection it + * iterates. An anonymous function a factory returns is called wherever the + * factory's calls take it (`server.append('Show', responseHandler(map))`). + */ +function parameterEvidence(ctx, fn, paramIndex, depth) { + const holder = (0, value_flow_1.holderOf)(ctx, fn); + if (!holder) + return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); + const { calls, handoffs } = (0, call_sites_1.collectCallSites)(ctx, holder); + const passed = [...calls, ...(0, hook_calls_1.hookCallSites)(ctx, fn)].flatMap((site) => { + const arg = site.args[paramIndex]; + return arg ? argumentEvidence(ctx, arg, depth) : []; + }); + return [...passed, ...handoffs.flatMap((handoff) => (0, signatures_1.handedOffParameterTypes)(ctx, handoff, paramIndex))]; +} +/** + * The types one call-site argument passes. A caller's own untyped parameter + * passed on as is (see {@link forwardedParameter}) is the same value, so its + * call sites are searched at the same depth: a chain of helpers handing a + * value down (`addProductToCart` -> `getExistingProductLineItemInCart` -> + * `getExistingProductLineItemsInCart` -> `getMatchingProducts`) costs one + * level, not one per helper. The search budget still bounds the chain. + */ +function argumentEvidence(ctx, arg, depth) { + const forwarded = (0, call_sites_1.forwardedParameter)(ctx, arg); + return forwarded ? inferParameterType(ctx, forwarded, depth) : resolveExpressionTypes(ctx, arg, depth); +} +/** + * Infers a parameter's type(s): from a platform contract when the function + * is one the platform calls (see ./framework-contracts), otherwise from the + * arguments it receives and its own usage (see ./policy for how the two are + * weighed). Plain un-annotated JS parameters default to `any` with no + * back-inference from call sites, which is the gap this fills. + * + * @param depth - Recursion budget already consumed by the call chain that + * led here; defaults to 0 for a top-level request. + */ +function inferParameterType(ctx, param, depth = 0) { + const { ts } = ctx; + const fn = param.parent; + if ((0, ast_helpers_1.hasExplicitParameterType)(param, ts) || !ts.isFunctionLike(fn)) + return []; + return (0, context_1.withInferenceGuards)(ctx, param, depth, () => { + const paramIndex = fn.parameters.indexOf(param); + const contract = (0, framework_contracts_1.frameworkParameterTypes)(ctx, fn, paramIndex); + if (contract.length > 0) + return contract; + const evidence = parameterEvidence(ctx, fn, paramIndex, depth); + return (0, policy_1.decideType)(ctx, evidence, (0, usage_profile_1.usageProfileOf)(ctx, param), param.name, true); + }); +} +/** + * Infers a function's return type(s) from its own return statements, + * chasing into undocumented callees when a return expression itself + * resolves to `any`. + * + * @param depth - Recursion budget already consumed by the call chain that + * led here; defaults to 0 for a top-level request. + */ +function inferReturnType(ctx, fn, depth = 0) { + if ((0, ast_helpers_1.hasExplicitReturnType)(fn, ctx.ts)) + return []; + return (0, context_1.withInferenceGuards)(ctx, fn, depth, () => { + const types = (0, ast_helpers_1.collectReturnExpressions)(fn, ctx.ts).flatMap((expr) => resolveExpressionTypes(ctx, expr, depth)); + return (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, types)); + }); +} +/** + * Entry point for hover and completions on an identifier: infers the type of + * the parameter or variable it names, or the return type of the function it + * names. + */ +function inferTypeForNode(ctx, node) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(node)) + return []; + const decl = checker.getSymbolAtLocation(node)?.valueDeclaration; + if (!decl) + return []; + if (ts.isParameter(decl)) + return inferParameterType(ctx, decl); + if (ts.isVariableDeclaration(decl)) + return resolveVariableTypes(ctx, decl, 0, 0); + if (ts.isFunctionLike(decl)) + return inferReturnType(ctx, decl); + return []; +} +/** + * Like {@link inferTypeForNode}, but for an arbitrary expression — the + * completion case `product.getPriceModel().|` and member hovers, where the + * thing to type is a call or chain rather than a declared name. + */ +function inferTypeForExpression(ctx, expr) { + if (ctx.ts.isIdentifier(expr)) + return inferTypeForNode(ctx, expr); + return (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, resolveExpressionTypes(ctx, expr, 0))); +} diff --git a/packages/b2c-script-types/plugin/inference/editor-hooks.js b/packages/b2c-script-types/plugin/inference/editor-hooks.js new file mode 100644 index 000000000..364b92689 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/editor-hooks.js @@ -0,0 +1,226 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createUsageInferenceHooks = createUsageInferenceHooks; +const ast_helpers_1 = require("./ast-helpers"); +const constants_1 = require("./constants"); +const context_1 = require("./context"); +const core_1 = require("./core"); +const super_module_1 = require("./super-module"); +const type_helpers_1 = require("./type-helpers"); +function createDisplayCache() { + let byProgram = new WeakMap(); + return { + get(key, program, compute) { + const entries = byProgram.get(program) ?? new Map(); + byProgram.set(program, entries); + if (entries.has(key)) + return entries.get(key); + const value = compute(); + if (entries.size >= constants_1.MAX_DISPLAY_CACHE_ENTRIES) + entries.clear(); + entries.set(key, value); + return value; + }, + clear() { + byProgram = new WeakMap(); + }, + }; +} +/** + * Runs the inference augmentation and degrades to `fallback` (the untouched + * underlying result) if it throws, so a bug here can't take the whole + * tsserver request down. Deliberately wraps only the augmentation, never the + * underlying language-service call: an exception from TypeScript itself must + * reach tsserver's own error reporting exactly as it would without the + * plugin. `OperationCanceledException` always propagates: TS throws it + * cooperatively when the host cancels a request (the user kept typing), and + * tsserver handles a cancellation very differently from an empty response. + */ +function guarded(options, label, fallback, compute) { + try { + return compute(); + } + catch (error) { + if (error instanceof options.ts.OperationCanceledException) + throw error; + options.log(`usage-inference ${label} failed: ${error.message}`); + return fallback; + } +} +function openFile(env, fileName) { + const program = env.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + return program && sourceFile ? { program, sourceFile, checker: program.getTypeChecker() } : undefined; +} +/** + * True when the checker's own type for `expr` leaves room for inference: an + * open type (see isOpenForUsageInference), or a value derived from + * `module.superModule`, whose checker type is never meaningful (sometimes + * `any`, sometimes an opaque circular `typeof base`). + */ +function isInferenceTarget(ts, checker, expr) { + return (0, type_helpers_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(expr)) || !!(0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); +} +/** + * The declaration a member-name hover documents: the member every inferred + * receiver type resolves the name to. Receivers that resolve it to different + * declarations (an override, or unrelated classes) document nothing rather + * than an arbitrary one of them. + */ +function memberSymbol(ctx, access) { + const symbols = new Set((0, core_1.inferTypeForExpression)(ctx, access.expression).map((type) => (0, type_helpers_1.getMemberOfType)(ctx.checker, type, access.name.text))); + const [symbol] = symbols; + return symbols.size === 1 ? symbol : undefined; +} +/** The class a hover documents when inference resolved exactly one; a union has no single doc comment. */ +function soleTypeSymbol(types) { + return types.length === 1 ? types[0].getSymbol() : undefined; +} +function documentationOf(checker, symbol) { + const documentation = symbol?.getDocumentationComment(checker) ?? []; + const tags = symbol?.getJsDocTags(checker) ?? []; + return { + documentation: documentation.length > 0 ? documentation : undefined, + tags: tags.length > 0 ? tags : undefined, + }; +} +/** + * Infers what a hover on `node` shows. A member name (`productLineItems` in + * `shipment.productLineItems`) has no declaration of its own until the + * receiver's type is known, so the whole access is resolved instead. + * Documentation comes from the member's declaration, or from the inferred + * class when there is exactly one. + */ +function inferHover(env, file, node) { + const ctx = (0, context_1.contextForProgram)(env.ts, file.program, env); + const access = (0, ast_helpers_1.propertyAccessNamedBy)(node, env.ts); + const types = access ? (0, core_1.inferTypeForExpression)(ctx, access) : (0, core_1.inferTypeForNode)(ctx, node); + if (types.length === 0) + return undefined; + const symbol = access ? memberSymbol(ctx, access) : soleTypeSymbol(types); + return { description: (0, type_helpers_1.describeTypes)(ctx, types), ...documentationOf(ctx.checker, symbol) }; +} +// Header type texts TypeScript renders for a value it could not type: `any`, +// the `object` keyword, and the bare `Object` interface. +const OPEN_TYPE_TEXTS = new Set(['any', 'object', 'Object']); +/** How many trailing display parts spell an open type: `any` / `object` / `Object`, or the two-part `{}`. */ +function openTypeTailLength(parts) { + const last = parts[parts.length - 1]; + if (!last) + return 0; + if (OPEN_TYPE_TEXTS.has(last.text) && last.kind !== 'text') + return 1; + return last.text === '}' && parts[parts.length - 2]?.text === '{' ? 2 : 0; +} +/** + * Swaps the open type at the end of a hover header (`(parameter) shipment: + * any`) for the inferred description, so the bold header reads + * `(parameter) shipment: Shipment`. Any other header shape is left as it is + * rather than guessed at. + */ +function replaceOpenTypeTail(parts, description) { + const tail = openTypeTailLength(parts ?? []); + if (!parts || tail === 0) + return parts; + return [...parts.slice(0, -tail), { kind: 'text', text: description }]; +} +function withHoverInference(original, inferred) { + const note = { kind: 'text', text: `\n\nInferred from usage: ${inferred.description}` }; + return { + ...original, + displayParts: replaceOpenTypeTail(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags ? [...inferred.tags] : original.tags, + }; +} +function decorateQuickInfo(env, cache, fileName, position, original) { + const { ts } = env; + const file = openFile(env, fileName); + const node = file && (0, ast_helpers_1.getNodeAtPosition)(file.sourceFile, ts, position); + if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) + return original; + const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; + const inferred = cache.get(key, file.program, () => inferHover(env, file, node)); + return inferred ? withHoverInference(original, inferred) : original; +} +/** + * Member entries for `receiver`: the members of its inferred types, plus the + * ones pass-through superModule overlay levels add + * (`module.exports = base; module.exports.extra = fn;`), which no candidate + * type can carry. + */ +function inferMemberEntries(env, file, receiver, position) { + const ctx = (0, context_1.contextForProgram)(env.ts, file.program, env, position); + const augmented = (0, super_module_1.collectSuperModuleAugmentedMembers)(ctx, receiver).map((member) => (0, type_helpers_1.inferredCompletionEntry)(env.ts, member.name, member.isMethod)); + return [...(0, type_helpers_1.typesToCompletionEntries)(env.ts, ctx.checker, (0, core_1.inferTypeForExpression)(ctx, receiver)), ...augmented]; +} +/** + * Appends the inferred entries TypeScript did not already offer (first one + * per name wins), preserving every other field of its result (isIncomplete, + * optionalReplacementSpan, metadata, ...). A fresh member CompletionInfo is + * synthesized only when TypeScript returned nothing at all. + */ +function mergeCompletions(original, inferred) { + const seen = new Set((original?.entries ?? []).map((entry) => entry.name)); + const added = []; + for (const entry of inferred) { + if (seen.has(entry.name)) + continue; + seen.add(entry.name); + added.push(entry); + } + if (added.length === 0) + return original; + if (original) + return { ...original, entries: [...original.entries, ...added] }; + return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, entries: added }; +} +function decorateCompletions(env, cache, fileName, position, original) { + const file = openFile(env, fileName); + const access = file && (0, ast_helpers_1.memberCompletionAccess)(file.sourceFile, env.ts, position); + if (!file || !access || !isInferenceTarget(env.ts, file.checker, access.expression)) + return original; + // The receiver can be any expression (`product.getPriceModel().|`), and + // nested receivers share a start (`a` and `a.b`), so the key spans it. + const receiver = access.expression; + const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; + return mergeCompletions(original, cache.get(key, file.program, () => inferMemberEntries(env, file, receiver, position))); +} +/** + * Creates the hover and completion decorators for one language service. Each + * request builds one inference context; finished results are cached per + * Program, and `reset()` drops them (the plugin calls it when its + * configuration changes). The host's hook registrations are read once per + * Program too: hooks.json is no part of the Program, so the next edit is + * what picks up a change to it. + */ +function createUsageInferenceHooks(host) { + const hoverCache = createDisplayCache(); + const completionCache = createDisplayCache(); + const registrationCache = createDisplayCache(); + const readRegistrations = host.hookRegistrations; + const options = { + ...host, + hookRegistrations: readRegistrations && ((program) => registrationCache.get('hooks', program, () => readRegistrations(program))), + }; + return { + decorateQuickInfo(fileName, position, original) { + if (!original) + return original; + return guarded(options, 'hover', original, () => decorateQuickInfo(options, hoverCache, fileName, position, original)); + }, + decorateCompletions(fileName, position, original) { + return guarded(options, 'completions', original, () => decorateCompletions(options, completionCache, fileName, position, original)); + }, + reset() { + hoverCache.clear(); + completionCache.clear(); + registrationCache.clear(); + }, + }; +} diff --git a/packages/b2c-script-types/plugin/inference/framework-contracts.js b/packages/b2c-script-types/plugin/inference/framework-contracts.js new file mode 100644 index 000000000..c92138b84 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/framework-contracts.js @@ -0,0 +1,33 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.frameworkParameterTypes = frameworkParameterTypes; +const ambient_index_1 = require("./ambient-index"); +const value_flow_1 = require("./value-flow"); +const FRAMEWORK_CONTRACTS = [ + { + directory: '/cartridge/experience/components/', + functionName: 'render', + parameterTypes: ['ComponentScriptContext', 'Map'], + }, + { + directory: '/cartridge/experience/pages/', + functionName: 'render', + parameterTypes: ['PageScriptContext', 'Map'], + }, +]; +/** The contract-defined type of `fn`'s parameter at `paramIndex`, if `fn` is a platform entry point. */ +function frameworkParameterTypes(ctx, fn, paramIndex) { + const functionName = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts)?.text; + if (!functionName) + return []; + const fileName = fn.getSourceFile().fileName.replace(/\\/g, '/'); + const contract = FRAMEWORK_CONTRACTS.find((candidate) => candidate.functionName === functionName && fileName.includes(candidate.directory)); + const typeName = contract?.parameterTypes[paramIndex]; + const type = typeName ? (0, ambient_index_1.findAmbientClassType)(ctx, typeName) : undefined; + return type ? [type] : []; +} diff --git a/packages/b2c-script-types/plugin/inference/generic-calls.js b/packages/b2c-script-types/plugin/inference/generic-calls.js new file mode 100644 index 000000000..d70b1c5e3 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/generic-calls.js @@ -0,0 +1,50 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.genericResultSource = genericResultSource; +const signatures_1 = require("./signatures"); +/** True when `node` is a bare reference to the type parameter `name` (`T`). */ +function isTypeParameterReference(ts, node, name) { + return (node !== undefined && + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === name && + node.typeArguments === undefined); +} +/** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ +function returnedTypeParameter(ts, declaration) { + const result = declaration.type; + const name = result && ts.isTypeReferenceNode(result) && ts.isIdentifier(result.typeName) && result.typeName.text; + return name && declaration.typeParameters?.some((parameter) => parameter.name.text === name) ? name : undefined; +} +/** + * Where `call`'s result comes from when its callee's declaration returns a + * type parameter it takes as an argument (`identity(value: T): T`) or as + * a callback's result (`wrap(callback: () => T): T`). + */ +function genericResultSource(ctx, call) { + const { ts, checker } = ctx; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || ts.isJSDocSignature(declaration)) + return undefined; + const typeParameter = returnedTypeParameter(ts, declaration); + if (!typeParameter) + return undefined; + for (const [index, parameter] of declaration.parameters.entries()) { + const argument = call.arguments[index]; + const type = parameter.type; + if (!argument || !type) + continue; + if (isTypeParameterReference(ts, type, typeParameter)) + return { kind: 'value', argument }; + const fn = ts.isFunctionTypeNode(type) && isTypeParameterReference(ts, type.type, typeParameter); + const callback = fn ? (0, signatures_1.functionOf)(ctx, argument) : undefined; + if (callback) + return { kind: 'return', fn: callback }; + } + return undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/hook-calls.js b/packages/b2c-script-types/plugin/inference/hook-calls.js new file mode 100644 index 000000000..16de6b0c5 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/hook-calls.js @@ -0,0 +1,183 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.hookCallSites = hookCallSites; +exports.hookImplementations = hookImplementations; +const constants_1 = require("./constants"); +const ast_helpers_1 = require("./ast-helpers"); +const member_values_1 = require("./member-values"); +const reference_search_1 = require("./reference-search"); +const usage_profile_1 = require("./usage-profile"); +// Where the Script API declares `HookMgr.callHook`, whichever copy of the dw +// types a project loads. +const HOOK_MANAGER_FILE = '/dw/system/HookMgr.d.ts'; +const CALL_HOOK = 'callHook'; +// Request-scoped: a request asks the host for its registrations once, and +// searches for callHook calls once. +const scriptsByRequest = new WeakMap(); +const hookCallsByRequest = new WeakMap(); +/** The extension points each script of the program is registered for. */ +function registeredScripts(ctx) { + let scripts = scriptsByRequest.get(ctx); + if (!scripts) { + const index = new Map(); + for (const { extensionPoint, script } of ctx.host.hookRegistrations?.(ctx.program) ?? []) { + const file = ctx.program.getSourceFile(script); + if (file) + index.set(file, [...(index.get(file) ?? []), extensionPoint]); + } + scripts = index; + scriptsByRequest.set(ctx, scripts); + } + return scripts; +} +function reaches(pattern, extensionPoint) { + return pattern.isPrefix ? extensionPoint.startsWith(pattern.text) : extensionPoint === pattern.text; +} +/** The string `expr` always evaluates to: a literal, or a value the checker types as one (a `const`). */ +function stringValue(ctx, expr) { + if (ctx.ts.isStringLiteralLike(expr)) + return expr.text; + const type = ctx.checker.getTypeAtLocation(expr); + return type.isStringLiteral() ? type.value : undefined; +} +/** The literal text a string built at runtime starts with: `'app.payment.processor.' + id`, `` `app.${id}` ``. */ +function literalPrefix(ctx, expr) { + const { ts } = ctx; + if (ts.isTemplateExpression(expr)) + return expr.head.text || undefined; + if (ts.isParenthesizedExpression(expr)) + return literalPrefix(ctx, expr.expression); + if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.PlusToken) + return undefined; + return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); +} +/** What a local variable `expr` names always holds: its initializer, when nothing else is assigned to it. */ +function soleValue(ctx, expr) { + const { ts } = ctx; + const declaration = ts.isIdentifier(expr) ? (0, member_values_1.valueDeclarationOf)(ctx, expr) : undefined; + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) + return undefined; + return (0, usage_profile_1.usageProfileOf)(ctx, declaration).assignedValues.length === 0 ? declaration.initializer : undefined; +} +/** The extension points `expr` names, read through one local variable (`var hookName = 'app.payment.' + id`). */ +function extensionPointPattern(ctx, expr) { + const value = soleValue(ctx, expr) ?? expr; + const name = stringValue(ctx, expr) ?? stringValue(ctx, value); + if (name !== undefined) + return { text: name, isPrefix: false }; + const prefix = literalPrefix(ctx, value); + return prefix ? { text: prefix, isPrefix: true } : undefined; +} +/** True when `call` invokes the Script API's `HookMgr.callHook`, through whatever name it is required as. */ +function isCallHookCall(ctx, call) { + const { ts, checker } = ctx; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== CALL_HOOK) + return false; + const declaration = checker.getSymbolAtLocation(callee.name)?.valueDeclaration; + return declaration?.getSourceFile().fileName.endsWith(HOOK_MANAGER_FILE) === true; +} +/** `call` read as a callHook call, when it is one and its extension point and function name are known. */ +function hookCallOf(ctx, call) { + const [extensionPointArgument, functionNameArgument] = call.arguments; + if (!functionNameArgument || !isCallHookCall(ctx, call)) + return undefined; + const extensionPoint = extensionPointPattern(ctx, extensionPointArgument); + const functionName = stringValue(ctx, functionNameArgument); + return extensionPoint && functionName ? { call, extensionPoint, functionName } : undefined; +} +/** The name `HookMgr.callHook` is declared by, when the program loads the Script API's HookMgr. */ +function callHookName(ctx) { + const { ts, program } = ctx; + const file = program + .getSourceFiles() + .find((sourceFile) => sourceFile.isDeclarationFile && sourceFile.fileName.endsWith(HOOK_MANAGER_FILE)); + for (const statement of file?.statements ?? []) { + if (!ts.isClassDeclaration(statement)) + continue; + const member = statement.members.find((m) => m.name && ts.isIdentifier(m.name) && m.name.text === CALL_HOOK); + if (member) + return member.name; + } + return undefined; +} +/** The callHook call a reference to `callHook` is the callee of. */ +function hookCallAt(ctx, reference) { + const { ts } = ctx; + const access = (0, ast_helpers_1.propertyAccessNamedBy)(reference, ts); + const call = access?.parent; + return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; +} +/** Every callHook call in the project: one project-wide search, made once per request. */ +function hookCalls(ctx) { + let calls = hookCallsByRequest.get(ctx); + if (!calls) { + const name = callHookName(ctx); + const searchable = name !== undefined && ctx.searchBudget > 0; + if (searchable) + ctx.searchBudget--; + calls = searchable ? (0, reference_search_1.searchReferences)(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + hookCallsByRequest.set(ctx, calls); + } + return calls; +} +/** + * What a CommonJS module exports, as the checker types the object its first + * top-level `exports.x = ...` / `module.exports = ...` statement writes to + * (the public API gives no module symbol for a CommonJS file). + */ +function moduleExportsType(ctx, file) { + for (const statement of file.statements) { + const exportsObject = (0, ast_helpers_1.exportsObjectWrittenBy)(statement, ctx.ts); + if (exportsObject) + return ctx.checker.getTypeAtLocation(exportsObject); + } + return undefined; +} +/** The functions `file` exports, with the names it exports them under; only those named `name` when given. */ +function exportedFunctions(ctx, file, name) { + const { ts, checker } = ctx; + const exportsType = moduleExportsType(ctx, file); + const members = exportsType ? checker.getPropertiesOfType(exportsType) : []; + return members + .filter((member) => name === undefined || member.name === name) + .flatMap((member) => checker + .getTypeOfSymbolAtLocation(member, file) + .getCallSignatures() + .flatMap((signature) => { + const fn = signature.getDeclaration(); + return fn && ts.isFunctionLike(fn) ? [{ name: member.name, fn }] : []; + })); +} +/** + * The callHook calls that invoke `fn`, an export of a registered hook script, + * with the hook's own arguments by parameter index. Each call spends one + * reference from the request's budget. + */ +function hookCallSites(ctx, fn) { + const extensionPoints = registeredScripts(ctx).get(fn.getSourceFile()); + if (!extensionPoints) + return []; + const exported = exportedFunctions(ctx, fn.getSourceFile()).filter((entry) => entry.fn === fn); + const names = new Set(exported.map((entry) => entry.name)); + if (names.size === 0) + return []; + const calls = hookCalls(ctx).filter((hook) => names.has(hook.functionName) && extensionPoints.some((point) => reaches(hook.extensionPoint, point))); + const sites = calls.slice(0, Math.max(0, Math.min(constants_1.MAX_REFERENCES_PER_CALL, ctx.referenceBudget))); + ctx.referenceBudget -= sites.length; + return sites.map(({ call }) => ({ node: call, args: call.arguments.slice(2) })); +} +/** The hook functions a callHook call dispatches to: the matching export of every script registered for it. */ +function hookImplementations(ctx, call) { + const hook = hookCallOf(ctx, call); + if (!hook) + return []; + return [...registeredScripts(ctx)].flatMap(([file, extensionPoints]) => extensionPoints.some((point) => reaches(hook.extensionPoint, point)) + ? exportedFunctions(ctx, file, hook.functionName).map((entry) => entry.fn) + : []); +} diff --git a/packages/b2c-script-types/plugin/inference/member-values.js b/packages/b2c-script-types/plugin/inference/member-values.js new file mode 100644 index 000000000..de88e2397 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/member-values.js @@ -0,0 +1,57 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.memberValueExpressions = memberValueExpressions; +exports.localMemberValues = localMemberValues; +exports.valueDeclarationOf = valueDeclarationOf; +const usage_profile_1 = require("./usage-profile"); +/** The value of one member declaration: `key: value`, `{key}`, `key = value;` in a class, `this.key = value`. */ +function declaredValue(ctx, declaration) { + const { ts } = ctx; + if (ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) + return declaration.initializer; + if (ts.isShorthandPropertyAssignment(declaration)) + return declaration.name; + const assignment = ts.isBinaryExpression(declaration) ? declaration : declaration.parent; + const isAssignment = assignment !== undefined && + ts.isBinaryExpression(assignment) && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} +/** + * The values a member symbol is declared with: an object literal's + * `key: value` and `{key}` shorthand, a class field initializer, and the + * right-hand sides of `this.key = value` / `obj.key = value` assignments a + * JavaScript constructor or expando declares it by. + */ +function memberValueExpressions(ctx, member) { + return (member.declarations ?? []).flatMap((declaration) => declaredValue(ctx, declaration) ?? []); +} +/** + * The values the code writes to `memberName` on an untyped local receiver + * (`productData.apiProduct = ProductMgr.getProduct(id)`), read back later + * through the same parameter or variable (`return productData.apiProduct`). + */ +function localMemberValues(ctx, receiver, memberName) { + const { ts } = ctx; + const declaration = ts.isIdentifier(receiver) ? valueDeclarationOf(ctx, receiver) : undefined; + const isLocal = declaration && (ts.isParameter(declaration) || ts.isVariableDeclaration(declaration)); + return isLocal ? ((0, usage_profile_1.usageProfileOf)(ctx, declaration).memberValues.get(memberName) ?? []) : []; +} +/** + * The declaration of the value `identifier` reads. The name of a `{key}` + * shorthand resolves to the object literal's property, not to the variable + * whose value it copies, so that one asks the checker for the value symbol. + */ +function valueDeclarationOf(ctx, identifier) { + const { ts, checker } = ctx; + const shorthand = ts.isShorthandPropertyAssignment(identifier.parent) ? identifier.parent : undefined; + const symbol = shorthand + ? checker.getShorthandAssignmentValueSymbol(shorthand) + : checker.getSymbolAtLocation(identifier); + return symbol?.valueDeclaration; +} diff --git a/packages/b2c-script-types/plugin/inference/naming.js b/packages/b2c-script-types/plugin/inference/naming.js new file mode 100644 index 000000000..1f95435d8 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/naming.js @@ -0,0 +1,92 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pickByName = pickByName; +// Identifier-name hints. SFCC code conventionally names a variable after the +// Script API class it holds (`customer`, `currentBasket`, `apiProduct`, +// `pli`). Usage alone often fits several classes (`.email` + `.firstName` +// fits both Profile and ProductListRegistrant), so the name is used to pick +// one — but only ever among classes the usage already fits: a name never adds +// a candidate of its own. +// Abbreviations whose class the identifier does not spell out, keyed and +// valued in lowercase. Everything else is derived from the identifier's own +// camelCase words, so this stays short. +const ABBREVIATIONS = new Map([ + ['pli', 'productlineitem'], + ['lineitem', 'productlineitem'], + ['paymentinstrument', 'orderpaymentinstrument'], + ['shippingaddress', 'orderaddress'], + ['billingaddress', 'orderaddress'], +]); +// All-lowercase qualifiers SFRA code glues onto a class name +// (`apiproduct`, `currentbasket`): stripping them leaves the class. +const QUALIFIER_PREFIXES = ['api', 'current', 'default', 'selected', 'new']; +// Candidates by lowercased name, per candidate list. Every request on a +// Program picks among the same ambient class list (see ./ambient-index), so +// its table is built once per Program instead of once per pick. +const tablesByCandidates = new WeakMap(); +function byLowercaseName(candidates) { + const cached = tablesByCandidates.get(candidates); + if (cached) + return cached; + const table = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = table.get(key); + if (named) + named.push(candidate); + else + table.set(key, [candidate]); + } + tablesByCandidates.set(candidates, table); + return table; +} +// The expansion goes first: SFRA's `lineItem` parameters hold a +// ProductLineItem, even where the usage would also fit the LineItem base. +function withAbbreviation(key) { + const expanded = ABBREVIATIONS.get(key); + return expanded ? [expanded, key] : [key]; +} +/** + * Lowercased class names `identifier` conventionally denotes, most specific + * first: the whole name, then each shorter camelCase word suffix + * (`resettingCustomer` → `customer`, `productShippingLineItem` → + * `shippinglineitem` → `lineitem` → `item`), each preceded by its + * abbreviation expansion, then the name with a qualifier prefix stripped. + * Splitting on camelCase boundaries only is what keeps an all-lowercase + * `border` from reading as `Order`. + */ +function conventionalClassNames(identifier) { + const words = identifier.split(/(?=[A-Z])/); + const keys = words.flatMap((_, start) => withAbbreviation(words.slice(start).join('').toLowerCase())); + const lower = identifier.toLowerCase(); + for (const prefix of QUALIFIER_PREFIXES) { + if (lower.length > prefix.length && lower.startsWith(prefix)) + keys.push(...withAbbreviation(lower.slice(prefix.length))); + } + return keys; +} +/** + * Picks the one candidate `identifier` names, or `undefined` when the name + * points at none of them (or at several equally). Besides + * {@link conventionalClassNames}, a camelCase identifier that is the tail of + * exactly one candidate's name also counts (`priceModel` → + * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). + */ +function pickByName(identifier, candidates) { + const byName = byLowercaseName(candidates); + for (const key of conventionalClassNames(identifier)) { + const named = byName.get(key); + if (named?.length === 1) + return named[0]; + } + const lower = identifier.toLowerCase(); + if (identifier === lower) + return undefined; + const tails = [...byName].filter(([name]) => name.endsWith(lower)).flatMap(([, named]) => named); + return tails.length === 1 ? tails[0] : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js new file mode 100644 index 000000000..f6ad80cc9 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -0,0 +1,287 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizeCandidates = normalizeCandidates; +exports.limitUnion = limitUnion; +exports.matchAmbientTypesByUsage = matchAmbientTypesByUsage; +exports.decideType = decideType; +const ambient_index_1 = require("./ambient-index"); +const constants_1 = require("./constants"); +const naming_1 = require("./naming"); +const type_helpers_1 = require("./type-helpers"); +const NO_MEMBERS = new Set(); +/** The members every value the profile describes must have: those used without a presence test first. */ +function requiredMembers(profile) { + if (profile.optionalMemberNames.size === 0) + return profile.memberNames; + return new Set([...profile.memberNames].filter((name) => !profile.optionalMemberNames.has(name))); +} +/** Step 1: split unions, widen literals, drop uninformative types, dedupe by display. */ +function normalizeCandidates(ctx, types) { + return (0, type_helpers_1.dedupeTypes)(ctx, types.flatMap((type) => (0, type_helpers_1.informativeParts)(ctx, type))); +} +/** The class or interface behind `type` (`Product` for `Product`), if it is one. */ +function classOf(ctx, type) { + const { ts } = ctx; + if (!(type.flags & ts.TypeFlags.Object)) + return undefined; + const objectType = type; + const target = objectType.objectFlags & ts.ObjectFlags.Reference ? type.target : objectType; + return target.objectFlags & ts.ObjectFlags.ClassOrInterface ? target : undefined; +} +/** Every superclass and implemented interface of `type`, nearest first (excluding `type` itself). */ +function ancestorsOf(ctx, type) { + const ancestors = []; + const start = classOf(ctx, type); + for (let queue = start ? [start] : []; queue.length > 0;) { + const current = queue.shift(); + for (const base of ctx.checker.getBaseTypes(current)) { + const baseClass = classOf(ctx, base); + if (baseClass && baseClass !== start && !ancestors.includes(baseClass)) { + ancestors.push(baseClass); + queue.push(baseClass); + } + } + } + return ancestors; +} +function isAncestorOf(ctx, ancestor, type) { + const ancestorClass = classOf(ctx, ancestor); + return ancestorClass !== undefined && ancestorsOf(ctx, type).includes(ancestorClass); +} +/** The class behind `type` and its ancestors, root classes (`PersistentObject`, ...) aside. */ +function lineageOf(ctx, type) { + const own = classOf(ctx, type); + return [...(own ? [own] : []), ...ancestorsOf(ctx, type)].filter((lineageClass) => !constants_1.UNINFORMATIVE_ANCESTORS.has(lineageClass.symbol?.name ?? '')); +} +/** + * What the value is passed or assigned as, each use as the informative + * alternatives it accepts (`Product | null` accepts a `Product`; an `any`, + * `Object` or `any[]` use accepts anything and is left out). + */ +function constrainingUses(ctx, profile) { + return profile.contextualTypes.map((use) => (0, type_helpers_1.informativeParts)(ctx, use)).filter((parts) => parts.length > 0); +} +/** + * True when `type` can stand wherever the body uses the value: assignable to + * (an alternative of) each use, or at least from the same class family (an + * `Order` passed to a helper documented as taking a `Basket` that really + * takes any `LineItemCtnr`, a common over-narrow JSDoc). A different kind of + * value never fits: a `string` argument at one buggy call site of + * `getBonusUnitPrice(apiProduct)`, whose body passes it on as a `Product`. + * TypeScript builds without the public `isTypeAssignableTo` skip the check. + */ +function fitsEveryUse(ctx, type, uses) { + const { checker } = ctx; + if (uses.length === 0 || typeof checker.isTypeAssignableTo !== 'function') + return true; + const lineage = lineageOf(ctx, type); + const fits = (use) => checker.isTypeAssignableTo(type, use) || lineageOf(ctx, use).some((useClass) => lineage.includes(useClass)); + return uses.every((alternatives) => alternatives.some(fits)); +} +/** + * True when `type` is an object literal's (`{ID: 'x'}`), as opposed to a + * declared class's. Read from its symbol: the widened type of a literal (what + * a variable holding it has) no longer carries the literal's object flag. + */ +function isObjectLiteralType(ctx, type) { + return ((type.getSymbol()?.flags ?? 0) & ctx.ts.SymbolFlags.ObjectLiteral) !== 0; +} +/** + * Drops every object literal a candidate class has all the properties of: a + * stand-in for that class (a test double, `{httpHeaders: {get: ...}}` for a + * `Request`, or a partial default), which offers nothing the class doesn't. + */ +function withoutStandIns(ctx, types) { + const { checker } = ctx; + const classes = types.filter((type) => classOf(ctx, type) !== undefined); + if (classes.length === 0) + return [...types]; + const standsIn = (literal) => { + const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); + return classes.some((candidateClass) => (0, type_helpers_1.hasAllMembers)(ctx, candidateClass, names)); + }; + return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); +} +/** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ +function mostGeneral(ctx, types) { + const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); + return types.filter((type) => !ancestorsOf(ctx, type).some((ancestor) => candidateClasses.has(ancestor))); +} +/** The nearest superclass all `types` share that still has `memberNames`, ignoring root classes. */ +function closestCommonAncestor(ctx, types, memberNames) { + const [first, ...rest] = types; + const restAncestors = rest.map((type) => ancestorsOf(ctx, type)); + return ancestorsOf(ctx, first).find((ancestor) => !constants_1.UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && + restAncestors.every((ancestors) => ancestors.includes(ancestor)) && + (0, type_helpers_1.hasAllMembers)(ctx, ancestor, memberNames)); +} +/** + * The generic class `type` instantiates, when its declared type reads as the + * bare class name. Arrays don't: the declared `Array` renders as `T[]`. + */ +function genericClassOf(ctx, type) { + const generic = classOf(ctx, type); + return generic?.typeParameters?.length && !ctx.checker.isArrayType(type) ? generic : undefined; +} +/** + * Reads two or more instantiations of the same generic class as the class + * itself: its declared type, whose unbound type parameters render as nothing + * (`Collection` and `Collection` -> `Collection`). + * A lone instantiation keeps its informative type argument. + */ +function mergeInstantiations(ctx, types) { + const instantiations = new Map(); + for (const type of types) { + const generic = genericClassOf(ctx, type); + if (generic) + instantiations.set(generic, (instantiations.get(generic) ?? 0) + 1); + } + const merged = types.map((type) => { + const generic = genericClassOf(ctx, type); + return generic && (instantiations.get(generic) ?? 0) > 1 ? generic : type; + }); + return (0, type_helpers_1.dedupeTypes)(ctx, merged); +} +/** + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, stand-ins + * aside; wider evidence merged by generic class, else the closest shared + * superclass, else silence. + */ +function limitUnion(ctx, types, memberNames = NO_MEMBERS) { + const general = mostGeneral(ctx, withoutStandIns(ctx, types)); + if (general.length <= constants_1.MAX_UNION_TYPES) + return general; + const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); + if (merged.length <= constants_1.MAX_UNION_TYPES) + return merged; + const ancestor = closestCommonAncestor(ctx, merged, memberNames); + return ancestor ? [ancestor] : []; +} +/** + * The single most specific type the value is used as: one every other such + * type is a superclass of (`basket` passed both where a `Basket` and where a + * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. + */ +function mostSpecificUse(ctx, profile, required) { + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => (0, type_helpers_1.hasAllMembers)(ctx, type, required)); + const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); + return mostSpecific.length === 1 ? mostSpecific : []; +} +/** Resolves matched index entries to types, merging declarations of the same symbol. */ +function resolveMatches(ctx, classes) { + const bySymbol = new Map(); + for (const ambientClass of classes) { + const type = (0, ambient_index_1.ambientClassType)(ctx, ambientClass); + const symbol = type?.getSymbol(); + if (type && symbol && !bySymbol.has(symbol)) + bySymbol.set(symbol, { name: ambientClass.name, type }); + } + return [...bySymbol.values()]; +} +/** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ +function fitsBuiltin(ctx, memberNames) { + return (0, ambient_index_1.builtinValueTypes)(ctx).some((builtin) => (0, type_helpers_1.hasAllMembers)(ctx, builtin, memberNames)); +} +/** + * Step 4b: matches a usage signature against every ambient class (see + * ./ambient-index) except those describing one global object (`Module`). + * Silent when no class has every member; when only ubiquitous members + * (`.custom`, `.UUID`) were used and several classes fit; when a single + * member fits several classes the identifier name can't choose between; and + * when a JavaScript built-in fits as well and the name doesn't pick a class. + * A name that denotes a class the usage does *not* fit never falls back to a + * vaguer reading of the same name (`bonusDiscountLineItem.getQuantity()` is + * not a ProductLineItem hint), and silences a single-member signature + * outright: `lineItem.preorderable` uniquely matching ProductInventoryRecord + * is a coincidence, not a hint. + * + * @param identifierName - the parameter's or variable's own name, used only + * to pick among classes the usage already fits (see ./naming). + */ +function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { + if (memberNames.size === 0) + return []; + const classes = (0, ambient_index_1.getAmbientClasses)(ctx); + const fitting = (0, ambient_index_1.ambientClassesWithMembers)(ctx, memberNames).filter((ambientClass) => !constants_1.GLOBAL_OBJECT_CLASSES.has(ambientClass.name)); + const onlyWeakMembers = [...memberNames].every((name) => constants_1.WEAK_USAGE_MEMBERS.has(name)); + if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) + return []; + const matches = resolveMatches(ctx, fitting); + const denoted = identifierName ? (0, naming_1.pickByName)(identifierName, classes) : undefined; + const named = denoted + ? matches.find((match) => match.name === denoted.name) + : identifierName && (0, naming_1.pickByName)(identifierName, matches); + if (named) + return [named.type]; + const thinSignature = memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS; + if ((thinSignature && (denoted || matches.length > 1)) || fitsBuiltin(ctx, memberNames)) + return []; + return limitUnion(ctx, matches.map((match) => match.type), memberNames); +} +/** + * What a candidate lacking some members the body uses can still be: the one + * subclass that has them all (a body relying on `paymentTransaction` is + * handed `OrderPaymentInstrument`s, whatever the argument is documented as), + * or — when several subclasses would do (`getMasterProduct()` on a `Variant` + * or a `VariationGroup`, called once the body knows which) — the candidate + * itself. Root classes (`PersistentObject`, ...) never stand in for a + * subclass this way; nearly every class extends them. + */ +function downcastOf(ctx, type, memberNames) { + const own = classOf(ctx, type); + if (!own || constants_1.UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) + return []; + const fitting = (0, ambient_index_1.ambientClassesWithMembers)(ctx, memberNames); + const subclasses = mostGeneral(ctx, resolveMatches(ctx, fitting) + .map((match) => match.type) + .filter((subclass) => isAncestorOf(ctx, type, subclass))); + return subclasses.length > 1 ? [type] : subclasses; +} +/** + * The required members that can tell candidates apart: those some ambient + * class, JavaScript built-in or candidate declares. A member nothing declares + * (`searchHit.discountedPromotionIDs`, newer than the vendored Script API) + * would otherwise drop every candidate, the right one included. + */ +function checkableMembers(ctx, required, candidates) { + const declaredBy = (types, name) => types.some((type) => (0, type_helpers_1.hasMember)(ctx, type, name)); + const declared = (name) => declaredBy(candidates, name) || + (0, ambient_index_1.getAmbientClasses)(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || + declaredBy((0, ambient_index_1.builtinValueTypes)(ctx), name); + return new Set([...required].filter(declared)); +} +/** + * Decides what a parameter or variable holds from the evidence ./core + * gathered for it (call-site arguments, assigned values, framework + * callbacks) plus its own usage profile. + * + * @param fitUsage - drop candidates lacking a member the profile shows in + * use. On for parameters, whose call sites are independent samples that may + * pass a look-alike; off for a variable's own assigned values. + */ +function decideType(ctx, evidence, profile, name, fitUsage) { + const guards = normalizeCandidates(ctx, profile.guardTypes); + const guardKeys = new Set(guards.map((type) => (0, type_helpers_1.typeDisplayString)(ctx, type))); + const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); + const required = requiredMembers(profile); + const checkable = checkableMembers(ctx, required, candidates); + const uses = constrainingUses(ctx, profile); + const fit = (type) => { + if (guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type))) + return [type]; + const fitted = (0, type_helpers_1.hasAllMembers)(ctx, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); + }; + const fitting = fitUsage ? (0, type_helpers_1.dedupeTypes)(ctx, candidates.flatMap(fit)) : candidates; + if (fitting.length > 0) + return limitUnion(ctx, fitting, checkable); + const used = mostSpecificUse(ctx, profile, required); + if (used.length > 0) + return used; + return matchAmbientTypesByUsage(ctx, profile.memberNames, ctx.ts.isIdentifier(name) ? name.text : undefined); +} diff --git a/packages/b2c-script-types/plugin/inference/reference-search.js b/packages/b2c-script-types/plugin/inference/reference-search.js new file mode 100644 index 000000000..4ca368932 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/reference-search.js @@ -0,0 +1,240 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.searchReferences = searchReferences; +// Reference searches, sized for the question inference asks: where is this +// name's value used? TypeScript's find-all-references answers a wider one, +// and pays for it: for any exported name it first maps every import of the +// whole program, work that grows with the project rather than with the name, +// and a single hover may search a dozen names. Inference follows imports +// itself, one hop at a time (see ./value-flow), so a search here only reads +// the files that contain the name, confirming each occurrence with the +// checker; and a name nothing outside its file can refer to (a function's or +// a CommonJS module's own variable) is searched in that one file. +// +// Each search that actually runs (not one served from the cache) is +// published on the `@salesforce/b2c-script-types:reference-search` +// diagnostics channel as `{name, projectWide}`: a deterministic cost measure +// for tests and diagnostics, free when nothing subscribes. +const node_diagnostics_channel_1 = __importDefault(require("node:diagnostics_channel")); +const ast_helpers_1 = require("./ast-helpers"); +const searchChannel = node_diagnostics_channel_1.default.channel('@salesforce/b2c-script-types:reference-search'); +// An alias is followed this many links at most (`var {helper} = require()` +// -> `exports.helper = helper` -> the function): real chains are two or +// three links, and the cap keeps a pathological re-export chain bounded. +const MAX_ALIAS_LINKS = 4; +// TypeScript reuses an unchanged file's SourceFile from one Program to the +// next, so a scanned table survives edits elsewhere in the project. +const scannedNames = new WeakMap(); +// A Program never changes, so neither does a search's answer: kept for as +// long as the Program lives, hovering one name after another in an unchanged +// project runs each search once. +const referencesByProgram = new WeakMap(); +/** + * The identifier names `file` contains. The parser records exactly this table + * on every SourceFile (`identifiers`, which TypeScript's own reference search + * reads); it is not public API, so it is used only when it has the expected + * shape, and otherwise rebuilt with the public scanner. + */ +function namesIn(ts, file) { + const parsed = file.identifiers; + if (parsed instanceof Map) + return parsed; + let names = scannedNames.get(file); + if (!names) { + const scanned = new Set(); + const scanner = ts.createScanner(file.languageVersion, true, file.languageVariant, file.text); + for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) { + if (kind === ts.SyntaxKind.Identifier) + scanned.add(scanner.getTokenValue()); + } + names = scanned; + scannedNames.set(file, names); + } + return names; +} +/** The identifiers named `text` in `file`, read along the paths that lead to where its text spells them. */ +function identifiersNamed(ts, file, text) { + const spells = (0, ast_helpers_1.spellingFilter)(file, [text]); + const found = []; + const visit = (node) => { + if (!spells(node)) + return; + if (!ts.isIdentifier(node)) + ts.forEachChild(node, visit); + else if (node.text === text) + found.push(node); + }; + visit(file); + return found; +} +/** + * The symbols `name` stands for. A `{helper}` shorthand is both a property + * and a use of the `helper` value; the checker names the property. + */ +function symbolsAt(ctx, name) { + const { ts, checker } = ctx; + const symbol = checker.getSymbolAtLocation(name); + const value = ts.isShorthandPropertyAssignment(name.parent) + ? checker.getShorthandAssignmentValueSymbol(name.parent) + : undefined; + return [symbol, value].filter((s) => s !== undefined); +} +/** `symbol` and the symbols it was instantiated, merged or widened from. */ +function withRoots(ctx, symbol) { + return [symbol, ...ctx.checker.getRootSymbols(symbol)]; +} +/** + * What a name found by the search may stand for: its own symbols, and every + * link of an alias chain they start (`var {helper} = require('./helpers')` -> + * `exports.helper = helper` -> the `helper` function). + */ +function identitiesOf(ctx, name) { + const { ts, checker } = ctx; + return symbolsAt(ctx, name).flatMap((symbol) => { + const links = []; + let link = symbol; + for (let hops = 0; link && hops < MAX_ALIAS_LINKS; hops++) { + links.push(...withRoots(ctx, link)); + link = link.flags & ts.SymbolFlags.Alias ? checker.getImmediateAliasedSymbol(link) : undefined; + } + return links; + }); +} +/** True when a search for `target` reads every file of the project: for its name, or for the require() calls of its module. */ +function readsProject(target) { + return target.homeFile === undefined || target.module !== undefined; +} +/** The module `symbol` is the exports of: `exports` in `module.exports = ...`, or the module symbol itself. */ +function moduleOf(ctx, symbol) { + const { ts } = ctx; + if (symbol.valueDeclaration && ts.isSourceFile(symbol.valueDeclaration)) + return symbol.valueDeclaration; + return symbol.escapedName === ts.InternalSymbolName.ExportEquals + ? symbol.declarations?.[0]?.getSourceFile() + : undefined; +} +/** True when `declaration` introduces a name into the scope around it, as opposed to a member of some object. */ +function declaresScopedName(ts, declaration) { + return (ts.isVariableDeclaration(declaration) || + ts.isBindingElement(declaration) || + ts.isParameter(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isClassDeclaration(declaration) || + ts.isClassExpression(declaration)); +} +/** + * The file `symbol` is visible in alone, when nothing elsewhere can name it: + * a variable, parameter, function or class declared inside a function, or at + * the top level of a CommonJS module. A CommonJS module exports only by + * assignment (`module.exports.helper = helper`), and that assignment is a + * reference in the same file, which inference follows on from. Undefined for + * a member (an `exports.helper` included), a global of a plain script, and an + * ES module export. + */ +function homeFileOf(ctx, symbol) { + const { ts, checker } = ctx; + const declarations = symbol.declarations ?? []; + const file = declarations[0]?.getSourceFile(); + if (!file || file.isDeclarationFile || declarations.some((d) => d.getSourceFile() !== file)) + return undefined; + if (!declarations.every((declaration) => declaresScopedName(ts, declaration))) + return undefined; + if (checker.getExportSymbolOfSymbol(symbol) !== symbol) + return undefined; + const isGlobal = checker.resolveName(symbol.name, undefined, ts.SymbolFlags.Value, false) === symbol; + return isGlobal ? undefined : file; +} +/** The one file all of `symbols` are visible in alone, or undefined when any file may refer to one of them. */ +function sharedHomeFile(ctx, symbols) { + const files = new Set(symbols.map((symbol) => homeFileOf(ctx, symbol))); + const [file] = files; + return files.size === 1 ? file : undefined; +} +function searchTargetOf(ctx, name) { + const own = symbolsAt(ctx, name); + const symbols = new Set(own.flatMap((symbol) => withRoots(ctx, symbol))); + const declarations = new Set([...symbols].flatMap((symbol) => symbol.declarations ?? [])); + const module = own.map((symbol) => moduleOf(ctx, symbol)).find((file) => file !== undefined); + return { name, symbols, declarations, homeFile: module ?? sharedHomeFile(ctx, own), module }; +} +/** True when `candidate` stands for what `target` names: the same symbol, directly or through an alias. */ +function standsFor(ctx, candidate, target) { + return identitiesOf(ctx, candidate).some((symbol) => target.symbols.has(symbol) || + (symbol.declarations ?? []).some((declaration) => target.declarations.has(declaration))); +} +/** The require() module paths `file` contains: the parser's own list of them, else the `require('…')` calls found by name. */ +function modulePathsIn(ctx, file) { + const { ts } = ctx; + const imports = file.imports; + if (Array.isArray(imports) && imports.every((node) => ts.isStringLiteralLike(node))) + return imports; + return identifiersNamed(ts, file, 'require').flatMap((callee) => { + const call = callee.parent; + const path = ts.isCallExpression(call) && call.expression === callee ? call.arguments[0] : undefined; + return path && ts.isStringLiteralLike(path) ? [path] : []; + }); +} +/** The name a module path must contain to load `module`: `productHelpers`, or `models` for models/index.js. */ +function loadedAs(module) { + const segments = module.fileName.replace(/\.[^./]+$/, '').split('/'); + const last = segments.pop() ?? ''; + return last === 'index' ? (segments.pop() ?? last) : last; +} +/** The module paths in `files` that load `module`. */ +function modulePathsLoading(ctx, module, files) { + const text = loadedAs(module); + return files.flatMap((file) => modulePathsIn(ctx, file).filter((path) => path.text.includes(text) && ctx.checker.getSymbolAtLocation(path)?.valueDeclaration === module)); +} +function throwIfCancelled(ctx) { + if (ctx.host.isCancellationRequested?.()) + throw new ctx.ts.OperationCanceledException(); +} +function runSearch(ctx, target) { + const { ts, program } = ctx; + const text = target.name.text; + const projectFiles = program.getSourceFiles().filter((file) => !file.isDeclarationFile); + const references = []; + for (const file of target.homeFile ? [target.homeFile] : projectFiles) { + if (!namesIn(ts, file).has(text)) + continue; + throwIfCancelled(ctx); + references.push(...identifiersNamed(ts, file, text).filter((candidate) => standsFor(ctx, candidate, target))); + } + if (target.module) + references.push(...modulePathsLoading(ctx, target.module, projectFiles)); + return references; +} +/** + * Every reference to what `name` declares or refers to: each identifier the + * checker resolves to the same symbol, directly or through an alias, and for + * a module's `exports`, the require() calls that load the module. A name + * that is visible in one file alone is searched in that file only; nothing + * in a declaration file is a use. Cached per Program; spends no budget, the + * caller does. + */ +function searchReferences(ctx, name) { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + throwIfCancelled(ctx); + const target = searchTargetOf(ctx, name); + if (searchChannel.hasSubscribers) + searchChannel.publish({ name: name.text, projectWide: readsProject(target) }); + references = runSearch(ctx, target); + cache.set(name, references); + } + return references; +} diff --git a/packages/b2c-script-types/plugin/inference/signatures.js b/packages/b2c-script-types/plugin/inference/signatures.js new file mode 100644 index 000000000..5b230d047 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/signatures.js @@ -0,0 +1,117 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveCalleeDeclaration = resolveCalleeDeclaration; +exports.functionOf = functionOf; +exports.isElementFirstCallbackCall = isElementFirstCallbackCall; +exports.acceptsArgumentCount = acceptsArgumentCount; +exports.callbackParameterTypes = callbackParameterTypes; +exports.handedOffParameterTypes = handedOffParameterTypes; +const constants_1 = require("./constants"); +const type_helpers_1 = require("./type-helpers"); +/** + * Resolves the function-like declaration a call expression's callee refers + * to, via its symbol or — as a fallback for shapes the symbol lookup misses + * (`var Model = require('./model'); new Model(x)`) — the signature the call + * resolves to. Only a callee with several signatures needs the checker to + * pick one, which checks every argument the call passes (callback bodies + * included); a callee with one signature can only mean that one. + */ +function resolveCalleeDeclaration(ctx, call) { + const { checker, ts } = ctx; + const sym = checker.getSymbolAtLocation(call.expression); + const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; + if (decl && ts.isFunctionLike(decl)) + return decl; + const signatures = calleeSignatures(ctx, call); + const sigDecl = (signatures.length > 1 ? checker.getResolvedSignature(call) : signatures[0])?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; +} +/** + * The signatures `call` chooses from: a `new` call's construct signatures, + * or the call signatures of a callee without any (a JS constructor + * function, which `new` calls like any other function). + */ +function calleeSignatures(ctx, call) { + const callee = ctx.checker.getTypeAtLocation(call.expression); + const construct = ctx.ts.isNewExpression(call) ? callee.getConstructSignatures() : []; + return construct.length > 0 ? construct : callee.getCallSignatures(); +} +/** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +function functionOf(ctx, value) { + const { ts, checker } = ctx; + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) + return value; + const declaration = ts.isIdentifier(value) ? checker.getSymbolAtLocation(value)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} +/** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ +function isElementFirstCallbackCall(ctx, call) { + const { ts } = ctx; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + return ts.isIdentifier(callee) && constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text); +} +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +function acceptsArgumentCount(ctx, signature, count) { + const { ts, checker } = ctx; + const declaration = signature.getDeclaration(); + if (!declaration) + return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} +/** + * The type `signature` gives the argument at `argIndex`: its parameter's + * type, or for an argument a rest parameter collects (`...middleware: + * Middleware[]`), the element type of that parameter's array. + */ +function argumentTypeAt(ctx, signature, argIndex, location) { + const { ts, checker } = ctx; + const parameters = signature.getParameters(); + const last = parameters[parameters.length - 1]; + const rest = last?.valueDeclaration && ts.isParameter(last.valueDeclaration) && ts.isRestParameter(last.valueDeclaration); + if (argIndex < parameters.length - (rest ? 1 : 0)) { + return checker.getTypeOfSymbolAtLocation(parameters[argIndex], location); + } + return rest + ? checker.getIndexTypeOfType(checker.getTypeOfSymbolAtLocation(last, location), ts.IndexKind.Number) + : undefined; +} +/** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ +function callbackParameterType(ctx, signature, call, argIndex, paramIndex) { + const { checker } = ctx; + const callbackType = argumentTypeAt(ctx, signature, argIndex, call); + const callbackParameter = callbackType && checker.getNonNullableType(callbackType).getCallSignatures()[0]?.getParameters()[paramIndex]; + return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); +} +/** + * The types `methodTypes`' declared signatures give parameter `paramIndex` + * of the callback `call` passes as argument `argIndex`: `value: T` of + * `filter(predicate: (value: T, ...) => unknown)` on a `ProductLineItem[]`. + * Overloads taking that many arguments must agree, so the accumulator of + * `reduce(callback, initialValue)` (`T` or `U`, by overload) stays unknown. + */ +function callbackParameterTypes(ctx, methodTypes, call, argIndex, paramIndex) { + return methodTypes.flatMap((methodType) => { + const overloads = methodType + .getCallSignatures() + .filter((signature) => acceptsArgumentCount(ctx, signature, call.arguments.length)); + const [first, ...rest] = overloads.map((signature) => callbackParameterType(ctx, signature, call, argIndex, paramIndex)); + const key = first && (0, type_helpers_1.typeDisplayString)(ctx, first); + return first && rest.every((type) => type && (0, type_helpers_1.typeDisplayString)(ctx, type) === key) ? [first] : []; + }); +} +/** + * What the callee of `handoff` declares it passes parameter `paramIndex` of + * the function it is handed: `Request` for `req` of the middleware step in + * `server.get('Show', cache.applyDefaultCache)`. + */ +function handedOffParameterTypes(ctx, handoff, paramIndex) { + const callee = ctx.checker.getTypeAtLocation(handoff.call.expression); + return callbackParameterTypes(ctx, [callee], handoff.call, handoff.argIndex, paramIndex); +} diff --git a/packages/b2c-script-types/plugin/inference/super-module.js b/packages/b2c-script-types/plugin/inference/super-module.js new file mode 100644 index 000000000..ddb552e19 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/super-module.js @@ -0,0 +1,204 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.traceSuperModuleAccess = traceSuperModuleAccess; +exports.resolveSuperModuleTypes = resolveSuperModuleTypes; +exports.resolveSuperModuleMemberTypes = resolveSuperModuleMemberTypes; +exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; +const ast_helpers_1 = require("./ast-helpers"); +const constants_1 = require("./constants"); +const context_1 = require("./context"); +const type_helpers_1 = require("./type-helpers"); +/** + * The SFCC `module.superModule` expression — the runtime handle to the + * same-path module in the next cartridge down the cartridge path, which SFRA + * plugin cartridges use to extend base modules. Identified structurally, like + * the require() detection above. + */ +function isSuperModuleAccess(expr, ts) { + return ts.isIdentifier(expr.expression) && expr.expression.text === 'module' && expr.name.text === 'superModule'; +} +/** + * Locates the source file `module.superModule` refers to for `fromFileName` + * — the same-subpath module in the next cartridge down the path, per the + * host-supplied ctx.host.resolveSuperModulePath. Only works when that file is + * part of the current program (true under the recommended jsconfig setup + * that includes all cartridge files, but not in a bare inferred project + * where nothing require()s the base file). + */ +function findSuperModuleFile(ctx, fromFileName) { + const { program } = ctx; + const superPath = ctx.host.resolveSuperModulePath?.(fromFileName); + if (!superPath) + return undefined; + // The resolver returns host-normalized (possibly case-folded) paths; + // program keys may differ in case on case-insensitive filesystems. + const direct = program.getSourceFile(superPath); + if (direct) + return direct; + const target = superPath.toLowerCase(); + return program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); +} +/** + * A module's top-level export assignments, gathered structurally: + * `full` — every `module.exports = X` right-hand side; + * `members` — every `module.exports. = X` / `exports. = X` + * augmentation, the shape SFRA plugin overlays use to add helpers on top of + * a re-exported base (`module.exports = base; module.exports.extra = extra;`). + */ +function collectExportAssignments(sf, ts) { + const full = []; + const members = []; + for (const stmt of sf.statements) { + const assignment = (0, ast_helpers_1.memberAssignmentOf)(stmt, ts); + if (!assignment) + continue; + const { binary, target } = assignment; + if ((0, ast_helpers_1.isExportsObject)(target.expression, ts)) + members.push({ name: target.name.text, expr: binary.right }); + else if ((0, ast_helpers_1.isModuleExports)(target, ts)) + full.push(binary); + } + return { full, members }; +} +/** + * True when a `module.exports = X` assignment gives the checker a genuinely + * usable exports type: not `any`, and actually exposing members. A + * pass-through overlay (`module.exports = base` where base came from + * `module.superModule`) fails this — depending on program shape the checker + * reports its exports as `any` or as an opaque, member-less `typeof base` — + * and must be resolved by recursing down the cartridge chain instead. + */ +function isConcreteExportAssignment(ctx, bin) { + const { ts, checker } = ctx; + const exportsType = checker.getTypeAtLocation(bin.left); + if ((0, type_helpers_1.isAnyType)(ts, exportsType)) + return false; + return checker.getPropertiesOfType(checker.getApparentType(exportsType)).length > 0; +} +/** + * Follows `expr` back to a `module.superModule` access if there is one: the + * expression itself, or — the universal SFRA idiom — a reference to a local + * `var base = module.superModule;` binding. Exported so the plugin's + * hover/completion gates can recognize superModule-derived expressions: the + * checker's own type for them is never meaningful (sometimes `any`, + * sometimes an opaque circular `typeof base`), so "is the type any?" alone + * would skip inference exactly where it's needed. + */ +function traceSuperModuleAccess(ts, checker, expr) { + if (ts.isPropertyAccessExpression(expr) && isSuperModuleAccess(expr, ts)) + return expr; + if (ts.isIdentifier(expr)) { + const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + if (decl && + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isPropertyAccessExpression(decl.initializer) && + isSuperModuleAccess(decl.initializer, ts)) { + return decl.initializer; + } + } + return undefined; +} +/** True when a level re-exports what is below it (`module.exports = base`) rather than replacing it. */ +function passesThrough(ctx, exports) { + const { ts, checker } = ctx; + return exports.full.some((bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined); +} +/** + * Walks the superModule chain below the file containing `superAccess`, one + * cartridge level at a time, yielding each level's export assignments. The + * walk continues downward only through a pass-through level + * (`module.exports = base`): a level with a concrete `module.exports` + * replaces everything below it at runtime, unless it carries the base along. + */ +function* superModuleLevels(ctx, superAccess) { + const seen = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < constants_1.MAX_SUPERMODULE_HOPS; hop++) { + const superFile = findSuperModuleFile(ctx, fromFileName); + if (!superFile || seen.has(superFile)) + return; + seen.add(superFile); + const exports = collectExportAssignments(superFile, ctx.ts); + yield exports; + if (!passesThrough(ctx, exports)) + return; + fromFileName = superFile.fileName; + } +} +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes(ctx, assignment, resolve) { + const { ts, checker } = ctx; + const concrete = isConcreteExportAssignment(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) + types.push(...resolve(assignment.right)); + return types; +} +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. + */ +function resolveSuperModuleTypes(ctx, expr, resolve) { + const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); + if (!superFile) + return []; + // The guard catches overlay cycles from a misconfigured cartridge path. + return (0, context_1.withCycleGuard)(ctx, superFile, [], () => (0, type_helpers_1.dedupeTypes)(ctx, collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => superModuleExportTypes(ctx, assignment, resolve)))); +} +/** + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. + */ +function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, resolve) { + for (const { members } of superModuleLevels(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) + continue; + return (0, type_helpers_1.dedupeKnownTypes)(ctx, matches.flatMap((member) => resolve(member.expr))); + } + return []; +} +/** + * Collects every member the superModule chain reachable from `expr` + * contributes through export augmentations (`module.exports.name = fn`) at + * pass-through levels — the members the superModule's export types cannot + * carry. Used to complete after `base.` in an overlay; the first (highest) + * level defining a name wins, matching runtime override order. + */ +function collectSuperModuleAugmentedMembers(ctx, expr) { + const { ts, checker } = ctx; + const superAccess = traceSuperModuleAccess(ts, checker, expr); + if (!superAccess) + return []; + const out = []; + const seenNames = new Set(); + for (const { members } of superModuleLevels(ctx, superAccess)) { + for (const member of members) { + if (seenNames.has(member.name)) + continue; + seenNames.add(member.name); + out.push({ name: member.name, isMethod: checker.getTypeAtLocation(member.expr).getCallSignatures().length > 0 }); + } + } + return out; +} diff --git a/packages/b2c-script-types/plugin/inference/this-properties.js b/packages/b2c-script-types/plugin/inference/this-properties.js new file mode 100644 index 000000000..7dc9ed892 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/this-properties.js @@ -0,0 +1,116 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.thisMembersStoring = thisMembersStoring; +exports.isMemberDefinedOnThis = isMemberDefinedOnThis; +exports.isStoredMemberRead = isStoredMemberRead; +const ast_helpers_1 = require("./ast-helpers"); +/** `this.x = value`: a plain assignment to a member of `this`. */ +function isThisMemberStore(ts, node) { + return (ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.expression.kind === ts.SyntaxKind.ThisKeyword); +} +/** True when `node` gives its body a `this` of its own (a function, method or class; arrow functions share it). */ +function hasOwnThis(ts, node) { + return (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLike(node); +} +/** + * The members of `this` the function declaring `param` stores it in + * (`this.refinementValue = refinementValue`). Stores inside nested functions + * are skipped: their `this` is another value. + */ +function thisMembersStoring(ctx, param) { + const { ts, checker } = ctx; + const members = new Set(); + const body = param.parent.body; + const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; + if (!body || !paramSymbol) + return members; + const spellsParam = (0, ast_helpers_1.spellingFilter)(body, [paramSymbol.name]); + const visit = (node) => { + if (!spellsParam(node)) + return; + const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; + const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; + if (member) + members.add(member); + if (!hasOwnThis(ts, node)) + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return members; +} +/** True when `callee` is `Object.defineProperty`. */ +function isObjectDefineProperty(ts, callee) { + return (ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' && + callee.name.text === 'defineProperty'); +} +/** `Object.defineProperty(this, 'name', …)`: the name of the member the call defines on `this`. */ +function memberDefinedOnThisBy(ts, node) { + if (!ts.isCallExpression(node) || !isObjectDefineProperty(ts, node.expression)) + return undefined; + const [target, key] = node.arguments; + if (target?.kind !== ts.SyntaxKind.ThisKeyword || !key || !ts.isStringLiteralLike(key)) + return undefined; + return key.text; +} +/** The body that builds an instance: a constructor function's own, or a class's constructor. */ +function constructorBody(ts, declaration) { + const constructor = ts.isVariableDeclaration(declaration) ? declaration.initializer : declaration; + if (constructor && (ts.isFunctionDeclaration(constructor) || ts.isFunctionExpression(constructor))) { + return constructor.body; + } + return ts.isClassLike(declaration) ? declaration.members.find(ts.isConstructorDeclaration)?.body : undefined; +} +// Keyed by declaration node: a syntax tree never changes, and tsserver reuses +// it across Programs for as long as its file is unchanged. +const membersDefinedByDeclaration = new WeakMap(); +/** The members the constructor `declaration` declares defines on its own `this` (not a nested function's). */ +function membersDefinedIn(ts, declaration) { + const cached = membersDefinedByDeclaration.get(declaration); + if (cached) + return cached; + const members = new Set(); + const visit = (node) => { + const member = memberDefinedOnThisBy(ts, node); + if (member !== undefined) + members.add(member); + if (!hasOwnThis(ts, node)) + ts.forEachChild(node, visit); + }; + const body = constructorBody(ts, declaration); + if (body) + ts.forEachChild(body, visit); + membersDefinedByDeclaration.set(declaration, members); + return members; +} +/** + * True when `type`'s constructor defines `name` with + * `Object.defineProperty(this, 'name', …)` (SFRA's request model defines most + * of its members this way). The checker binds such calls on exports and + * prototypes but not on `this`, so the instance type lacks these members. + */ +function isMemberDefinedOnThis(ts, type, name) { + const declarations = type.getSymbol()?.declarations ?? []; + return declarations.some((declaration) => membersDefinedIn(ts, declaration).has(name)); +} +/** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ +function isStoredMemberRead(ctx, members, node) { + const { ts, checker } = ctx; + if (members.size === 0 || !ts.isPropertyAccessExpression(node)) + return false; + const store = node.parent; + const written = isThisMemberStore(ts, store) && store.left === node; + if (node.expression.kind !== ts.SyntaxKind.ThisKeyword || written) + return false; + const member = checker.getSymbolAtLocation(node.name); + return member !== undefined && members.has(member); +} diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js new file mode 100644 index 000000000..9210f9b85 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -0,0 +1,334 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isAnyType = isAnyType; +exports.isOpenForUsageInference = isOpenForUsageInference; +exports.informativeParts = informativeParts; +exports.typeDisplayString = typeDisplayString; +exports.dedupeTypes = dedupeTypes; +exports.dedupeKnownTypes = dedupeKnownTypes; +exports.getMemberOfType = getMemberOfType; +exports.hasMember = hasMember; +exports.hasAllMembers = hasAllMembers; +exports.elementTypeOf = elementTypeOf; +exports.arrayTypeOf = arrayTypeOf; +exports.describeTypes = describeTypes; +exports.inferredCompletionEntry = inferredCompletionEntry; +exports.typesToCompletionEntries = typesToCompletionEntries; +const constants_1 = require("./constants"); +const this_properties_1 = require("./this-properties"); +/** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ +function isAnyType(ts, type) { + return (type.flags & ts.TypeFlags.Any) !== 0; +} +/** True when `type` can be called or constructed: a function type, whatever members it lacks. */ +function isCallable(type) { + return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; +} +/** + * True when the checker's type is too uninformative to prefer over usage + * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or + * the global `Object` interface. Used as the hover/completion gate. + * + * Deliberately excludes named classes (even wrong ones like a mis-documented + * `Request`) — those are strong enough that overriding them would fight both + * TypeScript and IntelliJ's JSDoc-first model. + * + * `Object` is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O, distinct from the lowercase `object` + * non-primitive and from `{*}`/`{}`/`{obj}`, which widen to `any` or an empty + * type). It carries no Script API information, so a value typed as bare + * `Object` is effectively undocumented — exactly the case usage inference + * exists for. No dw.* class is named plain `Object`, so keying on the name + * can't shadow a real Script API type. + */ +function isOpenForUsageInference(ts, type) { + if (isAnyType(ts, type)) + return true; + if (type.flags & ts.TypeFlags.NonPrimitive) + return true; + const symbol = type.getSymbol(); + if (symbol?.getName() === '__type' && type.getProperties().length === 0 && !isCallable(type)) + return true; + return symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; +} +// Type flags that never make a useful hover or completion candidate on their +// own: `null`/`undefined` (the empty branch of `x ? y : null`), `void`, +// `never`, `unknown`, and an unbound type parameter (`T` from a generic +// declaration nobody instantiated). +function uninformativeFlags(ts) { + return (ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Never | + ts.TypeFlags.Void | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.TypeParameter); +} +/** An object type with no members and no signatures: the `{}` of `options || {}`. */ +function isEmptyObjectType(ts, checker, type) { + return ((type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + type.getCallSignatures().length === 0 && + type.getConstructSignatures().length === 0 && + checker.getIndexInfosOfType(type).length === 0); +} +/** + * True when `type` tells a reader something: not one of the + * {@link uninformativeFlags} kinds, not open for inference (see + * {@link isOpenForUsageInference}), not an empty object, and — for an array — + * an array of something informative (`any[]`, `T[]` and `undefined[]` are as + * empty as `any`). + */ +function isInformativeType(ts, checker, type) { + if (type.flags & uninformativeFlags(ts)) + return false; + if (isOpenForUsageInference(ts, type) || isEmptyObjectType(ts, checker, type)) + return false; + if (!checker.isArrayType(type)) + return true; + const [element] = checker.getTypeArguments(type); + return element !== undefined && isInformativeType(ts, checker, element); +} +/** + * Splits `type` into the parts worth showing: union members are considered + * one by one (so `Product | null` keeps `Product`), literals are widened to + * their primitive (`"a" | "b"` reads as `string`), and every + * non-informative part is dropped. + */ +function informativeParts(ctx, type) { + const { ts, checker } = ctx; + const parts = type.isUnion() ? type.types : [type]; + return parts + .map((part) => checker.getBaseTypeOfLiteralType(part)) + .filter((part) => isInformativeType(ts, checker, part)); +} +/** + * True when a generic instantiation's type arguments add nothing a reader + * needs: each one is still an unbound type parameter (the declared type of + * `Product`, as ambient matching produces it), `any`, or exactly the + * parameter's declared default (`Product`). + */ +function hasOnlyDefaultTypeArguments(ts, checker, type) { + if (!(type.flags & ts.TypeFlags.Object)) + return true; + if (!(type.objectFlags & ts.ObjectFlags.Reference)) + return true; + const reference = type; + const parameters = reference.target.typeParameters ?? []; + return checker.getTypeArguments(reference).every((argument, index) => { + if (argument.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any)) + return true; + const parameter = parameters[index]; + return parameter !== undefined && checker.getDefaultFromTypeParameter(parameter) === argument; + }); +} +/** + * The name a reader knows a class or interface by. Usually its own name; a + * declaration nested in a namespace keeps the namespace (the vendored + * `ICustomAttributes.Shipment`, the type of `shipment.custom`, must not read + * as the unrelated `Shipment` class). The `global.` wrapper of + * `declare global` and a quoted ambient-module prefix + * (`"server/server".Response`) are implementation details and are dropped. + */ +function classDisplayName(checker, symbol) { + return checker + .getFullyQualifiedName(symbol) + .replace(/^global\./, '') + .replace(/^"[^"]*"\./, ''); +} +/** + * True for a union shown as its members. The checker reads `true | false` + * back as `boolean` (an enum's members as the enum), so it renders those. + */ +function isDisplayedAsUnion(ts, type) { + const collapsed = ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral; + return type.isUnion() && !type.types.some((member) => member.flags & collapsed); +} +/** `Product[]`, or `(Product | Basket)[]` for an array of a union. */ +function arrayDisplayString(ts, checker, array) { + const [element] = checker.getTypeArguments(array); + if (!element) + return 'any[]'; + const display = computeTypeDisplayString(ts, checker, element); + return element.isUnion() && !(element.flags & ts.TypeFlags.Boolean) ? `(${display})[]` : `${display}[]`; +} +/** + * Renders a candidate type for hover text and dedupe keys. A class or + * interface instantiated only with default type arguments reads as the bare + * class name (`Product`, `LineItemCtnr`); one with real type arguments + * renders them the same way (`Collection`, not the checker's + * `Collection>`), an array reads as its element's display + * (`Product[]`) and a union as its members' (`(Product | Basket)[]`). + * Everything else — primitives, object literals, lib types — is rendered by + * the checker as TypeScript itself would. + */ +function computeTypeDisplayString(ts, checker, type) { + if (isDisplayedAsUnion(ts, type)) { + return type.types.map((member) => computeTypeDisplayString(ts, checker, member)).join(' | '); + } + if (checker.isArrayType(type)) + return arrayDisplayString(ts, checker, type); + const symbol = type.getSymbol(); + const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; + if (!isClassOrInterface) + return checker.typeToString(type); + if (hasOnlyDefaultTypeArguments(ts, checker, type)) + return classDisplayName(checker, symbol); + const typeArguments = checker + .getTypeArguments(type) + .map((argument) => computeTypeDisplayString(ts, checker, argument)); + return `${classDisplayName(checker, symbol)}<${typeArguments.join(', ')}>`; +} +/** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ +function typeDisplayString(ctx, type) { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) + return cached; + const display = computeTypeDisplayString(ctx.ts, ctx.checker, type); + ctx.typeDisplayStrings.set(type, display); + return display; +} +/** + * Deduplicates candidate types by their display string. Two distinct types + * that render identically (`Product` and `Product` both read as + * `Product`) collapse into one — acceptable because every consumer of the + * result is display-oriented (hover text, completion-member names). + */ +function dedupeTypes(ctx, types) { + const seen = new Set(); + return types.filter((type) => { + const key = typeDisplayString(ctx, type); + if (seen.has(key)) + return false; + seen.add(key); + return true; + }); +} +/** The distinct types among `types` that say something: {@link dedupeTypes} without `any`. */ +function dedupeKnownTypes(ctx, types) { + return dedupeTypes(ctx, types.filter((type) => !isAnyType(ctx.ts, type))); +} +/** + * Strips any nullable part from `type` and computes its apparent type before + * a member lookup. `getPropertyOfType` on a union only returns members common + * to *every* constituent, so an un-stripped `Product | null` (the shape of + * `ProductMgr.getProduct()`) would never resolve any member; the apparent + * type also exposes a primitive's wrapper-object members (`.length`). + */ +function getNonNullableApparentType(checker, type) { + return checker.getApparentType(checker.getNonNullableType(type)); +} +/** Looks up a member by name on `type`'s non-nullable apparent type — see {@link getNonNullableApparentType}. */ +function getMemberOfType(checker, type, name) { + return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); +} +/** + * True when `type` has a member called `name`: one the checker knows, or one + * its constructor defines on `this` with `Object.defineProperty` (see + * {@link isMemberDefinedOnThis}). + */ +function hasMember(ctx, type, name) { + return getMemberOfType(ctx.checker, type, name) !== undefined || (0, this_properties_1.isMemberDefinedOnThis)(ctx.ts, type, name); +} +/** True when `type` has every member in `memberNames` (vacuously true for none); see {@link hasMember}. */ +function hasAllMembers(ctx, type, memberNames) { + for (const name of memberNames) { + if (!hasMember(ctx, type, name)) + return false; + } + return true; +} +/** + * Extracts the element type from a collection-like `type`: something with an + * `iterator()` method whose result has a typed `next()` (dw.util.Collection + * and friends), or something that is itself such an iterator. Returns + * `undefined` when `type` doesn't look like a collection or its element type + * is unknown — never `any`. + * + * @param location - any node in the file where the type is being used; + * required by getTypeOfSymbolAtLocation to resolve member types. + */ +function collectionElementType(ctx, type, location) { + const { ts, checker } = ctx; + const firstCallReturn = (t, memberName) => { + const sym = getMemberOfType(checker, t, memberName); + if (!sym) + return undefined; + const [signature] = checker.getTypeOfSymbolAtLocation(sym, location).getCallSignatures(); + return signature && checker.getReturnTypeOfSignature(signature); + }; + const iteratorType = firstCallReturn(type, 'iterator') ?? type; + const element = firstCallReturn(iteratorType, 'next'); + if (!element || isAnyType(ts, element)) + return undefined; + if (element.flags & (ts.TypeFlags.Void | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) + return undefined; + return element; +} +/** + * The element type of an array (`ProductLineItem[]`) or of a Script API + * collection or iterator (see {@link collectionElementType}): what `x[i]`, + * `x.pop()` or a `forEach` callback over `x` sees. + */ +function elementTypeOf(ctx, type, location) { + const { checker } = ctx; + if (checker.isArrayType(type)) + return checker.getTypeArguments(type)[0]; + return collectionElementType(ctx, type, location); +} +/** + * An array of `elements` (`ProductLineItem[]`, or `(OrderAddress | Shipment)[]` + * for an array filled with either), built by the checker itself so an + * inferred array reads, and resolves members (`filter`, `[0]`, `forEach`), + * exactly like a declared one. The factories are internal to TypeScript, so + * they are feature-detected: a TypeScript without them leaves inferred + * arrays silent rather than wrong. + */ +function arrayTypeOf(ctx, elements) { + const factories = ctx.checker; + if (typeof factories.createArrayType !== 'function') + return undefined; + if (elements.length === 1) + return factories.createArrayType(elements[0]); + if (elements.length === 0 || typeof factories.getUnionType !== 'function') + return undefined; + return factories.createArrayType(factories.getUnionType([...elements])); +} +/** Renders candidate types as hover text, e.g. `"Product | Category"`. */ +function describeTypes(ctx, types) { + return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); +} +/** + * One synthesized member completion. `sortText` '11' mirrors TS's own + * SortText.LocationPriority — the rank ordinary resolved members get — so + * inferred members sort alongside real ones rather than above or below them. + */ +function inferredCompletionEntry(ts, name, isMethod) { + return { + name, + kind: isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: constants_1.INFERRED_COMPLETION_SOURCE, + }; +} +/** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ +function typesToCompletionEntries(ts, checker, types) { + const seen = new Set(); + const entries = []; + for (const type of types) { + for (const sym of checker.getPropertiesOfType(getNonNullableApparentType(checker, type))) { + const name = sym.getName(); + if (seen.has(name)) + continue; + seen.add(name); + entries.push(inferredCompletionEntry(ts, name, (sym.flags & ts.SymbolFlags.Method) !== 0)); + } + } + return entries; +} diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js new file mode 100644 index 000000000..4df0ed9fa --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -0,0 +1,442 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.usageProfileOf = usageProfileOf; +const ast_helpers_1 = require("./ast-helpers"); +const constants_1 = require("./constants"); +const signatures_1 = require("./signatures"); +const this_properties_1 = require("./this-properties"); +const type_helpers_1 = require("./type-helpers"); +const value_flow_1 = require("./value-flow"); +function emptyProfile() { + return { + memberNames: new Set(), + optionalMemberNames: new Set(), + reliedOnMemberNames: new Set(), + guardTypes: [], + contextualTypes: [], + assignedValues: [], + pushedValues: [], + memberValues: new Map(), + forwardedTo: [], + }; +} +const EMPTY_PROFILE = emptyProfile(); +/** + * The instance type an `instanceof` right-hand side tests for + * (`dw.order.ProductLineItem`, a local class binding, ...): its construct + * signature's return type, or — for a JS constructor function, which has + * none — the declared type of the right-hand side's symbol. A test against a + * root type (`x instanceof Object`) says nothing and yields undefined. + */ +function instanceTypeOf(ctx, constructor) { + const { checker, ts } = ctx; + const constructorType = checker.getTypeAtLocation(constructor); + const [signature] = constructorType.getConstructSignatures(); + const symbol = constructorType.getSymbol() ?? checker.getSymbolAtLocation(constructor); + const instance = signature + ? checker.getReturnTypeOfSignature(signature) + : symbol && checker.getDeclaredTypeOfSymbol(symbol); + return instance && !(0, type_helpers_1.isOpenForUsageInference)(ts, instance) ? instance : undefined; +} +/** The primitive a `typeof x === 'string'` comparison names, if `typeOf` sits in one. */ +function primitiveOfTypeofCheck(ctx, typeOf) { + const { ts, checker } = ctx; + const comparison = typeOf.parent; + if (!ts.isBinaryExpression(comparison)) + return undefined; + const other = comparison.left === typeOf ? comparison.right : comparison.left; + if (!ts.isStringLiteralLike(other)) + return undefined; + if (other.text === 'string') + return checker.getStringType(); + if (other.text === 'number') + return checker.getNumberType(); + if (other.text === 'boolean') + return checker.getBooleanType(); + return undefined; +} +/** The string a presence test names, if `node` is a string literal. */ +function literalText(ctx, node) { + return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; +} +/** `'m' in x`: the member an `in` test on `reference` names. */ +function inOperatorMember(ctx, reference) { + const { ts } = ctx; + const test = reference.parent; + const isInTest = ts.isBinaryExpression(test) && test.operatorToken.kind === ts.SyntaxKind.InKeyword && test.right === reference; + return isInTest ? literalText(ctx, test.left) : undefined; +} +/** `x.hasOwnProperty('m')`: the member an own-property method call on `reference` names. */ +function ownPropertyMethodMember(ctx, reference) { + const { ts } = ctx; + const access = reference.parent; + const isHasOwn = ts.isPropertyAccessExpression(access) && access.expression === reference && access.name.text === 'hasOwnProperty'; + const call = access.parent; + return isHasOwn && ts.isCallExpression(call) && call.expression === access + ? literalText(ctx, call.arguments[0]) + : undefined; +} +/** `Object[.prototype].hasOwnProperty.call(x, 'm')`: the member a borrowed own-property call on `reference` names. */ +function ownPropertyCallMember(ctx, reference) { + const { ts } = ctx; + const call = reference.parent; + if (!ts.isCallExpression(call) || call.arguments[0] !== reference) + return undefined; + const callee = call.expression; + const isHasOwnCall = ts.isPropertyAccessExpression(callee) && + callee.name.text === 'call' && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === 'hasOwnProperty'; + return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; +} +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx, reference) { + return (inOperatorMember(ctx, reference) ?? ownPropertyMethodMember(ctx, reference) ?? ownPropertyCallMember(ctx, reference)); +} +/** + * True when `access` is the member access the current request's own cursor + * sits in (see {@link InferenceContext.triggerPosition}): a member name being + * typed (`shipment.pro|`) is not evidence yet, and a dangling `shipment.` + * followed by more code parses as an access to whatever identifier comes + * next, which no real class has. + */ +function isTriggerAccess(ctx, access) { + const trigger = ctx.triggerPosition; + return trigger !== undefined && access.expression.getEnd() <= trigger && trigger <= access.name.getEnd(); +} +/** The type `signature` declares for argument `index` of `call`, unless a rest parameter collects it. */ +function declaredArgumentType(ctx, signature, index, call) { + const parameter = signature.getParameters()[index]; + const declaration = parameter?.valueDeclaration; + if (!parameter || (declaration && ctx.ts.isParameter(declaration) && declaration.dotDotDotToken)) + return undefined; + return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); +} +/** + * The type argument `index` of `call` is passed as. For an overloaded callee + * it counts only when every overload taking that many arguments agrees on it + * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type + * the checker settles on the first such overload, which says nothing about + * the one the code means (`variationModel.getAllValues(attribute)` would + * make `attribute` an `ObjectAttributeDefinition`). + */ +function argumentType(ctx, call, reference, index) { + const { ts, checker } = ctx; + const argumentCount = call.arguments?.length ?? 0; + const callee = checker.getTypeAtLocation(call.expression); + const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); + const overloads = signatures.filter((signature) => (0, signatures_1.acceptsArgumentCount)(ctx, signature, argumentCount)); + if (overloads.length <= 1) + return checker.getContextualType(reference); + const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); + return first && rest.every((type) => type === first) ? first : undefined; +} +/** The type `reference` is used as: what its context expects, or the argument type of the call it is passed to. */ +function usedAsType(ctx, reference) { + const { ts, checker } = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const index = isCall ? (call.arguments?.indexOf(reference) ?? -1) : -1; + return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); +} +function isReferenceTo(ctx, target, node) { + const isName = ctx.ts.isIdentifier(node) && + node !== target.declarationName && + node.text === target.declarationName.text && + ctx.checker.getSymbolAtLocation(node) === target.symbol; + return isName || (0, this_properties_1.isStoredMemberRead)(ctx, target.storedIn, node); +} +/** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ +function testsPresence(ctx, target, condition) { + const visit = (node) => target.mayReference(node) && + ((isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true); + return visit(condition); +} +/** True when `statement` always leaves its block: a `return`/`throw`, or a block ending in one. */ +function alwaysExits(ctx, statement) { + const { ts } = ctx; + const last = ts.isBlock(statement) ? statement.statements[statement.statements.length - 1] : statement; + return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last)); +} +/** The statements after an early-exit presence test (`if ('m' in x) { return …; }`): its implicit `else`. */ +function statementsAfterPresenceExit(ctx, target, statements) { + const { ts } = ctx; + const guard = statements.findIndex((statement) => ts.isIfStatement(statement) && + !statement.elseStatement && + alwaysExits(ctx, statement.thenStatement) && + testsPresence(ctx, target, statement.expression)); + return guard < 0 ? [] : statements.slice(guard + 1); +} +/** What a branching `node` tests and the branches it picks between, for `if`, `?:` and `&&`. */ +function branchingOf(ts, node) { + if (ts.isIfStatement(node)) { + const branches = node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + return { condition: node.expression, branches }; + } + if (ts.isConditionalExpression(node)) + return { condition: node.condition, branches: [node.whenTrue, node.whenFalse] }; + const isAnd = ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isAnd ? { condition: node.left, branches: [node.right] } : undefined; +} +/** + * The branches of `node` that only run for some variants of the value: both + * branches of an `if`/`?:` that tests it for a member first (an early + * `return` making the rest of the block the `else`), and the right side of + * `'m' in x && …`. The test tells the code which kind of value it holds, so + * a member either branch uses belongs to that kind alone + * (`if ('searchPhraseSuggestions' in s) … else s.hasNext()`). + */ +function variantBranches(ctx, target, node) { + const { ts } = ctx; + if (ts.isBlock(node) || ts.isSourceFile(node)) + return statementsAfterPresenceExit(ctx, target, node.statements); + const branching = branchingOf(ts, node); + return branching && testsPresence(ctx, target, branching.condition) ? branching.branches : []; +} +/** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ +function recordMember(profile, name, optional) { + profile.memberNames.add(name); + if (optional) + profile.optionalMemberNames.add(name); +} +function pushDefined(list, value) { + if (value !== undefined) + list.push(value); +} +/** The binary expression `reference` is the left operand of, when its operator is `operator`. */ +function leftOperandOf(ctx, reference, operator) { + const parent = reference.parent; + const matches = ctx.ts.isBinaryExpression(parent) && parent.left === reference; + return matches && parent.operatorToken.kind === operator ? parent : undefined; +} +/** The member name a `x.m` / `x['m']` access reads, except the one the cursor is still typing. */ +function accessedMemberName(ctx, access) { + if (!ctx.ts.isPropertyAccessExpression(access)) + return literalText(ctx, access.argumentExpression); + return isTriggerAccess(ctx, access) ? undefined : access.name.text; +} +// Each recorder recognizes one role a reference can play and records it, +// returning true when the reference played that role. +/** `'m' in x`, `x.hasOwnProperty('m')`, ...: a member only some variants of the value have. */ +function recordPresenceTest({ ctx, reference }, profile) { + const member = presenceTestedMember(ctx, reference); + if (member !== undefined) + recordMember(profile, member, true); + return member !== undefined; +} +// Array methods whose arguments become elements of the receiver. +const ELEMENT_ADDING_METHODS = new Set(['push', 'unshift']); +/** The value `access = value` assigns, if `access` is the target of a plain assignment. */ +function valueAssignedTo(ctx, access) { + const { ts } = ctx; + const assignment = access.parent; + const isAssignment = ts.isBinaryExpression(assignment) && + assignment.left === access && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} +/** The values `x.push(a, b)` / `x.unshift(a)` add as elements, when `access` is such a call's callee. */ +function addedElements(ctx, access, member) { + const { ts } = ctx; + const call = access.parent; + if (member === undefined || !ELEMENT_ADDING_METHODS.has(member)) + return undefined; + if (!ts.isCallExpression(call) || call.expression !== access) + return undefined; + return call.arguments.filter((argument) => !ts.isSpreadElement(argument)); +} +/** What an access writes into the value: elements (`x.push(v)`, `x[i] = v`) or a member (`x.m = v`). */ +function recordWrite(ctx, access, member, profile) { + const added = addedElements(ctx, access, member); + const value = added ? undefined : valueAssignedTo(ctx, access); + if (added) + profile.pushedValues.push(...added); + else if (value && member !== undefined) { + profile.memberValues.set(member, [...(profile.memberValues.get(member) ?? []), value]); + } + else if (value && ctx.ts.isElementAccessExpression(access)) + profile.pushedValues.push(value); +} +/** `x.m` / `x['m']`: a member the value is accessed by, and anything the access writes into it. */ +function recordMemberAccess({ ctx, reference, inVariantBranch }, profile) { + const { ts } = ctx; + const access = reference.parent; + const isAccess = ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access); + if (!isAccess || access.expression !== reference) + return false; + const member = accessedMemberName(ctx, access); + if (member !== undefined) + recordMember(profile, member, inVariantBranch); + if (member !== undefined && !(0, ast_helpers_1.isTestedOrAlternative)(access, ts)) + profile.reliedOnMemberNames.add(member); + recordWrite(ctx, access, member, profile); + return true; +} +/** `typeof x === 'string'`: a primitive the value is tested against. */ +function recordTypeofTest({ ctx, reference }, profile) { + const typeOf = reference.parent; + if (!ctx.ts.isTypeOfExpression(typeOf)) + return false; + pushDefined(profile.guardTypes, primitiveOfTypeofCheck(ctx, typeOf)); + return true; +} +/** `x instanceof C`: a class the value is tested against. */ +function recordInstanceofTest({ ctx, reference }, profile) { + const test = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.InstanceOfKeyword); + if (test) + pushDefined(profile.guardTypes, instanceTypeOf(ctx, test.right)); + return test !== undefined; +} +/** `x = value`: a value the variable is reassigned. */ +function recordAssignment({ ctx, reference }, profile) { + const assignment = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.EqualsToken); + if (assignment) + profile.assignedValues.push(assignment.right); + return assignment !== undefined; +} +/** + * `helper(x)`, `new Model(x)`, `Base.call(this, x)` with an undocumented + * project function: the value is used however that function uses its + * parameter. Never the only role a reference plays — the contextual use is + * recorded too. + */ +function recordForwarding({ ctx, reference, inVariantBranch }, profile) { + const parameter = (0, value_flow_1.receivingParameter)(ctx, reference); + if (parameter && !(0, ast_helpers_1.hasExplicitParameterType)(parameter, ctx.ts)) { + profile.forwardedTo.push({ parameter, optional: inVariantBranch }); + } + return false; +} +/** + * Any other use: the type the code uses the value as. A reference the + * checker narrowed (inside `if (typeof x === 'string')`) is used as what that + * one branch holds, which says nothing about every value. + */ +function recordContextualUse({ ctx, target, reference }, profile) { + if (ctx.checker.getTypeAtLocation(reference) === target.declaredType) { + pushDefined(profile.contextualTypes, usedAsType(ctx, reference)); + } + return true; +} +const REFERENCE_RECORDERS = [ + recordPresenceTest, + recordMemberAccess, + recordTypeofTest, + recordInstanceofTest, + recordAssignment, + recordForwarding, + recordContextualUse, +]; +/** Records what one reference to the profiled value says about it: the first role it plays wins. */ +function recordReference(use, profile) { + REFERENCE_RECORDERS.some((record) => record(use, profile)); +} +function collectProfile(ctx, target, scope) { + const profile = emptyProfile(); + const visit = (node, inVariantBranch) => { + if (!target.mayReference(node)) + return; + if (isReferenceTo(ctx, target, node)) + recordReference({ ctx, target, reference: node, inVariantBranch }, profile); + const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); + ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); + }; + visit(scope, false); + for (const name of profile.memberNames) { + if (!profile.reliedOnMemberNames.has(name)) + profile.optionalMemberNames.add(name); + } + return profile; +} +/** Walks up from `node` to the body of the nearest enclosing function, if any. */ +function enclosingFunctionBody(ctx, node) { + for (let current = node.parent; current; current = current.parent) { + if (ctx.ts.isFunctionLike(current)) + return current.body; + } + return undefined; +} +/** + * Where a value's uses can be: a parameter's function body (nested closures + * included), a variable's enclosing function or file, or — once a parameter + * is stored on `this` — the whole file its prototype methods read it in. + */ +function profileScope(ctx, declaration, storedIn) { + if (storedIn.size > 0) + return declaration.getSourceFile(); + return ctx.ts.isParameter(declaration) + ? declaration.parent.body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); +} +/** + * What the value's own scope says about it, gathered over its + * {@link profileScope}; a parameter stored on `this` is also read through + * that member (see ./this-properties). Destructured declarations have no + * single value to profile and get an empty profile. Memoized per request. + */ +function ownProfileOf(ctx, declaration) { + const { ts, checker } = ctx; + const declarationName = declaration.name; + if (!ts.isIdentifier(declarationName)) + return EMPTY_PROFILE; + const symbol = checker.getSymbolAtLocation(declarationName); + const cached = symbol && ctx.profiles.get(symbol); + if (!symbol || cached) + return cached ?? EMPTY_PROFILE; + const storedIn = ts.isParameter(declaration) ? (0, this_properties_1.thisMembersStoring)(ctx, declaration) : new Set(); + const scope = profileScope(ctx, declaration, storedIn); + if (!scope) + return EMPTY_PROFILE; + const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const mayReference = (0, ast_helpers_1.spellingFilter)(scope, [declarationName.text, ...[...storedIn].map((member) => member.name)]); + const profile = collectProfile(ctx, { symbol, declarationName, declaredType, storedIn, mayReference }, scope); + ctx.profiles.set(symbol, profile); + return profile; +} +/** + * The own profiles of the parameters `profile`'s value is passed on to, + * breadth first, up to {@link MAX_USAGE_FORWARDING_HOPS} functions away; a + * profile reached through a variant branch anywhere along the way is + * optional. + */ +function forwardedProfiles(ctx, declaration, profile) { + const reached = []; + const seen = new Set([declaration]); + let frontier = profile.forwardedTo; + for (let hop = 0; hop < constants_1.MAX_USAGE_FORWARDING_HOPS && frontier.length > 0; hop++) { + const unseen = frontier.filter(({ parameter }) => !seen.has(parameter) && seen.add(parameter)); + const profiles = unseen.map(({ parameter, optional }) => ({ profile: ownProfileOf(ctx, parameter), optional })); + reached.push(...profiles); + frontier = profiles.flatMap(({ profile: next, optional }) => next.forwardedTo.map((use) => ({ parameter: use.parameter, optional: optional || use.optional }))); + } + return reached; +} +/** + * The usage profile of a parameter or a variable: what its own scope says + * about it (see {@link ownProfileOf}), plus the members and contextual types + * of the parameters it is passed on to unchanged — a wrapper's `x` must + * support whatever the `new Model(x)` it forwards to does with it. + */ +function usageProfileOf(ctx, declaration) { + const own = ownProfileOf(ctx, declaration); + if (own.forwardedTo.length === 0) + return own; + const members = { memberNames: new Set(own.memberNames), optionalMemberNames: new Set(own.optionalMemberNames) }; + const contextualTypes = [...own.contextualTypes]; + for (const { profile, optional } of forwardedProfiles(ctx, declaration, own)) { + for (const name of profile.memberNames) { + recordMember(members, name, optional || profile.optionalMemberNames.has(name)); + } + contextualTypes.push(...profile.contextualTypes); + } + return { ...own, ...members, contextualTypes }; +} diff --git a/packages/b2c-script-types/plugin/inference/value-flow.js b/packages/b2c-script-types/plugin/inference/value-flow.js new file mode 100644 index 000000000..caf96ab48 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/value-flow.js @@ -0,0 +1,240 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getReferenceNameNode = getReferenceNameNode; +exports.enclosingFunction = enclosingFunction; +exports.receivingParameter = receivingParameter; +exports.holderOf = holderOf; +exports.valueTarget = valueTarget; +const signatures_1 = require("./signatures"); +/** The name a function declaration, method or class declares itself by. */ +function ownName(fn, ts) { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; +} +/** The name `left = value` binds: `x`, `obj.x`, `exports.x`, or `exports` for `module.exports`. */ +function assignedName(ts, left) { + if (ts.isPropertyAccessExpression(left)) + return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : undefined; +} +/** The variable or property `value` initializes: `var helper = …`, `{helper: …}`, `{helper}`, `const {helper} = …`. */ +function declaredName(ts, value) { + const parent = value.parent; + if (ts.isShorthandPropertyAssignment(parent)) + return parent.name; + const initializes = ts.isBindingElement(parent) || + ((ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) && parent.initializer === value); + return initializes && ts.isIdentifier(parent.name) ? parent.name : undefined; +} +/** + * The name `value` is bound to: the variable or property it initializes + * (see {@link declaredName}), or the target it is assigned to + * (`exports.helper = …`). For `module.exports = …` that is the `exports` + * name, whose references are the `require()` calls of the module. + */ +function bindingNameOf(ts, value) { + const parent = value.parent; + const isAssignment = ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === value; + return isAssignment ? assignedName(ts, parent.left) : declaredName(ts, value); +} +/** + * Identifies the name to run findReferences on for a function-like + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. + */ +function getReferenceNameNode(fn, ts) { + const callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + return ownName(callable, ts) ?? (ts.isExpression(callable) ? bindingNameOf(ts, callable) : undefined); +} +/** The function enclosing `node`, if any. */ +function enclosingFunction(node, ts) { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) + return current; + } + return undefined; +} +/** + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call, ts) { + const list = call.arguments[1]; + if (!list) + return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') + return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} +/** What a reference stands for as a value: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function accessOf(node, ts) { + const { parent } = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee, ts) { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? { node: call, args: call.arguments ?? [] } : undefined; +} +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee, ts) { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) + return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) + return undefined; + if (access.name.text === 'call') + return { node: call, args: call.arguments.slice(1) }; + return access.name.text === 'apply' ? { node: call, args: applyArguments(call, ts) } : undefined; +} +/** + * A `require('specifier')` call, identified structurally (only public + * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part + * of TypeScript's public API surface, so isn't safe to depend on here). + */ +function isRequireCallExpression(node, ts) { + return (ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length > 0 && + ts.isStringLiteralLike(node.arguments[0])); +} +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node, ts) { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} +/** True when `parent` can evaluate to `child` unchanged: `(x)`, `c ? x : y`, `a || x`, `a ?? x`, `a && x`. */ +function passesThrough(ts, parent, child) { + if (ts.isParenthesizedExpression(parent)) + return true; + if (ts.isConditionalExpression(parent)) + return parent.condition !== child; + if (!ts.isBinaryExpression(parent)) + return false; + const operator = parent.operatorToken.kind; + const { SyntaxKind } = ts; + if (operator === SyntaxKind.AmpersandAmpersandToken) + return parent.right === child; + return operator === SyntaxKind.BarBarToken || operator === SyntaxKind.QuestionQuestionToken; +} +/** The outermost expression that still evaluates to `node` (`getModel(a) || Fallback` for `Fallback`). */ +function outermostValue(ts, node) { + let value = node; + while (value.parent && passesThrough(ts, value.parent, value)) + value = value.parent; + return value; +} +/** + * The project function `call` passes its arguments to, and the argument its + * first parameter receives: `fn(a)`, `new Fn(a)`, and `Fn.call(this, a)`, the + * SFRA inheritance idiom. Only a function whose body is in the project (not + * one a declaration file describes) does anything worth following. + */ +function argumentReceiver(ctx, call) { + const { ts, checker } = ctx; + const callee = call.expression; + const borrowed = ts.isCallExpression(call) && ts.isPropertyAccessExpression(callee) && callee.name.text === 'call'; + const fn = borrowed + ? checker.getTypeAtLocation(callee.expression).getCallSignatures()[0]?.getDeclaration() + : (0, signatures_1.resolveCalleeDeclaration)(ctx, call); + const hasBody = fn !== undefined && fn.body !== undefined; + return hasBody ? { fn: fn, firstArgument: borrowed ? 1 : 0 } : undefined; +} +/** + * The parameter of a project function that receives `value` as an argument: + * `Model` of `createRefinement(search, definition, Model)`, or + * `refinementValue` of `new BooleanAttributeValue(search, definition, + * refinementValue)`. + */ +function receivingParameter(ctx, value) { + const { ts } = ctx; + const call = value.parent; + if (!ts.isCallExpression(call) && !ts.isNewExpression(call)) + return undefined; + const index = call.arguments?.indexOf(value) ?? -1; + const receiver = index >= 0 ? argumentReceiver(ctx, call) : undefined; + const parameter = receiver?.fn.parameters[index - receiver.firstArgument]; + return parameter && !parameter.dotDotDotToken && ts.isIdentifier(parameter.name) ? parameter : undefined; +} +/** The function `value` is the result of: `return value;`, or an arrow function's expression body. */ +function returningFunction(ts, value) { + const parent = value.parent; + if (ts.isArrowFunction(parent)) + return parent.body === value ? parent : undefined; + return ts.isReturnStatement(parent) ? enclosingFunction(parent, ts) : undefined; +} +/** The next name holding `value` in `role`: a binding, an assignment target, or the parameter it is passed to. */ +function heldBy(ctx, value, role) { + const name = bindingNameOf(ctx.ts, value) ?? receivingParameter(ctx, value)?.name; + return name && { kind: 'name', name, role }; +} +/** A function returning `value` is a factory of it: its calls evaluate to `value`. */ +function returnedBy(ctx, value) { + const factory = returningFunction(ctx.ts, value); + const name = factory && getReferenceNameNode(factory, ctx.ts); + return name && { kind: 'name', name, role: 'factory' }; +} +/** The call `value` is an argument of, as a {@link Handoff}: what {@link receivingParameter} leaves over. */ +function handedOff(ctx, value) { + const call = value.parent; + if (!ctx.ts.isCallExpression(call)) + return undefined; + const argIndex = call.arguments.indexOf(value); + return argIndex >= 0 ? { kind: 'handoff', handoff: { call, argIndex } } : undefined; +} +/** + * Where a search for the calls of `fn` starts: the name it is declared or + * bound by (see {@link getReferenceNameNode}), or for an anonymous function a + * factory returns (`return function (req, res, next) {...}`), the factory, + * whose calls evaluate to it. + */ +function holderOf(ctx, fn) { + const { ts } = ctx; + const name = getReferenceNameNode(fn, ts); + if (name) + return { name, role: 'value' }; + const anonymous = ts.isFunctionExpression(fn) || ts.isArrowFunction(fn); + const factory = anonymous ? returnedBy(ctx, outermostValue(ts, fn)) : undefined; + return factory?.kind === 'name' ? factory : undefined; +} +/** + * Where one reference to the function leads. In the `value` role the + * reference is the function itself: a call invoking it + * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, + * `require('./helper')(x)`) is a call site; a binding, an assignment, an + * argument a project function receives or a `return` leads on to the next + * name holding it; any other argument is a handoff. In the + * `factory` role the reference names a function returning it, so a call of + * that reference evaluates to the function and is followed as a value in + * turn. + */ +function valueTarget(ctx, reference, role) { + const { ts } = ctx; + const value = outermostValue(ts, requireCallOf(reference, ts) ?? accessOf(reference, ts)); + if (role === 'factory') { + const call = value.parent; + const invoked = ts.isCallExpression(call) && call.expression === value; + return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); + } + const call = directCall(value, ts) ?? borrowedCall(value, ts); + if (call) + return { kind: 'call', call }; + return heldBy(ctx, value, 'value') ?? returnedBy(ctx, value) ?? handedOff(ctx, value); +} diff --git a/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js new file mode 100644 index 000000000..bbfc390a4 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js @@ -0,0 +1,155 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.readJsonFile = readJsonFile; +exports.discoverCartridgesOnDisk = discoverCartridgesOnDisk; +exports.readDwJsonCartridges = readDwJsonCartridges; +exports.orderCartridges = orderCartridges; +exports.parseDeclareModuleRanges = parseDeclareModuleRanges; +// Standalone helpers for finding and ordering cartridges without any of the +// plugin's mutable state. They take the `ts` namespace (and, where needed, a +// `fileExists` probe) as plain arguments and return data, so they're easy to +// read and test in isolation. index.ts wires them into the plugin's +// auto-discovery step. +const node_fs_1 = require("node:fs"); +const node_path_1 = __importDefault(require("node:path")); +const constants_1 = require("./constants"); +/** + * Parses a workspace JSON file (dw.json, a cartridge's package.json) with a + * hard size ceiling (see MAX_JSON_BYTES). Never throws: a missing, oversized, + * or malformed file yields `undefined`, and callers treat that as "absent" + * rather than failing the whole request. + * + * The size check always uses `fs.statSync` rather than `ts.sys.getFileSize` + * (which is optional per the TS API and, when absent, would otherwise force + * `ts.sys.readFile` to load the whole file before we can measure it) so the + * DoS guard holds on every host. + */ +function readJsonFile(ts, filePath) { + try { + if ((0, node_fs_1.statSync)(filePath).size > constants_1.MAX_JSON_BYTES) + return undefined; + const content = ts.sys.readFile(filePath); + if (content === undefined || content.length > constants_1.MAX_JSON_BYTES) + return undefined; + return JSON.parse(content); + } + catch { + return undefined; + } +} +/** + * Recursively walks projectRoot for `.project` markers. Stops descending into + * a cartridge once found (cartridges don't nest). Depth-limited to keep + * tsserver startup snappy on huge monorepos. + */ +function discoverCartridgesOnDisk(ts, projectRoot, fileExists) { + const found = []; + const stack = [{ dir: projectRoot, depth: 0 }]; + while (stack.length > 0) { + const { dir, depth } = stack.pop(); + if (fileExists(node_path_1.default.join(dir, '.project'))) { + found.push({ name: node_path_1.default.basename(dir), src: dir }); + continue; + } + if (depth >= constants_1.DISCOVERY_MAX_DEPTH) + continue; + let subdirs = []; + try { + subdirs = ts.sys.getDirectories(dir); + } + catch { + subdirs = []; + } + for (const sub of subdirs) { + if (constants_1.DISCOVERY_IGNORE.has(sub)) + continue; + stack.push({ dir: node_path_1.default.join(dir, sub), depth: depth + 1 }); + } + } + // Stable ordering for deterministic auto-discovery output. + found.sort((a, b) => a.src.localeCompare(b.src)); + return found; +} +/** + * Reads the top-level dw.json `cartridges` field (string with comma/colon + * separators OR array of names) for an explicit cartridge-path order. + * Mirrors what the b2c CLI's resolved config exposes; we don't try to honor + * SFCC_CARTRIDGES / .env / plugins here — hosts that need that complexity + * should push the resolved list in via configurePlugin(). + */ +function readDwJsonCartridges(ts, projectRoot, fileExists) { + const dwJsonPath = node_path_1.default.join(projectRoot, 'dw.json'); + if (!fileExists(dwJsonPath)) + return undefined; + const parsed = readJsonFile(ts, dwJsonPath); + const value = parsed?.cartridges; + if (typeof value === 'string') { + return value + .split(/[,:]/) + .map((s) => s.trim()) + .filter(Boolean); + } + if (Array.isArray(value)) { + return value.filter((s) => typeof s === 'string' && s.length > 0); + } + return undefined; +} +/** The configured cartridges that exist, in configured order, then every other discovered one in discovery order. */ +function namedFirst(discovered, configured) { + const byName = new Map(discovered.map((c) => [c.name, c])); + const named = [...new Set(configured)].flatMap((name) => byName.get(name) ?? []); + const namedNames = new Set(named.map((c) => c.name)); + return [...named, ...discovered.filter((c) => !namedNames.has(c.name))]; +} +/** Discovery order with the known base cartridges moved last (the sort is stable). */ +function baseCartridgesLast(discovered) { + // hasOwn guard so a cartridge directory literally named `__proto__` or + // `constructor` can't read an inherited Object.prototype value here (which + // would make the rank a non-number and corrupt the sort comparator). + const rankOf = (name) => Object.prototype.hasOwnProperty.call(constants_1.BASE_CARTRIDGE_RANK, name) ? constants_1.BASE_CARTRIDGE_RANK[name] : 0; + return [...discovered].sort((a, b) => rankOf(a.name) - rankOf(b.name)); +} +/** + * Applies cartridge ordering: if `configured` is set, named-first then any + * remaining discovered cartridges in their original order; otherwise + * discovery order with the known base cartridges sorted last. + */ +function orderCartridges(discovered, configured) { + return configured && configured.length > 0 ? namedFirst(discovered, configured) : baseCartridgesLast(discovered); +} +/** + * Finds the byte ranges of each `declare module 'X' { ... }` block in a .d.ts + * file, so go-to-definition results landing inside the bundled SFRA + * server.d.ts can be mapped back to the module they belong to. Linear scan + * with brace-matching — the regex only matches the block opener, never the + * whole (possibly huge) body. + */ +function parseDeclareModuleRanges(content) { + const ranges = []; + const re = /declare module ['"]([^'"]+)['"]\s*\{/g; + let m; + while ((m = re.exec(content)) !== null) { + const start = m.index; + // Walk forward from the opening brace to find the matching close. + let depth = 1; + let i = m.index + m[0].length; + while (i < content.length && depth > 0) { + const ch = content[i]; + if (ch === '{') + depth++; + else if (ch === '}') + depth--; + i++; + } + ranges.push({ start, end: i, module: m[1] }); + } + return ranges; +} diff --git a/packages/b2c-script-types/plugin/resolver/constants.js b/packages/b2c-script-types/plugin/resolver/constants.js new file mode 100644 index 000000000..349198525 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/constants.js @@ -0,0 +1,55 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.MAX_JSON_BYTES = exports.DISCOVERY_MAX_DEPTH = exports.DISCOVERY_IGNORE = exports.BASE_CARTRIDGE_RANK = exports.CANDIDATE_EXTENSIONS = exports.SFRA_AMBIENT_MODULES = exports.PLUGIN_NAME = void 0; +// Shared types and plain-data constants for the plugin's cartridge resolution +// and discovery. Kept separate from index.ts so the plugin factory there reads +// as "what the plugin does", not "what its lookup tables are". Path constants +// that depend on the plugin's on-disk location (the bundled types dir) stay in +// index.ts, where `__dirname` points at the right place. +exports.PLUGIN_NAME = '@salesforce/b2c-script-types'; +// Bare-name requires that the SFRA server.d.ts ambient declaration covers. +// We deliberately do NOT redirect these to modules/.js, so TS uses the +// ambient declaration's types instead of the inferred .js types (which can't +// see the dynamic `server.middleware = ...` assignments in modules/server.js). +exports.SFRA_AMBIENT_MODULES = new Set([ + 'server', + 'server/server', + 'server/middleware', + 'server/render', + 'server/route', + 'server/request', + 'server/response', + 'server/queryString', + 'server/forms', + 'server/forms/forms', +]); +// Candidate suffixes appended when resolving a SFCC-style relative require to +// a cartridge file. SFRA convention is to omit the .js extension, so .js wins +// first; .json captures the occasional resource bundle import; .ds covers +// legacy pipeline-era scripts, which the platform also resolves after .js. +exports.CANDIDATE_EXTENSIONS = ['.js', '.json', '.ds', '/index.js', '/index.ds']; +// Cartridges that conventionally sit at the bottom of the cartridge path when +// the user hasn't told us otherwise (no `cartridges` in dw.json/SFCC_CARTRIDGES). +// Higher rank = lower in the cartridge path. SFRA's runtime path ends with +// `app_storefront_base:modules`, so `modules` sorts strictly last. +// Mirrors BASE_CARTRIDGE_RANK in packages/b2c-vs-extension/src/cartridges/cartridge-service.ts. +exports.BASE_CARTRIDGE_RANK = { + app_storefront_base: 1, + modules: 2, +}; +// Directories skipped during recursive .project discovery. Mirrors the ignore +// list in @salesforce/b2c-tooling-sdk's findCartridges() so plain LSP usage +// matches CLI/extension discovery. +exports.DISCOVERY_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.cache', 'tmp', 'temp']); +exports.DISCOVERY_MAX_DEPTH = 8; +// Hard size ceiling for workspace JSON files (dw.json, a cartridge's +// package.json) parsed on tsserver's thread. Both are attacker-controlled in a +// cloned repo, so a multi-hundred-megabyte file would be a denial-of-service +// vector (memory + parse time) — a real file is a few KB, so anything past +// 1 MiB is refused outright rather than best-effort parsed. +exports.MAX_JSON_BYTES = 1024 * 1024; diff --git a/packages/b2c-script-types/plugin/resolver/hook-registry.js b/packages/b2c-script-types/plugin/resolver/hook-registry.js new file mode 100644 index 000000000..abee4d60e --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/hook-registry.js @@ -0,0 +1,74 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.readHookRegistrations = readHookRegistrations; +// Hook registrations: which script implements which extension point. The +// platform reads the `hooks` entry of a cartridge's package.json, which names +// a hooks.json file listing `{name, script}` pairs; each script path is +// relative to that hooks.json and, like a require(), may leave out its +// extension. Usage inference reads the registrations to treat +// `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` as a call of +// the `calculate` export of every script registered for `dw.order.calculate`. +// +// Both files are project content: they are only ever parsed as JSON, and every +// path they name is contained to the cartridge that declares it. +const node_path_1 = __importDefault(require("node:path")); +// A registered script resolves like a require(): as written, then with the +// extensions the platform tries for a script. +const SCRIPT_EXTENSIONS = ['', '.js', '.ds']; +function readJson(deps, fileName) { + try { + const text = deps.readFile(fileName); + return text === undefined ? undefined : JSON.parse(text); + } + catch { + return undefined; + } +} +/** `value[key]` when `value` is an object and that field holds a non-empty string. */ +function stringField(value, key) { + const field = value !== null && typeof value === 'object' ? value[key] : undefined; + return typeof field === 'string' && field.length > 0 ? field : undefined; +} +/** `relative` resolved against the directory `dir`, when that stays inside `cartridge`. */ +function containedPath(deps, cartridge, dir, relative) { + const candidate = node_path_1.default.posix.join(dir, relative.replace(/\\/g, '/')); + return deps.isWithinRoot(candidate, cartridge.root) ? candidate : undefined; +} +function resolveScript(deps, cartridge, dir, script) { + const base = containedPath(deps, cartridge, dir, script); + if (base === undefined) + return undefined; + return SCRIPT_EXTENSIONS.map((ext) => base + ext).find((candidate) => deps.fileExists(candidate)); +} +/** The hooks.json file a cartridge's package.json names, if any. */ +function hooksFileOf(deps, cartridge) { + const hooksPath = stringField(readJson(deps, cartridge.rawRoot + 'package.json'), 'hooks'); + return hooksPath === undefined ? undefined : containedPath(deps, cartridge, cartridge.rawRoot, hooksPath); +} +/** The registrations one cartridge's package.json and hooks.json declare. */ +function cartridgeRegistrations(deps, cartridge) { + const hooksFile = hooksFileOf(deps, cartridge); + const hooks = hooksFile === undefined ? undefined : readJson(deps, hooksFile); + const entries = hooks !== null && typeof hooks === 'object' ? hooks.hooks : undefined; + if (hooksFile === undefined || !Array.isArray(entries)) + return []; + const hooksDir = node_path_1.default.posix.dirname(hooksFile); + return entries.flatMap((entry) => { + const extensionPoint = stringField(entry, 'name'); + const script = stringField(entry, 'script'); + const resolved = extensionPoint && script ? resolveScript(deps, cartridge, hooksDir, script) : undefined; + return extensionPoint && resolved ? [{ extensionPoint, script: resolved }] : []; + }); +} +/** Every hook registration the configured cartridges declare. Unreadable or malformed files declare none. */ +function readHookRegistrations(cartridges, deps) { + return cartridges.flatMap((cartridge) => cartridgeRegistrations(deps, cartridge)); +} diff --git a/packages/b2c-script-types/plugin/resolver/module-resolution.js b/packages/b2c-script-types/plugin/resolver/module-resolution.js new file mode 100644 index 000000000..cd165c2b5 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/module-resolution.js @@ -0,0 +1,184 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createPathContainment = createPathContainment; +exports.ownerCartridge = ownerCartridge; +exports.resolveCartridgeModule = resolveCartridgeModule; +exports.resolveModulesCartridge = resolveModulesCartridge; +// Security-critical path containment and cartridge-relative module resolution, +// extracted out of index.ts so these can be read and tested against plain +// data (a cartridge list, a fileExists probe) without a full LanguageService +// fixture. Every resolver here is a pure function of its arguments; index.ts +// wires them to the live plugin state (cartridges, ts.sys, the LS host). +const node_path_1 = __importDefault(require("node:path")); +const cartridge_discovery_1 = require("./cartridge-discovery"); +const constants_1 = require("./constants"); +/** + * Builds the two path-safety primitives every resolver below is checked + * against: `normalize` (forward slashes, case-folded on case-insensitive + * filesystems) and `isWithinRoot` (true when `candidate` resolves to a + * location at or beneath `rootDir`). This is the trust boundary for every + * resolver in this file: import specifiers, cartridge names, and a + * cartridge's package.json `main` are all attacker-controlled in a cloned + * repository, so a resolved path that escapes its intended root (via `..`, + * an absolute/UNC/drive form, or a symlink) must be rejected rather than + * read into the TS program. + * + * `isWithinRoot` resolves symlinks once, at check time, via + * `ts.sys.realpath` — it does not re-check immediately before the caller's + * subsequent `fileExists`/`readFile`. A symlink swapped in between those two + * calls (e.g. by a build script running concurrently in the cloned repo) + * could in principle bypass containment. The accepted threat model here is + * malicious *content* in a cloned repository, not an active attacker + * racing the filesystem during a single hover/completion request, so this + * gap is left unaddressed; revisit if that threat model changes. + */ +function createPathContainment(ts, caseSensitive) { + const normalize = (p) => { + const slashed = p.replace(/\\/g, '/'); + return caseSensitive ? slashed : slashed.toLowerCase(); + }; + // Canonical, real form of a path for containment checks: resolve symlinks + // (ts.sys.realpath) so an in-repo symlink can't point a require() at a file + // outside its root, then collapse `.`/`..` and fold to the same slash/case + // convention cartridge roots use. Falls back to a purely lexical resolve + // when the path doesn't exist or realpath is unavailable, so a crafted + // non-existent candidate is still `..`-collapsed before the check. + const canonicalPath = (p) => { + let real = p; + try { + if (ts.sys.realpath) + real = ts.sys.realpath(p); + } + catch { + // Non-existent path (or realpath failure) — fall back to lexical. + } + return normalize(node_path_1.default.resolve(real)); + }; + const isWithinRoot = (candidate, rootDir) => { + const root = canonicalPath(rootDir); + const resolved = canonicalPath(candidate); + return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); + }; + return { normalize, isWithinRoot }; +} +function ownerCartridge(cartridges, normalize, containingFile) { + const f = normalize(containingFile); + return cartridges.find((c) => f.startsWith(c.root)); +} +function reorderForContainingFile(cartridges, normalize, containingFile) { + const owner = ownerCartridge(cartridges, normalize, containingFile); + if (!owner) + return cartridges; + return [owner, ...cartridges.filter((c) => c !== owner)]; +} +function cartridgeLookup(cartridges, moduleName, containingFile, normalize) { + if (moduleName.startsWith('~/')) { + // ~ is the current cartridge — restrict to the cartridge that owns the + // calling file. If the containing file isn't inside any known cartridge, + // there is no current cartridge, so the require can't be resolved. + const owner = ownerCartridge(cartridges, normalize, containingFile); + return owner ? { order: [owner], subpath: moduleName.slice(2) } : undefined; + } + if (moduleName.startsWith('*/')) { + // * walks the cartridge path. Owner-first matches SFRA-style overrides + // (the requesting cartridge wins before falling through to others). + return { order: reorderForContainingFile(cartridges, normalize, containingFile), subpath: moduleName.slice(2) }; + } + // /cartridge/... — only treat as a cartridge require if the + // first segment matches a known cartridge name. Otherwise pass through so + // node_modules and other resolutions still work. + const slash = moduleName.indexOf('/'); + if (slash <= 0) + return undefined; + const head = moduleName.slice(0, slash); + const known = cartridges.find((c) => c.name === head); + return known ? { order: [known], subpath: moduleName.slice(slash + 1) } : undefined; +} +/** + * The first `/` that exists and stays inside the + * cartridge. `subpath` comes straight from the import specifier, so a `..` + * segment (or an absolute/symlinked target) can point outside the cartridge — + * every candidate is resolved and contained before it is accepted. + */ +function firstContainedCandidate(cartridge, subpath, deps) { + const baseAbs = cartridge.rawRoot + subpath; + for (const ext of constants_1.CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, cartridge.root)) { + return { resolved: candidate, source: cartridge.name }; + } + } + return undefined; +} +/** + * Resolves a SFCC cartridge-style require relative to the configured + * cartridge path. Returns the absolute path to the resolved JS file, or + * undefined if no cartridge contains the target. + * + * ~/cartridge/scripts/foo -> only the cartridge that owns containingFile + * * /cartridge/scripts/foo -> walks the cartridge path, owner-first + * bar/cartridge/scripts/foo -> only the cartridge named "bar" + */ +function resolveCartridgeModule(cartridges, moduleName, containingFile, deps) { + const lookup = cartridges.length > 0 ? cartridgeLookup(cartridges, moduleName, containingFile, deps.normalize) : undefined; + if (!lookup?.subpath) + return undefined; + for (const cartridge of lookup.order) { + const found = firstContainedCandidate(cartridge, lookup.subpath, deps); + if (found) + return found; + } + return undefined; +} +const NON_MODULES_PREFIXES = ['.', '/', '~/', '*/', 'dw/']; +/** True for a bare specifier (`server`, `server/middleware`) the `modules` cartridge may own. */ +function isModulesCartridgeSpecifier(moduleName) { + if (NON_MODULES_PREFIXES.some((prefix) => moduleName.startsWith(prefix))) + return false; + // Let the bundled SFRA ambient declarations win for these names. If we + // resolved them to the .js file here, TS would infer types from the JS + // (which misses dynamic property assignments in modules/server.js) and + // ignore the ambient `declare module 'server' { ... }` shape. + return !constants_1.SFRA_AMBIENT_MODULES.has(moduleName); +} +/** package.json `main` fallback for a `modules` directory without an index.js. */ +function packageMainCandidate(ts, modulesCart, moduleName, deps) { + const pkgPath = modulesCart.rawRoot + moduleName + '/package.json'; + if (!deps.fileExists(pkgPath) || !deps.isWithinRoot(pkgPath, modulesCart.root)) + return undefined; + const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; + if (typeof main !== 'string' || main.length === 0) + return undefined; + const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + return deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root) + ? { resolved, source: modulesCart.name } + : undefined; +} +/** + * Resolves a bare `require('server')`-style import against the SFRA `modules` + * cartridge. Unlike normal cartridges (which expose files under + * `cartridge/scripts/...`), the `modules` cartridge exposes its entire tree + * at the root, so `require('server')` -> `/server[.js|/index.js]` + * and `require('server/middleware')` -> `/server/middleware[.js]`. + * Falls through unless a cartridge literally named `modules` is in the list. + * `moduleName` may carry `..` after its first segment (it only can't *start* + * with `.`/`/`), so every candidate is contained against the modules root. + */ +function resolveModulesCartridge(ts, cartridges, moduleName, deps) { + const modulesCart = isModulesCartridgeSpecifier(moduleName) + ? cartridges.find((c) => c.name === 'modules') + : undefined; + if (!modulesCart) + return undefined; + return (firstContainedCandidate(modulesCart, moduleName, deps) ?? packageMainCandidate(ts, modulesCart, moduleName, deps)); +} diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js new file mode 100644 index 000000000..09c20a34d --- /dev/null +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -0,0 +1,58 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.usageProfileOf = exports.inferTypeForNode = exports.inferReturnType = exports.inferParameterType = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.describeTypes = exports.memberCompletionAccess = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; +// Public entry point for the usage-inference engine. The implementation is +// split across the ./inference/ modules by responsibility; this barrel just +// re-exports the pieces the tsserver plugin (and the test suite) consume, so +// callers have one stable import path and don't need to know the internal +// layout. The engine gathers evidence (./inference/core) and then applies one +// decision policy (./inference/policy) at every parameter, return value and +// variable. Read the modules in this order to understand it: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo, guards) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/value-flow - where a function value goes: calls, exports, factories, parameters +// inference/reference-search - where a name is used, read from the files that can name it +// inference/call-sites - find where a function is called across the project +// inference/signatures - the function a call invokes and its declared callback types +// inference/callback-arguments - what a project helper passes the callback it is handed +// inference/generic-calls - generic calls whose result is an argument or a callback's return +// inference/member-values - the values an untyped member is built with +// inference/super-module - module.superModule detection and resolution +// inference/this-properties - constructor arguments kept on `this` and read in prototype methods +// inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/hook-calls - hook script exports called through HookMgr.callHook and hooks.json +// inference/bindings - a helper's parameters bound to one call's arguments +// inference/ambient-index - every ambient class by member names +// inference/naming - identifier-name tiebreak between usage matches +// inference/framework-contracts - parameters the platform itself passes (Page Designer) +// inference/policy - turning evidence into the type(s) shown +// inference/core - the recursive evidence-gathering engine +// inference/editor-hooks - hover and completion decoration for the plugin +var constants_1 = require("./inference/constants"); +Object.defineProperty(exports, "INFERRED_COMPLETION_SOURCE", { enumerable: true, get: function () { return constants_1.INFERRED_COMPLETION_SOURCE; } }); +var context_1 = require("./inference/context"); +Object.defineProperty(exports, "createInferenceContext", { enumerable: true, get: function () { return context_1.createInferenceContext; } }); +var ast_helpers_1 = require("./inference/ast-helpers"); +Object.defineProperty(exports, "memberCompletionAccess", { enumerable: true, get: function () { return ast_helpers_1.memberCompletionAccess; } }); +var type_helpers_1 = require("./inference/type-helpers"); +Object.defineProperty(exports, "describeTypes", { enumerable: true, get: function () { return type_helpers_1.describeTypes; } }); +Object.defineProperty(exports, "typesToCompletionEntries", { enumerable: true, get: function () { return type_helpers_1.typesToCompletionEntries; } }); +var super_module_1 = require("./inference/super-module"); +Object.defineProperty(exports, "collectSuperModuleAugmentedMembers", { enumerable: true, get: function () { return super_module_1.collectSuperModuleAugmentedMembers; } }); +var core_1 = require("./inference/core"); +Object.defineProperty(exports, "inferParameterType", { enumerable: true, get: function () { return core_1.inferParameterType; } }); +Object.defineProperty(exports, "inferReturnType", { enumerable: true, get: function () { return core_1.inferReturnType; } }); +Object.defineProperty(exports, "inferTypeForNode", { enumerable: true, get: function () { return core_1.inferTypeForNode; } }); +var usage_profile_1 = require("./inference/usage-profile"); +Object.defineProperty(exports, "usageProfileOf", { enumerable: true, get: function () { return usage_profile_1.usageProfileOf; } }); +var policy_1 = require("./inference/policy"); +Object.defineProperty(exports, "matchAmbientTypesByUsage", { enumerable: true, get: function () { return policy_1.matchAmbientTypesByUsage; } }); +var editor_hooks_1 = require("./inference/editor-hooks"); +Object.defineProperty(exports, "createUsageInferenceHooks", { enumerable: true, get: function () { return editor_hooks_1.createUsageInferenceHooks; } }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index d5c55396b..f909c8bd3 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -7,41 +7,23 @@ import path from 'node:path'; import type tsserver from 'typescript/lib/tsserverlibrary'; -interface ConfiguredCartridge { - name: string; - src: string; -} - -interface PluginConfig { - /** @deprecated use cartridges; kept for backward compatibility */ - cartridgeRoots?: string[]; - cartridges?: ConfiguredCartridge[]; - enabled?: boolean; - /** - * Disable filesystem auto-discovery when no cartridges are pushed in. Defaults - * to false — i.e. auto-discovery runs unless the host explicitly opts out. - */ - autoDiscover?: boolean; -} +import {createUsageInferenceHooks} from './usage-inference'; +import {PLUGIN_NAME} from './resolver/constants'; +import type {ConfiguredCartridge, NormalizedCartridge, PluginConfig} from './resolver/constants'; +import { + discoverCartridgesOnDisk, + orderCartridges, + parseDeclareModuleRanges, + readDwJsonCartridges, +} from './resolver/cartridge-discovery'; +import {readHookRegistrations} from './resolver/hook-registry'; +import { + createPathContainment, + ownerCartridge as ownerCartridgeImpl, + resolveCartridgeModule as resolveCartridgeModuleImpl, + resolveModulesCartridge as resolveModulesCartridgeImpl, +} from './resolver/module-resolution'; -interface NormalizedCartridge { - name: string; - /** - * Forward-slash path with trailing '/'. Lowercased on case-insensitive - * filesystems, so it may only be compared against other normalize()d paths — - * never handed to the filesystem. - */ - root: string; - /** - * Forward-slash path with trailing '/', in its original case. Use this for - * every filesystem probe and for paths handed back to TypeScript: `root` is - * case-folded whenever ts.sys reports a case-insensitive filesystem, which - * breaks lookups for projects that live on a case-sensitive volume. - */ - srcRoot: string; -} - -const PLUGIN_NAME = '@salesforce/b2c-script-types'; const TYPES_DIR = path.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, // `customer`, `empty(...)`, the `dw.*` namespace alias, etc.). The plugin @@ -52,56 +34,28 @@ const GLOBAL_DTS = path.join(TYPES_DIR, 'global.d.ts').replace(/\\/g, '/'); // and friends so cartridge code works under `checkJs: true` despite the dynamic // property assignments in modules/server.js that TS can't infer. const SFRA_SERVER_DTS = path.join(TYPES_DIR, 'sfra', 'server.d.ts').replace(/\\/g, '/'); -// Bare-name requires that the SFRA server.d.ts ambient declaration covers. -// We deliberately do NOT redirect these to modules/.js, so TS uses the -// ambient declaration's types instead of the inferred .js types (which can't -// see the dynamic `server.middleware = ...` assignments in modules/server.js). -const SFRA_AMBIENT_MODULES = new Set([ - 'server', - 'server/server', - 'server/middleware', - 'server/render', - 'server/route', - 'server/request', - 'server/response', - 'server/queryString', - 'server/forms', - 'server/forms/forms', -]); - -// Candidate suffixes appended when resolving a SFCC-style relative require to -// a cartridge file. SFRA convention is to omit the .js extension, so .js wins -// first; .json captures the occasional resource bundle import; .ds covers -// legacy pipeline-era scripts, which the platform also resolves after .js. -const CANDIDATE_EXTENSIONS = ['.js', '.json', '.ds', '/index.js', '/index.ds']; - -// Cartridges that conventionally sit at the bottom of the cartridge path when -// the user hasn't told us otherwise (no `cartridges` in dw.json/SFCC_CARTRIDGES). -// Higher rank = lower in the cartridge path. SFRA's runtime path ends with -// `app_storefront_base:modules`, so `modules` sorts strictly last. -// Mirrors BASE_CARTRIDGE_RANK in packages/b2c-vs-extension/src/cartridges/cartridge-service.ts. -const BASE_CARTRIDGE_RANK: Record = { - app_storefront_base: 1, - modules: 2, -}; - -// Directories skipped during recursive .project discovery. Mirrors the ignore -// list in @salesforce/b2c-tooling-sdk's findCartridges() so plain LSP usage -// matches CLI/extension discovery. -const DISCOVERY_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.cache', 'tmp', 'temp']); - -const DISCOVERY_MAX_DEPTH = 8; + +/** True when two file lists name the same files in the same order. */ +function sameFileNames(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((name, i) => name === b[i]); +} function init({typescript: ts}: {typescript: typeof tsserver}) { - // Module-scoped state shared across all projects in the TS server. The host - // calls onConfigurationChanged() on this module when configurePlugin() runs; - // each project's wrapped resolver reads from these variables. + // tsserver calls this factory function fresh for every project that loads + // the plugin (once per tsconfig/jsconfig root), so these variables are a + // private closure per project, not shared state across a multi-root + // workspace. configurePlugin() broadcasts the same config to every open + // project, but each project's own onConfigurationChanged() call only + // updates its own copy of these variables. let cartridges: NormalizedCartridge[] = []; let enabled = true; let autoDiscoverEnabled = true; + let inferUsageEnabled = false; // Whether the most recent applyConfig() received an explicit cartridges list. // When true, we skip auto-discovery; when false, create() may auto-populate. let cartridgesFromHost = false; + // The cache resets of every language service create() decorated. + const usageInferenceResets = new Set<() => void>(); // tsserver internally canonicalizes file paths to forward slashes regardless of // platform (so containingFile is "C:/proj/..." on Windows). The cartridge roots @@ -109,11 +63,11 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // backslashes on Windows — we have to normalize to match. We also fold case on // case-insensitive filesystems (Windows + default macOS HFS+/APFS) so a path // like "C:/Proj" matches a cartridge root of "c:/proj". - const caseSensitive = ts.sys.useCaseSensitiveFileNames; - const normalize = (p: string): string => { - const slashed = p.replace(/\\/g, '/'); - return caseSensitive ? slashed : slashed.toLowerCase(); - }; + // + // isWithinRoot is the trust boundary for every resolver below — see its + // doc comment in resolver/module-resolution.ts for the full rationale and + // known limitations. + const {normalize, isWithinRoot} = createPathContainment(ts, ts.sys.useCaseSensitiveFileNames); const setCartridges = (list: ConfiguredCartridge[]) => { cartridges = list.map(({name, src}) => { @@ -122,7 +76,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return { name, root: n.endsWith('/') ? n : n + '/', - srcRoot: raw.endsWith('/') ? raw : raw + '/', + rawRoot: raw.endsWith('/') ? raw : raw + '/', }; }); }; @@ -131,6 +85,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const c = (config ?? {}) as PluginConfig; enabled = c.enabled !== false; autoDiscoverEnabled = c.autoDiscover !== false; + inferUsageEnabled = c.inferUsage === true; // Only touch the cartridge list if the host explicitly provided one. // This lets onConfigurationChanged() update flags (enabled, autoDiscover) @@ -154,102 +109,6 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { setCartridges(list); }; - // Recursively walk projectRoot for `.project` markers. Stops descending into - // a cartridge once found (cartridges don't nest). Depth-limited to keep - // tsserver startup snappy on huge monorepos. - const discoverCartridgesOnDisk = (projectRoot: string): ConfiguredCartridge[] => { - const found: ConfiguredCartridge[] = []; - const stack: {dir: string; depth: number}[] = [{dir: projectRoot, depth: 0}]; - while (stack.length > 0) { - const {dir, depth} = stack.pop()!; - if (fileExists(path.join(dir, '.project'))) { - found.push({name: path.basename(dir), src: dir}); - continue; - } - if (depth >= DISCOVERY_MAX_DEPTH) continue; - let subdirs: readonly string[] = []; - try { - subdirs = ts.sys.getDirectories(dir); - } catch { - subdirs = []; - } - for (const sub of subdirs) { - if (DISCOVERY_IGNORE.has(sub)) continue; - stack.push({dir: path.join(dir, sub), depth: depth + 1}); - } - } - // Stable ordering for deterministic auto-discovery output. - found.sort((a, b) => a.src.localeCompare(b.src)); - return found; - }; - - // Read the top-level dw.json `cartridges` field (string with comma/colon - // separators OR array of names) for an explicit cartridge-path order. - // Mirrors what the b2c CLI's resolved config exposes; we don't try to honor - // SFCC_CARTRIDGES / .env / plugins here — hosts that need that complexity - // should push the resolved list in via configurePlugin(). - const readDwJsonCartridges = (projectRoot: string): string[] | undefined => { - const dwJsonPath = path.join(projectRoot, 'dw.json'); - if (!fileExists(dwJsonPath)) return undefined; - let content: string | undefined; - try { - content = ts.sys.readFile(dwJsonPath); - } catch { - return undefined; - } - if (!content) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - return undefined; - } - const value = (parsed as {cartridges?: unknown})?.cartridges; - if (typeof value === 'string') { - return value - .split(/[,:]/) - .map((s) => s.trim()) - .filter(Boolean); - } - if (Array.isArray(value)) { - return value.filter((s): s is string => typeof s === 'string' && s.length > 0); - } - return undefined; - }; - - // Apply cartridge ordering: if `configured` is set, named-first then any - // remaining discovered cartridges in their original order; otherwise - // discovery order with KNOWN_BASE_CARTRIDGES sorted last. - const orderCartridges = ( - discovered: ConfiguredCartridge[], - configured: string[] | undefined, - ): ConfiguredCartridge[] => { - if (configured && configured.length > 0) { - const byName = new Map(discovered.map((c) => [c.name, c])); - const ordered: ConfiguredCartridge[] = []; - const seen = new Set(); - for (const name of configured) { - const found = byName.get(name); - if (found && !seen.has(name)) { - ordered.push(found); - seen.add(name); - } - } - for (const c of discovered) { - if (!seen.has(c.name)) ordered.push(c); - } - return ordered; - } - const indexed = discovered.map((c, i) => ({c, i})); - indexed.sort((a, b) => { - const ar = BASE_CARTRIDGE_RANK[a.c.name] ?? 0; - const br = BASE_CARTRIDGE_RANK[b.c.name] ?? 0; - if (ar !== br) return ar - br; - return a.i - b.i; - }); - return indexed.map((x) => x.c); - }; - const isCartridgeFile = (filePath: string): boolean => { if (!enabled || cartridges.length === 0) return false; const f = normalize(filePath); @@ -264,7 +123,11 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // tsserver keys its internal file map on forward-slash paths, so normalize // the return value here — path.join produces backslashes on Windows. if (moduleName.startsWith('dw/')) { - return path.join(TYPES_DIR, moduleName + '.d.ts').replace(/\\/g, '/'); + const resolved = path.join(TYPES_DIR, moduleName + '.d.ts').replace(/\\/g, '/'); + // A crafted name like `dw/../../../etc/passwd` would otherwise join to a + // path outside the bundled types dir. Reject anything that escapes it. + if (!isWithinRoot(resolved, TYPES_DIR)) return undefined; + return resolved; } return undefined; }; @@ -277,112 +140,39 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { } }; - // Resolve a SFCC cartridge-style require relative to the configured cartridge - // path. Returns the absolute path to the resolved JS file, or undefined if no - // cartridge contains the target. - // - // ~/cartridge/scripts/foo -> only the cartridge that owns containingFile - // * /cartridge/scripts/foo -> walks the cartridge path, owner-first - // bar/cartridge/scripts/foo -> only the cartridge named "bar" - const resolveCartridgeModule = ( - moduleName: string, - containingFile: string, - ): {resolved: string; source: string} | undefined => { - if (cartridges.length === 0) return undefined; - - let subpath: string | undefined; - let order: NormalizedCartridge[] | undefined; - - if (moduleName.startsWith('~/')) { - // ~ is the current cartridge — restrict to the cartridge that owns the - // calling file. If the containing file isn't inside any known cartridge, - // there is no current cartridge, so the require can't be resolved. - subpath = moduleName.slice(2); - const owner = ownerCartridge(containingFile); - if (!owner) return undefined; - order = [owner]; - } else if (moduleName.startsWith('*/')) { - // * walks the cartridge path. Owner-first matches SFRA-style overrides - // (the requesting cartridge wins before falling through to others). - subpath = moduleName.slice(2); - order = reorderForContainingFile(cartridges, containingFile); - } else { - // /cartridge/... — only treat as a cartridge require if the - // first segment matches a known cartridge name. Otherwise pass through so - // node_modules and other resolutions still work. - const slash = moduleName.indexOf('/'); - if (slash <= 0) return undefined; - const head = moduleName.slice(0, slash); - const known = cartridges.find((c) => c.name === head); - if (!known) return undefined; - subpath = moduleName.slice(slash + 1); - order = [known]; - } - - if (!subpath) return undefined; - - for (const c of order) { - const baseAbs = c.srcRoot + subpath; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return {resolved: candidate, source: c.name}; - } - } - } - return undefined; - }; - - // Resolve a bare `require('server')`-style import against the SFRA `modules` - // cartridge. Unlike normal cartridges (which expose files under - // `cartridge/scripts/...`), the `modules` cartridge exposes its entire tree - // at the root, so `require('server')` -> `/server[.js|/index.js]` - // and `require('server/middleware')` -> `/server/middleware[.js]`. - // Falls through unless a cartridge literally named `modules` is in the list. - const resolveModulesCartridge = (moduleName: string): {resolved: string; source: string} | undefined => { - if (cartridges.length === 0) return undefined; - if (moduleName.startsWith('.') || moduleName.startsWith('/')) return undefined; - if (moduleName.startsWith('~/') || moduleName.startsWith('*/') || moduleName.startsWith('dw/')) return undefined; - // Let the bundled SFRA ambient declarations win for these names. If we - // resolved them to the .js file here, TS would infer types from the JS - // (which misses dynamic property assignments in modules/server.js) and - // ignore the ambient `declare module 'server' { ... }` shape. - if (SFRA_AMBIENT_MODULES.has(moduleName)) return undefined; - const modulesCart = cartridges.find((c) => c.name === 'modules'); - if (!modulesCart) return undefined; - - const baseAbs = modulesCart.srcRoot + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return {resolved: candidate, source: modulesCart.name}; - } - } + const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => + ownerCartridgeImpl(cartridges, normalize, containingFile); - // package.json `main` fallback for directories without an index.js. - const pkgPath = baseAbs + '/package.json'; - if (fileExists(pkgPath)) { - try { - const content = ts.sys.readFile(pkgPath); - if (content) { - const main = (JSON.parse(content) as {main?: string}).main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.srcRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - if (fileExists(resolved)) { - return {resolved, source: modulesCart.name}; - } - } - } - } catch { - // best-effort - } + // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. + // Neovim with typescript-language-server). Walks the project root for + // `.project` markers and honors dw.json's `cartridges` field for ordering. + const needsAutoDiscovery = (): boolean => + enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0; + const autoDiscoverCartridges = (projectRoot: string, log: (msg: string) => void): void => { + if (!projectRoot) return; + try { + const discovered = discoverCartridgesOnDisk(ts, projectRoot, fileExists); + const configured = readDwJsonCartridges(ts, projectRoot, fileExists); + setCartridges(orderCartridges(discovered, configured)); + log( + `auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + + (configured ? ` (ordered by dw.json cartridges)` : ''), + ); + } catch (e) { + log(`auto-discovery failed: ${(e as Error).message}`); } - return undefined; }; - const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => { - const f = normalize(containingFile); - return cartridges.find((c) => f.startsWith(c.root)); + // The ambient declarations a project with cartridge files needs and its + // file list doesn't already include (dedup by normalized path): + // - global.d.ts: SFCC platform globals (session, request, response, + // customer, empty(), the ambient `dw` namespace). + // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, + // middleware, etc.) — only when a `modules` cartridge is configured. + const missingAmbientDeclarations = (list: readonly string[]): string[] => { + const present = new Set(list.map((f) => normalize(f))); + const wanted = cartridges.some((c) => c.name === 'modules') ? [GLOBAL_DTS, SFRA_SERVER_DTS] : [GLOBAL_DTS]; + return wanted.filter((dts) => fileExists(dts) && !present.has(normalize(dts))); }; // Cached map of byte ranges in types/sfra/server.d.ts to the SFRA module @@ -399,83 +189,131 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { } return undefined; }; - const parseDeclareModuleRanges = (content: string): Array<{start: number; end: number; module: string}> => { - const ranges: Array<{start: number; end: number; module: string}> = []; - const re = /declare module ['"]([^'"]+)['"]\s*\{/g; - let m: RegExpExecArray | null; - while ((m = re.exec(content)) !== null) { - const start = m.index; - // Walk forward from the opening brace to find the matching close. - let depth = 1; - let i = m.index + m[0].length; - while (i < content.length && depth > 0) { - const ch = content[i]; - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - } - ranges.push({start, end: i, module: m[1]}); - } - return ranges; - }; - - const reorderForContainingFile = (list: NormalizedCartridge[], containingFile: string): NormalizedCartridge[] => { - const owner = ownerCartridge(containingFile); - if (!owner) return list; - return [owner, ...list.filter((c) => c !== owner)]; - }; function create(info: tsserver.server.PluginCreateInfo): tsserver.LanguageService { const log = (msg: string) => info.project.projectService.logger.info(`[${PLUGIN_NAME}] ${msg}`); applyConfig(info.config); - // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. - // Neovim with typescript-language-server). Walks the project root for - // `.project` markers and honors dw.json's `cartridges` field for ordering. - if (enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0) { - const projectRoot = info.project.getCurrentDirectory(); - if (projectRoot) { - try { - const discovered = discoverCartridgesOnDisk(projectRoot); - const configured = readDwJsonCartridges(projectRoot); - const ordered = orderCartridges(discovered, configured); - setCartridges(ordered); - log( - `auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + - (configured ? ` (ordered by dw.json cartridges)` : ''), - ); - } catch (e) { - log(`auto-discovery failed: ${(e as Error).message}`); - } - } - } + if (needsAutoDiscovery()) autoDiscoverCartridges(info.project.getCurrentDirectory(), log); const host = info.languageServiceHost; - // Inject ambient declarations into the TS program when the project - // contains at least one cartridge file: - // - global.d.ts: SFCC platform globals (session, request, response, - // customer, empty(), the ambient `dw` namespace). - // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, - // middleware, etc.) — only injected when a `modules` cartridge is - // configured. Configured projects that already include either via a - // jsconfig include glob are unaffected (dedup by normalized path). + // What `module.superModule` refers to at runtime: the same-subpath file + // in the next cartridge down the cartridge path that has one. Powers the + // usage-inference engine's handling of SFRA overlay modules. Probes + // existence through the language-service host (not ts.sys) so it sees + // the same filesystem view as the rest of this project. + const hostFileExists = (p: string): boolean => { + try { + return host.fileExists ? host.fileExists(p) : ts.sys.fileExists(p); + } catch { + return false; + } + }; + // Prefer the language-service host's view of the filesystem for require() + // resolution (not ts.sys): in-memory / virtualized hosts (tests, some LSP + // setups) otherwise never see cartridge files, and `~/` / `*/` requires + // silently stay unresolved. Auto-discovery above still uses ts.sys because + // it walks the real project root on disk. + const resolveCartridgeModuleOnHost = ( + moduleName: string, + containingFile: string, + ): {resolved: string; source: string} | undefined => + resolveCartridgeModuleImpl(cartridges, moduleName, containingFile, { + normalize, + isWithinRoot, + fileExists: hostFileExists, + }); + const resolveModulesCartridgeOnHost = (moduleName: string): {resolved: string; source: string} | undefined => + resolveModulesCartridgeImpl(ts, cartridges, moduleName, { + isWithinRoot, + fileExists: hostFileExists, + }); + const resolveSuperModulePath = (containingFile: string): string | undefined => { + const owner = ownerCartridge(containingFile); + if (!owner) return undefined; + // Slice from the slash-normalized-but-original-case form (not + // normalize()'s case-folded one) so the candidate built below from + // rawRoot doesn't get a folded-case tail spliced onto a real-case + // root — case folding never changes string length, so `owner.root`'s + // length is safe to reuse here. + const rawSubpath = containingFile.replace(/\\/g, '/').slice(owner.root.length); + for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { + const candidate = cartridges[i].rawRoot + rawSubpath; + // `subpath` is derived from an editor-supplied file path; contain the + // next-cartridge-down candidate so a crafted path or an overlapping + // cartridge root can't point it at a file outside that cartridge. + if (hostFileExists(candidate) && isWithinRoot(candidate, cartridges[i].root)) return candidate; + } + return undefined; + }; + + // Which scripts the cartridges' hooks.json files register for which + // extension points, so usage inference can follow + // `HookMgr.callHook(extensionPoint, functionName, ...args)` into them. + // Read through the language-service host, like require() resolution; a + // read that throws declares nothing. + const hookRegistrations = () => + readHookRegistrations(cartridges, { + readFile: (p) => (host.readFile ? host.readFile(p) : ts.sys.readFile(p)), + fileExists: hostFileExists, + isWithinRoot, + }); + + // Inject the ambient declarations into the TS program when the project + // contains at least one cartridge file (isCartridgeFile is false while the + // plugin is disabled or no cartridge is known). Projects that already + // include them via a jsconfig include glob are unaffected. + // + // tsserver asks for this list on every language-service request (each + // hover, completion and diagnostics pass), building a new array each time + // from the same names while the project's files stay put. The answer is + // kept until a name or the configuration changes, so a request costs one + // name-by-name comparison rather than normalizing every path in the + // project and probing the disk. const origGetScriptFileNames = host.getScriptFileNames.bind(host); + const withAmbientDeclarations = (list: string[]): string[] => { + if (!list.some((f) => isCartridgeFile(f))) return list; + const additions = missingAmbientDeclarations(list); + return additions.length > 0 ? [...list, ...additions] : list; + }; + let lastFileNames: + | {list: string[]; cartridges: NormalizedCartridge[]; enabled: boolean; result: string[]} + | undefined; host.getScriptFileNames = () => { const list = origGetScriptFileNames(); - if (!enabled || cartridges.length === 0) return list; - if (!list.some((f) => isCartridgeFile(f))) return list; - const additions: string[] = []; - const present = new Set(list.map((f) => normalize(f))); - if (fileExists(GLOBAL_DTS) && !present.has(normalize(GLOBAL_DTS))) { - additions.push(GLOBAL_DTS); + const last = lastFileNames; + if (last?.cartridges === cartridges && last.enabled === enabled && sameFileNames(last.list, list)) { + return last.result; } - const hasModules = cartridges.some((c) => c.name === 'modules'); - if (hasModules && fileExists(SFRA_SERVER_DTS) && !present.has(normalize(SFRA_SERVER_DTS))) { - additions.push(SFRA_SERVER_DTS); + const result = withAmbientDeclarations(list); + lastFileNames = {list, cartridges, enabled, result}; + return result; + }; + + // Shared by both host resolution hooks below (the modern + // resolveModuleNameLiterals and the legacy TS 4.x resolveModuleNames): + // tries dw/* types, then SFCC cartridge-relative requires, then the SFRA + // `modules` cartridge, in that priority order. Each hook only differs in + // the shape TS expects the result wrapped in. + const resolveOne = ( + text: string, + containingFile: string, + ): {resolvedFileName: string; extension: tsserver.Extension; isExternalLibraryImport: boolean} | undefined => { + const dw = resolveDwModule(text); + // Bundled dw/* types live on the real disk next to the plugin — ts.sys + // (via fileExists) is the right probe there, not the project host. + if (dw && fileExists(dw)) { + return {resolvedFileName: dw, extension: ts.Extension.Dts, isExternalLibraryImport: true}; } - return additions.length > 0 ? [...list, ...additions] : list; + const local = resolveCartridgeModuleOnHost(text, containingFile) ?? resolveModulesCartridgeOnHost(text); + if (!local) return undefined; + return { + resolvedFileName: local.resolved, + extension: local.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; }; // TS derives a file's ScriptKind from its extension and falls back to TS @@ -510,41 +348,11 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (!isCartridgeFile(containingFile)) return original; return original.map((res, i) => { if (res.resolvedModule) return res; - const text = moduleLiterals[i].text; - const dw = resolveDwModule(text); - if (dw && fileExists(dw)) { - return { - resolvedModule: { - resolvedFileName: dw, - extension: ts.Extension.Dts, - isExternalLibraryImport: true, - packageId: undefined, - }, - } satisfies tsserver.ResolvedModuleWithFailedLookupLocations; - } - const cart = resolveCartridgeModule(text, containingFile); - if (cart) { - return { - resolvedModule: { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - packageId: undefined, - }, - } satisfies tsserver.ResolvedModuleWithFailedLookupLocations; - } - const mod = resolveModulesCartridge(text); - if (mod) { - return { - resolvedModule: { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - packageId: undefined, - }, - } satisfies tsserver.ResolvedModuleWithFailedLookupLocations; - } - return res; + const resolved = resolveOne(moduleLiterals[i].text, containingFile); + if (!resolved) return res; + return { + resolvedModule: {...resolved, packageId: undefined}, + } satisfies tsserver.ResolvedModuleWithFailedLookupLocations; }); }; } @@ -571,32 +379,8 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (!isCartridgeFile(containingFile)) return original; return original.map((res, i) => { if (res) return res; - const text = moduleNames[i]; - const dw = resolveDwModule(text); - if (dw && fileExists(dw)) { - return { - resolvedFileName: dw, - extension: ts.Extension.Dts, - isExternalLibraryImport: true, - } as tsserver.ResolvedModuleFull; - } - const cart = resolveCartridgeModule(text, containingFile); - if (cart) { - return { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - } as tsserver.ResolvedModuleFull; - } - const mod = resolveModulesCartridge(text); - if (mod) { - return { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - } as tsserver.ResolvedModuleFull; - } - return res; + const resolved = resolveOne(moduleNames[i], containingFile); + return resolved ? (resolved as tsserver.ResolvedModuleFull) : res; }); }; } @@ -621,7 +405,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (!modulesCart) return def; const moduleName = sfraModuleAtOffset(def.textSpan.start); if (!moduleName) return def; - const candidates = [modulesCart.srcRoot + moduleName + '.js', modulesCart.srcRoot + moduleName + '/index.js']; + const candidates = [modulesCart.rawRoot + moduleName + '.js', modulesCart.rawRoot + moduleName + '/index.js']; for (const candidate of candidates) { if (fileExists(candidate)) { return {...def, fileName: candidate, textSpan: {start: 0, length: 0}}; @@ -648,12 +432,41 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return result?.map(remapDefinition); }; + // Usage-based inference (opt-in, `inferUsage`): when hover or member + // completion hits a value the checker has given up on (typically a + // parameter of an undocumented helper), add what call sites and the + // value's own usage say about it. See inference/editor-hooks. + const usageInference = createUsageInferenceHooks({ + ts, + languageService: info.languageService, + resolveSuperModulePath, + hookRegistrations, + // The project's own token: tsserver flags it when the editor cancels + // the request this hover or completion belongs to. + isCancellationRequested: () => host.getCancellationToken?.().isCancellationRequested() ?? false, + log, + }); + usageInferenceResets.add(usageInference.reset); + const inferenceActive = (fileName: string) => inferUsageEnabled && isCartridgeFile(fileName); + + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + return inferenceActive(fileName) ? usageInference.decorateQuickInfo(fileName, position, original) : original; + }; + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + return inferenceActive(fileName) ? usageInference.decorateCompletions(fileName, position, original) : original; + }; + log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } function onConfigurationChanged(config: unknown) { applyConfig(config); + // A new configuration can change what inference sees (cartridge order + // decides `module.superModule`), so drop every cached hover and list. + for (const reset of usageInferenceResets) reset(); } return {create, onConfigurationChanged}; diff --git a/packages/b2c-script-types/src/inference/ambient-index.ts b/packages/b2c-script-types/src/inference/ambient-index.ts new file mode 100644 index 000000000..43bb3ec18 --- /dev/null +++ b/packages/b2c-script-types/src/inference/ambient-index.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// An index of every ambient class and interface the program declares in a +// `.d.ts` file (the vendored dw.* Script API, chiefly), by member names. It +// answers "which classes have all of these members?" for usage-based +// matching (see ./policy) and "which class is called X?" for framework +// contracts (see ./framework-contracts). +// +// The index holds only names and declaration nodes, never a Type: a Type +// pins the checker, and through it the whole Program it came from. Types are +// resolved on demand with the current request's checker. Entries are cached +// per declaration SourceFile, which tsserver reuses across Programs for as +// long as the file is unchanged, so editing a cartridge `.js` file never +// re-indexes the Script API. A file is indexed with the members its classes +// had when it was first seen; a declaration file that changes is a new +// SourceFile and is indexed afresh. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {BUILTIN_VALUE_TYPES} from './constants'; +import type {InferenceContext} from './context'; + +/** One indexed class or interface. */ +export interface AmbientClass { + readonly name: string; + readonly memberNames: ReadonlySet; + readonly declaration: tsserver.ClassDeclaration | tsserver.InterfaceDeclaration; +} + +const classesByFile = new WeakMap(); +const classesByProgram = new WeakMap(); + +function indexDeclaration(ctx: InferenceContext, statement: tsserver.Statement): AmbientClass | undefined { + const {ts, checker} = ctx; + if (!(ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) || !statement.name) return undefined; + const symbol = checker.getSymbolAtLocation(statement.name); + if (!symbol) return undefined; + const memberNames = new Set(checker.getPropertiesOfType(checker.getDeclaredTypeOfSymbol(symbol)).map((m) => m.name)); + if (memberNames.size === 0) return undefined; + return {name: statement.name.text, memberNames, declaration: statement}; +} + +function indexFile(ctx: InferenceContext, sourceFile: tsserver.SourceFile): readonly AmbientClass[] { + const cached = classesByFile.get(sourceFile); + if (cached) return cached; + const classes = sourceFile.statements.flatMap((statement) => indexDeclaration(ctx, statement) ?? []); + classesByFile.set(sourceFile, classes); + return classes; +} + +/** + * Every top-level class and interface in the program's declaration files, + * except TypeScript's own default library (DOM and ES classes such as + * `HTMLDataElement` would otherwise match ordinary SFCC usage). + */ +export function getAmbientClasses(ctx: InferenceContext): readonly AmbientClass[] { + const {program} = ctx; + const cached = classesByProgram.get(program); + if (cached) return cached; + const classes = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.isDeclarationFile && !program.isSourceFileDefaultLibrary(sourceFile)) + .flatMap((sourceFile) => indexFile(ctx, sourceFile)); + classesByProgram.set(program, classes); + return classes; +} + +/** The indexed classes that have every member in `memberNames`, in index order. */ +export function ambientClassesWithMembers(ctx: InferenceContext, memberNames: ReadonlySet): AmbientClass[] { + const names = [...memberNames]; + return getAmbientClasses(ctx).filter((ambientClass) => names.every((name) => ambientClass.memberNames.has(name))); +} + +/** The class's declared (uninstantiated) type, resolved with the current checker. */ +export function ambientClassType(ctx: InferenceContext, ambientClass: AmbientClass): tsserver.Type | undefined { + const {checker} = ctx; + const name = ambientClass.declaration.name; + const symbol = name && checker.getSymbolAtLocation(name); + return symbol ? checker.getDeclaredTypeOfSymbol(symbol) : undefined; +} + +/** + * The declared type of the ambient class called `name`, preferring the + * vendored Script API's declaration (under a `/dw/` directory) when a + * project declares another class of the same name. + */ +export function findAmbientClassType(ctx: InferenceContext, name: string): tsserver.Type | undefined { + const named = getAmbientClasses(ctx).filter((ambientClass) => ambientClass.name === name); + const preferred = + named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; + return preferred && ambientClassType(ctx, preferred); +} + +/** The declared types of the JavaScript built-ins in BUILTIN_VALUE_TYPES (`String`, `Array`, ...). */ +export function builtinValueTypes(ctx: InferenceContext): tsserver.Type[] { + const {ts, checker} = ctx; + return BUILTIN_VALUE_TYPES.flatMap((name) => { + const symbol = checker.resolveName(name, undefined, ts.SymbolFlags.Type, false); + return symbol ? [checker.getDeclaredTypeOfSymbol(symbol)] : []; + }); +} diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts new file mode 100644 index 000000000..c148f4915 --- /dev/null +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Small, self-contained helpers for walking the TypeScript AST: finding the +// node under the cursor, skipping the subtrees a walk for a name can't find +// it in, the property access a member name or completion belongs to, +// checking whether a parameter/return/variable already has an explicit type, +// whether a value is only tested, and collecting a function's return +// expressions. Everything here depends +// only on the `ts` namespace — no checker, no inference context — so it's +// the safest, most reusable layer to read first. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +/** + * Finds the most specific node whose span contains `pos`. Standard technique + * built only on public Node/forEachChild APIs — deliberately avoids TS's + * internal (unversioned) getTokenAtPosition helper. + * + * The walk stops scanning a sibling list as soon as it passes `pos` + * (forEachChild aborts when the callback returns truthy, and siblings are + * ordered and non-overlapping), and skips the siblings before `pos` by their + * end alone, so a node's start (which means scanning its leading comments) + * is computed only for the one sibling that reaches `pos`. Without that, a + * hover in a file whose top-level (or any enclosing) node has thousands of + * children — a generated data file with an 8,000-element array literal, say + * — pays for the full child list. + */ +export function getNodeAtPosition( + sourceFile: tsserver.SourceFile, + ts: typeof tsserver, + pos: number, +): tsserver.Node | undefined { + let result: tsserver.Node | undefined; + const visit = (node: tsserver.Node): boolean | undefined => { + if (pos >= node.getEnd()) return undefined; // before pos — keep scanning this sibling list + if (pos < node.getStart(sourceFile)) return true; // walked past pos — later siblings can't contain it + result = node; + ts.forEachChild(node, visit); + return true; // containing child handled — siblings don't overlap + }; + visit(sourceFile); + return result; +} + +/** True when `node`'s text holds one of the identifiers a {@link spellingFilter} looks for. */ +export type SpellingFilter = (node: tsserver.Node) => boolean; + +/** + * A pre-test for walks of `scope` looking for identifiers named one of + * `names`: true when a node's text spells one of them. An identifier's text + * starts with its name, so a subtree the test is false for holds none of + * them and the walk can skip it, reading only the few paths that lead to + * one. An identifier written with a unicode escape sequence spells its + * name in other characters, so a subtree holding an escape is always read. + */ +export function spellingFilter(scope: tsserver.Node, names: Iterable): SpellingFilter { + const {text} = scope.getSourceFile(); + const offsets: number[] = []; + for (const spelling of new Set([...names, '\\u'])) { + let at = text.indexOf(spelling, scope.pos); + for (; at >= 0 && at < scope.end; at = text.indexOf(spelling, at + spelling.length)) offsets.push(at); + } + offsets.sort((a, b) => a - b); + return (node) => { + const next = firstAtOrAfter(offsets, node.pos); + return next < offsets.length && offsets[next] < node.end; + }; +} + +/** The index of the first of the ascending `offsets` that is at or after `pos`. */ +function firstAtOrAfter(offsets: readonly number[], pos: number): number { + let low = 0; + let high = offsets.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (offsets[middle] < pos) low = middle + 1; + else high = middle; + } + return low; +} + +/** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ +export function propertyAccessNamedBy( + node: tsserver.Node, + ts: typeof tsserver, +): tsserver.PropertyAccessExpression | undefined { + const {parent} = node; + return parent && ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : undefined; +} + +/** + * The property access a member completion at `position` is completing: the + * cursor sits right after its dot (`shipment.|`) or inside its member name + * (`shipment.pro|`). Anywhere else — inside the receiver, or inside an + * argument list further along the chain (`basket.getShipment(|).ID`) — the + * enclosing access's members are not what is being typed, so this returns + * `undefined`. + */ +export function memberCompletionAccess( + sourceFile: tsserver.SourceFile, + ts: typeof tsserver, + position: number, +): tsserver.PropertyAccessExpression | undefined { + const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) return undefined; + // The node is the access itself only when position - 1 falls on a token it + // owns directly (its dot), never inside the receiver or the name. + if (ts.isPropertyAccessExpression(node)) return position >= node.name.pos ? node : undefined; + return propertyAccessNamedBy(node, ts); +} + +/** + * SFRA helpers are often "documented" with a placeholder type that carries no + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`), and + * arrays of those (`{Array}`, `{Object[]}`, `{Array.<*>}`), which say nothing + * about their elements. Those are ubiquitous in real cartridges (and IntelliJ + * mainly helps when authors write a real `dw.*` JSDoc), so treating them as + * deliberate annotations would permanently silence usage inference on the + * exact helpers that need it most. + * + * Deliberate `{any}` / `: any` is *not* weak: that is an author saying "do not + * pretend you know this type", and we still respect it (`{any[]}` too). + */ +function isWeakTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): boolean { + const node = unwrapTypeNode(typeNode, ts); + // `{Object|null}`, `{Object|undefined}`: a placeholder that may be missing is still a placeholder. + if (ts.isUnionTypeNode(node)) { + const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); + return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); + } + if (ts.isArrayTypeNode(node)) return isWeakTypeNode(node.elementType, ts); + if (isArrayReference(node, ts)) return node.typeArguments?.every((element) => isWeakTypeNode(element, ts)) ?? true; + return isPlaceholderTypeNode(node, ts); +} + +/** One placeholder on its own: `{*}`, `{}`, `{object}`, `{Object}` or `{obj}`. */ +function isPlaceholderTypeNode(node: tsserver.TypeNode, ts: typeof tsserver): boolean { + // JSDoc `{*}` — "any value", not a real shape. + if (node.kind === ts.SyntaxKind.JSDocAllType) return true; + // Empty object literal type `{}`. + if (ts.isTypeLiteralNode(node) && node.members.length === 0) return true; + // Lowercase `object` keyword (TS/JSDoc) — non-primitive bag, not a dw.* class. + if (node.kind === ts.SyntaxKind.ObjectKeyword) return true; + const lower = typeReferenceName(node, ts)?.toLowerCase(); + // `Object` / `object` / the SFRA-conventional misspelling `obj`. + return lower === 'object' || lower === 'obj'; +} + +/** The type `(T)`, `?T`, `!T` and `T=` wrap: parentheses and JSDoc nullability or optionality change no shape. */ +function unwrapTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): tsserver.TypeNode { + let node = typeNode; + while ( + ts.isParenthesizedTypeNode(node) || + ts.isJSDocNullableType(node) || + ts.isJSDocNonNullableType(node) || + ts.isJSDocOptionalType(node) + ) { + node = node.type; + } + return node; +} + +/** `Array`, `Array` or JSDoc's `Array.`. */ +function isArrayReference(node: tsserver.TypeNode, ts: typeof tsserver): node is tsserver.TypeReferenceNode { + return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Array'; +} + +/** `null` or `undefined` as a type. */ +function isNullishTypeNode(node: tsserver.TypeNode, ts: typeof tsserver): boolean { + if (node.kind === ts.SyntaxKind.UndefinedKeyword) return true; + return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword; +} + +/** The name a type reference names: `Object` in `{Object}` or `extends Object`. */ +function typeReferenceName(node: tsserver.TypeNode, ts: typeof tsserver): string | undefined { + if (ts.isTypeReferenceNode(node)) return node.typeName.getText(); + return ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression) ? node.expression.text : undefined; +} + +/** True when `typeNode` is a real annotation we must not second-guess (including deliberate `any`). */ +function isStrongTypeNode(typeNode: tsserver.TypeNode | undefined, ts: typeof tsserver): boolean { + return typeNode !== undefined && !isWeakTypeNode(typeNode, ts); +} + +/** + * True when the developer already gave this parameter an explicit, meaningful + * type — TS syntax or JSDoc — even if that type is literally `any`. In that + * case the checker's type reflects a deliberate choice, not an inference + * failure, so usage inference must never second-guess it. Placeholder SFRA + * annotations (`Object` / `obj` / `*` / `{}`) do **not** count; see + * {@link isWeakTypeNode}. + */ +export function hasExplicitParameterType(param: tsserver.ParameterDeclaration, ts: typeof tsserver): boolean { + return isStrongTypeNode(param.type, ts) || isStrongTypeNode(ts.getJSDocType(param), ts); +} + +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ +export function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): boolean { + return isStrongTypeNode(fn.type, ts) || isStrongTypeNode(ts.getJSDocReturnType(fn), ts); +} + +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +export function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: typeof tsserver): boolean { + return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); +} + +/** The `x.name = value` assignment a statement is, if any. */ +export function memberAssignmentOf( + stmt: tsserver.Statement, + ts: typeof tsserver, +): {binary: tsserver.BinaryExpression; target: tsserver.PropertyAccessExpression} | undefined { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? {binary, target: binary.left} : undefined; +} + +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +export function isExportsObject(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} + +/** `module.exports`, identified structurally. */ +export function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports' + ); +} + +/** The `exports` / `module.exports` object a top-level statement assigns to or adds a member to. */ +export function exportsObjectWrittenBy(stmt: tsserver.Statement, ts: typeof tsserver): tsserver.Expression | undefined { + const target = memberAssignmentOf(stmt, ts)?.target; + if (!target) return undefined; + if (isExportsObject(target.expression, ts)) return target.expression; + return isModuleExports(target, ts) ? target : undefined; +} + +/** A call read as an invocation of the function its callee names, with the arguments that function receives. */ +export interface Invocation { + readonly callee: tsserver.Expression; + /** Empty for `fn.apply(scope, args)`, whose arguments are not known one by one. */ + readonly args: readonly tsserver.Expression[]; +} + +/** + * `call` as an invocation of the function its callee names: `fn(a)`, + * `fn.call(scope, a)` without its `this` argument, or `fn.apply(scope, args)`. + * Any other method call (`list.push(a)`) invokes a member, not its receiver. + */ +export function invocationOf(call: tsserver.CallExpression, ts: typeof tsserver): Invocation | undefined { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return {callee, args: call.arguments}; + if (callee.name.text === 'call') return {callee: callee.expression, args: call.arguments.slice(1)}; + return callee.name.text === 'apply' ? {callee: callee.expression, args: []} : undefined; +} + +/** True when `node` is the right side of `a && node`, which evaluates to it whenever it is reached. */ +function isRightOfAnd(node: tsserver.Node, ts: typeof tsserver): boolean { + const parent = node.parent; + return ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parent.right === node + ); +} + +/** True when `operator` offers its operands as alternatives: `a || b`, `a ?? b`. */ +function isAlternativeOperator(operator: tsserver.SyntaxKind, ts: typeof tsserver): boolean { + return operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.QuestionQuestionToken; +} + +/** + * True when the value `expr` evaluates to is only tested, or offered as one + * of several alternatives: the condition of an `if`, a loop or a `?:`, the + * operand of `!` or `typeof`, the left side of `&&`, or either side of `||` / + * `??` (`if (res.setStatusCode)`, `(res && res.statusCode) || res.errorCode`). + * Code that reads a member only this way expects values that lack it. + */ +export function isTestedOrAlternative(expr: tsserver.Expression, ts: typeof tsserver): boolean { + let value: tsserver.Node = expr; + while (ts.isParenthesizedExpression(value.parent) || isRightOfAnd(value, ts)) value = value.parent; + const parent = value.parent; + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + return ( + isAlternativeOperator(operator, ts) || + (operator === ts.SyntaxKind.AmpersandAmpersandToken && parent.left === value) + ); + } + if (ts.isPrefixUnaryExpression(parent)) return parent.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isConditionalExpression(parent)) return parent.condition === value; + const tests = ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent); + return ts.isTypeOfExpression(parent) || (tests && parent.expression === value); +} + +/** + * Recursively walks a function body collecting `return` expressions, without + * descending into nested function-like boundaries (their returns belong to + * them, not to `fn`). + */ +export function collectReturnExpressions( + fn: tsserver.SignatureDeclaration, + ts: typeof tsserver, +): tsserver.Expression[] { + if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) { + return [fn.body]; + } + const body = (fn as tsserver.FunctionLikeDeclaration).body; + const out: tsserver.Expression[] = []; + if (!body) return out; + const visit = (n: tsserver.Node) => { + if (ts.isFunctionLike(n) && n !== fn) return; + if (ts.isReturnStatement(n) && n.expression) { + out.push(n.expression); + return; + } + ts.forEachChild(n, visit); + }; + visit(body); + return out; +} diff --git a/packages/b2c-script-types/src/inference/bindings.ts b/packages/b2c-script-types/src/inference/bindings.ts new file mode 100644 index 000000000..7f66a4b07 --- /dev/null +++ b/packages/b2c-script-types/src/inference/bindings.ts @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Call-specific bindings. What a generic helper returns can depend on what one +// call passes it: SFRA's `collections.find(collection, match)` returns an +// element of `collection`, a Shipment for `basket.shipments` and a +// ProductLineItem for `basket.productLineItems`. Across all of its callers +// the helper's return says nothing, so ./core infers it once more for the call +// being resolved, with the helper's parameters bound to that call's +// arguments. A callback argument is bound too: `collections.map(collection, +// callback)` pushes what `callback(item)` returns, which is what the function +// this call passes returns for that item. Neither IntelliJ nor the checker +// substitutes arguments into an undocumented JavaScript function this way. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {invocationOf} from './ast-helpers'; +import type {InferenceContext} from './context'; +import {valueDeclarationOf} from './member-values'; +import {functionOf} from './signatures'; +import {isAnyType} from './type-helpers'; + +/** One parameter bound to the argument a single call passes it. */ +export interface ArgumentBinding { + readonly argument: tsserver.Expression; + /** The depth of the call, which the argument is resolved at. */ + readonly depth: number; + /** The bindings in force where the call is written, which the argument is resolved under. */ + readonly outer: Bindings; + /** The argument's types, resolved on first use. */ + resolved?: tsserver.Type[]; +} + +export type Bindings = ReadonlyMap; + +/** No parameter bound: what every inference outside a call-specific retry runs with. */ +export const NO_BINDINGS: Bindings = new Map(); + +/** Runs `compute` with `bindings` in force, restoring the previous ones afterwards. */ +export function withBindings(ctx: InferenceContext, bindings: Bindings, compute: () => T): T { + const previous = ctx.bindings; + ctx.bindings = bindings; + try { + return compute(); + } finally { + ctx.bindings = previous; + } +} + +/** A function a bound parameter holds, called with `args` (`callback(item)`, `callback.call(scope, item)`). */ +export interface BoundCallback { + readonly fn: tsserver.SignatureDeclaration; + readonly args: readonly tsserver.Expression[]; +} + +/** + * `fn`'s plain parameters bound to the arguments `args` of one call, on top of + * the bindings already in force (a helper nested in a bound function still + * sees its parameters). Arguments after a spread, rest parameters and + * destructured parameters are left unbound; `undefined` when nothing binds. + */ +export function argumentBindings( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + args: readonly tsserver.Expression[], + depth: number, +): Bindings | undefined { + const {ts} = ctx; + const outer = ctx.bindings; + const bindings = new Map(outer); + for (const [index, parameter] of fn.parameters.entries()) { + const argument = args[index]; + if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) break; + if (ts.isIdentifier(parameter.name)) bindings.set(parameter, {argument, depth, outer}); + } + return bindings.size > outer.size ? bindings : undefined; +} + +/** The binding the parameter `expr` names has in the bindings in force. */ +export function boundArgument(ctx: InferenceContext, expr: tsserver.Expression): ArgumentBinding | undefined { + const {ts} = ctx; + if (ctx.bindings.size === 0 || !ts.isIdentifier(expr)) return undefined; + const declaration = valueDeclarationOf(ctx, expr); + return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; +} + +/** + * The function the bound parameter `expr` holds: the function its argument + * is or names, followed through parameters each enclosing call passes on as + * is (`function mapAll(items, fn) { return collections.map(items, fn); }`). + * Each step moves to the bindings in force where that call was written, + * which hold fewer parameters, so the walk ends. + */ +function boundFunction(ctx: InferenceContext, expr: tsserver.Expression): tsserver.SignatureDeclaration | undefined { + let binding = boundArgument(ctx, expr); + while (binding) { + const fn = functionOf(ctx, binding.argument); + if (fn) return fn; + const {argument, outer} = binding; + binding = withBindings(ctx, outer, () => boundArgument(ctx, argument)); + } + return undefined; +} + +/** + * `call` read as a call of the function a bound parameter holds, with the + * arguments that function receives: `callback(item)`, or `callback.call(scope, + * item)` without its `this` argument. A `callback.apply(scope, args)` call + * binds none of them. + */ +export function boundCallback(ctx: InferenceContext, call: tsserver.CallExpression): BoundCallback | undefined { + const invocation = invocationOf(call, ctx.ts); + const fn = invocation && boundFunction(ctx, invocation.callee); + return fn && {fn, args: invocation.args}; +} + +/** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ +export function hasAnyTypeArgument(ctx: InferenceContext, type: tsserver.Type): boolean { + const {ts, checker} = ctx; + if (!(type.flags & ts.TypeFlags.Object)) return false; + if (!((type as tsserver.ObjectType).objectFlags & ts.ObjectFlags.Reference)) return false; + return checker.getTypeArguments(type as tsserver.TypeReference).some((argument) => isAnyType(ts, argument)); +} + +/** + * True when `types`, recovered for one call, may stand in for the `general` + * types the checker gives the same expression without it: when those say + * nothing, or when every recovered type is assignable to one of them + * (`Collection` for a parameter declared `{dw.util.Collection}`). + * Anything else is left alone, and TypeScript builds without the public + * `isTypeAssignableTo` never narrow a type that says something. + */ +export function narrows( + ctx: InferenceContext, + general: readonly tsserver.Type[], + types: readonly tsserver.Type[], +): boolean { + const {checker} = ctx; + if (types.length === 0) return false; + if (general.length === 0) return true; + if (typeof checker.isTypeAssignableTo !== 'function') return false; + return types.every((type) => general.some((part) => checker.isTypeAssignableTo(type, part))); +} diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts new file mode 100644 index 000000000..ea1c7f114 --- /dev/null +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Finds where an undocumented function is actually *called* across the whole +// project — the raw material for inferring a parameter's type from the +// arguments it receives. A reference search can land on a name that isn't a +// direct call (a require() binding, an export, a factory returning the +// function, a parameter it is passed to — see ./value-flow), so this layer +// follows a bounded number of those hops to reach the real call expressions. +// "Call" includes `new Helper(x)` and `Helper.call(this, x)` — the shapes +// SFRA's constructor-function "class" models are invoked and inherited with +// — and a function handed to a declared API that calls it (`server.get( +// 'Show', cache.applyDefaultCache)`), whose declaration types the arguments. +// An argument that is the caller's own untyped parameter is recognized here +// too, so ./core can follow a value handed down a chain of helpers to where +// it really comes from. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_REFERENCE_HOPS, MAX_REFERENCES_PER_CALL} from './constants'; +import type {InferenceContext} from './context'; +import {hasExplicitParameterType} from './ast-helpers'; +import {boundArgument} from './bindings'; +import {valueDeclarationOf} from './member-values'; +import {searchReferences} from './reference-search'; +import {informativeParts} from './type-helpers'; +import {enclosingFunction, valueTarget} from './value-flow'; +import type {CallSites, ReferenceTarget, ValueHolder} from './value-flow'; + +/** + * True when `name` declares a parameter or a variable inside a function. + * Such a search reads that function only (see ./reference-search) and can be + * reached only through a hop from a search that was charged, so it is not + * charged to ctx.searchBudget, though each of its hits still spends + * ctx.referenceBudget. Every other search spends one unit, even one confined + * to its own file: the budget bounds how far a request follows values from + * helper to helper, not only how many files it reads. + */ +function isFunctionLocal(ts: typeof tsserver, name: tsserver.Identifier): boolean { + const declaration = name.parent; + if (ts.isParameter(declaration)) return true; + const isVariable = ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration); + return isVariable && enclosingFunction(declaration, ts) !== undefined; +} + +function holderKey({name, role}: ValueHolder): string { + const sourceFile = name.getSourceFile(); + return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; +} + +/** + * True when `next` is searched now: it was not searched before in this walk + * and the request can still pay for it. Records the search and spends its + * unit of ctx.searchBudget, when it costs one. + */ +function claimSearch(ctx: InferenceContext, next: ValueHolder, searched: Set): boolean { + const key = holderKey(next); + const charged = !isFunctionLocal(ctx.ts, next.name); + if (searched.has(key) || (charged && ctx.searchBudget <= 0)) return false; + searched.add(key); + if (charged) ctx.searchBudget--; + return true; +} + +/** + * Finds the actual call sites of the function `holder` holds (see + * ./value-flow's holderOf), and the handoffs to declared callees, following up to MAX_REFERENCE_HOPS names the function value flows + * into (see ./value-flow: require() bindings, exports, aliases, factories + * returning it, parameters it is passed to) when a reference doesn't sit + * directly in callee position. Stops early once ctx.referenceBudget (result + * count) or ctx.searchBudget (searches) runs out, returning whatever call + * sites were already found rather than continuing to fan out — an + * under-inferred (but still heuristic, clearly-labeled) result beats hanging + * on a widely-referenced helper. Results are memoized per name node for the + * duration of the request. + */ +export function collectCallSites(ctx: InferenceContext, holder: ValueHolder): CallSites { + const key = holderKey(holder); + const memoized = ctx.callSiteMemo.get(key); + if (memoized) return memoized; + const found: CallSites = {calls: [], handoffs: []}; + const searched = new Set(); + let frontier: ValueHolder[] = [holder]; + let localBudget = Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget); + + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { + const nextFrontier: ValueHolder[] = []; + for (const next of frontier) { + if (localBudget <= 0) break; + if (claimSearch(ctx, next, searched)) { + localBudget = collectCallsFromName(ctx, next, found, nextFrontier, localBudget); + } + } + frontier = nextFrontier; + } + + ctx.callSiteMemo.set(key, found); + return found; +} + +/** + * Runs one reference search for `next.name` and sorts each hit into a + * resolved call site or handoff (recorded in `found`, once per call) or a + * further name to chase on the next hop (pushed to `nextFrontier`). Consumes + * up to `localBudget` result slots, returning the remaining local budget so + * the caller can stop fanning out once it's exhausted. + */ +function collectCallsFromName( + ctx: InferenceContext, + next: ValueHolder, + found: CallSites, + nextFrontier: ValueHolder[], + localBudget: number, +): number { + for (const reference of searchReferences(ctx, next.name)) { + if (localBudget <= 0) break; + localBudget--; + ctx.referenceBudget--; + const target = valueTarget(ctx, reference, next.role); + if (target?.kind === 'name') nextFrontier.push(target); + else if (target) record(found, target); + } + return localBudget; +} + +/** Records a call site or handoff in `found`, unless a reference found earlier already led to the same call. */ +function record(found: CallSites, target: Exclude): void { + if (target.kind === 'call') { + if (!found.calls.some((site) => site.node === target.call.node)) found.calls.push(target.call); + } else if (!found.handoffs.some((handoff) => handoff.call === target.handoff.call)) { + found.handoffs.push(target.handoff); + } +} + +/** + * The caller's own parameter an argument passes on as is (`items` in + * `getMatchingProducts(productId, items)` inside a function taking `items`), + * when only that parameter's call sites can say what it holds: the checker + * has no type for it, it has no default value and no type of its own, and it + * is not bound to a call being resolved (see ./bindings). + */ +export function forwardedParameter( + ctx: InferenceContext, + argument: tsserver.Expression, +): tsserver.ParameterDeclaration | undefined { + const {ts, checker} = ctx; + if (!ts.isIdentifier(argument) || boundArgument(ctx, argument)) return undefined; + const declaration = valueDeclarationOf(ctx, argument); + if (!declaration || !ts.isParameter(declaration) || declaration.initializer) return undefined; + if (hasExplicitParameterType(declaration, ts)) return undefined; + return informativeParts(ctx, checker.getTypeAtLocation(argument)).length === 0 ? declaration : undefined; +} diff --git a/packages/b2c-script-types/src/inference/callback-arguments.ts b/packages/b2c-script-types/src/inference/callback-arguments.ts new file mode 100644 index 000000000..c1778c74e --- /dev/null +++ b/packages/b2c-script-types/src/inference/callback-arguments.ts @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// What a project helper hands the callback it is passed. SFRA's +// `collections.reduce(collection, function (total, item) {...})` calls its +// callback as `callback(value, item, index, collection)`, so the callback's +// second parameter is whatever `item` is: an element of the collection one +// call passes. Reading the helper's own calls of its callback parameter, +// rather than assuming an element-first shape from the helper's name, types +// the callbacks of any helper a project defines +// (`eachShipment(basket, function (shipment) {...})`), whichever parameter +// receives what. ./core resolves the arguments found here with the helper's +// parameters bound to the call (see ./bindings). + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {invocationOf, spellingFilter} from './ast-helpers'; +import type {InferenceContext} from './context'; + +/** A call a helper hands its callback on to, as argument `argIndex`. */ +export interface ForwardedCallback { + readonly call: tsserver.CallExpression; + readonly argIndex: number; +} + +/** How a helper's body uses the callback parameter it is passed. */ +export interface CallbackUses { + /** What the helper passes as the callback's parameter, one argument per call of the callback. */ + readonly passed: readonly tsserver.Expression[]; + /** Calls the helper hands the callback on to (`collections.map(items, fn)` in a wrapper). */ + readonly forwarded: readonly ForwardedCallback[]; +} + +/** A helper that never calls or hands on its callback. */ +export const NO_CALLBACK_USES: CallbackUses = {passed: [], forwarded: []}; + +/** + * True when `call`'s result is assigned back to the variable `argument` + * names: an accumulator (`value = callback(value, item)`), which holds an + * initial value, an element or the callback's own result, by call. Like the + * accumulator of a native `reduce`, it stays unknown. + */ +function isFedBack(ctx: InferenceContext, call: tsserver.CallExpression, argument: tsserver.Expression): boolean { + const {ts, checker} = ctx; + const assignment = call.parent; + const assigns = + ts.isBinaryExpression(assignment) && + assignment.right === call && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(assignment.left); + return ( + assigns && + ts.isIdentifier(argument) && + checker.getSymbolAtLocation(assignment.left) === checker.getSymbolAtLocation(argument) + ); +} + +/** + * How `helper` uses its parameter `callbackIndex`: the argument each call of + * it (`callback(item)`, `callback.call(scope, item)`) passes as parameter + * `paramIndex`, and the calls it is handed on to. Arguments after a spread + * are not known by position and are left out, and so is an accumulator (see + * {@link isFedBack}). + */ +export function callbackUses( + ctx: InferenceContext, + helper: tsserver.SignatureDeclaration, + callbackIndex: number, + paramIndex: number, +): CallbackUses { + const {ts, checker} = ctx; + const parameter = helper.parameters[callbackIndex]; + const body = (helper as tsserver.FunctionLikeDeclaration).body; + if (!body || !parameter || parameter.dotDotDotToken || !ts.isIdentifier(parameter.name)) return NO_CALLBACK_USES; + const {text} = parameter.name; + const symbol = checker.getSymbolAtLocation(parameter.name); + const namesCallback = (node: tsserver.Node): boolean => + ts.isIdentifier(node) && node.text === text && checker.getSymbolAtLocation(node) === symbol; + const passed: tsserver.Expression[] = []; + const forwarded: ForwardedCallback[] = []; + const record = (call: tsserver.CallExpression): void => { + const invocation = invocationOf(call, ts); + if (invocation && namesCallback(invocation.callee)) { + const argument = argumentAt(ts, invocation.args, paramIndex); + if (argument && !isFedBack(ctx, call, argument)) passed.push(argument); + return; + } + const argIndex = call.arguments.findIndex(namesCallback); + if (argIndex >= 0) forwarded.push({call, argIndex}); + }; + const spellsCallback = spellingFilter(body, [text]); + const visit = (node: tsserver.Node): void => { + if (!spellsCallback(node)) return; + if (ts.isCallExpression(node)) record(node); + ts.forEachChild(node, visit); + }; + visit(body); + return {passed, forwarded}; +} + +/** The argument at `index`, unless a spread at or before it leaves its position unknown. */ +function argumentAt( + ts: typeof tsserver, + args: readonly tsserver.Expression[], + index: number, +): tsserver.Expression | undefined { + const known = args.slice(0, index + 1); + return known.length > index && !known.some((arg) => ts.isSpreadElement(arg)) ? known[index] : undefined; +} diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts new file mode 100644 index 000000000..6ce0ebba7 --- /dev/null +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Tunable limits for the usage-inference engine. They exist so a crafted (or +// merely huge) cartridge can't make a single hover/completion do unbounded +// work — every recursive walk and reference search is capped by one of these. +// Grouping them here keeps the "how hard will this try?" knobs in one place. + +// How far we chase an undocumented call chain (helper calls helper calls +// helper...) before giving up. Keeps worst-case cost predictable regardless of +// how deep a cartridge's helper stack goes. +export const MAX_INFERENCE_DEPTH = 3; + +// How many functions a parameter's usage is followed into when the body +// passes the parameter on unchanged (`new BooleanAttributeValue(search, +// definition, refinementValue)`): what the receiving parameter's function +// does with it is usage of this one too. Each hop is one walk of a function +// (or of its file, for a value stored on `this`) and no reference search. +export const MAX_USAGE_FORWARDING_HOPS = 3; + +// How many names a function value is followed through (see ./value-flow) +// before collectCallSites() gives up on finding an actual call site. SFRA's +// refinement models need four: the model -> `module.exports` (its require() +// calls) -> the factory returning it -> `var Model = factory(...)` -> the +// `Model` parameter it is passed on to, where `new Model(...)` finally runs. +// Hops into function-local names cost no project-wide search. +export const MAX_REFERENCE_HOPS = 4; + +// Hard cap on how many reference-search hits collectCallSites() will process +// across a single top-level inference request (not just one call site) — +// bounds worst-case cost for a helper referenced from dozens of places, +// complementing MAX_INFERENCE_DEPTH's cap on recursion depth. Generous enough +// to cover realistic cartridge helper usage without being effectively +// unlimited. Note what this does and doesn't bound: it caps how many results +// get processed and how far the search fans out, but a single search still +// checks every occurrence of the name in the files that can refer to it — +// on a large project the dominant cost is that first search, and the real +// bound on it is cancellation: the search polls the host's cancellation +// token between files and throws TypeScript's OperationCanceledException, +// which the plugin's `guarded` wrapper rethrows, never swallows. +export const MAX_REFERENCES_PER_REQUEST = 200; + +// Caps how much of that shared request-wide budget a *single* collectCallSites +// call can spend, so one widely-referenced sub-helper (e.g. reached from the +// first of several sibling return statements or call-site arguments) can't +// exhaust the whole budget and starve the others processed later in the same +// request. +export const MAX_REFERENCES_PER_CALL = 50; + +// How many `.method()` hops resolveExpressionTypes() will chase within a +// single static method-chain expression (e.g. `a.b().c().d()`). This is +// separate from MAX_INFERENCE_DEPTH, which only bounds crossing into another +// undocumented helper's own return-type inference — an in-expression chain +// never crosses a function boundary, so without its own cap it would be +// bounded only by how long an expression a cartridge author (or a generated +// file) happens to write, not by a predictable cost. +export const MAX_CHAIN_HOPS = 10; + +// How many cartridge levels the superModule member walk descends (top overlay +// -> mid overlay -> ... -> base). Real cartridge paths rarely stack more than +// three or four overlays of the same module. +export const MAX_SUPERMODULE_HOPS = 8; + +// Hard cap on how many reference SEARCHES one top-level request may issue +// (one for a name declared inside a function is not counted: it reads that +// function only). This is a different axis from MAX_REFERENCES_PER_REQUEST, +// which only bounds how many search *results* get processed: every search +// costs its reads and checks even when it returns almost nothing, so a +// helper whose call sites feed it results of many DISTINCT sub-helpers (each +// searched once, each contributing only 2-3 results) drains the result +// budget at ~2-3 per search — measured at 76 searches ≈ 115ms for a single +// hover on an SFRA-sized program (~1,900 cartridge files) before this cap +// existed. Legitimate scenarios in the perf baseline suite need at most 6 +// searches; 12 doubles that headroom while keeping the worst case at ~12 +// searches per request. +export const MAX_SEARCHES_PER_REQUEST = 12; + +// Bounds the editor's per-Program cache of finished hovers and completion +// lists during a long session without edits (hours of hovering around one +// Program). Entries are small plain data, so this is belt-and-braces, and a +// wholesale clear is honest: no LRU bookkeeping for a cache this cheap to +// refill. +export const MAX_DISPLAY_CACHE_ENTRIES = 512; + +// Marks the completion entries this plugin synthesizes (as opposed to ones the +// TypeScript language service produced itself), so the editor can tell them +// apart. Purely a label — it carries no path or other data. +export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; + +// Ambient usage matching (see ./ambient-index): a usage signature with fewer +// distinct member names than this is only trusted when exactly one ambient +// class declares it (`.addresses` is AddressBook's alone) or when the +// identifier name picks one of the matches (`customer` + `.profile`). +export const MIN_USAGE_SIGNATURE_MEMBERS = 2; + +// Most distinct types a hover or completion list shows as a union, IntelliJ +// style (`Product | Order`). More than this collapses to the candidates' +// closest shared ancestor class when one fits, and otherwise stays silent: a +// longer union is noise rather than a hint. +export const MAX_UNION_TYPES = 3; + +// Root classes nearly every Script API class inherits from. Offering them as +// the "common ancestor" of unrelated candidates tells the reader nothing. +export const UNINFORMATIVE_ANCESTORS: ReadonlySet = new Set(['Object', 'ExtensibleObject', 'PersistentObject']); + +// Member names so common across dw.* that they barely discriminate a class +// on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). +// They still narrow an ambient match, but a signature made only of these +// never picks one class out of several. +export const WEAK_USAGE_MEMBERS: ReadonlySet = new Set(['custom', 'UUID', 'toString', 'valueOf']); + +// Script API TopLevel classes that describe one global object (`module`, +// `arguments`) rather than a kind of value code passes around. Usage matching +// skips them: `regionDefinition.id` is not a Module. +export const GLOBAL_OBJECT_CLASSES: ReadonlySet = new Set(['Module', 'arguments']); + +// JavaScript built-ins whose members ordinary code uses all the time. A usage +// signature one of them also satisfies (`msg.replace(...)`, `x.length`) is +// ambiguous: the value may well be a string or an array, so ambient matching +// stays silent rather than naming the Script API class that happens to fit. +export const BUILTIN_VALUE_TYPES: readonly string[] = [ + 'String', + 'Number', + 'Boolean', + 'Array', + 'Function', + 'Date', + 'RegExp', +]; + +// Callee names whose callbacks lead with the collection element +// (`collections.forEach(coll, function (item) {...})`). A project helper's +// callbacks are typed from what its body passes them; this list only covers +// helpers whose body can't be read, and for those only these names get the +// sibling-collection element-type heuristic. `reduce` (accumulator first) +// and other unknown helpers stay out. +export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ + 'forEach', + 'map', + 'filter', + 'every', + 'some', + // SFRA `collections.find(coll, function (item) {...})` — same element-first + // shape; used heavily for address-book and line-item lookups. + 'find', + // Stock SFRA `collections.first` takes only the collection, but several + // storefronts (and common calculate.js ports) call it with a predicate + // the same shape as `find`. Treat that second-arg callback as element-first + // when present so the predicate parameter still gets a type. + 'first', +]); diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts new file mode 100644 index 000000000..46bb46255 --- /dev/null +++ b/packages/b2c-script-types/src/inference/context.ts @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// The InferenceContext is the "scratchpad" for a single hover/completion +// request: the TypeScript program/checker to ask questions of, the budgets +// that keep one request bounded, and the per-request memo/guards that stop +// the recursive walk from repeating work or looping forever. A fresh one is +// built per request and thrown away when it finishes. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {NO_BINDINGS} from './bindings'; +import type {Bindings} from './bindings'; +import type {CallSites} from './value-flow'; +import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; +import type {UsageProfile} from './usage-profile'; +import type {HookRegistration} from '../resolver/hook-registry'; + +/** + * What the plugin host knows about the project beyond its Program: the + * cartridge path and the cartridges' hook registrations. Without it, the + * evidence that depends on them is simply not gathered. + */ +export interface InferenceHost { + /** + * Maps a cartridge file to the same-subpath file in the next cartridge + * down the cartridge path — the module `module.superModule` refers to at + * runtime. Without it, `module.superModule` expressions stay uninferred. + */ + readonly resolveSuperModulePath?: (containingFile: string) => string | undefined; + /** + * The extension points the cartridges' hooks.json files register scripts + * for (see ./hook-calls). Asked at most once per request; a host may cache + * the answer per Program. + */ + readonly hookRegistrations?: (program: tsserver.Program) => readonly HookRegistration[]; + /** + * True once the editor has cancelled the request (the user kept typing). + * Reference searches poll it between files and throw TypeScript's + * OperationCanceledException, the way TypeScript's own searches do. + */ + readonly isCancellationRequested?: () => boolean; +} + +interface MemoEntry { + /** + * The `depth` this was computed at — i.e. how much of the recursion budget + * had already been spent getting here. A result computed at an equal-or- + * shallower depth (equal-or-more remaining budget) is always safe to reuse + * for a request now at an equal-or-deeper depth, since more budget can only + * surface the same types or more, never fewer. + */ + readonly atDepth: number; + readonly types: tsserver.Type[]; +} + +export interface InferenceContext { + readonly ts: typeof tsserver; + readonly program: tsserver.Program; + readonly checker: tsserver.TypeChecker; + /** + * Recursion guard for the current inference request only (cleared as the + * call stack unwinds) — NOT a cross-request memoization cache. It exists + * solely to break cycles like `function a(){return b()} function b(){return a()}`. + */ + readonly visiting: Set; + /** + * Request-scoped memoization so sibling branches (e.g. several return + * statements or call-site arguments that all resolve through the same + * undocumented sub-helper) don't redo the same reference search and + * recursive inference repeatedly within one hover/completion request. + */ + readonly memo: Map; + /** + * Mutable, shared across the whole request — decremented by + * collectCallSites() every time it processes a reference. + */ + referenceBudget: number; + /** + * Mutable, shared across the whole request — decremented by + * collectCallSites() for every reference search it runs, except one for a + * name declared inside a function. See MAX_SEARCHES_PER_REQUEST for why + * this needs its own budget alongside the result-count one. + */ + searchBudget: number; + /** + * Request-scoped memo of collectCallSites() results, keyed by the searched + * name and the role it holds the function in (see ./value-flow). Two different parameters of the same function (or two return + * paths reaching the same parameter set) otherwise each re-run the exact + * same reference searches within one request. Reuse is sound because the + * budgets only ever decrease during a request: a memoized result was + * computed with at least as much budget as any later call would have had, + * so it can only be equally or more complete. + */ + readonly callSiteMemo: Map; + /** + * Request-scoped memo of checker.typeToString() results, used by + * dedupeTypes(). Candidate types propagate up through every recursion + * level (parameter -> return -> forwarding helper -> ...), and each level + * dedupes its combined result — without the memo the same Type objects get + * re-stringified once per level (measured: 192 stringifications for 48 + * unique candidate types, 13ms of a 34ms request, when 50 call sites pass + * large distinct object literals through a two-hop forwarding chain). + * Stringifying a type is pure for a given checker, and the context never + * outlives its checker, so memoizing per request is sound. + */ + readonly typeDisplayStrings: Map; + /** + * Request-scoped memo of own usage profiles (see ./usage-profile; the + * usage of the parameters a value is passed on to is merged in on top), + * keyed by the parameter's or variable's symbol. Every consumer of a profile (the + * body-usage filter, contextual constraints, ambient matching, reassigned + * values) reads the same single walk of the declaring scope. + */ + readonly profiles: Map; + /** + * Mutable, shared across the whole request — incremented every time a + * cycle guard fires (a `visiting` hit). A result computed while this moved + * is potentially incomplete *for this call stack only* (the cycle member it + * skipped could resolve fine from a different entry point later in the same + * request), so such results must not be memoized — see withInferenceGuards. + */ + cycleHits: number; + /** + * The parameters bound to one call's arguments while that call's + * result is inferred (see ./bindings); empty otherwise. Swapped in and out + * as calls nest, never shared between them. + */ + bindings: Bindings; + /** What the plugin host supplies about the project (cartridge path, hook registrations). */ + readonly host: InferenceHost; + /** + * A completion request's cursor position. Exists so usage profiling (see + * ./usage-profile) can exclude the property access the completion is + * itself sitting inside of from its own evidence. A member name still + * being typed (`shipment.pro|`) is not a member anything has, and a + * dangling `shipment.` immediately followed (after a line + * break) by more code doesn't get automatic semicolon insertion — `.` + * always demands a following identifier — so the parser merges it with + * whatever statement comes next (`shipment.\n\nTransaction.wrap(...)` + * parses as one expression, `shipment.Transaction.wrap(...)`). Left + * uncorrected, that phantom `Transaction` member would count as real usage + * evidence and poison the match with a member no real class has, silently + * producing no completions for the very position asking for them. + */ + readonly triggerPosition?: number; +} + +/** + * Builds a fresh inference context for one top-level hover/completion + * request, or `undefined` if the language service has no program yet. + */ +export function createInferenceContext( + ts: typeof tsserver, + languageService: tsserver.LanguageService, + host: InferenceHost = {}, + triggerPosition?: number, +): InferenceContext | undefined { + const program = languageService.getProgram(); + return program && contextForProgram(ts, program, host, triggerPosition); +} + +/** + * Like {@link createInferenceContext}, for a caller that already holds the + * request's Program: asking the language service for it again brings it up + * to date with the host again, which reads the version of every file. + */ +export function contextForProgram( + ts: typeof tsserver, + program: tsserver.Program, + host: InferenceHost = {}, + triggerPosition?: number, +): InferenceContext { + return { + ts, + program, + checker: program.getTypeChecker(), + visiting: new Set(), + memo: new Map(), + referenceBudget: MAX_REFERENCES_PER_REQUEST, + searchBudget: MAX_SEARCHES_PER_REQUEST, + callSiteMemo: new Map(), + typeDisplayStrings: new Map(), + profiles: new Map(), + cycleHits: 0, + bindings: NO_BINDINGS, + host, + triggerPosition, + }; +} + +/** + * Runs `compute` with `node` marked as in progress, so a cycle that leads + * back to the same node (`var a = b; var b = a;`, or two helpers returning + * each other's result) gets `fallback` instead of recursing forever. Each hit + * is counted in ctx.cycleHits; see {@link withInferenceGuards}. + */ +export function withCycleGuard(ctx: InferenceContext, node: tsserver.Node, fallback: T, compute: () => T): T { + if (ctx.visiting.has(node)) { + ctx.cycleHits++; + return fallback; + } + ctx.visiting.add(node); + try { + return compute(); + } finally { + ctx.visiting.delete(node); + } +} + +/** + * The shared preamble for every memoized inference entry point: serve a memo + * hit, enforce MAX_INFERENCE_DEPTH, break cycles, and memoize the result. + * + * The memo is consulted before the depth cap: a result computed at an equal + * or shallower depth had at least as much budget as this call would, so it is + * reusable however deep the current path is. A result whose computation hit + * a cycle guard is not memoized: it was cut short by what happened to be on + * the current call stack, and the same node reached later from outside the + * cycle could resolve more. While call-specific bindings are in force + * nothing is memoized, since a result for one call's arguments is not the + * node's general result; and inside a bound function the memo is not read + * either, since its general result is what the bindings are there to + * improve on. Anywhere else the general result still holds. + */ +export function withInferenceGuards( + ctx: InferenceContext, + node: tsserver.Node, + depth: number, + compute: () => tsserver.Type[], +): tsserver.Type[] { + const unbound = ctx.bindings.size === 0; + const cached = unbound || !isInBoundFunction(ctx, node) ? ctx.memo.get(node) : undefined; + if (cached && cached.atDepth <= depth) return cached.types; + if (depth > MAX_INFERENCE_DEPTH) return []; + return withCycleGuard(ctx, node, [], () => { + const cycleHitsBefore = ctx.cycleHits; + const types = compute(); + if (unbound && ctx.cycleHits === cycleHitsBefore) ctx.memo.set(node, {atDepth: depth, types}); + return types; + }); +} + +/** True when `node` lies inside a function whose parameters are bound to one call's arguments. */ +function isInBoundFunction(ctx: InferenceContext, node: tsserver.Node): boolean { + const file = node.getSourceFile(); + for (const parameter of ctx.bindings.keys()) { + const fn = parameter.parent; + if (fn.getSourceFile() === file && node.pos >= fn.pos && node.end <= fn.end) return true; + } + return false; +} diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts new file mode 100644 index 000000000..0d50a5734 --- /dev/null +++ b/packages/b2c-script-types/src/inference/core.ts @@ -0,0 +1,635 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// The recursive heart of usage inference. Given an untyped value the checker +// gave up on (`any`), these functions gather evidence of what it really is: a +// parameter from its call-site arguments, a function from its return +// expressions, a variable from the values assigned to it, a method chain by +// resolving its receiver first, and superModule overlays by descending the +// cartridge path. They call each other (parameter -> return -> forwarding +// helper -> ...), so they live together in one module. What to *show* for the +// evidence is decided in ./policy, at every parameter, return and variable, +// so every path into the same value reaches the same answer. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_CHAIN_HOPS, MAX_INFERENCE_DEPTH} from './constants'; +import {withCycleGuard, withInferenceGuards} from './context'; +import type {InferenceContext} from './context'; +import { + collectReturnExpressions, + hasExplicitParameterType, + hasExplicitReturnType, + hasExplicitVariableType, +} from './ast-helpers'; +import { + NO_BINDINGS, + argumentBindings, + boundArgument, + boundCallback, + hasAnyTypeArgument, + narrows, + withBindings, +} from './bindings'; +import type {ArgumentBinding, BoundCallback} from './bindings'; +import {NO_CALLBACK_USES, callbackUses} from './callback-arguments'; +import {collectCallSites, forwardedParameter} from './call-sites'; +import {holderOf} from './value-flow'; +import {frameworkParameterTypes} from './framework-contracts'; +import {genericResultSource} from './generic-calls'; +import {hookCallSites, hookImplementations} from './hook-calls'; +import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; +import {decideType, limitUnion, normalizeCandidates} from './policy'; +import { + callbackParameterTypes, + handedOffParameterTypes, + isElementFirstCallbackCall, + resolveCalleeDeclaration, +} from './signatures'; +import type {ExpressionResolver} from './super-module'; +import {resolveSuperModuleMemberTypes, resolveSuperModuleTypes, traceSuperModuleAccess} from './super-module'; +import { + arrayTypeOf, + dedupeKnownTypes, + dedupeTypes, + elementTypeOf, + getMemberOfType, + informativeParts, + isAnyType, +} from './type-helpers'; +import {usageProfileOf} from './usage-profile'; + +/** + * Everything a local variable can hold: its initializer plus every later + * `x = value` assignment (`var result = null; if (a) result = x; else result + * = y;`), decided as one candidate set. Covers the idiomatic SFCC style of + * splitting a chain across intermediate variables (`var priceModel = + * product.getPriceModel(); return priceModel.getPrice();`), which would + * otherwise dead-end at the variable even though the inline expression + * resolves fine. An explicit annotation means the variable's `any` is + * deliberate (same rule as parameters and returns). Following a variable + * never crosses a function boundary, so it is charged to `chainHops`, not to + * the recursion depth. + */ +function resolveVariableTypes( + ctx: InferenceContext, + decl: tsserver.VariableDeclaration, + depth: number, + chainHops: number, +): tsserver.Type[] { + if (hasExplicitVariableType(decl, ctx.ts)) return []; + return withCycleGuard(ctx, decl, [], () => { + const profile = usageProfileOf(ctx, decl); + const initial = decl.initializer + ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) + : []; + const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; + return decideType(ctx, evidence, profile, decl.name, false); + }); +} + +/** A variable's initializer; an array literal later filled by `items.push(x)` is an array of what it holds. */ +function resolveInitializerTypes( + ctx: InferenceContext, + initializer: tsserver.Expression, + pushedValues: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + if (pushedValues.length === 0 || !ctx.ts.isArrayLiteralExpression(initializer)) { + return resolveExpressionTypes(ctx, initializer, depth, chainHops); + } + return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); +} + +/** Resolves values one hop further along the expression `depth` and `chainHops` describe. */ +function nextHop(ctx: InferenceContext, depth: number, chainHops: number): ExpressionResolver { + return (value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1); +} + +/** The types of several values the same thing can hold, each one hop further along the expression. */ +function resolveValues( + ctx: InferenceContext, + values: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); +} + +/** + * Infers a callback parameter's type from the call the callback is passed + * to. A method on a known receiver declares it (`value: T` of + * `items.filter(function (item) {...})` once `items` is inferred as a + * `ProductLineItem[]`). A project helper says it by what it passes the + * callback (see {@link invokedCallbackTypes}). A helper that can't be read, + * when it is an element-first one by name (`collections.forEach(coll, fn)`, + * see {@link isElementFirstCallbackCall}), hands the first parameter an + * element of the collection travelling alongside it. Other unknown helpers + * are left alone: guessing their shape would be wrong more often than not. + */ +function inferCallbackParameterTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const call = fn.parent; + if (!call || !ctx.ts.isCallExpression(call)) return []; + const argIndex = call.arguments.indexOf(fn as tsserver.Expression); + const access = call.expression; + const declared = ctx.ts.isPropertyAccessExpression(access) + ? callbackParameterTypes( + ctx, + memberTypesOfReceiver(ctx, access.expression, access.name, depth, 0), + call, + argIndex, + paramIndex, + ) + : []; + if (declared.length > 0) return declared; + const invoked = invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth); + if (invoked.length > 0 || paramIndex !== 0 || !isElementFirstCallbackCall(ctx, call)) return invoked; + return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); +} + +/** + * What the project helper `call` invokes passes parameter `paramIndex` of + * the callback it receives as argument `argIndex`, resolved with the + * helper's parameters bound to this call's arguments: an element of the + * collection this call passes, for `collections.reduce(lineItems, function + * (total, lineItem) {...})` as for a project's own `eachShipment(basket, + * fn)`. A helper handing the callback on to another one is followed into + * that one, a level deeper. + */ +function invokedCallbackTypes( + ctx: InferenceContext, + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const helper = depth < MAX_INFERENCE_DEPTH ? resolveCalleeDeclaration(ctx, call) : undefined; + const {passed, forwarded} = helper ? callbackUses(ctx, helper, argIndex, paramIndex) : NO_CALLBACK_USES; + if (!helper || (passed.length === 0 && forwarded.length === 0)) return []; + const bindings = argumentBindings(ctx, helper, call.arguments, depth) ?? ctx.bindings; + return withBindings(ctx, bindings, () => [ + ...passed.flatMap((value) => resolveExpressionTypes(ctx, value, depth + 1)), + ...forwarded.flatMap((use) => invokedCallbackTypes(ctx, use.call, use.argIndex, paramIndex, depth + 1)), + ]); +} + +/** + * The checker's type of `expr`. Inside a class or a constructor function the + * checker types `this` as an open `this` type; what it holds is the instance + * that type stands for (`helper(this)` in `function Basket() {…}` passes a + * `Basket`). + */ +function checkerTypeOf(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type { + const {ts, checker} = ctx; + const type = checker.getTypeAtLocation(expr); + if (expr.kind !== ts.SyntaxKind.ThisKeyword || !(type.flags & ts.TypeFlags.TypeParameter)) return type; + return checker.getBaseConstraintOfType(type) ?? type; +} + +/** + * Resolves the candidate type(s) of `expr`: the checker's own type when it is + * informative, otherwise a type recovered through whatever `expr` is built + * from (an undocumented callee's returns, a receiver's real members, a + * variable's values, a parameter's call sites). + * + * @param chainHops - how many `.method()`/`.prop` hops within the *same* + * static expression have already been chased (e.g. the `2` in + * `a.b().c().d()` when resolving `d`'s receiver `a.b().c()`). This is + * distinct from `depth`, which only advances when crossing into another + * undocumented helper's own inference — chain-hopping never crosses a + * function boundary, so it needs its own bound (`MAX_CHAIN_HOPS`) to keep the + * cost of a very long inline chain predictable. + * @returns An array (rather than a single unioned Type) because the public + * TypeChecker API has no way to synthesize a union Type. + */ +function resolveExpressionTypes( + ctx: InferenceContext, + expr: tsserver.Expression, + depth: number, + chainHops = 0, +): tsserver.Type[] { + const {ts, checker} = ctx; + // module.superModule (or a `var base = module.superModule` alias) first: + // TS knows nothing about SFCC overlay semantics, and its type for these + // expressions is never meaningful — sometimes `any`, sometimes an opaque + // circular `typeof base` that would pass for informative. + const superAccess = traceSuperModuleAccess(ts, checker, expr); + if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, nextHop(ctx, depth, chainHops)); + const direct = informativeParts(ctx, checkerTypeOf(ctx, expr)); + const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; + if (bound) return bound; + if (direct.length > 0) return direct; + return chainHops < MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; +} + +/** + * While a call's arguments are bound (see ./bindings): a bound parameter is + * what its argument is, and an expression the checker types as an `any` + * instantiation (`collection.iterator()` on a bare `{dw.util.Collection}`) + * is recovered through its parts, which may reach a bound parameter. Either + * stands in for the checker's type only where it narrows it. A bound + * parameter whose argument says nothing keeps its general type, resolved + * without the bindings (and so served from the memo). + */ +function resolveBoundTypes( + ctx: InferenceContext, + expr: tsserver.Expression, + declared: readonly tsserver.Type[], + depth: number, + chainHops: number, +): tsserver.Type[] | undefined { + const binding = boundArgument(ctx, expr); + if (binding) { + const types = argumentTypes(ctx, binding); + if (narrows(ctx, declared, types)) return types; + return withBindings(ctx, NO_BINDINGS, () => resolveExpressionTypes(ctx, expr, depth, chainHops)); + } + if (chainHops >= MAX_CHAIN_HOPS || !declared.some((type) => hasAnyTypeArgument(ctx, type))) return undefined; + const narrowed = resolveFromParts(ctx, expr, depth, chainHops); + return narrows(ctx, declared, narrowed) ? narrowed : undefined; +} + +/** What a bound argument evaluates to where its call is written, resolved once per binding. */ +function argumentTypes(ctx: InferenceContext, binding: ArgumentBinding): tsserver.Type[] { + binding.resolved ??= withBindings(ctx, binding.outer, () => + limitUnion(ctx, normalizeCandidates(ctx, resolveExpressionTypes(ctx, binding.argument, binding.depth))), + ); + return binding.resolved; +} + +/** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ +function resolveFromParts( + ctx: InferenceContext, + expr: tsserver.Expression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; + if (ts.isCallExpression(expr)) return resolveCallResultTypes(ctx, expr, depth, chainHops); + if (ts.isPropertyAccessExpression(expr)) return resolvePropertyTypes(ctx, expr, depth, chainHops); + if (ts.isIdentifier(expr)) return resolveIdentifierTypes(ctx, expr, depth, chainHops); + if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + if (ts.isElementAccessExpression(expr)) return resolveElementAccessTypes(ctx, expr, depth, chainHops); + if (ts.isArrayLiteralExpression(expr)) return resolveArrayTypes(ctx, expr.elements, depth, chainHops); + // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) + // and `a || b` / `a ?? b` can each evaluate to either side. + if (ts.isConditionalExpression(expr)) { + return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); + } + return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; +} + +/** The element types of whatever arrays or collections `collection` holds. */ +function resolveElementTypes( + ctx: InferenceContext, + collection: tsserver.Expression, + depth: number, + chainHops: number, +): tsserver.Type[] { + return resolveExpressionTypes(ctx, collection, depth, chainHops + 1).flatMap( + (type) => elementTypeOf(ctx, type, collection) ?? [], + ); +} + +/** `x[i]` is an element of `x` (a Script API collection has no index signature to say so); `x['m']` reads member `m`. */ +function resolveElementAccessTypes( + ctx: InferenceContext, + expr: tsserver.ElementAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const index = expr.argumentExpression; + if (ctx.ts.isStringLiteralLike(index)) { + return dedupeKnownTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + } + return dedupeTypes(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); +} + +/** `[a, ...rest]`: an array of what its elements are, decided like any other choice of types (see ./policy). */ +function resolveArrayTypes( + ctx: InferenceContext, + elements: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; + const elementTypes = elements.flatMap((element) => + ts.isSpreadElement(element) + ? resolveElementTypes(ctx, element.expression, depth, chainHops) + : resolveExpressionTypes(ctx, element, depth, chainHops + 1), + ); + const array = arrayTypeOf(ctx, limitUnion(ctx, normalizeCandidates(ctx, elementTypes))); + return array ? [array] : []; +} + +function resolveAlternatives( + ctx: InferenceContext, + alternatives: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + return dedupeTypes( + ctx, + alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1)), + ); +} + +/** `a || b` and `a ?? b` evaluate to either side; `a && b` to `b` when it's worth showing at all. */ +function resolveLogicalTypes( + ctx: InferenceContext, + expr: tsserver.BinaryExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {SyntaxKind} = ctx.ts; + switch (expr.operatorToken.kind) { + case SyntaxKind.BarBarToken: + case SyntaxKind.QuestionQuestionToken: + return resolveAlternatives(ctx, [expr.left, expr.right], depth, chainHops); + case SyntaxKind.AmpersandAmpersandToken: + return resolveExpressionTypes(ctx, expr.right, depth, chainHops + 1); + default: + return []; + } +} + +/** + * The types of member `name` on each candidate type of `receiver` + * (`x.getPriceModel` when `x` is undocumented but inferable). When no + * receiver type carries the member and the receiver is (an alias of) + * `module.superModule`, the member may be an export augmentation added by a + * pass-through overlay level, which no export type can carry. A member the + * checker binds on a receiver it types as nothing worth showing (`this` in a + * prototype method) is the member it binds; on an untyped local receiver, it + * is whatever the code writes to it (`productData.apiProduct = ...`). + */ +function memberTypesOfReceiver( + ctx: InferenceContext, + receiver: tsserver.Expression, + name: tsserver.MemberName | tsserver.StringLiteralLike, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const memberName = name.text; + const types = resolveExpressionTypes(ctx, receiver, depth, chainHops + 1).flatMap((receiverType) => { + const member = getMemberOfType(checker, receiverType, memberName); + return member ? memberTypes(ctx, member, name, depth, chainHops) : []; + }); + if (types.length > 0) return types; + const superAccess = traceSuperModuleAccess(ts, checker, receiver); + if (superAccess) { + return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, nextHop(ctx, depth, chainHops)); + } + const bound = checker.getSymbolAtLocation(name); + if (bound) return memberTypes(ctx, bound, name, depth, chainHops); + return resolveValues(ctx, localMemberValues(ctx, receiver, memberName), depth, chainHops); +} + +/** + * A member's declared type, or — when that says nothing, as for most + * undocumented members (`{apiProduct: apiProduct}`, `this.productSearch = + * productSearch`) — the types of the values it is declared with. + */ +function memberTypes( + ctx: InferenceContext, + member: tsserver.Symbol, + location: tsserver.Node, + depth: number, + chainHops: number, +): tsserver.Type[] { + const declared = ctx.checker.getTypeOfSymbolAtLocation(member, location); + const declaration = member.valueDeclaration ?? member.declarations?.[0]; + if (informativeParts(ctx, declared).length > 0 || !declaration) return [declared]; + return withCycleGuard(ctx, declaration, [], () => + resolveValues(ctx, memberValueExpressions(ctx, member), depth, chainHops), + ); +} + +/** + * The return types of `methodType`'s call signatures. A signature returning + * `any` (an undocumented function reached through a superModule export type) + * is inferred from its own declaration instead. + */ +function signatureReturnTypes(ctx: InferenceContext, methodType: tsserver.Type, depth: number): tsserver.Type[] { + const {ts, checker} = ctx; + return methodType.getCallSignatures().flatMap((signature) => { + const returnType = checker.getReturnTypeOfSignature(signature); + if (!isAnyType(ts, returnType)) return [returnType]; + const declaration = signature.declaration; + return declaration && ts.isFunctionLike(declaration) ? inferReturnType(ctx, declaration, depth + 1) : []; + }); +} + +/** + * What `fn` returns for this `call`. Its return across all callers, unless + * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a + * helper handed either collection): then what it returns for the arguments + * this call passes it (see ./bindings), when that says anything. A choice + * across callers is the union of what each of them gets, and this call is + * one of them, so its own answer is the more precise one, also where the + * union missed it (a caller the depth or reference budget left out). + */ +function calleeReturnTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + call: tsserver.CallExpression, + depth: number, +): tsserver.Type[] { + const general = inferReturnType(ctx, fn, depth + 1); + if (general.length === 1 || hasExplicitReturnType(fn, ctx.ts)) return general; + const bindings = argumentBindings(ctx, fn, call.arguments, depth); + const specific = bindings ? withBindings(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; + return specific.length > 0 ? specific : general; +} + +/** + * What a call of a bound parameter returns (`callback(item)` inside + * `collections.map`, inferred for one call): what the function the call + * being resolved passes returns, with its parameters bound to these + * arguments. + */ +function boundCallbackReturnTypes(ctx: InferenceContext, {fn, args}: BoundCallback, depth: number): tsserver.Type[] { + const bindings = argumentBindings(ctx, fn, args, depth) ?? ctx.bindings; + return withBindings(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)); +} + +/** + * Resolves an `any` call expression: first by inferring the callee's own + * return type, then — for a method call on an undocumented receiver + * (`x.getPriceModel().getPrice()`) — from the method's real signature(s) on + * the receiver's inferred type(s). + */ +function resolveCallResultTypes( + ctx: InferenceContext, + expr: tsserver.CallExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const callback = ctx.bindings.size > 0 ? boundCallback(ctx, expr) : undefined; + if (callback) return boundCallbackReturnTypes(ctx, callback, depth); + const calleeFn = resolveCalleeDeclaration(ctx, expr); + const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; + if (inferred.length > 0) return inferred; + const hookResults = hookImplementations(ctx, expr).flatMap((hook) => inferReturnType(ctx, hook, depth + 1)); + if (hookResults.length > 0) return dedupeTypes(ctx, hookResults); + const generic = genericResultSource(ctx, expr); + if (generic) { + return generic.kind === 'return' + ? inferReturnType(ctx, generic.fn, depth + 1) + : resolveExpressionTypes(ctx, generic.argument, depth, chainHops + 1); + } + const callee = expr.expression; + if (!ctx.ts.isPropertyAccessExpression(callee)) return []; + return dedupeTypes( + ctx, + memberTypesOfReceiver(ctx, callee.expression, callee.name, depth, chainHops).flatMap((methodType) => + signatureReturnTypes(ctx, methodType, depth), + ), + ); +} + +/** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ +function resolvePropertyTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + return dedupeKnownTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); +} + +/** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ +function resolveIdentifierTypes( + ctx: InferenceContext, + expr: tsserver.Identifier, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; + const decl = valueDeclarationOf(ctx, expr); + // Only a search of another function's call sites costs depth: an anonymous + // callback's parameter is read off the call it is passed to, in this body. + if (decl && ts.isParameter(decl)) { + return inferParameterType(ctx, decl, holderOf(ctx, decl.parent) ? depth + 1 : depth); + } + if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); + return []; +} + +/** + * The argument types a parameter receives: at every call site of its + * function across the project (`helper(x)`, `new Helper(x)`, + * `Helper.call(this, x)`, and for a hook script's export, the + * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it + * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an + * anonymous callback with no name to search for — from the collection it + * iterates. An anonymous function a factory returns is called wherever the + * factory's calls take it (`server.append('Show', responseHandler(map))`). + */ +function parameterEvidence( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const holder = holderOf(ctx, fn); + if (!holder) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); + const {calls, handoffs} = collectCallSites(ctx, holder); + const passed = [...calls, ...hookCallSites(ctx, fn)].flatMap((site) => { + const arg = site.args[paramIndex]; + return arg ? argumentEvidence(ctx, arg, depth) : []; + }); + return [...passed, ...handoffs.flatMap((handoff) => handedOffParameterTypes(ctx, handoff, paramIndex))]; +} + +/** + * The types one call-site argument passes. A caller's own untyped parameter + * passed on as is (see {@link forwardedParameter}) is the same value, so its + * call sites are searched at the same depth: a chain of helpers handing a + * value down (`addProductToCart` -> `getExistingProductLineItemInCart` -> + * `getExistingProductLineItemsInCart` -> `getMatchingProducts`) costs one + * level, not one per helper. The search budget still bounds the chain. + */ +function argumentEvidence(ctx: InferenceContext, arg: tsserver.Expression, depth: number): tsserver.Type[] { + const forwarded = forwardedParameter(ctx, arg); + return forwarded ? inferParameterType(ctx, forwarded, depth) : resolveExpressionTypes(ctx, arg, depth); +} + +/** + * Infers a parameter's type(s): from a platform contract when the function + * is one the platform calls (see ./framework-contracts), otherwise from the + * arguments it receives and its own usage (see ./policy for how the two are + * weighed). Plain un-annotated JS parameters default to `any` with no + * back-inference from call sites, which is the gap this fills. + * + * @param depth - Recursion budget already consumed by the call chain that + * led here; defaults to 0 for a top-level request. + */ +export function inferParameterType( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, + depth = 0, +): tsserver.Type[] { + const {ts} = ctx; + const fn = param.parent; + if (hasExplicitParameterType(param, ts) || !ts.isFunctionLike(fn)) return []; + return withInferenceGuards(ctx, param, depth, () => { + const paramIndex = fn.parameters.indexOf(param); + const contract = frameworkParameterTypes(ctx, fn, paramIndex); + if (contract.length > 0) return contract; + const evidence = parameterEvidence(ctx, fn, paramIndex, depth); + return decideType(ctx, evidence, usageProfileOf(ctx, param), param.name, true); + }); +} + +/** + * Infers a function's return type(s) from its own return statements, + * chasing into undocumented callees when a return expression itself + * resolves to `any`. + * + * @param depth - Recursion budget already consumed by the call chain that + * led here; defaults to 0 for a top-level request. + */ +export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDeclaration, depth = 0): tsserver.Type[] { + if (hasExplicitReturnType(fn, ctx.ts)) return []; + return withInferenceGuards(ctx, fn, depth, () => { + const types = collectReturnExpressions(fn, ctx.ts).flatMap((expr) => resolveExpressionTypes(ctx, expr, depth)); + return limitUnion(ctx, normalizeCandidates(ctx, types)); + }); +} + +/** + * Entry point for hover and completions on an identifier: infers the type of + * the parameter or variable it names, or the return type of the function it + * names. + */ +export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): tsserver.Type[] { + const {ts, checker} = ctx; + if (!ts.isIdentifier(node)) return []; + const decl = checker.getSymbolAtLocation(node)?.valueDeclaration; + if (!decl) return []; + if (ts.isParameter(decl)) return inferParameterType(ctx, decl); + if (ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, 0, 0); + if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); + return []; +} + +/** + * Like {@link inferTypeForNode}, but for an arbitrary expression — the + * completion case `product.getPriceModel().|` and member hovers, where the + * thing to type is a call or chain rather than a declared name. + */ +export function inferTypeForExpression(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type[] { + if (ctx.ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); + return limitUnion(ctx, normalizeCandidates(ctx, resolveExpressionTypes(ctx, expr, 0))); +} diff --git a/packages/b2c-script-types/src/inference/editor-hooks.ts b/packages/b2c-script-types/src/inference/editor-hooks.ts new file mode 100644 index 000000000..093be220f --- /dev/null +++ b/packages/b2c-script-types/src/inference/editor-hooks.ts @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Editor integration: decorates the language service's own hover and member +// completion results with what usage inference finds for a value the checker +// left open (`any`, `object`, `{}`, bare `Object`, or a `module.superModule` +// binding). Everything here is additive: the caller computes the underlying +// result, and it comes back untouched whenever inference has nothing to add +// or fails. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {getNodeAtPosition, memberCompletionAccess, propertyAccessNamedBy} from './ast-helpers'; +import {MAX_DISPLAY_CACHE_ENTRIES} from './constants'; +import type {HookRegistration} from '../resolver/hook-registry'; +import {contextForProgram} from './context'; +import type {InferenceContext, InferenceHost} from './context'; +import {inferTypeForExpression, inferTypeForNode} from './core'; +import {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './super-module'; +import { + describeTypes, + getMemberOfType, + inferredCompletionEntry, + isOpenForUsageInference, + typesToCompletionEntries, +} from './type-helpers'; + +interface HookEnvironment extends InferenceHost { + readonly ts: typeof tsserver; + readonly languageService: tsserver.LanguageService; +} + +interface HookOptions extends HookEnvironment { + readonly log: (message: string) => void; +} + +/** + * What a hover shows once inference resolved a value: the type text, plus the + * doc comment and tags borrowed from the real declaration it resolved to + * (e.g. the `custom` property on `dw.object.ExtensibleObject`). Plain data + * only — see {@link DisplayCache} for why. + */ +interface HoverInference { + readonly description: string; + readonly documentation?: readonly tsserver.SymbolDisplayPart[]; + readonly tags?: readonly tsserver.JSDocTagInfo[]; +} + +/** + * Finished display products (hover results, completion entries) per Program, + * keyed by the request's node. `undefined` ("inference found nothing") is a + * cached answer too: re-deriving nothing costs the same reference searches as + * re-deriving something. + * + * Values are plain data, never checker Types, Symbols or Nodes: those pin + * their checker and, through it, the whole Program they came from. Keying on + * the Program through a WeakMap retires every entry the moment TypeScript + * drops that Program (it builds a new one for any semantic change and reuses + * the same instance otherwise), without holding it alive in the meantime. + */ +interface DisplayCache { + get(key: string, program: tsserver.Program, compute: () => V): V; + clear(): void; +} + +function createDisplayCache(): DisplayCache { + let byProgram = new WeakMap>(); + return { + get(key, program, compute) { + const entries = byProgram.get(program) ?? new Map(); + byProgram.set(program, entries); + if (entries.has(key)) return entries.get(key) as V; + const value = compute(); + if (entries.size >= MAX_DISPLAY_CACHE_ENTRIES) entries.clear(); + entries.set(key, value); + return value; + }, + clear() { + byProgram = new WeakMap(); + }, + }; +} + +/** + * Runs the inference augmentation and degrades to `fallback` (the untouched + * underlying result) if it throws, so a bug here can't take the whole + * tsserver request down. Deliberately wraps only the augmentation, never the + * underlying language-service call: an exception from TypeScript itself must + * reach tsserver's own error reporting exactly as it would without the + * plugin. `OperationCanceledException` always propagates: TS throws it + * cooperatively when the host cancels a request (the user kept typing), and + * tsserver handles a cancellation very differently from an empty response. + */ +function guarded(options: HookOptions, label: string, fallback: T, compute: () => T): T { + try { + return compute(); + } catch (error) { + if (error instanceof options.ts.OperationCanceledException) throw error; + options.log(`usage-inference ${label} failed: ${(error as Error).message}`); + return fallback; + } +} + +interface OpenFile { + readonly program: tsserver.Program; + readonly sourceFile: tsserver.SourceFile; + readonly checker: tsserver.TypeChecker; +} + +function openFile(env: HookEnvironment, fileName: string): OpenFile | undefined { + const program = env.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + return program && sourceFile ? {program, sourceFile, checker: program.getTypeChecker()} : undefined; +} + +/** + * True when the checker's own type for `expr` leaves room for inference: an + * open type (see isOpenForUsageInference), or a value derived from + * `module.superModule`, whose checker type is never meaningful (sometimes + * `any`, sometimes an opaque circular `typeof base`). + */ +function isInferenceTarget(ts: typeof tsserver, checker: tsserver.TypeChecker, expr: tsserver.Expression): boolean { + return isOpenForUsageInference(ts, checker.getTypeAtLocation(expr)) || !!traceSuperModuleAccess(ts, checker, expr); +} + +/** + * The declaration a member-name hover documents: the member every inferred + * receiver type resolves the name to. Receivers that resolve it to different + * declarations (an override, or unrelated classes) document nothing rather + * than an arbitrary one of them. + */ +function memberSymbol(ctx: InferenceContext, access: tsserver.PropertyAccessExpression): tsserver.Symbol | undefined { + const symbols = new Set( + inferTypeForExpression(ctx, access.expression).map((type) => getMemberOfType(ctx.checker, type, access.name.text)), + ); + const [symbol] = symbols; + return symbols.size === 1 ? symbol : undefined; +} + +/** The class a hover documents when inference resolved exactly one; a union has no single doc comment. */ +function soleTypeSymbol(types: readonly tsserver.Type[]): tsserver.Symbol | undefined { + return types.length === 1 ? types[0].getSymbol() : undefined; +} + +function documentationOf( + checker: tsserver.TypeChecker, + symbol: tsserver.Symbol | undefined, +): Pick { + const documentation = symbol?.getDocumentationComment(checker) ?? []; + const tags = symbol?.getJsDocTags(checker) ?? []; + return { + documentation: documentation.length > 0 ? documentation : undefined, + tags: tags.length > 0 ? tags : undefined, + }; +} + +/** + * Infers what a hover on `node` shows. A member name (`productLineItems` in + * `shipment.productLineItems`) has no declaration of its own until the + * receiver's type is known, so the whole access is resolved instead. + * Documentation comes from the member's declaration, or from the inferred + * class when there is exactly one. + */ +function inferHover(env: HookEnvironment, file: OpenFile, node: tsserver.Identifier): HoverInference | undefined { + const ctx = contextForProgram(env.ts, file.program, env); + const access = propertyAccessNamedBy(node, env.ts); + const types = access ? inferTypeForExpression(ctx, access) : inferTypeForNode(ctx, node); + if (types.length === 0) return undefined; + const symbol = access ? memberSymbol(ctx, access) : soleTypeSymbol(types); + return {description: describeTypes(ctx, types), ...documentationOf(ctx.checker, symbol)}; +} + +// Header type texts TypeScript renders for a value it could not type: `any`, +// the `object` keyword, and the bare `Object` interface. +const OPEN_TYPE_TEXTS: ReadonlySet = new Set(['any', 'object', 'Object']); + +/** How many trailing display parts spell an open type: `any` / `object` / `Object`, or the two-part `{}`. */ +function openTypeTailLength(parts: readonly tsserver.SymbolDisplayPart[]): number { + const last = parts[parts.length - 1]; + if (!last) return 0; + if (OPEN_TYPE_TEXTS.has(last.text) && last.kind !== 'text') return 1; + return last.text === '}' && parts[parts.length - 2]?.text === '{' ? 2 : 0; +} + +/** + * Swaps the open type at the end of a hover header (`(parameter) shipment: + * any`) for the inferred description, so the bold header reads + * `(parameter) shipment: Shipment`. Any other header shape is left as it is + * rather than guessed at. + */ +function replaceOpenTypeTail( + parts: tsserver.SymbolDisplayPart[] | undefined, + description: string, +): tsserver.SymbolDisplayPart[] | undefined { + const tail = openTypeTailLength(parts ?? []); + if (!parts || tail === 0) return parts; + return [...parts.slice(0, -tail), {kind: 'text', text: description}]; +} + +function withHoverInference(original: tsserver.QuickInfo, inferred: HoverInference): tsserver.QuickInfo { + const note: tsserver.SymbolDisplayPart = {kind: 'text', text: `\n\nInferred from usage: ${inferred.description}`}; + return { + ...original, + displayParts: replaceOpenTypeTail(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags ? [...inferred.tags] : original.tags, + }; +} + +function decorateQuickInfo( + env: HookEnvironment, + cache: DisplayCache, + fileName: string, + position: number, + original: tsserver.QuickInfo, +): tsserver.QuickInfo { + const {ts} = env; + const file = openFile(env, fileName); + const node = file && getNodeAtPosition(file.sourceFile, ts, position); + if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) return original; + const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; + const inferred = cache.get(key, file.program, () => inferHover(env, file, node)); + return inferred ? withHoverInference(original, inferred) : original; +} + +/** + * Member entries for `receiver`: the members of its inferred types, plus the + * ones pass-through superModule overlay levels add + * (`module.exports = base; module.exports.extra = fn;`), which no candidate + * type can carry. + */ +function inferMemberEntries( + env: HookEnvironment, + file: OpenFile, + receiver: tsserver.Expression, + position: number, +): tsserver.CompletionEntry[] { + const ctx = contextForProgram(env.ts, file.program, env, position); + const augmented = collectSuperModuleAugmentedMembers(ctx, receiver).map((member) => + inferredCompletionEntry(env.ts, member.name, member.isMethod), + ); + return [...typesToCompletionEntries(env.ts, ctx.checker, inferTypeForExpression(ctx, receiver)), ...augmented]; +} + +/** + * Appends the inferred entries TypeScript did not already offer (first one + * per name wins), preserving every other field of its result (isIncomplete, + * optionalReplacementSpan, metadata, ...). A fresh member CompletionInfo is + * synthesized only when TypeScript returned nothing at all. + */ +function mergeCompletions( + original: tsserver.CompletionInfo | undefined, + inferred: readonly tsserver.CompletionEntry[], +): tsserver.CompletionInfo | undefined { + const seen = new Set((original?.entries ?? []).map((entry) => entry.name)); + const added: tsserver.CompletionEntry[] = []; + for (const entry of inferred) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + added.push(entry); + } + if (added.length === 0) return original; + if (original) return {...original, entries: [...original.entries, ...added]}; + return {isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, entries: added}; +} + +function decorateCompletions( + env: HookEnvironment, + cache: DisplayCache, + fileName: string, + position: number, + original: tsserver.CompletionInfo | undefined, +): tsserver.CompletionInfo | undefined { + const file = openFile(env, fileName); + const access = file && memberCompletionAccess(file.sourceFile, env.ts, position); + if (!file || !access || !isInferenceTarget(env.ts, file.checker, access.expression)) return original; + // The receiver can be any expression (`product.getPriceModel().|`), and + // nested receivers share a start (`a` and `a.b`), so the key spans it. + const receiver = access.expression; + const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; + return mergeCompletions( + original, + cache.get(key, file.program, () => inferMemberEntries(env, file, receiver, position)), + ); +} + +/** + * Creates the hover and completion decorators for one language service. Each + * request builds one inference context; finished results are cached per + * Program, and `reset()` drops them (the plugin calls it when its + * configuration changes). The host's hook registrations are read once per + * Program too: hooks.json is no part of the Program, so the next edit is + * what picks up a change to it. + */ +export function createUsageInferenceHooks(host: HookOptions) { + const hoverCache = createDisplayCache(); + const completionCache = createDisplayCache(); + const registrationCache = createDisplayCache(); + const readRegistrations = host.hookRegistrations; + const options: HookOptions = { + ...host, + hookRegistrations: + readRegistrations && ((program) => registrationCache.get('hooks', program, () => readRegistrations(program))), + }; + return { + decorateQuickInfo(fileName: string, position: number, original: tsserver.QuickInfo | undefined) { + if (!original) return original; + return guarded(options, 'hover', original, () => + decorateQuickInfo(options, hoverCache, fileName, position, original), + ); + }, + decorateCompletions(fileName: string, position: number, original: tsserver.CompletionInfo | undefined) { + return guarded(options, 'completions', original, () => + decorateCompletions(options, completionCache, fileName, position, original), + ); + }, + reset() { + hoverCache.clear(); + completionCache.clear(); + registrationCache.clear(); + }, + }; +} diff --git a/packages/b2c-script-types/src/inference/framework-contracts.ts b/packages/b2c-script-types/src/inference/framework-contracts.ts new file mode 100644 index 000000000..513d06fc3 --- /dev/null +++ b/packages/b2c-script-types/src/inference/framework-contracts.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Functions the platform itself calls, so no call site exists in the project +// to infer from. Their parameter types are fixed by the platform contract +// rather than guessed: Page Designer invokes the `render(context, modelIn)` +// export of every component and page script with a script context and an +// optional model map. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {findAmbientClassType} from './ambient-index'; +import {getReferenceNameNode} from './value-flow'; +import type {InferenceContext} from './context'; + +interface FrameworkContract { + /** Path segment identifying the scripts the platform calls. */ + readonly directory: string; + /** Name of the exported function the platform calls. */ + readonly functionName: string; + /** Ambient class names of its parameters, by index. */ + readonly parameterTypes: readonly string[]; +} + +const FRAMEWORK_CONTRACTS: readonly FrameworkContract[] = [ + { + directory: '/cartridge/experience/components/', + functionName: 'render', + parameterTypes: ['ComponentScriptContext', 'Map'], + }, + { + directory: '/cartridge/experience/pages/', + functionName: 'render', + parameterTypes: ['PageScriptContext', 'Map'], + }, +]; + +/** The contract-defined type of `fn`'s parameter at `paramIndex`, if `fn` is a platform entry point. */ +export function frameworkParameterTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + paramIndex: number, +): tsserver.Type[] { + const functionName = getReferenceNameNode(fn, ctx.ts)?.text; + if (!functionName) return []; + const fileName = fn.getSourceFile().fileName.replace(/\\/g, '/'); + const contract = FRAMEWORK_CONTRACTS.find( + (candidate) => candidate.functionName === functionName && fileName.includes(candidate.directory), + ); + const typeName = contract?.parameterTypes[paramIndex]; + const type = typeName ? findAmbientClassType(ctx, typeName) : undefined; + return type ? [type] : []; +} diff --git a/packages/b2c-script-types/src/inference/generic-calls.ts b/packages/b2c-script-types/src/inference/generic-calls.ts new file mode 100644 index 000000000..0f6d5654c --- /dev/null +++ b/packages/b2c-script-types/src/inference/generic-calls.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Generic Script API functions whose result is one of their arguments: +// `Transaction.wrap(callback: () => T): T` returns what its callback +// returns. A JavaScript callback returning an undocumented value instantiates +// `T` to `any`, so the checker's result says nothing; the declaration still +// says where `T` comes from, and ./core resolves that argument instead. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {functionOf} from './signatures'; + +/** Where a generic call's result comes from: an argument itself, or what a function argument returns. */ +export type GenericResultSource = + | {readonly kind: 'value'; readonly argument: tsserver.Expression} + | {readonly kind: 'return'; readonly fn: tsserver.SignatureDeclaration}; + +/** True when `node` is a bare reference to the type parameter `name` (`T`). */ +function isTypeParameterReference(ts: typeof tsserver, node: tsserver.TypeNode | undefined, name: string): boolean { + return ( + node !== undefined && + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === name && + node.typeArguments === undefined + ); +} + +/** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ +function returnedTypeParameter(ts: typeof tsserver, declaration: tsserver.SignatureDeclaration): string | undefined { + const result = declaration.type; + const name = result && ts.isTypeReferenceNode(result) && ts.isIdentifier(result.typeName) && result.typeName.text; + return name && declaration.typeParameters?.some((parameter) => parameter.name.text === name) ? name : undefined; +} + +/** + * Where `call`'s result comes from when its callee's declaration returns a + * type parameter it takes as an argument (`identity(value: T): T`) or as + * a callback's result (`wrap(callback: () => T): T`). + */ +export function genericResultSource( + ctx: InferenceContext, + call: tsserver.CallExpression, +): GenericResultSource | undefined { + const {ts, checker} = ctx; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || ts.isJSDocSignature(declaration)) return undefined; + const typeParameter = returnedTypeParameter(ts, declaration); + if (!typeParameter) return undefined; + for (const [index, parameter] of declaration.parameters.entries()) { + const argument = call.arguments[index]; + const type = parameter.type; + if (!argument || !type) continue; + if (isTypeParameterReference(ts, type, typeParameter)) return {kind: 'value', argument}; + const fn = ts.isFunctionTypeNode(type) && isTypeParameterReference(ts, type.type, typeParameter); + const callback = fn ? functionOf(ctx, argument) : undefined; + if (callback) return {kind: 'return', fn: callback}; + } + return undefined; +} diff --git a/packages/b2c-script-types/src/inference/hook-calls.ts b/packages/b2c-script-types/src/inference/hook-calls.ts new file mode 100644 index 000000000..59100bc85 --- /dev/null +++ b/packages/b2c-script-types/src/inference/hook-calls.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Hooks. Nothing in a project calls a hook script's exports by name: the +// platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', +// basket)` to the `calculate` export of every script a cartridge's hooks.json +// registers for `dw.order.calculate` (see ../resolver/hook-registry). So the +// call sites of such an export are the callHook calls naming one of its +// extension points and its export name, passing the hook's arguments from the +// third argument on; and a callHook call returns what those exports return. +// An extension point built at runtime (`'app.payment.processor.' + +// processor.ID.toLowerCase()`) reaches every registered one its literal +// prefix starts. IntelliJ follows hooks in neither direction. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_REFERENCES_PER_CALL} from './constants'; +import type {InferenceContext} from './context'; +import {exportsObjectWrittenBy, propertyAccessNamedBy} from './ast-helpers'; +import {valueDeclarationOf} from './member-values'; +import {searchReferences} from './reference-search'; +import type {Reference} from './reference-search'; +import {usageProfileOf} from './usage-profile'; +import type {CallSite} from './value-flow'; + +// Where the Script API declares `HookMgr.callHook`, whichever copy of the dw +// types a project loads. +const HOOK_MANAGER_FILE = '/dw/system/HookMgr.d.ts'; +const CALL_HOOK = 'callHook'; + +/** The extension points a callHook call reaches: one name, or every name starting with a literal prefix. */ +interface ExtensionPointPattern { + readonly text: string; + readonly isPrefix: boolean; +} + +/** One `HookMgr.callHook(extensionPoint, functionName, ...args)` call whose two names are known. */ +interface HookCall { + readonly call: tsserver.CallExpression; + readonly extensionPoint: ExtensionPointPattern; + readonly functionName: string; +} + +// Request-scoped: a request asks the host for its registrations once, and +// searches for callHook calls once. +const scriptsByRequest = new WeakMap>(); +const hookCallsByRequest = new WeakMap(); + +/** The extension points each script of the program is registered for. */ +function registeredScripts(ctx: InferenceContext): ReadonlyMap { + let scripts = scriptsByRequest.get(ctx); + if (!scripts) { + const index = new Map(); + for (const {extensionPoint, script} of ctx.host.hookRegistrations?.(ctx.program) ?? []) { + const file = ctx.program.getSourceFile(script); + if (file) index.set(file, [...(index.get(file) ?? []), extensionPoint]); + } + scripts = index; + scriptsByRequest.set(ctx, scripts); + } + return scripts; +} + +function reaches(pattern: ExtensionPointPattern, extensionPoint: string): boolean { + return pattern.isPrefix ? extensionPoint.startsWith(pattern.text) : extensionPoint === pattern.text; +} + +/** The string `expr` always evaluates to: a literal, or a value the checker types as one (a `const`). */ +function stringValue(ctx: InferenceContext, expr: tsserver.Expression): string | undefined { + if (ctx.ts.isStringLiteralLike(expr)) return expr.text; + const type = ctx.checker.getTypeAtLocation(expr); + return type.isStringLiteral() ? type.value : undefined; +} + +/** The literal text a string built at runtime starts with: `'app.payment.processor.' + id`, `` `app.${id}` ``. */ +function literalPrefix(ctx: InferenceContext, expr: tsserver.Expression): string | undefined { + const {ts} = ctx; + if (ts.isTemplateExpression(expr)) return expr.head.text || undefined; + if (ts.isParenthesizedExpression(expr)) return literalPrefix(ctx, expr.expression); + if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.PlusToken) return undefined; + return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); +} + +/** What a local variable `expr` names always holds: its initializer, when nothing else is assigned to it. */ +function soleValue(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Expression | undefined { + const {ts} = ctx; + const declaration = ts.isIdentifier(expr) ? valueDeclarationOf(ctx, expr) : undefined; + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) return undefined; + return usageProfileOf(ctx, declaration).assignedValues.length === 0 ? declaration.initializer : undefined; +} + +/** The extension points `expr` names, read through one local variable (`var hookName = 'app.payment.' + id`). */ +function extensionPointPattern(ctx: InferenceContext, expr: tsserver.Expression): ExtensionPointPattern | undefined { + const value = soleValue(ctx, expr) ?? expr; + const name = stringValue(ctx, expr) ?? stringValue(ctx, value); + if (name !== undefined) return {text: name, isPrefix: false}; + const prefix = literalPrefix(ctx, value); + return prefix ? {text: prefix, isPrefix: true} : undefined; +} + +/** True when `call` invokes the Script API's `HookMgr.callHook`, through whatever name it is required as. */ +function isCallHookCall(ctx: InferenceContext, call: tsserver.CallExpression): boolean { + const {ts, checker} = ctx; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== CALL_HOOK) return false; + const declaration = checker.getSymbolAtLocation(callee.name)?.valueDeclaration; + return declaration?.getSourceFile().fileName.endsWith(HOOK_MANAGER_FILE) === true; +} + +/** `call` read as a callHook call, when it is one and its extension point and function name are known. */ +function hookCallOf(ctx: InferenceContext, call: tsserver.CallExpression): HookCall | undefined { + const [extensionPointArgument, functionNameArgument] = call.arguments; + if (!functionNameArgument || !isCallHookCall(ctx, call)) return undefined; + const extensionPoint = extensionPointPattern(ctx, extensionPointArgument); + const functionName = stringValue(ctx, functionNameArgument); + return extensionPoint && functionName ? {call, extensionPoint, functionName} : undefined; +} + +/** The name `HookMgr.callHook` is declared by, when the program loads the Script API's HookMgr. */ +function callHookName(ctx: InferenceContext): tsserver.Identifier | undefined { + const {ts, program} = ctx; + const file = program + .getSourceFiles() + .find((sourceFile) => sourceFile.isDeclarationFile && sourceFile.fileName.endsWith(HOOK_MANAGER_FILE)); + for (const statement of file?.statements ?? []) { + if (!ts.isClassDeclaration(statement)) continue; + const member = statement.members.find((m) => m.name && ts.isIdentifier(m.name) && m.name.text === CALL_HOOK); + if (member) return member.name as tsserver.Identifier; + } + return undefined; +} + +/** The callHook call a reference to `callHook` is the callee of. */ +function hookCallAt(ctx: InferenceContext, reference: Reference): HookCall | undefined { + const {ts} = ctx; + const access = propertyAccessNamedBy(reference, ts); + const call = access?.parent; + return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; +} + +/** Every callHook call in the project: one project-wide search, made once per request. */ +function hookCalls(ctx: InferenceContext): readonly HookCall[] { + let calls = hookCallsByRequest.get(ctx); + if (!calls) { + const name = callHookName(ctx); + const searchable = name !== undefined && ctx.searchBudget > 0; + if (searchable) ctx.searchBudget--; + calls = searchable ? searchReferences(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + hookCallsByRequest.set(ctx, calls); + } + return calls; +} + +/** + * What a CommonJS module exports, as the checker types the object its first + * top-level `exports.x = ...` / `module.exports = ...` statement writes to + * (the public API gives no module symbol for a CommonJS file). + */ +function moduleExportsType(ctx: InferenceContext, file: tsserver.SourceFile): tsserver.Type | undefined { + for (const statement of file.statements) { + const exportsObject = exportsObjectWrittenBy(statement, ctx.ts); + if (exportsObject) return ctx.checker.getTypeAtLocation(exportsObject); + } + return undefined; +} + +/** The functions `file` exports, with the names it exports them under; only those named `name` when given. */ +function exportedFunctions( + ctx: InferenceContext, + file: tsserver.SourceFile, + name?: string, +): Array<{readonly name: string; readonly fn: tsserver.SignatureDeclaration}> { + const {ts, checker} = ctx; + const exportsType = moduleExportsType(ctx, file); + const members = exportsType ? checker.getPropertiesOfType(exportsType) : []; + return members + .filter((member) => name === undefined || member.name === name) + .flatMap((member) => + checker + .getTypeOfSymbolAtLocation(member, file) + .getCallSignatures() + .flatMap((signature) => { + const fn = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; + return fn && ts.isFunctionLike(fn) ? [{name: member.name, fn}] : []; + }), + ); +} + +/** + * The callHook calls that invoke `fn`, an export of a registered hook script, + * with the hook's own arguments by parameter index. Each call spends one + * reference from the request's budget. + */ +export function hookCallSites(ctx: InferenceContext, fn: tsserver.SignatureDeclaration): CallSite[] { + const extensionPoints = registeredScripts(ctx).get(fn.getSourceFile()); + if (!extensionPoints) return []; + const exported = exportedFunctions(ctx, fn.getSourceFile()).filter((entry) => entry.fn === fn); + const names = new Set(exported.map((entry) => entry.name)); + if (names.size === 0) return []; + const calls = hookCalls(ctx).filter( + (hook) => names.has(hook.functionName) && extensionPoints.some((point) => reaches(hook.extensionPoint, point)), + ); + const sites = calls.slice(0, Math.max(0, Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget))); + ctx.referenceBudget -= sites.length; + return sites.map(({call}) => ({node: call, args: call.arguments.slice(2)})); +} + +/** The hook functions a callHook call dispatches to: the matching export of every script registered for it. */ +export function hookImplementations( + ctx: InferenceContext, + call: tsserver.CallExpression, +): tsserver.SignatureDeclaration[] { + const hook = hookCallOf(ctx, call); + if (!hook) return []; + return [...registeredScripts(ctx)].flatMap(([file, extensionPoints]) => + extensionPoints.some((point) => reaches(hook.extensionPoint, point)) + ? exportedFunctions(ctx, file, hook.functionName).map((entry) => entry.fn) + : [], + ); +} diff --git a/packages/b2c-script-types/src/inference/member-values.ts b/packages/b2c-script-types/src/inference/member-values.ts new file mode 100644 index 000000000..19cb069b6 --- /dev/null +++ b/packages/b2c-script-types/src/inference/member-values.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Where an untyped member's value comes from. JavaScript gives a member the +// type of what it is first declared with, which for undocumented code is +// usually `any` (`{apiProduct: apiProduct}`, `this.productSearch = +// productSearch`). The value expressions themselves are still in the source, +// and ./core resolves them like any other expression, IntelliJ style: a member +// is what was put into it. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {usageProfileOf} from './usage-profile'; + +/** The value of one member declaration: `key: value`, `{key}`, `key = value;` in a class, `this.key = value`. */ +function declaredValue(ctx: InferenceContext, declaration: tsserver.Declaration): tsserver.Expression | undefined { + const {ts} = ctx; + if (ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) return declaration.initializer; + if (ts.isShorthandPropertyAssignment(declaration)) return declaration.name; + const assignment = ts.isBinaryExpression(declaration) ? declaration : declaration.parent; + const isAssignment = + assignment !== undefined && + ts.isBinaryExpression(assignment) && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} + +/** + * The values a member symbol is declared with: an object literal's + * `key: value` and `{key}` shorthand, a class field initializer, and the + * right-hand sides of `this.key = value` / `obj.key = value` assignments a + * JavaScript constructor or expando declares it by. + */ +export function memberValueExpressions(ctx: InferenceContext, member: tsserver.Symbol): tsserver.Expression[] { + return (member.declarations ?? []).flatMap((declaration) => declaredValue(ctx, declaration) ?? []); +} + +/** + * The values the code writes to `memberName` on an untyped local receiver + * (`productData.apiProduct = ProductMgr.getProduct(id)`), read back later + * through the same parameter or variable (`return productData.apiProduct`). + */ +export function localMemberValues( + ctx: InferenceContext, + receiver: tsserver.Expression, + memberName: string, +): readonly tsserver.Expression[] { + const {ts} = ctx; + const declaration = ts.isIdentifier(receiver) ? valueDeclarationOf(ctx, receiver) : undefined; + const isLocal = declaration && (ts.isParameter(declaration) || ts.isVariableDeclaration(declaration)); + return isLocal ? (usageProfileOf(ctx, declaration).memberValues.get(memberName) ?? []) : []; +} + +/** + * The declaration of the value `identifier` reads. The name of a `{key}` + * shorthand resolves to the object literal's property, not to the variable + * whose value it copies, so that one asks the checker for the value symbol. + */ +export function valueDeclarationOf( + ctx: InferenceContext, + identifier: tsserver.Identifier, +): tsserver.Declaration | undefined { + const {ts, checker} = ctx; + const shorthand = ts.isShorthandPropertyAssignment(identifier.parent) ? identifier.parent : undefined; + const symbol = shorthand + ? checker.getShorthandAssignmentValueSymbol(shorthand) + : checker.getSymbolAtLocation(identifier); + return symbol?.valueDeclaration; +} diff --git a/packages/b2c-script-types/src/inference/naming.ts b/packages/b2c-script-types/src/inference/naming.ts new file mode 100644 index 000000000..ef2b9a4fa --- /dev/null +++ b/packages/b2c-script-types/src/inference/naming.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Identifier-name hints. SFCC code conventionally names a variable after the +// Script API class it holds (`customer`, `currentBasket`, `apiProduct`, +// `pli`). Usage alone often fits several classes (`.email` + `.firstName` +// fits both Profile and ProductListRegistrant), so the name is used to pick +// one — but only ever among classes the usage already fits: a name never adds +// a candidate of its own. + +// Abbreviations whose class the identifier does not spell out, keyed and +// valued in lowercase. Everything else is derived from the identifier's own +// camelCase words, so this stays short. +const ABBREVIATIONS: ReadonlyMap = new Map([ + ['pli', 'productlineitem'], + ['lineitem', 'productlineitem'], + ['paymentinstrument', 'orderpaymentinstrument'], + ['shippingaddress', 'orderaddress'], + ['billingaddress', 'orderaddress'], +]); + +// All-lowercase qualifiers SFRA code glues onto a class name +// (`apiproduct`, `currentbasket`): stripping them leaves the class. +const QUALIFIER_PREFIXES = ['api', 'current', 'default', 'selected', 'new']; + +interface Named { + readonly name: string; +} + +// Candidates by lowercased name, per candidate list. Every request on a +// Program picks among the same ambient class list (see ./ambient-index), so +// its table is built once per Program instead of once per pick. +const tablesByCandidates = new WeakMap>(); + +function byLowercaseName(candidates: readonly T[]): ReadonlyMap { + const cached = tablesByCandidates.get(candidates) as ReadonlyMap | undefined; + if (cached) return cached; + const table = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = table.get(key); + if (named) named.push(candidate); + else table.set(key, [candidate]); + } + tablesByCandidates.set(candidates, table); + return table; +} + +// The expansion goes first: SFRA's `lineItem` parameters hold a +// ProductLineItem, even where the usage would also fit the LineItem base. +function withAbbreviation(key: string): string[] { + const expanded = ABBREVIATIONS.get(key); + return expanded ? [expanded, key] : [key]; +} + +/** + * Lowercased class names `identifier` conventionally denotes, most specific + * first: the whole name, then each shorter camelCase word suffix + * (`resettingCustomer` → `customer`, `productShippingLineItem` → + * `shippinglineitem` → `lineitem` → `item`), each preceded by its + * abbreviation expansion, then the name with a qualifier prefix stripped. + * Splitting on camelCase boundaries only is what keeps an all-lowercase + * `border` from reading as `Order`. + */ +function conventionalClassNames(identifier: string): string[] { + const words = identifier.split(/(?=[A-Z])/); + const keys = words.flatMap((_, start) => withAbbreviation(words.slice(start).join('').toLowerCase())); + const lower = identifier.toLowerCase(); + for (const prefix of QUALIFIER_PREFIXES) { + if (lower.length > prefix.length && lower.startsWith(prefix)) + keys.push(...withAbbreviation(lower.slice(prefix.length))); + } + return keys; +} + +/** + * Picks the one candidate `identifier` names, or `undefined` when the name + * points at none of them (or at several equally). Besides + * {@link conventionalClassNames}, a camelCase identifier that is the tail of + * exactly one candidate's name also counts (`priceModel` → + * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). + */ +export function pickByName(identifier: string, candidates: readonly T[]): T | undefined { + const byName = byLowercaseName(candidates); + for (const key of conventionalClassNames(identifier)) { + const named = byName.get(key); + if (named?.length === 1) return named[0]; + } + const lower = identifier.toLowerCase(); + if (identifier === lower) return undefined; + const tails = [...byName].filter(([name]) => name.endsWith(lower)).flatMap(([, named]) => named); + return tails.length === 1 ? tails[0] : undefined; +} diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts new file mode 100644 index 000000000..983ba1eba --- /dev/null +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -0,0 +1,379 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Decision rules: turning the raw evidence ./core gathers into the type(s) a +// hover or completion shows. The same rules run for every parameter, return +// value and variable at every depth, so a parameter hover, a member hover on +// it and a completion after it can never disagree. In order: +// +// 1. Normalize: split unions, widen literals, drop types that say nothing +// (`null`, `any`, `T`, `{}`), dedupe by display. +// 2. Fit: a parameter keeps only candidates exposing every member its body +// relies on — members it tests for first (`'ID' in x`) aside, members +// no declared type has at all (an API newer than the vendored types, +// an expando property), which can't tell candidates apart — and able +// to stand wherever the body passes the value to a documented helper, or +// a class its body tests for with `instanceof`/`typeof`. A duck-typed view +// model, or a wrong argument at one buggy call site, can't masquerade as +// the Script API class the body actually needs. A class missing members +// only its subclasses have is a downcast: it narrows to the one subclass +// that has them, or stays itself when several do. +// 3. Union like IntelliJ: up to MAX_UNION_TYPES, after dropping any candidate +// whose superclass is also a candidate (`Variant | Product` is `Product`) +// and any object literal standing in for a candidate class (a test +// double's `{httpHeaders: ...}` next to the `Request` it mimics). +// Wider evidence first reads instantiations of one generic class as the +// class (`Collection`, `Collection` -> `Collection`), +// then collapses to the closest shared superclass that still fits, or +// stays silent. +// 4. No usable evidence: the most specific type the value is used as +// (`ProductMgr.getProduct(pid)` makes `pid` a `string`), then matching the +// usage against every ambient class, with the identifier name as a +// tiebreak (see ./naming). A usage a JavaScript built-in satisfies too +// (`msg.replace(...)`) stays silent. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {ambientClassesWithMembers, ambientClassType, builtinValueTypes, getAmbientClasses} from './ambient-index'; +import type {AmbientClass} from './ambient-index'; +import { + GLOBAL_OBJECT_CLASSES, + MAX_UNION_TYPES, + MIN_USAGE_SIGNATURE_MEMBERS, + UNINFORMATIVE_ANCESTORS, + WEAK_USAGE_MEMBERS, +} from './constants'; +import type {InferenceContext} from './context'; +import {pickByName} from './naming'; +import {dedupeTypes, hasAllMembers, hasMember, informativeParts, typeDisplayString} from './type-helpers'; +import type {UsageProfile} from './usage-profile'; + +const NO_MEMBERS: ReadonlySet = new Set(); + +/** The members every value the profile describes must have: those used without a presence test first. */ +function requiredMembers(profile: UsageProfile): ReadonlySet { + if (profile.optionalMemberNames.size === 0) return profile.memberNames; + return new Set([...profile.memberNames].filter((name) => !profile.optionalMemberNames.has(name))); +} + +/** Step 1: split unions, widen literals, drop uninformative types, dedupe by display. */ +export function normalizeCandidates(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + return dedupeTypes( + ctx, + types.flatMap((type) => informativeParts(ctx, type)), + ); +} + +/** The class or interface behind `type` (`Product` for `Product`), if it is one. */ +function classOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType | undefined { + const {ts} = ctx; + if (!(type.flags & ts.TypeFlags.Object)) return undefined; + const objectType = type as tsserver.ObjectType; + const target = + objectType.objectFlags & ts.ObjectFlags.Reference ? (type as tsserver.TypeReference).target : objectType; + return target.objectFlags & ts.ObjectFlags.ClassOrInterface ? (target as tsserver.InterfaceType) : undefined; +} + +/** Every superclass and implemented interface of `type`, nearest first (excluding `type` itself). */ +function ancestorsOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType[] { + const ancestors: tsserver.InterfaceType[] = []; + const start = classOf(ctx, type); + for (let queue = start ? [start] : []; queue.length > 0; ) { + const current = queue.shift()!; + for (const base of ctx.checker.getBaseTypes(current)) { + const baseClass = classOf(ctx, base); + if (baseClass && baseClass !== start && !ancestors.includes(baseClass)) { + ancestors.push(baseClass); + queue.push(baseClass); + } + } + } + return ancestors; +} + +function isAncestorOf(ctx: InferenceContext, ancestor: tsserver.Type, type: tsserver.Type): boolean { + const ancestorClass = classOf(ctx, ancestor); + return ancestorClass !== undefined && ancestorsOf(ctx, type).includes(ancestorClass); +} + +/** The class behind `type` and its ancestors, root classes (`PersistentObject`, ...) aside. */ +function lineageOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType[] { + const own = classOf(ctx, type); + return [...(own ? [own] : []), ...ancestorsOf(ctx, type)].filter( + (lineageClass) => !UNINFORMATIVE_ANCESTORS.has(lineageClass.symbol?.name ?? ''), + ); +} + +/** + * What the value is passed or assigned as, each use as the informative + * alternatives it accepts (`Product | null` accepts a `Product`; an `any`, + * `Object` or `any[]` use accepts anything and is left out). + */ +function constrainingUses(ctx: InferenceContext, profile: UsageProfile): tsserver.Type[][] { + return profile.contextualTypes.map((use) => informativeParts(ctx, use)).filter((parts) => parts.length > 0); +} + +/** + * True when `type` can stand wherever the body uses the value: assignable to + * (an alternative of) each use, or at least from the same class family (an + * `Order` passed to a helper documented as taking a `Basket` that really + * takes any `LineItemCtnr`, a common over-narrow JSDoc). A different kind of + * value never fits: a `string` argument at one buggy call site of + * `getBonusUnitPrice(apiProduct)`, whose body passes it on as a `Product`. + * TypeScript builds without the public `isTypeAssignableTo` skip the check. + */ +function fitsEveryUse(ctx: InferenceContext, type: tsserver.Type, uses: readonly tsserver.Type[][]): boolean { + const {checker} = ctx; + if (uses.length === 0 || typeof checker.isTypeAssignableTo !== 'function') return true; + const lineage = lineageOf(ctx, type); + const fits = (use: tsserver.Type): boolean => + checker.isTypeAssignableTo(type, use) || lineageOf(ctx, use).some((useClass) => lineage.includes(useClass)); + return uses.every((alternatives) => alternatives.some(fits)); +} + +/** + * True when `type` is an object literal's (`{ID: 'x'}`), as opposed to a + * declared class's. Read from its symbol: the widened type of a literal (what + * a variable holding it has) no longer carries the literal's object flag. + */ +function isObjectLiteralType(ctx: InferenceContext, type: tsserver.Type): boolean { + return ((type.getSymbol()?.flags ?? 0) & ctx.ts.SymbolFlags.ObjectLiteral) !== 0; +} + +/** + * Drops every object literal a candidate class has all the properties of: a + * stand-in for that class (a test double, `{httpHeaders: {get: ...}}` for a + * `Request`, or a partial default), which offers nothing the class doesn't. + */ +function withoutStandIns(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const {checker} = ctx; + const classes = types.filter((type) => classOf(ctx, type) !== undefined); + if (classes.length === 0) return [...types]; + const standsIn = (literal: tsserver.Type): boolean => { + const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); + return classes.some((candidateClass) => hasAllMembers(ctx, candidateClass, names)); + }; + return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); +} + +/** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ +function mostGeneral(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); + return types.filter((type) => !ancestorsOf(ctx, type).some((ancestor) => candidateClasses.has(ancestor))); +} + +/** The nearest superclass all `types` share that still has `memberNames`, ignoring root classes. */ +function closestCommonAncestor( + ctx: InferenceContext, + types: readonly tsserver.Type[], + memberNames: ReadonlySet, +): tsserver.Type | undefined { + const [first, ...rest] = types; + const restAncestors = rest.map((type) => ancestorsOf(ctx, type)); + return ancestorsOf(ctx, first).find( + (ancestor) => + !UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && + restAncestors.every((ancestors) => ancestors.includes(ancestor)) && + hasAllMembers(ctx, ancestor, memberNames), + ); +} + +/** + * The generic class `type` instantiates, when its declared type reads as the + * bare class name. Arrays don't: the declared `Array` renders as `T[]`. + */ +function genericClassOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType | undefined { + const generic = classOf(ctx, type); + return generic?.typeParameters?.length && !ctx.checker.isArrayType(type) ? generic : undefined; +} + +/** + * Reads two or more instantiations of the same generic class as the class + * itself: its declared type, whose unbound type parameters render as nothing + * (`Collection` and `Collection` -> `Collection`). + * A lone instantiation keeps its informative type argument. + */ +function mergeInstantiations(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const instantiations = new Map(); + for (const type of types) { + const generic = genericClassOf(ctx, type); + if (generic) instantiations.set(generic, (instantiations.get(generic) ?? 0) + 1); + } + const merged = types.map((type) => { + const generic = genericClassOf(ctx, type); + return generic && (instantiations.get(generic) ?? 0) > 1 ? generic : type; + }); + return dedupeTypes(ctx, merged); +} + +/** + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, stand-ins + * aside; wider evidence merged by generic class, else the closest shared + * superclass, else silence. + */ +export function limitUnion( + ctx: InferenceContext, + types: readonly tsserver.Type[], + memberNames: ReadonlySet = NO_MEMBERS, +): tsserver.Type[] { + const general = mostGeneral(ctx, withoutStandIns(ctx, types)); + if (general.length <= MAX_UNION_TYPES) return general; + const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); + if (merged.length <= MAX_UNION_TYPES) return merged; + const ancestor = closestCommonAncestor(ctx, merged, memberNames); + return ancestor ? [ancestor] : []; +} + +/** + * The single most specific type the value is used as: one every other such + * type is a superclass of (`basket` passed both where a `Basket` and where a + * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. + */ +function mostSpecificUse(ctx: InferenceContext, profile: UsageProfile, required: ReadonlySet): tsserver.Type[] { + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => hasAllMembers(ctx, type, required)); + const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); + return mostSpecific.length === 1 ? mostSpecific : []; +} + +interface AmbientMatch { + readonly name: string; + readonly type: tsserver.Type; +} + +/** Resolves matched index entries to types, merging declarations of the same symbol. */ +function resolveMatches(ctx: InferenceContext, classes: readonly AmbientClass[]): AmbientMatch[] { + const bySymbol = new Map(); + for (const ambientClass of classes) { + const type = ambientClassType(ctx, ambientClass); + const symbol = type?.getSymbol(); + if (type && symbol && !bySymbol.has(symbol)) bySymbol.set(symbol, {name: ambientClass.name, type}); + } + return [...bySymbol.values()]; +} + +/** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ +function fitsBuiltin(ctx: InferenceContext, memberNames: ReadonlySet): boolean { + return builtinValueTypes(ctx).some((builtin) => hasAllMembers(ctx, builtin, memberNames)); +} + +/** + * Step 4b: matches a usage signature against every ambient class (see + * ./ambient-index) except those describing one global object (`Module`). + * Silent when no class has every member; when only ubiquitous members + * (`.custom`, `.UUID`) were used and several classes fit; when a single + * member fits several classes the identifier name can't choose between; and + * when a JavaScript built-in fits as well and the name doesn't pick a class. + * A name that denotes a class the usage does *not* fit never falls back to a + * vaguer reading of the same name (`bonusDiscountLineItem.getQuantity()` is + * not a ProductLineItem hint), and silences a single-member signature + * outright: `lineItem.preorderable` uniquely matching ProductInventoryRecord + * is a coincidence, not a hint. + * + * @param identifierName - the parameter's or variable's own name, used only + * to pick among classes the usage already fits (see ./naming). + */ +export function matchAmbientTypesByUsage( + ctx: InferenceContext, + memberNames: ReadonlySet, + identifierName?: string, +): tsserver.Type[] { + if (memberNames.size === 0) return []; + const classes = getAmbientClasses(ctx); + const fitting = ambientClassesWithMembers(ctx, memberNames).filter( + (ambientClass) => !GLOBAL_OBJECT_CLASSES.has(ambientClass.name), + ); + const onlyWeakMembers = [...memberNames].every((name) => WEAK_USAGE_MEMBERS.has(name)); + if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; + const matches = resolveMatches(ctx, fitting); + const denoted = identifierName ? pickByName(identifierName, classes) : undefined; + const named = denoted + ? matches.find((match) => match.name === denoted.name) + : identifierName && pickByName(identifierName, matches); + if (named) return [named.type]; + const thinSignature = memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS; + if ((thinSignature && (denoted || matches.length > 1)) || fitsBuiltin(ctx, memberNames)) return []; + return limitUnion( + ctx, + matches.map((match) => match.type), + memberNames, + ); +} + +/** + * What a candidate lacking some members the body uses can still be: the one + * subclass that has them all (a body relying on `paymentTransaction` is + * handed `OrderPaymentInstrument`s, whatever the argument is documented as), + * or — when several subclasses would do (`getMasterProduct()` on a `Variant` + * or a `VariationGroup`, called once the body knows which) — the candidate + * itself. Root classes (`PersistentObject`, ...) never stand in for a + * subclass this way; nearly every class extends them. + */ +function downcastOf(ctx: InferenceContext, type: tsserver.Type, memberNames: ReadonlySet): tsserver.Type[] { + const own = classOf(ctx, type); + if (!own || UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) return []; + const fitting = ambientClassesWithMembers(ctx, memberNames); + const subclasses = mostGeneral( + ctx, + resolveMatches(ctx, fitting) + .map((match) => match.type) + .filter((subclass) => isAncestorOf(ctx, type, subclass)), + ); + return subclasses.length > 1 ? [type] : subclasses; +} + +/** + * The required members that can tell candidates apart: those some ambient + * class, JavaScript built-in or candidate declares. A member nothing declares + * (`searchHit.discountedPromotionIDs`, newer than the vendored Script API) + * would otherwise drop every candidate, the right one included. + */ +function checkableMembers( + ctx: InferenceContext, + required: ReadonlySet, + candidates: readonly tsserver.Type[], +): ReadonlySet { + const declaredBy = (types: readonly tsserver.Type[], name: string): boolean => + types.some((type) => hasMember(ctx, type, name)); + const declared = (name: string): boolean => + declaredBy(candidates, name) || + getAmbientClasses(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || + declaredBy(builtinValueTypes(ctx), name); + return new Set([...required].filter(declared)); +} + +/** + * Decides what a parameter or variable holds from the evidence ./core + * gathered for it (call-site arguments, assigned values, framework + * callbacks) plus its own usage profile. + * + * @param fitUsage - drop candidates lacking a member the profile shows in + * use. On for parameters, whose call sites are independent samples that may + * pass a look-alike; off for a variable's own assigned values. + */ +export function decideType( + ctx: InferenceContext, + evidence: readonly tsserver.Type[], + profile: UsageProfile, + name: tsserver.BindingName, + fitUsage: boolean, +): tsserver.Type[] { + const guards = normalizeCandidates(ctx, profile.guardTypes); + const guardKeys = new Set(guards.map((type) => typeDisplayString(ctx, type))); + const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); + const required = requiredMembers(profile); + const checkable = checkableMembers(ctx, required, candidates); + const uses = constrainingUses(ctx, profile); + const fit = (type: tsserver.Type): tsserver.Type[] => { + if (guardKeys.has(typeDisplayString(ctx, type))) return [type]; + const fitted = hasAllMembers(ctx, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); + }; + const fitting = fitUsage ? dedupeTypes(ctx, candidates.flatMap(fit)) : candidates; + if (fitting.length > 0) return limitUnion(ctx, fitting, checkable); + const used = mostSpecificUse(ctx, profile, required); + if (used.length > 0) return used; + return matchAmbientTypesByUsage(ctx, profile.memberNames, ctx.ts.isIdentifier(name) ? name.text : undefined); +} diff --git a/packages/b2c-script-types/src/inference/reference-search.ts b/packages/b2c-script-types/src/inference/reference-search.ts new file mode 100644 index 000000000..8513d3413 --- /dev/null +++ b/packages/b2c-script-types/src/inference/reference-search.ts @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Reference searches, sized for the question inference asks: where is this +// name's value used? TypeScript's find-all-references answers a wider one, +// and pays for it: for any exported name it first maps every import of the +// whole program, work that grows with the project rather than with the name, +// and a single hover may search a dozen names. Inference follows imports +// itself, one hop at a time (see ./value-flow), so a search here only reads +// the files that contain the name, confirming each occurrence with the +// checker; and a name nothing outside its file can refer to (a function's or +// a CommonJS module's own variable) is searched in that one file. +// +// Each search that actually runs (not one served from the cache) is +// published on the `@salesforce/b2c-script-types:reference-search` +// diagnostics channel as `{name, projectWide}`: a deterministic cost measure +// for tests and diagnostics, free when nothing subscribes. + +import diagnosticsChannel from 'node:diagnostics_channel'; + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {spellingFilter} from './ast-helpers'; + +/** A reference a search finds: a name, or the module path of a require() call that loads the searched module. */ +export type Reference = tsserver.Identifier | tsserver.StringLiteralLike; + +const searchChannel = diagnosticsChannel.channel('@salesforce/b2c-script-types:reference-search'); + +// An alias is followed this many links at most (`var {helper} = require()` +// -> `exports.helper = helper` -> the function): real chains are two or +// three links, and the cap keeps a pathological re-export chain bounded. +const MAX_ALIAS_LINKS = 4; + +/** The names a SourceFile contains: the parser's own table, or one scanned once per SourceFile. */ +interface NameTable { + has(name: string): boolean; +} + +// TypeScript reuses an unchanged file's SourceFile from one Program to the +// next, so a scanned table survives edits elsewhere in the project. +const scannedNames = new WeakMap(); + +// A Program never changes, so neither does a search's answer: kept for as +// long as the Program lives, hovering one name after another in an unchanged +// project runs each search once. +const referencesByProgram = new WeakMap>(); + +/** + * The identifier names `file` contains. The parser records exactly this table + * on every SourceFile (`identifiers`, which TypeScript's own reference search + * reads); it is not public API, so it is used only when it has the expected + * shape, and otherwise rebuilt with the public scanner. + */ +function namesIn(ts: typeof tsserver, file: tsserver.SourceFile): NameTable { + const parsed = (file as {identifiers?: unknown}).identifiers; + if (parsed instanceof Map) return parsed; + let names = scannedNames.get(file); + if (!names) { + const scanned = new Set(); + const scanner = ts.createScanner(file.languageVersion, true, file.languageVariant, file.text); + for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) { + if (kind === ts.SyntaxKind.Identifier) scanned.add(scanner.getTokenValue()); + } + names = scanned; + scannedNames.set(file, names); + } + return names; +} + +/** The identifiers named `text` in `file`, read along the paths that lead to where its text spells them. */ +function identifiersNamed(ts: typeof tsserver, file: tsserver.SourceFile, text: string): tsserver.Identifier[] { + const spells = spellingFilter(file, [text]); + const found: tsserver.Identifier[] = []; + const visit = (node: tsserver.Node): void => { + if (!spells(node)) return; + if (!ts.isIdentifier(node)) ts.forEachChild(node, visit); + else if (node.text === text) found.push(node); + }; + visit(file); + return found; +} + +/** + * The symbols `name` stands for. A `{helper}` shorthand is both a property + * and a use of the `helper` value; the checker names the property. + */ +function symbolsAt(ctx: InferenceContext, name: tsserver.Identifier): tsserver.Symbol[] { + const {ts, checker} = ctx; + const symbol = checker.getSymbolAtLocation(name); + const value = ts.isShorthandPropertyAssignment(name.parent) + ? checker.getShorthandAssignmentValueSymbol(name.parent) + : undefined; + return [symbol, value].filter((s): s is tsserver.Symbol => s !== undefined); +} + +/** `symbol` and the symbols it was instantiated, merged or widened from. */ +function withRoots(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.Symbol[] { + return [symbol, ...ctx.checker.getRootSymbols(symbol)]; +} + +/** + * What a name found by the search may stand for: its own symbols, and every + * link of an alias chain they start (`var {helper} = require('./helpers')` -> + * `exports.helper = helper` -> the `helper` function). + */ +function identitiesOf(ctx: InferenceContext, name: tsserver.Identifier): tsserver.Symbol[] { + const {ts, checker} = ctx; + return symbolsAt(ctx, name).flatMap((symbol) => { + const links: tsserver.Symbol[] = []; + let link: tsserver.Symbol | undefined = symbol; + for (let hops = 0; link && hops < MAX_ALIAS_LINKS; hops++) { + links.push(...withRoots(ctx, link)); + link = link.flags & ts.SymbolFlags.Alias ? checker.getImmediateAliasedSymbol(link) : undefined; + } + return links; + }); +} + +/** What a search looks for: the searched name's symbols and their roots, and the declarations behind them. */ +interface SearchTarget { + readonly name: tsserver.Identifier; + readonly symbols: ReadonlySet; + readonly declarations: ReadonlySet; + /** The one file whose names can refer to the target, or undefined when any file's can. */ + readonly homeFile?: tsserver.SourceFile; + /** The module the target is the exports of (`module.exports`), whose require() calls are references too. */ + readonly module?: tsserver.SourceFile; +} + +/** True when a search for `target` reads every file of the project: for its name, or for the require() calls of its module. */ +function readsProject(target: SearchTarget): boolean { + return target.homeFile === undefined || target.module !== undefined; +} + +/** The module `symbol` is the exports of: `exports` in `module.exports = ...`, or the module symbol itself. */ +function moduleOf(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.SourceFile | undefined { + const {ts} = ctx; + if (symbol.valueDeclaration && ts.isSourceFile(symbol.valueDeclaration)) return symbol.valueDeclaration; + return symbol.escapedName === ts.InternalSymbolName.ExportEquals + ? symbol.declarations?.[0]?.getSourceFile() + : undefined; +} + +/** True when `declaration` introduces a name into the scope around it, as opposed to a member of some object. */ +function declaresScopedName(ts: typeof tsserver, declaration: tsserver.Declaration): boolean { + return ( + ts.isVariableDeclaration(declaration) || + ts.isBindingElement(declaration) || + ts.isParameter(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isClassDeclaration(declaration) || + ts.isClassExpression(declaration) + ); +} + +/** + * The file `symbol` is visible in alone, when nothing elsewhere can name it: + * a variable, parameter, function or class declared inside a function, or at + * the top level of a CommonJS module. A CommonJS module exports only by + * assignment (`module.exports.helper = helper`), and that assignment is a + * reference in the same file, which inference follows on from. Undefined for + * a member (an `exports.helper` included), a global of a plain script, and an + * ES module export. + */ +function homeFileOf(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.SourceFile | undefined { + const {ts, checker} = ctx; + const declarations = symbol.declarations ?? []; + const file = declarations[0]?.getSourceFile(); + if (!file || file.isDeclarationFile || declarations.some((d) => d.getSourceFile() !== file)) return undefined; + if (!declarations.every((declaration) => declaresScopedName(ts, declaration))) return undefined; + if (checker.getExportSymbolOfSymbol(symbol) !== symbol) return undefined; + const isGlobal = checker.resolveName(symbol.name, undefined, ts.SymbolFlags.Value, false) === symbol; + return isGlobal ? undefined : file; +} + +/** The one file all of `symbols` are visible in alone, or undefined when any file may refer to one of them. */ +function sharedHomeFile(ctx: InferenceContext, symbols: readonly tsserver.Symbol[]) { + const files = new Set(symbols.map((symbol) => homeFileOf(ctx, symbol))); + const [file] = files; + return files.size === 1 ? file : undefined; +} + +function searchTargetOf(ctx: InferenceContext, name: tsserver.Identifier): SearchTarget { + const own = symbolsAt(ctx, name); + const symbols = new Set(own.flatMap((symbol) => withRoots(ctx, symbol))); + const declarations = new Set([...symbols].flatMap((symbol) => symbol.declarations ?? [])); + const module = own.map((symbol) => moduleOf(ctx, symbol)).find((file) => file !== undefined); + return {name, symbols, declarations, homeFile: module ?? sharedHomeFile(ctx, own), module}; +} + +/** True when `candidate` stands for what `target` names: the same symbol, directly or through an alias. */ +function standsFor(ctx: InferenceContext, candidate: tsserver.Identifier, target: SearchTarget): boolean { + return identitiesOf(ctx, candidate).some( + (symbol) => + target.symbols.has(symbol) || + (symbol.declarations ?? []).some((declaration) => target.declarations.has(declaration)), + ); +} + +/** The require() module paths `file` contains: the parser's own list of them, else the `require('…')` calls found by name. */ +function modulePathsIn(ctx: InferenceContext, file: tsserver.SourceFile): readonly tsserver.StringLiteralLike[] { + const {ts} = ctx; + const imports = (file as {imports?: unknown}).imports; + if (Array.isArray(imports) && imports.every((node) => ts.isStringLiteralLike(node))) return imports; + return identifiersNamed(ts, file, 'require').flatMap((callee) => { + const call = callee.parent; + const path = ts.isCallExpression(call) && call.expression === callee ? call.arguments[0] : undefined; + return path && ts.isStringLiteralLike(path) ? [path] : []; + }); +} + +/** The name a module path must contain to load `module`: `productHelpers`, or `models` for models/index.js. */ +function loadedAs(module: tsserver.SourceFile): string { + const segments = module.fileName.replace(/\.[^./]+$/, '').split('/'); + const last = segments.pop() ?? ''; + return last === 'index' ? (segments.pop() ?? last) : last; +} + +/** The module paths in `files` that load `module`. */ +function modulePathsLoading( + ctx: InferenceContext, + module: tsserver.SourceFile, + files: readonly tsserver.SourceFile[], +): tsserver.StringLiteralLike[] { + const text = loadedAs(module); + return files.flatMap((file) => + modulePathsIn(ctx, file).filter( + (path) => path.text.includes(text) && ctx.checker.getSymbolAtLocation(path)?.valueDeclaration === module, + ), + ); +} + +function throwIfCancelled(ctx: InferenceContext): void { + if (ctx.host.isCancellationRequested?.()) throw new ctx.ts.OperationCanceledException(); +} + +function runSearch(ctx: InferenceContext, target: SearchTarget): Reference[] { + const {ts, program} = ctx; + const text = target.name.text; + const projectFiles = program.getSourceFiles().filter((file) => !file.isDeclarationFile); + const references: Reference[] = []; + for (const file of target.homeFile ? [target.homeFile] : projectFiles) { + if (!namesIn(ts, file).has(text)) continue; + throwIfCancelled(ctx); + references.push(...identifiersNamed(ts, file, text).filter((candidate) => standsFor(ctx, candidate, target))); + } + if (target.module) references.push(...modulePathsLoading(ctx, target.module, projectFiles)); + return references; +} + +/** + * Every reference to what `name` declares or refers to: each identifier the + * checker resolves to the same symbol, directly or through an alias, and for + * a module's `exports`, the require() calls that load the module. A name + * that is visible in one file alone is searched in that file only; nothing + * in a declaration file is a use. Cached per Program; spends no budget, the + * caller does. + */ +export function searchReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly Reference[] { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + throwIfCancelled(ctx); + const target = searchTargetOf(ctx, name); + if (searchChannel.hasSubscribers) searchChannel.publish({name: name.text, projectWide: readsProject(target)}); + references = runSearch(ctx, target); + cache.set(name, references); + } + return references; +} diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts new file mode 100644 index 000000000..1d7bc1c89 --- /dev/null +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Reading calls and their declared signatures: the function a call invokes, +// which overloads it can mean, and the type a declared callback hands its +// parameters. An inferred receiver +// (`items` built by `items.push(lineItem)`) gets no contextual typing from the +// checker, so `items.filter(function (item) {...})` leaves `item` untyped; +// the method's own declaration on the inferred type still says what `item` +// is, exactly as IntelliJ reads it. A named function handed to a declared +// API (`server.get('Show', cache.applyDefaultCache)`) is not contextually +// typed either, and is read the same way. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {ELEMENT_FIRST_CALLBACK_CALLEES} from './constants'; +import type {InferenceContext} from './context'; +import {typeDisplayString} from './type-helpers'; +import type {Handoff} from './value-flow'; + +/** + * Resolves the function-like declaration a call expression's callee refers + * to, via its symbol or — as a fallback for shapes the symbol lookup misses + * (`var Model = require('./model'); new Model(x)`) — the signature the call + * resolves to. Only a callee with several signatures needs the checker to + * pick one, which checks every argument the call passes (callback bodies + * included); a callee with one signature can only mean that one. + */ +export function resolveCalleeDeclaration( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, +): tsserver.SignatureDeclaration | undefined { + const {checker, ts} = ctx; + const sym = checker.getSymbolAtLocation(call.expression); + const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; + if (decl && ts.isFunctionLike(decl)) return decl; + const signatures = calleeSignatures(ctx, call); + const sigDecl = (signatures.length > 1 ? checker.getResolvedSignature(call) : signatures[0])?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; +} + +/** + * The signatures `call` chooses from: a `new` call's construct signatures, + * or the call signatures of a callee without any (a JS constructor + * function, which `new` calls like any other function). + */ +function calleeSignatures( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, +): readonly tsserver.Signature[] { + const callee = ctx.checker.getTypeAtLocation(call.expression); + const construct = ctx.ts.isNewExpression(call) ? callee.getConstructSignatures() : []; + return construct.length > 0 ? construct : callee.getCallSignatures(); +} + +/** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +export function functionOf( + ctx: InferenceContext, + value: tsserver.Expression, +): tsserver.SignatureDeclaration | undefined { + const {ts, checker} = ctx; + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) return value; + const declaration = ts.isIdentifier(value) ? checker.getSymbolAtLocation(value)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} + +/** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ +export function isElementFirstCallbackCall(ctx: InferenceContext, call: tsserver.CallExpression): boolean { + const {ts} = ctx; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + return ts.isIdentifier(callee) && ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text); +} + +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +export function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver.Signature, count: number): boolean { + const {ts, checker} = ctx; + const declaration = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; + if (!declaration) return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} + +/** + * The type `signature` gives the argument at `argIndex`: its parameter's + * type, or for an argument a rest parameter collects (`...middleware: + * Middleware[]`), the element type of that parameter's array. + */ +function argumentTypeAt( + ctx: InferenceContext, + signature: tsserver.Signature, + argIndex: number, + location: tsserver.Node, +): tsserver.Type | undefined { + const {ts, checker} = ctx; + const parameters = signature.getParameters(); + const last = parameters[parameters.length - 1]; + const rest = + last?.valueDeclaration && ts.isParameter(last.valueDeclaration) && ts.isRestParameter(last.valueDeclaration); + if (argIndex < parameters.length - (rest ? 1 : 0)) { + return checker.getTypeOfSymbolAtLocation(parameters[argIndex], location); + } + return rest + ? checker.getIndexTypeOfType(checker.getTypeOfSymbolAtLocation(last, location), ts.IndexKind.Number) + : undefined; +} + +/** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ +function callbackParameterType( + ctx: InferenceContext, + signature: tsserver.Signature, + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, +): tsserver.Type | undefined { + const {checker} = ctx; + const callbackType = argumentTypeAt(ctx, signature, argIndex, call); + const callbackParameter = + callbackType && checker.getNonNullableType(callbackType).getCallSignatures()[0]?.getParameters()[paramIndex]; + return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); +} + +/** + * The types `methodTypes`' declared signatures give parameter `paramIndex` + * of the callback `call` passes as argument `argIndex`: `value: T` of + * `filter(predicate: (value: T, ...) => unknown)` on a `ProductLineItem[]`. + * Overloads taking that many arguments must agree, so the accumulator of + * `reduce(callback, initialValue)` (`T` or `U`, by overload) stays unknown. + */ +export function callbackParameterTypes( + ctx: InferenceContext, + methodTypes: readonly tsserver.Type[], + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, +): tsserver.Type[] { + return methodTypes.flatMap((methodType) => { + const overloads = methodType + .getCallSignatures() + .filter((signature) => acceptsArgumentCount(ctx, signature, call.arguments.length)); + const [first, ...rest] = overloads.map((signature) => + callbackParameterType(ctx, signature, call, argIndex, paramIndex), + ); + const key = first && typeDisplayString(ctx, first); + return first && rest.every((type) => type && typeDisplayString(ctx, type) === key) ? [first] : []; + }); +} + +/** + * What the callee of `handoff` declares it passes parameter `paramIndex` of + * the function it is handed: `Request` for `req` of the middleware step in + * `server.get('Show', cache.applyDefaultCache)`. + */ +export function handedOffParameterTypes(ctx: InferenceContext, handoff: Handoff, paramIndex: number): tsserver.Type[] { + const callee = ctx.checker.getTypeAtLocation(handoff.call.expression); + return callbackParameterTypes(ctx, [callee], handoff.call, handoff.argIndex, paramIndex); +} diff --git a/packages/b2c-script-types/src/inference/super-module.ts b/packages/b2c-script-types/src/inference/super-module.ts new file mode 100644 index 000000000..d8d3ea98c --- /dev/null +++ b/packages/b2c-script-types/src/inference/super-module.ts @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// SFCC's `module.superModule` lets a cartridge extend the same-path module in +// the next cartridge down the path (SFRA plugin overlays). These helpers +// recognize a superModule access, find the file it points at, and scan a +// module's `module.exports = ...` / `module.exports.x = ...` assignments, and +// resolve what a superModule access evaluates to. Resolving the values an +// overlay exports needs the recursive engine (./core), which hands its +// resolver in as an {@link ExpressionResolver} so this module never imports it. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {isExportsObject, isModuleExports, memberAssignmentOf} from './ast-helpers'; +import {MAX_SUPERMODULE_HOPS} from './constants'; +import {withCycleGuard} from './context'; +import type {InferenceContext} from './context'; +import {dedupeKnownTypes, dedupeTypes, isAnyType} from './type-helpers'; + +/** The recursive engine's resolver for a value one hop further along the expression being resolved. */ +export type ExpressionResolver = (value: tsserver.Expression) => tsserver.Type[]; + +/** + * The SFCC `module.superModule` expression — the runtime handle to the + * same-path module in the next cartridge down the cartridge path, which SFRA + * plugin cartridges use to extend base modules. Identified structurally, like + * the require() detection above. + */ +function isSuperModuleAccess(expr: tsserver.PropertyAccessExpression, ts: typeof tsserver): boolean { + return ts.isIdentifier(expr.expression) && expr.expression.text === 'module' && expr.name.text === 'superModule'; +} + +/** + * Locates the source file `module.superModule` refers to for `fromFileName` + * — the same-subpath module in the next cartridge down the path, per the + * host-supplied ctx.host.resolveSuperModulePath. Only works when that file is + * part of the current program (true under the recommended jsconfig setup + * that includes all cartridge files, but not in a bare inferred project + * where nothing require()s the base file). + */ +function findSuperModuleFile(ctx: InferenceContext, fromFileName: string): tsserver.SourceFile | undefined { + const {program} = ctx; + const superPath = ctx.host.resolveSuperModulePath?.(fromFileName); + if (!superPath) return undefined; + // The resolver returns host-normalized (possibly case-folded) paths; + // program keys may differ in case on case-insensitive filesystems. + const direct = program.getSourceFile(superPath); + if (direct) return direct; + const target = superPath.toLowerCase(); + return program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); +} + +/** A module's top-level export assignments — see {@link collectExportAssignments}. */ +interface ExportAssignments { + readonly full: readonly tsserver.BinaryExpression[]; + readonly members: ReadonlyArray<{readonly name: string; readonly expr: tsserver.Expression}>; +} + +/** + * A module's top-level export assignments, gathered structurally: + * `full` — every `module.exports = X` right-hand side; + * `members` — every `module.exports. = X` / `exports. = X` + * augmentation, the shape SFRA plugin overlays use to add helpers on top of + * a re-exported base (`module.exports = base; module.exports.extra = extra;`). + */ +function collectExportAssignments(sf: tsserver.SourceFile, ts: typeof tsserver): ExportAssignments { + const full: tsserver.BinaryExpression[] = []; + const members: Array<{name: string; expr: tsserver.Expression}> = []; + for (const stmt of sf.statements) { + const assignment = memberAssignmentOf(stmt, ts); + if (!assignment) continue; + const {binary, target} = assignment; + if (isExportsObject(target.expression, ts)) members.push({name: target.name.text, expr: binary.right}); + else if (isModuleExports(target, ts)) full.push(binary); + } + return {full, members}; +} + +/** + * True when a `module.exports = X` assignment gives the checker a genuinely + * usable exports type: not `any`, and actually exposing members. A + * pass-through overlay (`module.exports = base` where base came from + * `module.superModule`) fails this — depending on program shape the checker + * reports its exports as `any` or as an opaque, member-less `typeof base` — + * and must be resolved by recursing down the cartridge chain instead. + */ +function isConcreteExportAssignment(ctx: InferenceContext, bin: tsserver.BinaryExpression): boolean { + const {ts, checker} = ctx; + const exportsType = checker.getTypeAtLocation(bin.left); + if (isAnyType(ts, exportsType)) return false; + return checker.getPropertiesOfType(checker.getApparentType(exportsType)).length > 0; +} + +/** + * Follows `expr` back to a `module.superModule` access if there is one: the + * expression itself, or — the universal SFRA idiom — a reference to a local + * `var base = module.superModule;` binding. Exported so the plugin's + * hover/completion gates can recognize superModule-derived expressions: the + * checker's own type for them is never meaningful (sometimes `any`, + * sometimes an opaque circular `typeof base`), so "is the type any?" alone + * would skip inference exactly where it's needed. + */ +export function traceSuperModuleAccess( + ts: typeof tsserver, + checker: tsserver.TypeChecker, + expr: tsserver.Expression, +): tsserver.PropertyAccessExpression | undefined { + if (ts.isPropertyAccessExpression(expr) && isSuperModuleAccess(expr, ts)) return expr; + if (ts.isIdentifier(expr)) { + const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + if ( + decl && + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isPropertyAccessExpression(decl.initializer) && + isSuperModuleAccess(decl.initializer, ts) + ) { + return decl.initializer; + } + } + return undefined; +} + +/** True when a level re-exports what is below it (`module.exports = base`) rather than replacing it. */ +function passesThrough(ctx: InferenceContext, exports: ExportAssignments): boolean { + const {ts, checker} = ctx; + return exports.full.some( + (bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined, + ); +} + +/** + * Walks the superModule chain below the file containing `superAccess`, one + * cartridge level at a time, yielding each level's export assignments. The + * walk continues downward only through a pass-through level + * (`module.exports = base`): a level with a concrete `module.exports` + * replaces everything below it at runtime, unless it carries the base along. + */ +function* superModuleLevels( + ctx: InferenceContext, + superAccess: tsserver.PropertyAccessExpression, +): Generator { + const seen = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < MAX_SUPERMODULE_HOPS; hop++) { + const superFile = findSuperModuleFile(ctx, fromFileName); + if (!superFile || seen.has(superFile)) return; + seen.add(superFile); + const exports = collectExportAssignments(superFile, ctx.ts); + yield exports; + if (!passesThrough(ctx, exports)) return; + fromFileName = superFile.fileName; + } +} + +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes( + ctx: InferenceContext, + assignment: tsserver.BinaryExpression, + resolve: ExpressionResolver, +): tsserver.Type[] { + const {ts, checker} = ctx; + const concrete = isConcreteExportAssignment(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) types.push(...resolve(assignment.right)); + return types; +} + +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. + */ +export function resolveSuperModuleTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + resolve: ExpressionResolver, +): tsserver.Type[] { + const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); + if (!superFile) return []; + // The guard catches overlay cycles from a misconfigured cartridge path. + return withCycleGuard(ctx, superFile, [], () => + dedupeTypes( + ctx, + collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => + superModuleExportTypes(ctx, assignment, resolve), + ), + ), + ); +} + +/** + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. + */ +export function resolveSuperModuleMemberTypes( + ctx: InferenceContext, + superAccess: tsserver.PropertyAccessExpression, + memberName: string, + resolve: ExpressionResolver, +): tsserver.Type[] { + for (const {members} of superModuleLevels(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) continue; + return dedupeKnownTypes( + ctx, + matches.flatMap((member) => resolve(member.expr)), + ); + } + return []; +} + +/** + * Collects every member the superModule chain reachable from `expr` + * contributes through export augmentations (`module.exports.name = fn`) at + * pass-through levels — the members the superModule's export types cannot + * carry. Used to complete after `base.` in an overlay; the first (highest) + * level defining a name wins, matching runtime override order. + */ +export function collectSuperModuleAugmentedMembers( + ctx: InferenceContext, + expr: tsserver.Expression, +): Array<{name: string; isMethod: boolean}> { + const {ts, checker} = ctx; + const superAccess = traceSuperModuleAccess(ts, checker, expr); + if (!superAccess) return []; + const out: Array<{name: string; isMethod: boolean}> = []; + const seenNames = new Set(); + for (const {members} of superModuleLevels(ctx, superAccess)) { + for (const member of members) { + if (seenNames.has(member.name)) continue; + seenNames.add(member.name); + out.push({name: member.name, isMethod: checker.getTypeAtLocation(member.expr).getCallSignatures().length > 0}); + } + } + return out; +} diff --git a/packages/b2c-script-types/src/inference/this-properties.ts b/packages/b2c-script-types/src/inference/this-properties.ts new file mode 100644 index 000000000..d434c6112 --- /dev/null +++ b/packages/b2c-script-types/src/inference/this-properties.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Constructor functions keep their arguments on `this` +// (`this.refinementValue = refinementValue`) and use them in prototype +// methods (`this.refinementValue.hitCount`), rarely through the parameter +// itself. IntelliJ reads those uses as uses of the parameter, and so does +// ./usage-profile: the checker binds every `this.refinementValue` of the +// constructor's prototype methods to the member the constructor declares, so +// a read of that member anywhere in the file is a use of the value stored in +// it. Constructors also define members with `Object.defineProperty(this, …)`, +// which the checker leaves off the instance type; ./type-helpers counts them +// as members all the same. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {spellingFilter} from './ast-helpers'; +import type {InferenceContext} from './context'; + +/** `this.x = value`: a plain assignment to a member of `this`. */ +function isThisMemberStore( + ts: typeof tsserver, + node: tsserver.Node, +): node is tsserver.BinaryExpression & {readonly left: tsserver.PropertyAccessExpression} { + return ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.expression.kind === ts.SyntaxKind.ThisKeyword + ); +} + +/** True when `node` gives its body a `this` of its own (a function, method or class; arrow functions share it). */ +function hasOwnThis(ts: typeof tsserver, node: tsserver.Node): boolean { + return (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLike(node); +} + +/** + * The members of `this` the function declaring `param` stores it in + * (`this.refinementValue = refinementValue`). Stores inside nested functions + * are skipped: their `this` is another value. + */ +export function thisMembersStoring( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, +): ReadonlySet { + const {ts, checker} = ctx; + const members = new Set(); + const body = (param.parent as tsserver.FunctionLikeDeclaration).body; + const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; + if (!body || !paramSymbol) return members; + const spellsParam = spellingFilter(body, [paramSymbol.name]); + const visit = (node: tsserver.Node): void => { + if (!spellsParam(node)) return; + const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; + const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; + if (member) members.add(member); + if (!hasOwnThis(ts, node)) ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return members; +} + +/** True when `callee` is `Object.defineProperty`. */ +function isObjectDefineProperty(ts: typeof tsserver, callee: tsserver.Expression): boolean { + return ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' && + callee.name.text === 'defineProperty' + ); +} + +/** `Object.defineProperty(this, 'name', …)`: the name of the member the call defines on `this`. */ +function memberDefinedOnThisBy(ts: typeof tsserver, node: tsserver.Node): string | undefined { + if (!ts.isCallExpression(node) || !isObjectDefineProperty(ts, node.expression)) return undefined; + const [target, key] = node.arguments; + if (target?.kind !== ts.SyntaxKind.ThisKeyword || !key || !ts.isStringLiteralLike(key)) return undefined; + return key.text; +} + +/** The body that builds an instance: a constructor function's own, or a class's constructor. */ +function constructorBody(ts: typeof tsserver, declaration: tsserver.Declaration): tsserver.Node | undefined { + const constructor = ts.isVariableDeclaration(declaration) ? declaration.initializer : declaration; + if (constructor && (ts.isFunctionDeclaration(constructor) || ts.isFunctionExpression(constructor))) { + return constructor.body; + } + return ts.isClassLike(declaration) ? declaration.members.find(ts.isConstructorDeclaration)?.body : undefined; +} + +// Keyed by declaration node: a syntax tree never changes, and tsserver reuses +// it across Programs for as long as its file is unchanged. +const membersDefinedByDeclaration = new WeakMap>(); + +/** The members the constructor `declaration` declares defines on its own `this` (not a nested function's). */ +function membersDefinedIn(ts: typeof tsserver, declaration: tsserver.Declaration): ReadonlySet { + const cached = membersDefinedByDeclaration.get(declaration); + if (cached) return cached; + const members = new Set(); + const visit = (node: tsserver.Node): void => { + const member = memberDefinedOnThisBy(ts, node); + if (member !== undefined) members.add(member); + if (!hasOwnThis(ts, node)) ts.forEachChild(node, visit); + }; + const body = constructorBody(ts, declaration); + if (body) ts.forEachChild(body, visit); + membersDefinedByDeclaration.set(declaration, members); + return members; +} + +/** + * True when `type`'s constructor defines `name` with + * `Object.defineProperty(this, 'name', …)` (SFRA's request model defines most + * of its members this way). The checker binds such calls on exports and + * prototypes but not on `this`, so the instance type lacks these members. + */ +export function isMemberDefinedOnThis(ts: typeof tsserver, type: tsserver.Type, name: string): boolean { + const declarations = type.getSymbol()?.declarations ?? []; + return declarations.some((declaration) => membersDefinedIn(ts, declaration).has(name)); +} + +/** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ +export function isStoredMemberRead( + ctx: InferenceContext, + members: ReadonlySet, + node: tsserver.Node, +): node is tsserver.PropertyAccessExpression { + const {ts, checker} = ctx; + if (members.size === 0 || !ts.isPropertyAccessExpression(node)) return false; + const store = node.parent; + const written = isThisMemberStore(ts, store) && store.left === node; + if (node.expression.kind !== ts.SyntaxKind.ThisKeyword || written) return false; + const member = checker.getSymbolAtLocation(node.name); + return member !== undefined && members.has(member); +} diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts new file mode 100644 index 000000000..58f0aa2f6 --- /dev/null +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Helpers for working with the checker's Type objects: recognizing `any` and +// other types that carry no information, rendering a type the way a reader +// expects to see it, de-duplicating candidates by that rendering, reaching a +// type's real members (stripping nullability), pulling the element type out +// of an array or collection (and building an array of one), and turning +// candidate types into completion entries. +// These are the "leaf" operations the recursive engine in ./core and the +// decision rules in ./policy build on. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {INFERRED_COMPLETION_SOURCE} from './constants'; +import type {InferenceContext} from './context'; +import {isMemberDefinedOnThis} from './this-properties'; + +/** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ +export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { + return (type.flags & ts.TypeFlags.Any) !== 0; +} + +/** True when `type` can be called or constructed: a function type, whatever members it lacks. */ +function isCallable(type: tsserver.Type): boolean { + return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; +} + +/** + * True when the checker's type is too uninformative to prefer over usage + * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or + * the global `Object` interface. Used as the hover/completion gate. + * + * Deliberately excludes named classes (even wrong ones like a mis-documented + * `Request`) — those are strong enough that overriding them would fight both + * TypeScript and IntelliJ's JSDoc-first model. + * + * `Object` is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O, distinct from the lowercase `object` + * non-primitive and from `{*}`/`{}`/`{obj}`, which widen to `any` or an empty + * type). It carries no Script API information, so a value typed as bare + * `Object` is effectively undocumented — exactly the case usage inference + * exists for. No dw.* class is named plain `Object`, so keying on the name + * can't shadow a real Script API type. + */ +export function isOpenForUsageInference(ts: typeof tsserver, type: tsserver.Type): boolean { + if (isAnyType(ts, type)) return true; + if (type.flags & ts.TypeFlags.NonPrimitive) return true; + const symbol = type.getSymbol(); + if (symbol?.getName() === '__type' && type.getProperties().length === 0 && !isCallable(type)) return true; + return symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; +} + +// Type flags that never make a useful hover or completion candidate on their +// own: `null`/`undefined` (the empty branch of `x ? y : null`), `void`, +// `never`, `unknown`, and an unbound type parameter (`T` from a generic +// declaration nobody instantiated). +function uninformativeFlags(ts: typeof tsserver): number { + return ( + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Never | + ts.TypeFlags.Void | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.TypeParameter + ); +} + +/** An object type with no members and no signatures: the `{}` of `options || {}`. */ +function isEmptyObjectType(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + return ( + (type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + type.getCallSignatures().length === 0 && + type.getConstructSignatures().length === 0 && + checker.getIndexInfosOfType(type).length === 0 + ); +} + +/** + * True when `type` tells a reader something: not one of the + * {@link uninformativeFlags} kinds, not open for inference (see + * {@link isOpenForUsageInference}), not an empty object, and — for an array — + * an array of something informative (`any[]`, `T[]` and `undefined[]` are as + * empty as `any`). + */ +function isInformativeType(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + if (type.flags & uninformativeFlags(ts)) return false; + if (isOpenForUsageInference(ts, type) || isEmptyObjectType(ts, checker, type)) return false; + if (!checker.isArrayType(type)) return true; + const [element] = checker.getTypeArguments(type as tsserver.TypeReference); + return element !== undefined && isInformativeType(ts, checker, element); +} + +/** + * Splits `type` into the parts worth showing: union members are considered + * one by one (so `Product | null` keeps `Product`), literals are widened to + * their primitive (`"a" | "b"` reads as `string`), and every + * non-informative part is dropped. + */ +export function informativeParts(ctx: InferenceContext, type: tsserver.Type): tsserver.Type[] { + const {ts, checker} = ctx; + const parts = type.isUnion() ? type.types : [type]; + return parts + .map((part) => checker.getBaseTypeOfLiteralType(part)) + .filter((part) => isInformativeType(ts, checker, part)); +} + +/** + * True when a generic instantiation's type arguments add nothing a reader + * needs: each one is still an unbound type parameter (the declared type of + * `Product`, as ambient matching produces it), `any`, or exactly the + * parameter's declared default (`Product`). + */ +function hasOnlyDefaultTypeArguments(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + if (!(type.flags & ts.TypeFlags.Object)) return true; + if (!((type as tsserver.ObjectType).objectFlags & ts.ObjectFlags.Reference)) return true; + const reference = type as tsserver.TypeReference; + const parameters = reference.target.typeParameters ?? []; + return checker.getTypeArguments(reference).every((argument, index) => { + if (argument.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any)) return true; + const parameter = parameters[index]; + return parameter !== undefined && checker.getDefaultFromTypeParameter(parameter) === argument; + }); +} + +/** + * The name a reader knows a class or interface by. Usually its own name; a + * declaration nested in a namespace keeps the namespace (the vendored + * `ICustomAttributes.Shipment`, the type of `shipment.custom`, must not read + * as the unrelated `Shipment` class). The `global.` wrapper of + * `declare global` and a quoted ambient-module prefix + * (`"server/server".Response`) are implementation details and are dropped. + */ +function classDisplayName(checker: tsserver.TypeChecker, symbol: tsserver.Symbol): string { + return checker + .getFullyQualifiedName(symbol) + .replace(/^global\./, '') + .replace(/^"[^"]*"\./, ''); +} + +/** + * True for a union shown as its members. The checker reads `true | false` + * back as `boolean` (an enum's members as the enum), so it renders those. + */ +function isDisplayedAsUnion(ts: typeof tsserver, type: tsserver.Type): type is tsserver.UnionType { + const collapsed = ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral; + return type.isUnion() && !type.types.some((member) => member.flags & collapsed); +} + +/** `Product[]`, or `(Product | Basket)[]` for an array of a union. */ +function arrayDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, array: tsserver.TypeReference): string { + const [element] = checker.getTypeArguments(array); + if (!element) return 'any[]'; + const display = computeTypeDisplayString(ts, checker, element); + return element.isUnion() && !(element.flags & ts.TypeFlags.Boolean) ? `(${display})[]` : `${display}[]`; +} + +/** + * Renders a candidate type for hover text and dedupe keys. A class or + * interface instantiated only with default type arguments reads as the bare + * class name (`Product`, `LineItemCtnr`); one with real type arguments + * renders them the same way (`Collection`, not the checker's + * `Collection>`), an array reads as its element's display + * (`Product[]`) and a union as its members' (`(Product | Basket)[]`). + * Everything else — primitives, object literals, lib types — is rendered by + * the checker as TypeScript itself would. + */ +function computeTypeDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): string { + if (isDisplayedAsUnion(ts, type)) { + return type.types.map((member) => computeTypeDisplayString(ts, checker, member)).join(' | '); + } + if (checker.isArrayType(type)) return arrayDisplayString(ts, checker, type as tsserver.TypeReference); + const symbol = type.getSymbol(); + const isClassOrInterface = + symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; + if (!isClassOrInterface) return checker.typeToString(type); + if (hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); + const typeArguments = checker + .getTypeArguments(type as tsserver.TypeReference) + .map((argument) => computeTypeDisplayString(ts, checker, argument)); + return `${classDisplayName(checker, symbol)}<${typeArguments.join(', ')}>`; +} + +/** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ +export function typeDisplayString(ctx: InferenceContext, type: tsserver.Type): string { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) return cached; + const display = computeTypeDisplayString(ctx.ts, ctx.checker, type); + ctx.typeDisplayStrings.set(type, display); + return display; +} + +/** + * Deduplicates candidate types by their display string. Two distinct types + * that render identically (`Product` and `Product` both read as + * `Product`) collapse into one — acceptable because every consumer of the + * result is display-oriented (hover text, completion-member names). + */ +export function dedupeTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const seen = new Set(); + return types.filter((type) => { + const key = typeDisplayString(ctx, type); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +/** The distinct types among `types` that say something: {@link dedupeTypes} without `any`. */ +export function dedupeKnownTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + return dedupeTypes( + ctx, + types.filter((type) => !isAnyType(ctx.ts, type)), + ); +} + +/** + * Strips any nullable part from `type` and computes its apparent type before + * a member lookup. `getPropertyOfType` on a union only returns members common + * to *every* constituent, so an un-stripped `Product | null` (the shape of + * `ProductMgr.getProduct()`) would never resolve any member; the apparent + * type also exposes a primitive's wrapper-object members (`.length`). + */ +function getNonNullableApparentType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { + return checker.getApparentType(checker.getNonNullableType(type)); +} + +/** Looks up a member by name on `type`'s non-nullable apparent type — see {@link getNonNullableApparentType}. */ +export function getMemberOfType( + checker: tsserver.TypeChecker, + type: tsserver.Type, + name: string, +): tsserver.Symbol | undefined { + return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); +} + +/** + * True when `type` has a member called `name`: one the checker knows, or one + * its constructor defines on `this` with `Object.defineProperty` (see + * {@link isMemberDefinedOnThis}). + */ +export function hasMember(ctx: InferenceContext, type: tsserver.Type, name: string): boolean { + return getMemberOfType(ctx.checker, type, name) !== undefined || isMemberDefinedOnThis(ctx.ts, type, name); +} + +/** True when `type` has every member in `memberNames` (vacuously true for none); see {@link hasMember}. */ +export function hasAllMembers(ctx: InferenceContext, type: tsserver.Type, memberNames: ReadonlySet): boolean { + for (const name of memberNames) { + if (!hasMember(ctx, type, name)) return false; + } + return true; +} + +/** + * Extracts the element type from a collection-like `type`: something with an + * `iterator()` method whose result has a typed `next()` (dw.util.Collection + * and friends), or something that is itself such an iterator. Returns + * `undefined` when `type` doesn't look like a collection or its element type + * is unknown — never `any`. + * + * @param location - any node in the file where the type is being used; + * required by getTypeOfSymbolAtLocation to resolve member types. + */ +function collectionElementType( + ctx: InferenceContext, + type: tsserver.Type, + location: tsserver.Node, +): tsserver.Type | undefined { + const {ts, checker} = ctx; + const firstCallReturn = (t: tsserver.Type, memberName: string): tsserver.Type | undefined => { + const sym = getMemberOfType(checker, t, memberName); + if (!sym) return undefined; + const [signature] = checker.getTypeOfSymbolAtLocation(sym, location).getCallSignatures(); + return signature && checker.getReturnTypeOfSignature(signature); + }; + const iteratorType = firstCallReturn(type, 'iterator') ?? type; + const element = firstCallReturn(iteratorType, 'next'); + if (!element || isAnyType(ts, element)) return undefined; + if (element.flags & (ts.TypeFlags.Void | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined; + return element; +} + +/** + * The element type of an array (`ProductLineItem[]`) or of a Script API + * collection or iterator (see {@link collectionElementType}): what `x[i]`, + * `x.pop()` or a `forEach` callback over `x` sees. + */ +export function elementTypeOf( + ctx: InferenceContext, + type: tsserver.Type, + location: tsserver.Node, +): tsserver.Type | undefined { + const {checker} = ctx; + if (checker.isArrayType(type)) return checker.getTypeArguments(type as tsserver.TypeReference)[0]; + return collectionElementType(ctx, type, location); +} + +/** The checker's own type factories, which TypeScript keeps off its public typings. */ +interface TypeFactories { + createArrayType?(elementType: tsserver.Type): tsserver.Type; + getUnionType?(types: tsserver.Type[]): tsserver.Type; +} + +/** + * An array of `elements` (`ProductLineItem[]`, or `(OrderAddress | Shipment)[]` + * for an array filled with either), built by the checker itself so an + * inferred array reads, and resolves members (`filter`, `[0]`, `forEach`), + * exactly like a declared one. The factories are internal to TypeScript, so + * they are feature-detected: a TypeScript without them leaves inferred + * arrays silent rather than wrong. + */ +export function arrayTypeOf(ctx: InferenceContext, elements: readonly tsserver.Type[]): tsserver.Type | undefined { + const factories = ctx.checker as tsserver.TypeChecker & TypeFactories; + if (typeof factories.createArrayType !== 'function') return undefined; + if (elements.length === 1) return factories.createArrayType(elements[0]); + if (elements.length === 0 || typeof factories.getUnionType !== 'function') return undefined; + return factories.createArrayType(factories.getUnionType([...elements])); +} + +/** Renders candidate types as hover text, e.g. `"Product | Category"`. */ +export function describeTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): string { + return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); +} + +/** + * One synthesized member completion. `sortText` '11' mirrors TS's own + * SortText.LocationPriority — the rank ordinary resolved members get — so + * inferred members sort alongside real ones rather than above or below them. + */ +export function inferredCompletionEntry( + ts: typeof tsserver, + name: string, + isMethod: boolean, +): tsserver.CompletionEntry { + return { + name, + kind: isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: INFERRED_COMPLETION_SOURCE, + }; +} + +/** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ +export function typesToCompletionEntries( + ts: typeof tsserver, + checker: tsserver.TypeChecker, + types: readonly tsserver.Type[], +): tsserver.CompletionEntry[] { + const seen = new Set(); + const entries: tsserver.CompletionEntry[] = []; + for (const type of types) { + for (const sym of checker.getPropertiesOfType(getNonNullableApparentType(checker, type))) { + const name = sym.getName(); + if (seen.has(name)) continue; + seen.add(name); + entries.push(inferredCompletionEntry(ts, name, (sym.flags & ts.SymbolFlags.Method) !== 0)); + } + } + return entries; +} diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts new file mode 100644 index 000000000..a5147c678 --- /dev/null +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -0,0 +1,601 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// What a parameter's or variable's own scope says about it, gathered in one +// walk: the members it is accessed by (duck typing), the classes it is +// tested against (`instanceof`, `typeof`), the types the code passes it as +// (a typed call argument or assignment target), and the values assigned to +// it, pushed into it and written to its members. Every rule in ./policy +// reads this same profile, so a hover, a member hover and a completion all +// see the same evidence. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {hasExplicitParameterType, isTestedOrAlternative, spellingFilter} from './ast-helpers'; +import type {SpellingFilter} from './ast-helpers'; +import {MAX_USAGE_FORWARDING_HOPS} from './constants'; +import type {InferenceContext} from './context'; +import {acceptsArgumentCount} from './signatures'; +import {isStoredMemberRead, thisMembersStoring} from './this-properties'; +import {isOpenForUsageInference} from './type-helpers'; +import {receivingParameter} from './value-flow'; + +/** + * An undocumented parameter of a project function the value is passed to + * unchanged; `optional` when only some variants of the value are (see + * {@link variantBranches}). + */ +export interface ForwardedUse { + readonly parameter: tsserver.ParameterDeclaration; + readonly optional: boolean; +} + +export interface UsageProfile { + /** + * Member names accessed directly on the value: `x.member`, + * `x['member']`, and the presence tests in {@link optionalMemberNames}. + * Only one hop counts — a chained `x.custom.fromStoreId` contributes + * `custom`, since the deeper name describes `custom`'s shape, not `x`'s. + */ + readonly memberNames: ReadonlySet; + /** + * Members the code tests for before relying on them (`'ID' in x`, + * `x.hasOwnProperty('ID')`, `Object.hasOwnProperty.call(x, 'ID')`), + * members used only in a branch such a test picks, and members only ever + * read as a test or an alternative (`if (x.m)`, `x.a || x.b`): a hint at + * what the value is, but not a member every value passed in must have. + */ + readonly optionalMemberNames: ReadonlySet; + /** Types the value is tested against: `x instanceof dw.order.ProductLineItem`, `typeof x === 'string'`. */ + readonly guardTypes: readonly tsserver.Type[]; + /** Types the value is used as: the parameter type of a typed call it is passed to, or the type of what it is assigned to. */ + readonly contextualTypes: readonly tsserver.Type[]; + /** Right-hand sides of `x = value` assignments to the value after its declaration. */ + readonly assignedValues: readonly tsserver.Expression[]; + /** Values the code adds to it as elements: `x.push(v)`, `x.unshift(v)`, `x[i] = v`. */ + readonly pushedValues: readonly tsserver.Expression[]; + /** Values the code writes to its members, by member name: `x.m = v`, `x['m'] = v`. */ + readonly memberValues: ReadonlyMap; + /** Parameters the value is passed on to (`helper(x)`, `new Model(x)`), whose usage is usage of it too. */ + readonly forwardedTo: readonly ForwardedUse[]; +} + +interface ProfileBuilder { + readonly memberNames: Set; + readonly optionalMemberNames: Set; + /** Members some access relies on: reads them other than as a test or an alternative. */ + readonly reliedOnMemberNames: Set; + readonly guardTypes: tsserver.Type[]; + readonly contextualTypes: tsserver.Type[]; + readonly assignedValues: tsserver.Expression[]; + readonly pushedValues: tsserver.Expression[]; + readonly memberValues: Map; + readonly forwardedTo: ForwardedUse[]; +} + +function emptyProfile(): ProfileBuilder { + return { + memberNames: new Set(), + optionalMemberNames: new Set(), + reliedOnMemberNames: new Set(), + guardTypes: [], + contextualTypes: [], + assignedValues: [], + pushedValues: [], + memberValues: new Map(), + forwardedTo: [], + }; +} + +const EMPTY_PROFILE: UsageProfile = emptyProfile(); + +/** + * The instance type an `instanceof` right-hand side tests for + * (`dw.order.ProductLineItem`, a local class binding, ...): its construct + * signature's return type, or — for a JS constructor function, which has + * none — the declared type of the right-hand side's symbol. A test against a + * root type (`x instanceof Object`) says nothing and yields undefined. + */ +function instanceTypeOf(ctx: InferenceContext, constructor: tsserver.Expression): tsserver.Type | undefined { + const {checker, ts} = ctx; + const constructorType = checker.getTypeAtLocation(constructor); + const [signature] = constructorType.getConstructSignatures(); + const symbol = constructorType.getSymbol() ?? checker.getSymbolAtLocation(constructor); + const instance = signature + ? checker.getReturnTypeOfSignature(signature) + : symbol && checker.getDeclaredTypeOfSymbol(symbol); + return instance && !isOpenForUsageInference(ts, instance) ? instance : undefined; +} + +/** The primitive a `typeof x === 'string'` comparison names, if `typeOf` sits in one. */ +function primitiveOfTypeofCheck(ctx: InferenceContext, typeOf: tsserver.TypeOfExpression): tsserver.Type | undefined { + const {ts, checker} = ctx; + const comparison = typeOf.parent; + if (!ts.isBinaryExpression(comparison)) return undefined; + const other = comparison.left === typeOf ? comparison.right : comparison.left; + if (!ts.isStringLiteralLike(other)) return undefined; + if (other.text === 'string') return checker.getStringType(); + if (other.text === 'number') return checker.getNumberType(); + if (other.text === 'boolean') return checker.getBooleanType(); + return undefined; +} + +/** The string a presence test names, if `node` is a string literal. */ +function literalText(ctx: InferenceContext, node: tsserver.Node | undefined): string | undefined { + return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; +} + +/** `'m' in x`: the member an `in` test on `reference` names. */ +function inOperatorMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { + const {ts} = ctx; + const test = reference.parent; + const isInTest = + ts.isBinaryExpression(test) && test.operatorToken.kind === ts.SyntaxKind.InKeyword && test.right === reference; + return isInTest ? literalText(ctx, test.left) : undefined; +} + +/** `x.hasOwnProperty('m')`: the member an own-property method call on `reference` names. */ +function ownPropertyMethodMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { + const {ts} = ctx; + const access = reference.parent; + const isHasOwn = + ts.isPropertyAccessExpression(access) && access.expression === reference && access.name.text === 'hasOwnProperty'; + const call = access.parent; + return isHasOwn && ts.isCallExpression(call) && call.expression === access + ? literalText(ctx, call.arguments[0]) + : undefined; +} + +/** `Object[.prototype].hasOwnProperty.call(x, 'm')`: the member a borrowed own-property call on `reference` names. */ +function ownPropertyCallMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { + const {ts} = ctx; + const call = reference.parent; + if (!ts.isCallExpression(call) || call.arguments[0] !== reference) return undefined; + const callee = call.expression; + const isHasOwnCall = + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'call' && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === 'hasOwnProperty'; + return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; +} + +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { + return ( + inOperatorMember(ctx, reference) ?? ownPropertyMethodMember(ctx, reference) ?? ownPropertyCallMember(ctx, reference) + ); +} + +/** + * True when `access` is the member access the current request's own cursor + * sits in (see {@link InferenceContext.triggerPosition}): a member name being + * typed (`shipment.pro|`) is not evidence yet, and a dangling `shipment.` + * followed by more code parses as an access to whatever identifier comes + * next, which no real class has. + */ +function isTriggerAccess(ctx: InferenceContext, access: tsserver.PropertyAccessExpression): boolean { + const trigger = ctx.triggerPosition; + return trigger !== undefined && access.expression.getEnd() <= trigger && trigger <= access.name.getEnd(); +} + +/** The type `signature` declares for argument `index` of `call`, unless a rest parameter collects it. */ +function declaredArgumentType( + ctx: InferenceContext, + signature: tsserver.Signature, + index: number, + call: tsserver.Node, +): tsserver.Type | undefined { + const parameter = signature.getParameters()[index]; + const declaration = parameter?.valueDeclaration; + if (!parameter || (declaration && ctx.ts.isParameter(declaration) && declaration.dotDotDotToken)) return undefined; + return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); +} + +/** + * The type argument `index` of `call` is passed as. For an overloaded callee + * it counts only when every overload taking that many arguments agrees on it + * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type + * the checker settles on the first such overload, which says nothing about + * the one the code means (`variationModel.getAllValues(attribute)` would + * make `attribute` an `ObjectAttributeDefinition`). + */ +function argumentType( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, + reference: tsserver.Expression, + index: number, +): tsserver.Type | undefined { + const {ts, checker} = ctx; + const argumentCount = call.arguments?.length ?? 0; + const callee = checker.getTypeAtLocation(call.expression); + const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); + const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, argumentCount)); + if (overloads.length <= 1) return checker.getContextualType(reference); + const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); + return first && rest.every((type) => type === first) ? first : undefined; +} + +/** The type `reference` is used as: what its context expects, or the argument type of the call it is passed to. */ +function usedAsType(ctx: InferenceContext, reference: tsserver.Expression): tsserver.Type | undefined { + const {ts, checker} = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const index = isCall ? (call.arguments?.indexOf(reference) ?? -1) : -1; + return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); +} + +/** + * The value a profile describes: its symbol, the name it is declared by, its + * own (unnarrowed) type, and the members of `this` it is stored in (see + * ./this-properties), whose reads are reads of the value. `mayReference` + * tells the walks which subtrees spell one of those names at all; no other + * one can hold a reference. + */ +interface ProfileTarget { + readonly symbol: tsserver.Symbol; + readonly declarationName: tsserver.Identifier; + readonly declaredType: tsserver.Type; + readonly storedIn: ReadonlySet; + readonly mayReference: SpellingFilter; +} + +function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): node is tsserver.Expression { + const isName = + ctx.ts.isIdentifier(node) && + node !== target.declarationName && + node.text === target.declarationName.text && + ctx.checker.getSymbolAtLocation(node) === target.symbol; + return isName || isStoredMemberRead(ctx, target.storedIn, node); +} + +/** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ +function testsPresence(ctx: InferenceContext, target: ProfileTarget, condition: tsserver.Node): boolean { + const visit = (node: tsserver.Node): boolean => + target.mayReference(node) && + ((isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true); + return visit(condition); +} + +/** True when `statement` always leaves its block: a `return`/`throw`, or a block ending in one. */ +function alwaysExits(ctx: InferenceContext, statement: tsserver.Statement): boolean { + const {ts} = ctx; + const last = ts.isBlock(statement) ? statement.statements[statement.statements.length - 1] : statement; + return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last)); +} + +/** The statements after an early-exit presence test (`if ('m' in x) { return …; }`): its implicit `else`. */ +function statementsAfterPresenceExit( + ctx: InferenceContext, + target: ProfileTarget, + statements: readonly tsserver.Statement[], +): tsserver.Node[] { + const {ts} = ctx; + const guard = statements.findIndex( + (statement) => + ts.isIfStatement(statement) && + !statement.elseStatement && + alwaysExits(ctx, statement.thenStatement) && + testsPresence(ctx, target, statement.expression), + ); + return guard < 0 ? [] : statements.slice(guard + 1); +} + +/** What a branching `node` tests and the branches it picks between, for `if`, `?:` and `&&`. */ +function branchingOf( + ts: typeof tsserver, + node: tsserver.Node, +): {condition: tsserver.Node; branches: tsserver.Node[]} | undefined { + if (ts.isIfStatement(node)) { + const branches = node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + return {condition: node.expression, branches}; + } + if (ts.isConditionalExpression(node)) return {condition: node.condition, branches: [node.whenTrue, node.whenFalse]}; + const isAnd = ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isAnd ? {condition: node.left, branches: [node.right]} : undefined; +} + +/** + * The branches of `node` that only run for some variants of the value: both + * branches of an `if`/`?:` that tests it for a member first (an early + * `return` making the rest of the block the `else`), and the right side of + * `'m' in x && …`. The test tells the code which kind of value it holds, so + * a member either branch uses belongs to that kind alone + * (`if ('searchPhraseSuggestions' in s) … else s.hasNext()`). + */ +function variantBranches(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): readonly tsserver.Node[] { + const {ts} = ctx; + if (ts.isBlock(node) || ts.isSourceFile(node)) return statementsAfterPresenceExit(ctx, target, node.statements); + const branching = branchingOf(ts, node); + return branching && testsPresence(ctx, target, branching.condition) ? branching.branches : []; +} + +/** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ +function recordMember( + profile: Pick, + name: string, + optional: boolean, +): void { + profile.memberNames.add(name); + if (optional) profile.optionalMemberNames.add(name); +} + +function pushDefined(list: T[], value: T | undefined): void { + if (value !== undefined) list.push(value); +} + +/** One reference to the profiled value, and whether only some variants of the value reach it (see {@link variantBranches}). */ +interface ReferenceUse { + readonly ctx: InferenceContext; + readonly target: ProfileTarget; + readonly reference: tsserver.Expression; + readonly inVariantBranch: boolean; +} + +/** The binary expression `reference` is the left operand of, when its operator is `operator`. */ +function leftOperandOf( + ctx: InferenceContext, + reference: tsserver.Expression, + operator: tsserver.SyntaxKind, +): tsserver.BinaryExpression | undefined { + const parent = reference.parent; + const matches = ctx.ts.isBinaryExpression(parent) && parent.left === reference; + return matches && parent.operatorToken.kind === operator ? parent : undefined; +} + +/** The member name a `x.m` / `x['m']` access reads, except the one the cursor is still typing. */ +function accessedMemberName( + ctx: InferenceContext, + access: tsserver.PropertyAccessExpression | tsserver.ElementAccessExpression, +): string | undefined { + if (!ctx.ts.isPropertyAccessExpression(access)) return literalText(ctx, access.argumentExpression); + return isTriggerAccess(ctx, access) ? undefined : access.name.text; +} + +// Each recorder recognizes one role a reference can play and records it, +// returning true when the reference played that role. + +/** `'m' in x`, `x.hasOwnProperty('m')`, ...: a member only some variants of the value have. */ +function recordPresenceTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const member = presenceTestedMember(ctx, reference); + if (member !== undefined) recordMember(profile, member, true); + return member !== undefined; +} + +// Array methods whose arguments become elements of the receiver. +const ELEMENT_ADDING_METHODS: ReadonlySet = new Set(['push', 'unshift']); + +/** The value `access = value` assigns, if `access` is the target of a plain assignment. */ +function valueAssignedTo(ctx: InferenceContext, access: tsserver.Expression): tsserver.Expression | undefined { + const {ts} = ctx; + const assignment = access.parent; + const isAssignment = + ts.isBinaryExpression(assignment) && + assignment.left === access && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} + +/** The values `x.push(a, b)` / `x.unshift(a)` add as elements, when `access` is such a call's callee. */ +function addedElements( + ctx: InferenceContext, + access: tsserver.Expression, + member: string | undefined, +): tsserver.Expression[] | undefined { + const {ts} = ctx; + const call = access.parent; + if (member === undefined || !ELEMENT_ADDING_METHODS.has(member)) return undefined; + if (!ts.isCallExpression(call) || call.expression !== access) return undefined; + return call.arguments.filter((argument) => !ts.isSpreadElement(argument)); +} + +/** What an access writes into the value: elements (`x.push(v)`, `x[i] = v`) or a member (`x.m = v`). */ +function recordWrite( + ctx: InferenceContext, + access: tsserver.PropertyAccessExpression | tsserver.ElementAccessExpression, + member: string | undefined, + profile: ProfileBuilder, +): void { + const added = addedElements(ctx, access, member); + const value = added ? undefined : valueAssignedTo(ctx, access); + if (added) profile.pushedValues.push(...added); + else if (value && member !== undefined) { + profile.memberValues.set(member, [...(profile.memberValues.get(member) ?? []), value]); + } else if (value && ctx.ts.isElementAccessExpression(access)) profile.pushedValues.push(value); +} + +/** `x.m` / `x['m']`: a member the value is accessed by, and anything the access writes into it. */ +function recordMemberAccess({ctx, reference, inVariantBranch}: ReferenceUse, profile: ProfileBuilder): boolean { + const {ts} = ctx; + const access = reference.parent; + const isAccess = ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access); + if (!isAccess || access.expression !== reference) return false; + const member = accessedMemberName(ctx, access); + if (member !== undefined) recordMember(profile, member, inVariantBranch); + if (member !== undefined && !isTestedOrAlternative(access, ts)) profile.reliedOnMemberNames.add(member); + recordWrite(ctx, access, member, profile); + return true; +} + +/** `typeof x === 'string'`: a primitive the value is tested against. */ +function recordTypeofTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const typeOf = reference.parent; + if (!ctx.ts.isTypeOfExpression(typeOf)) return false; + pushDefined(profile.guardTypes, primitiveOfTypeofCheck(ctx, typeOf)); + return true; +} + +/** `x instanceof C`: a class the value is tested against. */ +function recordInstanceofTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const test = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.InstanceOfKeyword); + if (test) pushDefined(profile.guardTypes, instanceTypeOf(ctx, test.right)); + return test !== undefined; +} + +/** `x = value`: a value the variable is reassigned. */ +function recordAssignment({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const assignment = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.EqualsToken); + if (assignment) profile.assignedValues.push(assignment.right); + return assignment !== undefined; +} + +/** + * `helper(x)`, `new Model(x)`, `Base.call(this, x)` with an undocumented + * project function: the value is used however that function uses its + * parameter. Never the only role a reference plays — the contextual use is + * recorded too. + */ +function recordForwarding({ctx, reference, inVariantBranch}: ReferenceUse, profile: ProfileBuilder): boolean { + const parameter = receivingParameter(ctx, reference); + if (parameter && !hasExplicitParameterType(parameter, ctx.ts)) { + profile.forwardedTo.push({parameter, optional: inVariantBranch}); + } + return false; +} + +/** + * Any other use: the type the code uses the value as. A reference the + * checker narrowed (inside `if (typeof x === 'string')`) is used as what that + * one branch holds, which says nothing about every value. + */ +function recordContextualUse({ctx, target, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + if (ctx.checker.getTypeAtLocation(reference) === target.declaredType) { + pushDefined(profile.contextualTypes, usedAsType(ctx, reference)); + } + return true; +} + +const REFERENCE_RECORDERS = [ + recordPresenceTest, + recordMemberAccess, + recordTypeofTest, + recordInstanceofTest, + recordAssignment, + recordForwarding, + recordContextualUse, +]; + +/** Records what one reference to the profiled value says about it: the first role it plays wins. */ +function recordReference(use: ReferenceUse, profile: ProfileBuilder): void { + REFERENCE_RECORDERS.some((record) => record(use, profile)); +} + +function collectProfile(ctx: InferenceContext, target: ProfileTarget, scope: tsserver.Node): UsageProfile { + const profile = emptyProfile(); + const visit = (node: tsserver.Node, inVariantBranch: boolean): void => { + if (!target.mayReference(node)) return; + if (isReferenceTo(ctx, target, node)) recordReference({ctx, target, reference: node, inVariantBranch}, profile); + const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); + ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); + }; + visit(scope, false); + for (const name of profile.memberNames) { + if (!profile.reliedOnMemberNames.has(name)) profile.optionalMemberNames.add(name); + } + return profile; +} + +/** Walks up from `node` to the body of the nearest enclosing function, if any. */ +function enclosingFunctionBody(ctx: InferenceContext, node: tsserver.Node): tsserver.Node | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ctx.ts.isFunctionLike(current)) return (current as tsserver.FunctionLikeDeclaration).body; + } + return undefined; +} + +/** + * Where a value's uses can be: a parameter's function body (nested closures + * included), a variable's enclosing function or file, or — once a parameter + * is stored on `this` — the whole file its prototype methods read it in. + */ +function profileScope( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, + storedIn: ReadonlySet, +): tsserver.Node | undefined { + if (storedIn.size > 0) return declaration.getSourceFile(); + return ctx.ts.isParameter(declaration) + ? (declaration.parent as tsserver.FunctionLikeDeclaration).body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); +} + +/** + * What the value's own scope says about it, gathered over its + * {@link profileScope}; a parameter stored on `this` is also read through + * that member (see ./this-properties). Destructured declarations have no + * single value to profile and get an empty profile. Memoized per request. + */ +function ownProfileOf( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, +): UsageProfile { + const {ts, checker} = ctx; + const declarationName = declaration.name; + if (!ts.isIdentifier(declarationName)) return EMPTY_PROFILE; + const symbol = checker.getSymbolAtLocation(declarationName); + const cached = symbol && ctx.profiles.get(symbol); + if (!symbol || cached) return cached ?? EMPTY_PROFILE; + const storedIn = ts.isParameter(declaration) ? thisMembersStoring(ctx, declaration) : new Set(); + const scope = profileScope(ctx, declaration, storedIn); + if (!scope) return EMPTY_PROFILE; + const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const mayReference = spellingFilter(scope, [declarationName.text, ...[...storedIn].map((member) => member.name)]); + const profile = collectProfile(ctx, {symbol, declarationName, declaredType, storedIn, mayReference}, scope); + ctx.profiles.set(symbol, profile); + return profile; +} + +/** + * The own profiles of the parameters `profile`'s value is passed on to, + * breadth first, up to {@link MAX_USAGE_FORWARDING_HOPS} functions away; a + * profile reached through a variant branch anywhere along the way is + * optional. + */ +function forwardedProfiles( + ctx: InferenceContext, + declaration: tsserver.Declaration, + profile: UsageProfile, +): {profile: UsageProfile; optional: boolean}[] { + const reached: {profile: UsageProfile; optional: boolean}[] = []; + const seen = new Set([declaration]); + let frontier = profile.forwardedTo; + for (let hop = 0; hop < MAX_USAGE_FORWARDING_HOPS && frontier.length > 0; hop++) { + const unseen = frontier.filter(({parameter}) => !seen.has(parameter) && seen.add(parameter)); + const profiles = unseen.map(({parameter, optional}) => ({profile: ownProfileOf(ctx, parameter), optional})); + reached.push(...profiles); + frontier = profiles.flatMap(({profile: next, optional}) => + next.forwardedTo.map((use) => ({parameter: use.parameter, optional: optional || use.optional})), + ); + } + return reached; +} + +/** + * The usage profile of a parameter or a variable: what its own scope says + * about it (see {@link ownProfileOf}), plus the members and contextual types + * of the parameters it is passed on to unchanged — a wrapper's `x` must + * support whatever the `new Model(x)` it forwards to does with it. + */ +export function usageProfileOf( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, +): UsageProfile { + const own = ownProfileOf(ctx, declaration); + if (own.forwardedTo.length === 0) return own; + const members = {memberNames: new Set(own.memberNames), optionalMemberNames: new Set(own.optionalMemberNames)}; + const contextualTypes = [...own.contextualTypes]; + for (const {profile, optional} of forwardedProfiles(ctx, declaration, own)) { + for (const name of profile.memberNames) { + recordMember(members, name, optional || profile.optionalMemberNames.has(name)); + } + contextualTypes.push(...profile.contextualTypes); + } + return {...own, ...members, contextualTypes}; +} diff --git a/packages/b2c-script-types/src/inference/value-flow.ts b/packages/b2c-script-types/src/inference/value-flow.ts new file mode 100644 index 000000000..82730f8ca --- /dev/null +++ b/packages/b2c-script-types/src/inference/value-flow.ts @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Where a function value travels once it has a name. A reference search on a +// function's name finds every place the function *value* sits: most are calls, +// but SFCC code also hands functions and constructor "classes" around — +// `module.exports = Model` (consumers reach it through `require()`), a +// factory that picks a model (`return require('…/boolean')`, then +// `var Model = getModel(definition); new Model(...)`), and a model passed on +// as an argument (`createRefinement(search, definition, Model)`), or to a +// callee outside the project that calls it (`server.get('Show', +// cache.applyDefaultCache)`). valueTarget() says where one such position +// leads: to a call that invokes the value, to a call handing it to such a +// callee, or to the next name that holds it. ./call-sites runs the reference +// searches and follows those names, a bounded number of hops. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {resolveCalleeDeclaration} from './signatures'; + +/** + * One invocation of a function and the arguments its parameters receive, by + * parameter index. Besides an ordinary call (`helper(x)`) this covers a + * constructor invocation (`new Helper(x)`, SFRA's "class" models; a bare + * `new Helper` passes nothing), `helper.call(thisArg, x)` and + * `helper.apply(thisArg, [x])` — the shapes SFRA model inheritance uses + * (`BaseAttributeValue.call(this, productSearch, ...)`). + */ +export interface CallSite { + readonly node: tsserver.CallExpression | tsserver.NewExpression; + readonly args: readonly tsserver.Expression[]; +} + +/** How a searched name holds the function: as the function itself, or as a factory whose calls return it. */ +export type ValueRole = 'factory' | 'value'; + +/** A name whose references lead to a function's calls, and how it holds the function. */ +export interface ValueHolder { + readonly name: tsserver.Identifier; + readonly role: ValueRole; +} + +/** + * A call handing the function as an argument to a callee that is not a + * project function receiving it in a parameter: a Script API or SFRA + * declaration (`server.get('Show', cache.applyDefaultCache)`, + * `lineItems.forEach(addLineItem)`). The callee calls it, with what its + * declaration says the callback at `argIndex` receives. + */ +export interface Handoff { + readonly call: tsserver.CallExpression; + readonly argIndex: number; +} + +/** Where a function is called: its call sites, and the calls handing it to a declared callee that calls it. */ +export interface CallSites { + readonly calls: CallSite[]; + readonly handoffs: Handoff[]; +} + +/** Where a reference leads: a call site, a handoff, or a further name whose references lead on. */ +export type ReferenceTarget = + | {readonly kind: 'call'; readonly call: CallSite} + | {readonly kind: 'handoff'; readonly handoff: Handoff} + | ({readonly kind: 'name'} & ValueHolder); + +/** A function-like declaration, or the class a constructor is invoked through. */ +type Callable = tsserver.SignatureDeclaration | tsserver.ClassLikeDeclaration; + +/** The name a function declaration, method or class declares itself by. */ +function ownName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; +} + +/** The name `left = value` binds: `x`, `obj.x`, `exports.x`, or `exports` for `module.exports`. */ +function assignedName(ts: typeof tsserver, left: tsserver.Expression): tsserver.Identifier | undefined { + if (ts.isPropertyAccessExpression(left)) return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : undefined; +} + +/** The variable or property `value` initializes: `var helper = …`, `{helper: …}`, `{helper}`, `const {helper} = …`. */ +function declaredName(ts: typeof tsserver, value: tsserver.Node): tsserver.Identifier | undefined { + const parent = value.parent; + if (ts.isShorthandPropertyAssignment(parent)) return parent.name; + const initializes = + ts.isBindingElement(parent) || + ((ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) && parent.initializer === value); + return initializes && ts.isIdentifier(parent.name) ? parent.name : undefined; +} + +/** + * The name `value` is bound to: the variable or property it initializes + * (see {@link declaredName}), or the target it is assigned to + * (`exports.helper = …`). For `module.exports = …` that is the `exports` + * name, whose references are the `require()` calls of the module. + */ +function bindingNameOf(ts: typeof tsserver, value: tsserver.Node): tsserver.Identifier | undefined { + const parent = value.parent; + const isAssignment = + ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === value; + return isAssignment ? assignedName(ts, parent.left) : declaredName(ts, value); +} + +/** + * Identifies the name to run findReferences on for a function-like + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. + */ +export function getReferenceNameNode( + fn: tsserver.SignatureDeclaration, + ts: typeof tsserver, +): tsserver.Identifier | undefined { + const callable: Callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + return ownName(callable, ts) ?? (ts.isExpression(callable) ? bindingNameOf(ts, callable) : undefined); +} + +/** The function enclosing `node`, if any. */ +export function enclosingFunction(node: tsserver.Node, ts: typeof tsserver): tsserver.SignatureDeclaration | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) return current; + } + return undefined; +} + +/** + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tsserver.Expression[] { + const list = call.arguments[1]; + if (!list) return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} + +/** What a reference stands for as a value: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function accessOf(node: tsserver.Node, ts: typeof tsserver): tsserver.Node { + const {parent} = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} + +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? {node: call, args: call.arguments ?? []} : undefined; +} + +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) return undefined; + if (access.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; + return access.name.text === 'apply' ? {node: call, args: applyArguments(call, ts)} : undefined; +} + +/** + * A `require('specifier')` call, identified structurally (only public + * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part + * of TypeScript's public API surface, so isn't safe to depend on here). + */ +function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node is tsserver.CallExpression { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length > 0 && + ts.isStringLiteralLike(node.arguments[0]) + ); +} + +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} + +/** True when `parent` can evaluate to `child` unchanged: `(x)`, `c ? x : y`, `a || x`, `a ?? x`, `a && x`. */ +function passesThrough(ts: typeof tsserver, parent: tsserver.Node, child: tsserver.Node): boolean { + if (ts.isParenthesizedExpression(parent)) return true; + if (ts.isConditionalExpression(parent)) return parent.condition !== child; + if (!ts.isBinaryExpression(parent)) return false; + const operator = parent.operatorToken.kind; + const {SyntaxKind} = ts; + if (operator === SyntaxKind.AmpersandAmpersandToken) return parent.right === child; + return operator === SyntaxKind.BarBarToken || operator === SyntaxKind.QuestionQuestionToken; +} + +/** The outermost expression that still evaluates to `node` (`getModel(a) || Fallback` for `Fallback`). */ +function outermostValue(ts: typeof tsserver, node: tsserver.Node): tsserver.Node { + let value = node; + while (value.parent && passesThrough(ts, value.parent, value)) value = value.parent; + return value; +} + +/** + * The project function `call` passes its arguments to, and the argument its + * first parameter receives: `fn(a)`, `new Fn(a)`, and `Fn.call(this, a)`, the + * SFRA inheritance idiom. Only a function whose body is in the project (not + * one a declaration file describes) does anything worth following. + */ +function argumentReceiver( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, +): {readonly fn: tsserver.FunctionLikeDeclaration; readonly firstArgument: number} | undefined { + const {ts, checker} = ctx; + const callee = call.expression; + const borrowed = ts.isCallExpression(call) && ts.isPropertyAccessExpression(callee) && callee.name.text === 'call'; + const fn = borrowed + ? checker.getTypeAtLocation(callee.expression).getCallSignatures()[0]?.getDeclaration() + : resolveCalleeDeclaration(ctx, call); + const hasBody = fn !== undefined && (fn as tsserver.FunctionLikeDeclaration).body !== undefined; + return hasBody ? {fn: fn as tsserver.FunctionLikeDeclaration, firstArgument: borrowed ? 1 : 0} : undefined; +} + +/** + * The parameter of a project function that receives `value` as an argument: + * `Model` of `createRefinement(search, definition, Model)`, or + * `refinementValue` of `new BooleanAttributeValue(search, definition, + * refinementValue)`. + */ +export function receivingParameter( + ctx: InferenceContext, + value: tsserver.Node, +): tsserver.ParameterDeclaration | undefined { + const {ts} = ctx; + const call = value.parent; + if (!ts.isCallExpression(call) && !ts.isNewExpression(call)) return undefined; + const index = call.arguments?.indexOf(value as tsserver.Expression) ?? -1; + const receiver = index >= 0 ? argumentReceiver(ctx, call) : undefined; + const parameter = receiver?.fn.parameters[index - receiver.firstArgument]; + return parameter && !parameter.dotDotDotToken && ts.isIdentifier(parameter.name) ? parameter : undefined; +} + +/** The function `value` is the result of: `return value;`, or an arrow function's expression body. */ +function returningFunction(ts: typeof tsserver, value: tsserver.Node): tsserver.SignatureDeclaration | undefined { + const parent = value.parent; + if (ts.isArrowFunction(parent)) return parent.body === value ? parent : undefined; + return ts.isReturnStatement(parent) ? enclosingFunction(parent, ts) : undefined; +} + +/** The next name holding `value` in `role`: a binding, an assignment target, or the parameter it is passed to. */ +function heldBy(ctx: InferenceContext, value: tsserver.Node, role: ValueRole): ReferenceTarget | undefined { + const name = + bindingNameOf(ctx.ts, value) ?? (receivingParameter(ctx, value)?.name as tsserver.Identifier | undefined); + return name && {kind: 'name', name, role}; +} + +/** A function returning `value` is a factory of it: its calls evaluate to `value`. */ +function returnedBy(ctx: InferenceContext, value: tsserver.Node): ReferenceTarget | undefined { + const factory = returningFunction(ctx.ts, value); + const name = factory && getReferenceNameNode(factory, ctx.ts); + return name && {kind: 'name', name, role: 'factory'}; +} + +/** The call `value` is an argument of, as a {@link Handoff}: what {@link receivingParameter} leaves over. */ +function handedOff(ctx: InferenceContext, value: tsserver.Node): ReferenceTarget | undefined { + const call = value.parent; + if (!ctx.ts.isCallExpression(call)) return undefined; + const argIndex = call.arguments.indexOf(value as tsserver.Expression); + return argIndex >= 0 ? {kind: 'handoff', handoff: {call, argIndex}} : undefined; +} + +/** + * Where a search for the calls of `fn` starts: the name it is declared or + * bound by (see {@link getReferenceNameNode}), or for an anonymous function a + * factory returns (`return function (req, res, next) {...}`), the factory, + * whose calls evaluate to it. + */ +export function holderOf(ctx: InferenceContext, fn: tsserver.SignatureDeclaration): ValueHolder | undefined { + const {ts} = ctx; + const name = getReferenceNameNode(fn, ts); + if (name) return {name, role: 'value'}; + const anonymous = ts.isFunctionExpression(fn) || ts.isArrowFunction(fn); + const factory = anonymous ? returnedBy(ctx, outermostValue(ts, fn)) : undefined; + return factory?.kind === 'name' ? factory : undefined; +} + +/** + * Where one reference to the function leads. In the `value` role the + * reference is the function itself: a call invoking it + * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, + * `require('./helper')(x)`) is a call site; a binding, an assignment, an + * argument a project function receives or a `return` leads on to the next + * name holding it; any other argument is a handoff. In the + * `factory` role the reference names a function returning it, so a call of + * that reference evaluates to the function and is followed as a value in + * turn. + */ +export function valueTarget( + ctx: InferenceContext, + reference: tsserver.Node, + role: ValueRole, +): ReferenceTarget | undefined { + const {ts} = ctx; + const value = outermostValue(ts, requireCallOf(reference, ts) ?? accessOf(reference, ts)); + if (role === 'factory') { + const call = value.parent; + const invoked = ts.isCallExpression(call) && call.expression === value; + return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); + } + const call = directCall(value, ts) ?? borrowedCall(value, ts); + if (call) return {kind: 'call', call}; + return heldBy(ctx, value, 'value') ?? returnedBy(ctx, value) ?? handedOff(ctx, value); +} diff --git a/packages/b2c-script-types/src/resolver/cartridge-discovery.ts b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts new file mode 100644 index 000000000..badb1abfc --- /dev/null +++ b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Standalone helpers for finding and ordering cartridges without any of the +// plugin's mutable state. They take the `ts` namespace (and, where needed, a +// `fileExists` probe) as plain arguments and return data, so they're easy to +// read and test in isolation. index.ts wires them into the plugin's +// auto-discovery step. + +import {statSync} from 'node:fs'; +import path from 'node:path'; + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {BASE_CARTRIDGE_RANK, DISCOVERY_IGNORE, DISCOVERY_MAX_DEPTH, MAX_JSON_BYTES} from './constants'; +import type {ConfiguredCartridge} from './constants'; + +/** + * Parses a workspace JSON file (dw.json, a cartridge's package.json) with a + * hard size ceiling (see MAX_JSON_BYTES). Never throws: a missing, oversized, + * or malformed file yields `undefined`, and callers treat that as "absent" + * rather than failing the whole request. + * + * The size check always uses `fs.statSync` rather than `ts.sys.getFileSize` + * (which is optional per the TS API and, when absent, would otherwise force + * `ts.sys.readFile` to load the whole file before we can measure it) so the + * DoS guard holds on every host. + */ +export function readJsonFile(ts: typeof tsserver, filePath: string): unknown { + try { + if (statSync(filePath).size > MAX_JSON_BYTES) return undefined; + const content = ts.sys.readFile(filePath); + if (content === undefined || content.length > MAX_JSON_BYTES) return undefined; + return JSON.parse(content); + } catch { + return undefined; + } +} + +/** + * Recursively walks projectRoot for `.project` markers. Stops descending into + * a cartridge once found (cartridges don't nest). Depth-limited to keep + * tsserver startup snappy on huge monorepos. + */ +export function discoverCartridgesOnDisk( + ts: typeof tsserver, + projectRoot: string, + fileExists: (p: string) => boolean, +): ConfiguredCartridge[] { + const found: ConfiguredCartridge[] = []; + const stack: {dir: string; depth: number}[] = [{dir: projectRoot, depth: 0}]; + while (stack.length > 0) { + const {dir, depth} = stack.pop()!; + if (fileExists(path.join(dir, '.project'))) { + found.push({name: path.basename(dir), src: dir}); + continue; + } + if (depth >= DISCOVERY_MAX_DEPTH) continue; + let subdirs: readonly string[] = []; + try { + subdirs = ts.sys.getDirectories(dir); + } catch { + subdirs = []; + } + for (const sub of subdirs) { + if (DISCOVERY_IGNORE.has(sub)) continue; + stack.push({dir: path.join(dir, sub), depth: depth + 1}); + } + } + // Stable ordering for deterministic auto-discovery output. + found.sort((a, b) => a.src.localeCompare(b.src)); + return found; +} + +/** + * Reads the top-level dw.json `cartridges` field (string with comma/colon + * separators OR array of names) for an explicit cartridge-path order. + * Mirrors what the b2c CLI's resolved config exposes; we don't try to honor + * SFCC_CARTRIDGES / .env / plugins here — hosts that need that complexity + * should push the resolved list in via configurePlugin(). + */ +export function readDwJsonCartridges( + ts: typeof tsserver, + projectRoot: string, + fileExists: (p: string) => boolean, +): string[] | undefined { + const dwJsonPath = path.join(projectRoot, 'dw.json'); + if (!fileExists(dwJsonPath)) return undefined; + const parsed = readJsonFile(ts, dwJsonPath); + const value = (parsed as {cartridges?: unknown})?.cartridges; + if (typeof value === 'string') { + return value + .split(/[,:]/) + .map((s) => s.trim()) + .filter(Boolean); + } + if (Array.isArray(value)) { + return value.filter((s): s is string => typeof s === 'string' && s.length > 0); + } + return undefined; +} + +/** The configured cartridges that exist, in configured order, then every other discovered one in discovery order. */ +function namedFirst(discovered: ConfiguredCartridge[], configured: string[]): ConfiguredCartridge[] { + const byName = new Map(discovered.map((c) => [c.name, c])); + const named = [...new Set(configured)].flatMap((name) => byName.get(name) ?? []); + const namedNames = new Set(named.map((c) => c.name)); + return [...named, ...discovered.filter((c) => !namedNames.has(c.name))]; +} + +/** Discovery order with the known base cartridges moved last (the sort is stable). */ +function baseCartridgesLast(discovered: ConfiguredCartridge[]): ConfiguredCartridge[] { + // hasOwn guard so a cartridge directory literally named `__proto__` or + // `constructor` can't read an inherited Object.prototype value here (which + // would make the rank a non-number and corrupt the sort comparator). + const rankOf = (name: string): number => + Object.prototype.hasOwnProperty.call(BASE_CARTRIDGE_RANK, name) ? BASE_CARTRIDGE_RANK[name] : 0; + return [...discovered].sort((a, b) => rankOf(a.name) - rankOf(b.name)); +} + +/** + * Applies cartridge ordering: if `configured` is set, named-first then any + * remaining discovered cartridges in their original order; otherwise + * discovery order with the known base cartridges sorted last. + */ +export function orderCartridges( + discovered: ConfiguredCartridge[], + configured: string[] | undefined, +): ConfiguredCartridge[] { + return configured && configured.length > 0 ? namedFirst(discovered, configured) : baseCartridgesLast(discovered); +} + +/** + * Finds the byte ranges of each `declare module 'X' { ... }` block in a .d.ts + * file, so go-to-definition results landing inside the bundled SFRA + * server.d.ts can be mapped back to the module they belong to. Linear scan + * with brace-matching — the regex only matches the block opener, never the + * whole (possibly huge) body. + */ +export function parseDeclareModuleRanges(content: string): Array<{start: number; end: number; module: string}> { + const ranges: Array<{start: number; end: number; module: string}> = []; + const re = /declare module ['"]([^'"]+)['"]\s*\{/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const start = m.index; + // Walk forward from the opening brace to find the matching close. + let depth = 1; + let i = m.index + m[0].length; + while (i < content.length && depth > 0) { + const ch = content[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + ranges.push({start, end: i, module: m[1]}); + } + return ranges; +} diff --git a/packages/b2c-script-types/src/resolver/constants.ts b/packages/b2c-script-types/src/resolver/constants.ts new file mode 100644 index 000000000..3b777445b --- /dev/null +++ b/packages/b2c-script-types/src/resolver/constants.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Shared types and plain-data constants for the plugin's cartridge resolution +// and discovery. Kept separate from index.ts so the plugin factory there reads +// as "what the plugin does", not "what its lookup tables are". Path constants +// that depend on the plugin's on-disk location (the bundled types dir) stay in +// index.ts, where `__dirname` points at the right place. + +export const PLUGIN_NAME = '@salesforce/b2c-script-types'; + +export interface ConfiguredCartridge { + name: string; + src: string; +} + +export interface PluginConfig { + /** @deprecated use cartridges; kept for backward compatibility */ + cartridgeRoots?: string[]; + cartridges?: ConfiguredCartridge[]; + enabled?: boolean; + /** + * Disable filesystem auto-discovery when no cartridges are pushed in. Defaults + * to false — i.e. auto-discovery runs unless the host explicitly opts out. + */ + autoDiscover?: boolean; + /** + * Opt-in, heuristic: when a parameter or return value has been widened to + * `any` (typically an undocumented helper function with no JSDoc), infer a + * better type from how it's actually called/used elsewhere in the project + * and surface it in hover text and member completions. Off by default. + */ + inferUsage?: boolean; +} + +export interface NormalizedCartridge { + name: string; + /** + * Forward-slash path with trailing '/', lowercased on case-insensitive + * filesystems — use ONLY for prefix comparisons (ownerCartridge, + * isCartridgeFile) against another `normalize()`d path. Never build a path + * to hand back to TypeScript from this: on a case-insensitive filesystem + * the lowercased form usually still opens the right file by luck, but it's + * not the file's real name, and on a case-sensitive filesystem a + * mixed-case cartridge root would make every resolution through it fail. + * Use `rawRoot` for that instead. + */ + root: string; + /** Forward-slash path with trailing '/', original case preserved — use to build any path returned to callers. */ + rawRoot: string; +} + +// Bare-name requires that the SFRA server.d.ts ambient declaration covers. +// We deliberately do NOT redirect these to modules/.js, so TS uses the +// ambient declaration's types instead of the inferred .js types (which can't +// see the dynamic `server.middleware = ...` assignments in modules/server.js). +export const SFRA_AMBIENT_MODULES = new Set([ + 'server', + 'server/server', + 'server/middleware', + 'server/render', + 'server/route', + 'server/request', + 'server/response', + 'server/queryString', + 'server/forms', + 'server/forms/forms', +]); + +// Candidate suffixes appended when resolving a SFCC-style relative require to +// a cartridge file. SFRA convention is to omit the .js extension, so .js wins +// first; .json captures the occasional resource bundle import; .ds covers +// legacy pipeline-era scripts, which the platform also resolves after .js. +export const CANDIDATE_EXTENSIONS = ['.js', '.json', '.ds', '/index.js', '/index.ds']; + +// Cartridges that conventionally sit at the bottom of the cartridge path when +// the user hasn't told us otherwise (no `cartridges` in dw.json/SFCC_CARTRIDGES). +// Higher rank = lower in the cartridge path. SFRA's runtime path ends with +// `app_storefront_base:modules`, so `modules` sorts strictly last. +// Mirrors BASE_CARTRIDGE_RANK in packages/b2c-vs-extension/src/cartridges/cartridge-service.ts. +export const BASE_CARTRIDGE_RANK: Record = { + app_storefront_base: 1, + modules: 2, +}; + +// Directories skipped during recursive .project discovery. Mirrors the ignore +// list in @salesforce/b2c-tooling-sdk's findCartridges() so plain LSP usage +// matches CLI/extension discovery. +export const DISCOVERY_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.cache', 'tmp', 'temp']); + +export const DISCOVERY_MAX_DEPTH = 8; + +// Hard size ceiling for workspace JSON files (dw.json, a cartridge's +// package.json) parsed on tsserver's thread. Both are attacker-controlled in a +// cloned repo, so a multi-hundred-megabyte file would be a denial-of-service +// vector (memory + parse time) — a real file is a few KB, so anything past +// 1 MiB is refused outright rather than best-effort parsed. +export const MAX_JSON_BYTES = 1024 * 1024; diff --git a/packages/b2c-script-types/src/resolver/hook-registry.ts b/packages/b2c-script-types/src/resolver/hook-registry.ts new file mode 100644 index 000000000..47d82786f --- /dev/null +++ b/packages/b2c-script-types/src/resolver/hook-registry.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Hook registrations: which script implements which extension point. The +// platform reads the `hooks` entry of a cartridge's package.json, which names +// a hooks.json file listing `{name, script}` pairs; each script path is +// relative to that hooks.json and, like a require(), may leave out its +// extension. Usage inference reads the registrations to treat +// `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` as a call of +// the `calculate` export of every script registered for `dw.order.calculate`. +// +// Both files are project content: they are only ever parsed as JSON, and every +// path they name is contained to the cartridge that declares it. + +import path from 'node:path'; + +import type {NormalizedCartridge} from './constants'; + +export interface HookRegistration { + /** The extension point (`dw.order.calculate`, `app.payment.processor.basic_credit`). */ + readonly extensionPoint: string; + /** The script registered for it, resolved to an existing file. */ + readonly script: string; +} + +export interface HookRegistryDeps { + readFile(fileName: string): string | undefined; + fileExists(fileName: string): boolean; + isWithinRoot(candidate: string, root: string): boolean; +} + +// A registered script resolves like a require(): as written, then with the +// extensions the platform tries for a script. +const SCRIPT_EXTENSIONS = ['', '.js', '.ds']; + +function readJson(deps: HookRegistryDeps, fileName: string): unknown { + try { + const text = deps.readFile(fileName); + return text === undefined ? undefined : JSON.parse(text); + } catch { + return undefined; + } +} + +/** `value[key]` when `value` is an object and that field holds a non-empty string. */ +function stringField(value: unknown, key: string): string | undefined { + const field = value !== null && typeof value === 'object' ? (value as Record)[key] : undefined; + return typeof field === 'string' && field.length > 0 ? field : undefined; +} + +/** `relative` resolved against the directory `dir`, when that stays inside `cartridge`. */ +function containedPath( + deps: HookRegistryDeps, + cartridge: NormalizedCartridge, + dir: string, + relative: string, +): string | undefined { + const candidate = path.posix.join(dir, relative.replace(/\\/g, '/')); + return deps.isWithinRoot(candidate, cartridge.root) ? candidate : undefined; +} + +function resolveScript( + deps: HookRegistryDeps, + cartridge: NormalizedCartridge, + dir: string, + script: string, +): string | undefined { + const base = containedPath(deps, cartridge, dir, script); + if (base === undefined) return undefined; + return SCRIPT_EXTENSIONS.map((ext) => base + ext).find((candidate) => deps.fileExists(candidate)); +} + +/** The hooks.json file a cartridge's package.json names, if any. */ +function hooksFileOf(deps: HookRegistryDeps, cartridge: NormalizedCartridge): string | undefined { + const hooksPath = stringField(readJson(deps, cartridge.rawRoot + 'package.json'), 'hooks'); + return hooksPath === undefined ? undefined : containedPath(deps, cartridge, cartridge.rawRoot, hooksPath); +} + +/** The registrations one cartridge's package.json and hooks.json declare. */ +function cartridgeRegistrations(deps: HookRegistryDeps, cartridge: NormalizedCartridge): HookRegistration[] { + const hooksFile = hooksFileOf(deps, cartridge); + const hooks = hooksFile === undefined ? undefined : readJson(deps, hooksFile); + const entries = hooks !== null && typeof hooks === 'object' ? (hooks as {hooks?: unknown}).hooks : undefined; + if (hooksFile === undefined || !Array.isArray(entries)) return []; + const hooksDir = path.posix.dirname(hooksFile); + return entries.flatMap((entry: unknown) => { + const extensionPoint = stringField(entry, 'name'); + const script = stringField(entry, 'script'); + const resolved = extensionPoint && script ? resolveScript(deps, cartridge, hooksDir, script) : undefined; + return extensionPoint && resolved ? [{extensionPoint, script: resolved}] : []; + }); +} + +/** Every hook registration the configured cartridges declare. Unreadable or malformed files declare none. */ +export function readHookRegistrations( + cartridges: readonly NormalizedCartridge[], + deps: HookRegistryDeps, +): HookRegistration[] { + return cartridges.flatMap((cartridge) => cartridgeRegistrations(deps, cartridge)); +} diff --git a/packages/b2c-script-types/src/resolver/module-resolution.ts b/packages/b2c-script-types/src/resolver/module-resolution.ts new file mode 100644 index 000000000..1ca3571aa --- /dev/null +++ b/packages/b2c-script-types/src/resolver/module-resolution.ts @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Security-critical path containment and cartridge-relative module resolution, +// extracted out of index.ts so these can be read and tested against plain +// data (a cartridge list, a fileExists probe) without a full LanguageService +// fixture. Every resolver here is a pure function of its arguments; index.ts +// wires them to the live plugin state (cartridges, ts.sys, the LS host). + +import path from 'node:path'; + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {readJsonFile} from './cartridge-discovery'; +import {CANDIDATE_EXTENSIONS, SFRA_AMBIENT_MODULES} from './constants'; +import type {NormalizedCartridge} from './constants'; + +export interface PathContainment { + normalize(p: string): string; + isWithinRoot(candidate: string, rootDir: string): boolean; +} + +/** + * Builds the two path-safety primitives every resolver below is checked + * against: `normalize` (forward slashes, case-folded on case-insensitive + * filesystems) and `isWithinRoot` (true when `candidate` resolves to a + * location at or beneath `rootDir`). This is the trust boundary for every + * resolver in this file: import specifiers, cartridge names, and a + * cartridge's package.json `main` are all attacker-controlled in a cloned + * repository, so a resolved path that escapes its intended root (via `..`, + * an absolute/UNC/drive form, or a symlink) must be rejected rather than + * read into the TS program. + * + * `isWithinRoot` resolves symlinks once, at check time, via + * `ts.sys.realpath` — it does not re-check immediately before the caller's + * subsequent `fileExists`/`readFile`. A symlink swapped in between those two + * calls (e.g. by a build script running concurrently in the cloned repo) + * could in principle bypass containment. The accepted threat model here is + * malicious *content* in a cloned repository, not an active attacker + * racing the filesystem during a single hover/completion request, so this + * gap is left unaddressed; revisit if that threat model changes. + */ +export function createPathContainment(ts: typeof tsserver, caseSensitive: boolean): PathContainment { + const normalize = (p: string): string => { + const slashed = p.replace(/\\/g, '/'); + return caseSensitive ? slashed : slashed.toLowerCase(); + }; + + // Canonical, real form of a path for containment checks: resolve symlinks + // (ts.sys.realpath) so an in-repo symlink can't point a require() at a file + // outside its root, then collapse `.`/`..` and fold to the same slash/case + // convention cartridge roots use. Falls back to a purely lexical resolve + // when the path doesn't exist or realpath is unavailable, so a crafted + // non-existent candidate is still `..`-collapsed before the check. + const canonicalPath = (p: string): string => { + let real = p; + try { + if (ts.sys.realpath) real = ts.sys.realpath(p); + } catch { + // Non-existent path (or realpath failure) — fall back to lexical. + } + return normalize(path.resolve(real)); + }; + + const isWithinRoot = (candidate: string, rootDir: string): boolean => { + const root = canonicalPath(rootDir); + const resolved = canonicalPath(candidate); + return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); + }; + + return {normalize, isWithinRoot}; +} + +export function ownerCartridge( + cartridges: NormalizedCartridge[], + normalize: (p: string) => string, + containingFile: string, +): NormalizedCartridge | undefined { + const f = normalize(containingFile); + return cartridges.find((c) => f.startsWith(c.root)); +} + +function reorderForContainingFile( + cartridges: NormalizedCartridge[], + normalize: (p: string) => string, + containingFile: string, +): NormalizedCartridge[] { + const owner = ownerCartridge(cartridges, normalize, containingFile); + if (!owner) return cartridges; + return [owner, ...cartridges.filter((c) => c !== owner)]; +} + +export interface ModuleResolutionDeps { + normalize: (p: string) => string; + isWithinRoot: (candidate: string, rootDir: string) => boolean; + fileExists: (p: string) => boolean; +} + +type ResolvedModule = {resolved: string; source: string}; + +/** Which cartridges a cartridge-style specifier may resolve in (in order), and the path inside them. */ +interface CartridgeLookup { + order: NormalizedCartridge[]; + subpath: string; +} + +function cartridgeLookup( + cartridges: NormalizedCartridge[], + moduleName: string, + containingFile: string, + normalize: (p: string) => string, +): CartridgeLookup | undefined { + if (moduleName.startsWith('~/')) { + // ~ is the current cartridge — restrict to the cartridge that owns the + // calling file. If the containing file isn't inside any known cartridge, + // there is no current cartridge, so the require can't be resolved. + const owner = ownerCartridge(cartridges, normalize, containingFile); + return owner ? {order: [owner], subpath: moduleName.slice(2)} : undefined; + } + if (moduleName.startsWith('*/')) { + // * walks the cartridge path. Owner-first matches SFRA-style overrides + // (the requesting cartridge wins before falling through to others). + return {order: reorderForContainingFile(cartridges, normalize, containingFile), subpath: moduleName.slice(2)}; + } + // /cartridge/... — only treat as a cartridge require if the + // first segment matches a known cartridge name. Otherwise pass through so + // node_modules and other resolutions still work. + const slash = moduleName.indexOf('/'); + if (slash <= 0) return undefined; + const head = moduleName.slice(0, slash); + const known = cartridges.find((c) => c.name === head); + return known ? {order: [known], subpath: moduleName.slice(slash + 1)} : undefined; +} + +/** + * The first `/` that exists and stays inside the + * cartridge. `subpath` comes straight from the import specifier, so a `..` + * segment (or an absolute/symlinked target) can point outside the cartridge — + * every candidate is resolved and contained before it is accepted. + */ +function firstContainedCandidate( + cartridge: NormalizedCartridge, + subpath: string, + deps: Pick, +): ResolvedModule | undefined { + const baseAbs = cartridge.rawRoot + subpath; + for (const ext of CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, cartridge.root)) { + return {resolved: candidate, source: cartridge.name}; + } + } + return undefined; +} + +/** + * Resolves a SFCC cartridge-style require relative to the configured + * cartridge path. Returns the absolute path to the resolved JS file, or + * undefined if no cartridge contains the target. + * + * ~/cartridge/scripts/foo -> only the cartridge that owns containingFile + * * /cartridge/scripts/foo -> walks the cartridge path, owner-first + * bar/cartridge/scripts/foo -> only the cartridge named "bar" + */ +export function resolveCartridgeModule( + cartridges: NormalizedCartridge[], + moduleName: string, + containingFile: string, + deps: ModuleResolutionDeps, +): ResolvedModule | undefined { + const lookup = + cartridges.length > 0 ? cartridgeLookup(cartridges, moduleName, containingFile, deps.normalize) : undefined; + if (!lookup?.subpath) return undefined; + for (const cartridge of lookup.order) { + const found = firstContainedCandidate(cartridge, lookup.subpath, deps); + if (found) return found; + } + return undefined; +} + +const NON_MODULES_PREFIXES = ['.', '/', '~/', '*/', 'dw/']; + +/** True for a bare specifier (`server`, `server/middleware`) the `modules` cartridge may own. */ +function isModulesCartridgeSpecifier(moduleName: string): boolean { + if (NON_MODULES_PREFIXES.some((prefix) => moduleName.startsWith(prefix))) return false; + // Let the bundled SFRA ambient declarations win for these names. If we + // resolved them to the .js file here, TS would infer types from the JS + // (which misses dynamic property assignments in modules/server.js) and + // ignore the ambient `declare module 'server' { ... }` shape. + return !SFRA_AMBIENT_MODULES.has(moduleName); +} + +/** package.json `main` fallback for a `modules` directory without an index.js. */ +function packageMainCandidate( + ts: typeof tsserver, + modulesCart: NormalizedCartridge, + moduleName: string, + deps: Pick, +): ResolvedModule | undefined { + const pkgPath = modulesCart.rawRoot + moduleName + '/package.json'; + if (!deps.fileExists(pkgPath) || !deps.isWithinRoot(pkgPath, modulesCart.root)) return undefined; + const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; + if (typeof main !== 'string' || main.length === 0) return undefined; + const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + return deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root) + ? {resolved, source: modulesCart.name} + : undefined; +} + +/** + * Resolves a bare `require('server')`-style import against the SFRA `modules` + * cartridge. Unlike normal cartridges (which expose files under + * `cartridge/scripts/...`), the `modules` cartridge exposes its entire tree + * at the root, so `require('server')` -> `/server[.js|/index.js]` + * and `require('server/middleware')` -> `/server/middleware[.js]`. + * Falls through unless a cartridge literally named `modules` is in the list. + * `moduleName` may carry `..` after its first segment (it only can't *start* + * with `.`/`/`), so every candidate is contained against the modules root. + */ +export function resolveModulesCartridge( + ts: typeof tsserver, + cartridges: NormalizedCartridge[], + moduleName: string, + deps: Pick, +): ResolvedModule | undefined { + const modulesCart = isModulesCartridgeSpecifier(moduleName) + ? cartridges.find((c) => c.name === 'modules') + : undefined; + if (!modulesCart) return undefined; + return ( + firstContainedCandidate(modulesCart, moduleName, deps) ?? packageMainCandidate(ts, modulesCart, moduleName, deps) + ); +} diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts new file mode 100644 index 000000000..ac3c372c0 --- /dev/null +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Public entry point for the usage-inference engine. The implementation is +// split across the ./inference/ modules by responsibility; this barrel just +// re-exports the pieces the tsserver plugin (and the test suite) consume, so +// callers have one stable import path and don't need to know the internal +// layout. The engine gathers evidence (./inference/core) and then applies one +// decision policy (./inference/policy) at every parameter, return value and +// variable. Read the modules in this order to understand it: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo, guards) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/value-flow - where a function value goes: calls, exports, factories, parameters +// inference/reference-search - where a name is used, read from the files that can name it +// inference/call-sites - find where a function is called across the project +// inference/signatures - the function a call invokes and its declared callback types +// inference/callback-arguments - what a project helper passes the callback it is handed +// inference/generic-calls - generic calls whose result is an argument or a callback's return +// inference/member-values - the values an untyped member is built with +// inference/super-module - module.superModule detection and resolution +// inference/this-properties - constructor arguments kept on `this` and read in prototype methods +// inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/hook-calls - hook script exports called through HookMgr.callHook and hooks.json +// inference/bindings - a helper's parameters bound to one call's arguments +// inference/ambient-index - every ambient class by member names +// inference/naming - identifier-name tiebreak between usage matches +// inference/framework-contracts - parameters the platform itself passes (Page Designer) +// inference/policy - turning evidence into the type(s) shown +// inference/core - the recursive evidence-gathering engine +// inference/editor-hooks - hover and completion decoration for the plugin + +export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; +export {createInferenceContext} from './inference/context'; +export {memberCompletionAccess} from './inference/ast-helpers'; +export {describeTypes, typesToCompletionEntries} from './inference/type-helpers'; +export {collectSuperModuleAugmentedMembers} from './inference/super-module'; +export {inferParameterType, inferReturnType, inferTypeForNode} from './inference/core'; +export {usageProfileOf} from './inference/usage-profile'; +export {matchAmbientTypesByUsage} from './inference/policy'; +export {createUsageInferenceHooks} from './inference/editor-hooks'; diff --git a/packages/b2c-script-types/test/cartridge-discovery.test.js b/packages/b2c-script-types/test/cartridge-discovery.test.js new file mode 100644 index 000000000..8db5a0842 --- /dev/null +++ b/packages/b2c-script-types/test/cartridge-discovery.test.js @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const {orderCartridges} = require('../src/resolver/cartridge-discovery'); + +const cartridge = (name) => ({name, src: `/ws/${name}`}); +const names = (list) => list.map((c) => c.name); + +describe('orderCartridges', () => { + const discovered = ['modules', 'app_storefront_base', 'plugin_a', 'app_custom'].map(cartridge); + + it('follows the configured cartridge path, then appends unnamed discovered cartridges', () => { + const ordered = orderCartridges(discovered, ['app_custom', 'missing', 'app_storefront_base', 'app_custom']); + assert.deepEqual(names(ordered), ['app_custom', 'app_storefront_base', 'modules', 'plugin_a']); + }); + + it('keeps discovery order without a configured path, with SFRA base cartridges last', () => { + assert.deepEqual(names(orderCartridges(discovered, undefined)), [ + 'plugin_a', + 'app_custom', + 'app_storefront_base', + 'modules', + ]); + assert.deepEqual(names(orderCartridges(discovered, [])), names(orderCartridges(discovered, undefined))); + }); + + it('ranks cartridges named like Object.prototype members as ordinary cartridges', () => { + const tricky = ['__proto__', 'modules', 'constructor', 'toString'].map(cartridge); + assert.deepEqual(names(orderCartridges(tricky, undefined)), ['__proto__', 'constructor', 'toString', 'modules']); + }); +}); diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json new file mode 100644 index 000000000..a2537b70d --- /dev/null +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -0,0 +1,809 @@ +[ + { + "id": "addressbook-unique-addresses", + "source": "storefront addressHelpers.getAddressBookAddressByForm", + "description": "Single unique member .addresses recovers AddressBook with no call sites", + "dwTypes": ["AddressBook", "CustomerAddress"], + "files": { + "/helpers.js": "function getAddressBookAddressByForm(addressBook, form) {\n return addressBook.addresses;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "getAddressBookAddressByForm", + "param": 0 + }, + "expect": "AddressBook" + }, + { + "id": "customer-profile-credentials-chain", + "source": "storefront accountHelpers.getPasswordResetToken", + "description": "Named customer + single strong .profile recovers Customer despite ServiceConfig also exposing .profile", + "dwTypes": ["Customer", "ServiceConfig"], + "files": { + "/helpers.js": "function getPasswordResetToken(customer) {\n return customer.profile.credentials.createResetPasswordToken();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "getPasswordResetToken", + "param": 0 + }, + "expect": "Customer" + }, + { + "id": "resetting-customer-pascal-suffix", + "source": "storefront accountHelpers.sendPasswordResetEmail", + "description": "PascalCase suffix resettingCustomer + .profile recovers Customer (not exact class name)", + "dwTypes": ["Customer", "ServiceConfig"], + "files": { + "/helpers.js": "/**\n * @param {Object} resettingCustomer\n */\nfunction sendPasswordResetEmail(email, resettingCustomer) {\n return resettingCustomer.profile.firstName;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "sendPasswordResetEmail", + "param": 1 + }, + "expect": "Customer" + }, + { + "id": "api-product-pascal-suffix", + "source": "storefront product models / feeds apiProduct", + "description": "apiProduct PascalCase suffix recovers Product from getPriceModel", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function priceOf(apiProduct) {\n return apiProduct.getPriceModel();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "priceOf", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "current-basket-pascal-suffix", + "source": "storefront helpers currentBasket", + "description": "currentBasket PascalCase suffix recovers Basket", + "dwTypes": ["Basket", "Order"], + "files": { + "/helpers.js": "function billingOf(currentBasket) {\n return currentBasket.billingAddress;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "billingOf", + "param": 0 + }, + "expect": "Basket" + }, + { + "id": "payment-instrument-alias", + "source": "storefront payment helpers paymentInstrument", + "description": "paymentInstrument alias recovers OrderPaymentInstrument", + "dwTypes": ["OrderPaymentInstrument"], + "files": { + "/helpers.js": "function amountOf(paymentInstrument) {\n return paymentInstrument.capturedAmount;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "amountOf", + "param": 0 + }, + "expect": "OrderPaymentInstrument" + }, + { + "id": "registered-customer-profile-suffix", + "source": "storefront accountHelpers registeredCustomerProfile", + "description": "PascalCase Profile suffix recovers Profile from email/firstName", + "dwTypes": ["Profile", "ProductListRegistrant"], + "files": { + "/helpers.js": "function greet(registeredCustomerProfile) {\n return registeredCustomerProfile.firstName + registeredCustomerProfile.email;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "greet", + "param": 0 + }, + "expect": "Profile" + }, + { + "id": "customer-object-jsdoc-placeholder", + "source": "storefront accountHelpers — @param {Object} placeholder", + "description": "Weak SFRA @param {Object} does not block Customer inference from usage", + "dwTypes": ["Customer", "ServiceConfig"], + "files": { + "/helpers.js": "/**\n * @param {Object} customer\n */\nfunction getPasswordResetToken(customer) {\n return customer.profile.credentials.createResetPasswordToken();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "getPasswordResetToken", + "param": 0 + }, + "expect": "Customer" + }, + { + "id": "shipment-custom-plus-line-items", + "source": "storefront shippingHelpers.markShipmentForShipping", + "description": ".custom + .productLineItems resolves Shipment via distinctiveness (Transaction.wrap nested)", + "dwTypes": ["Shipment", "ProductLineItem", "Profile"], + "files": { + "/helpers.js": "function markShipmentForShipping(shipment) {\n doInTransaction(function () {\n var items = shipment.productLineItems;\n var c = shipment.custom;\n return items || c;\n });\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "markShipmentForShipping", + "param": 0 + }, + "expect": "Shipment" + }, + { + "id": "profile-name-beats-smaller-registrant", + "source": "storefront accountHelpers profile local variable", + "description": "Variable named profile with email/firstName/lastName/custom prefers Profile over ProductListRegistrant", + "dwTypes": ["Profile", "ProductListRegistrant"], + "files": { + "/helpers.js": "function buildContactPayload(resettingCustomer) {\n var profile = resettingCustomer.profile;\n return {\n email: profile.email,\n firstName: profile.firstName,\n lastName: profile.lastName,\n flag: profile.custom\n };\n}\n" + }, + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "profile" + }, + "expect": "Profile" + }, + { + "id": "basket-billing-address-members", + "source": "storefront checkoutHelpers.copyBillingAddressToBasket (basket param)", + "description": "Basket inferred from billingAddress + createBillingAddress usage", + "dwTypes": ["Basket", "Order", "Shipment"], + "files": { + "/helpers.js": "function copyBillingAddressToBasket(address, basket) {\n var billingAddress = basket.billingAddress;\n if (!billingAddress) {\n billingAddress = basket.createBillingAddress();\n }\n return billingAddress;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "copyBillingAddressToBasket", + "param": 1 + }, + "expect": "Basket" + }, + { + "id": "order-product-line-items", + "source": "storefront checkoutHelpers.getProductsWithPreorderableLineItems", + "description": "Order inferred from .productLineItems when JSDoc would say Object", + "dwTypes": ["Order", "Basket", "Shipment"], + "files": { + "/helpers.js": "function collectLineItems(order) {\n return order.productLineItems;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "collectLineItems", + "param": 0 + }, + "expect": "Order" + }, + { + "id": "product-price-model-from-callsite", + "source": "storefront job checkPrice / productHelpers", + "description": "Product inferred from typed ProductMgr/getProduct call site", + "dwTypes": ["Product", "Category", "Order"], + "files": { + "/helpers.js": "function checkPrice(product) {\n var price = product.getPriceModel().getPrice();\n if (price && price.getValue() <= 0) {\n product.setSearchableFlag(false);\n return product.getID();\n }\n return null;\n}\ncheckPrice(getSomeProduct());\n" + }, + "globals": " function getSomeProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "checkPrice", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "product-get-categories-from-callsite", + "source": "storefront productHelpers isSpecialCategoryProduct", + "description": "Product inferred from call site when body only uses getCategories()", + "dwTypes": ["Product", "Category", "Shipment"], + "files": { + "/helpers.js": "function isSpecialCategoryProduct(product) {\n return product.getCategories();\n}\nisSpecialCategoryProduct(getSomeProduct());\n" + }, + "globals": " function getSomeProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "product-get-categories-ambient", + "source": "storefront productHelpers — ambient path (no call sites)", + "description": "Named product + getCategories() recovers Product from ambient index (generic classes included)", + "dwTypes": ["Product", "Variant", "Category"], + "files": { + "/helpers.js": "function isSpecialCategoryProduct(product) {\n return product.getCategories();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "silence-unnamed-product-api-methods", + "source": "synthetic — Product API without product name", + "description": "getCategories alone on an unnamed param stays silent when multiple Product-family classes match", + "dwTypes": ["Product", "Variant", "VariationGroup", "Category"], + "files": { + "/helpers.js": "function isSpecialCategoryProduct(obj) {\n return obj.getCategories();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, + "expect": null + }, + { + "id": "category-parent-tree-walk", + "source": "storefront categoryHelper.findSegmentCategory", + "description": "Category inferred from parent + ID + displayName tree walk", + "dwTypes": ["Category", "Product", "Shipment"], + "files": { + "/helpers.js": "function findSegmentCategory(category) {\n while (category) {\n if (category.ID === 'root') return null;\n if (category.displayName) return category;\n category = category.parent;\n }\n return null;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "findSegmentCategory", + "param": 0 + }, + "expect": "Category" + }, + { + "id": "address-setter-surface-union", + "source": "storefront checkoutHelpers.escapeAddress / copyAddress", + "description": "A dense setter + getter surface both address classes share infers their union", + "dwTypes": ["OrderAddress", "CustomerAddress", "Shipment"], + "files": { + "/helpers.js": "function escapeAddress(address) {\n address.setFirstName(address.firstName);\n address.setLastName(address.lastName);\n address.setAddress1(address.address1);\n address.setAddress2(address.address2);\n address.setCity(address.city);\n address.setPostalCode(address.postalCode);\n address.setCompanyName(address.companyName);\n return address.suite;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "escapeAddress", + "param": 0 + }, + "expect": "OrderAddress | CustomerAddress" + }, + { + "id": "profile-getter-methods", + "source": "storefront abandonedCartHelpers.getInfo", + "description": "Profile inferred from getEmail/getFirstName/getLastName method style", + "dwTypes": ["Profile", "Customer", "ProductListRegistrant"], + "files": { + "/helpers.js": "function getInfo(profile) {\n return {\n email: profile.getEmail(),\n firstName: profile.getFirstName(),\n lastName: profile.getLastName()\n };\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "getInfo", + "param": 0 + }, + "expect": "Profile" + }, + { + "id": "customer-addressbook-member", + "source": "storefront service-cloud addressHelpers.syncAddressesIfRequired", + "description": "Customer inferred from .addressBook (not Profile.addressBook alone when named customer)", + "dwTypes": ["Customer", "Profile", "AddressBook"], + "files": { + "/helpers.js": "function syncAddressesIfRequired(customer) {\n var book = customer.addressBook;\n return book;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "syncAddressesIfRequired", + "param": 0 + }, + "expect": "Customer" + }, + { + "id": "collections-map-element-callback", + "source": "storefront productHelpers.getLineItemOptionNames", + "description": "collections.map callback first param gets collection element type", + "files": { + "/types.d.ts": "interface FixtureIterator { hasNext(): boolean; next(): { optionID: string; optionValueID: string }; }\ninterface FixtureCollection { iterator(): FixtureIterator; }\ndeclare function getOptionItems(): FixtureCollection;\ndeclare function map(collection: FixtureCollection, callback: (item: any) => any): any[];\n", + "/helpers.js": "function getLineItemOptionNames(optionProductLineItems) {\n return map(optionProductLineItems, function (item) {\n return item.optionID;\n });\n}\ngetLineItemOptionNames(getOptionItems());\n" + }, + "target": { + "file": "/helpers.js", + "kind": "callbackParam", + "param": 0 + }, + "expect": "{ optionID: string; optionValueID: string; }" + }, + { + "id": "new-helper-constructor-callsite", + "source": "storefront constructor-function model pattern", + "description": "new Helper(x) is a call site for parameter inference", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function LineItemModel(product) {\n this.product = product;\n}\nnew LineItemModel(getSomeProduct());\n" + }, + "globals": " function getSomeProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "LineItemModel", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "member-in-custom-contributes-custom", + "source": "storefront seo/productHelpers 'attr' in x.custom", + "description": "Chained 'attr' in x.custom contributes .custom (not the attribute name) to x", + "dwTypes": ["Shipment"], + "files": { + "/helpers.js": "function describeShipment(shipment) {\n return 'ATTR' in shipment.custom;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "describeShipment", + "param": 0, + "collectMembers": true + }, + "expectMembers": ["custom"] + }, + { + "id": "union-conflicting-call-sites", + "source": "synthetic — conflicting Product vs Order call sites", + "description": "Call sites passing unrelated classes infer their union, like IntelliJ", + "dwTypes": ["Product", "Order"], + "files": { + "/helpers.js": "function describeThing(thing) {\n return thing;\n}\ndescribeThing(getSomeProduct());\ndescribeThing(getSomeOrder());\n" + }, + "globals": " function getSomeProduct(): Product;\n function getSomeOrder(): Order;", + "target": { + "file": "/helpers.js", + "function": "describeThing", + "param": 0 + }, + "expect": "Product | Order" + }, + { + "id": "silence-weak-custom-uuid-only", + "source": "synthetic — only .custom and .UUID", + "description": "Weak ubiquitous members alone must not pick a random ExtensibleObject", + "dwTypes": ["Shipment", "ProductLineItem", "Profile", "Customer"], + "files": { + "/helpers.js": "function touch(obj) {\n var c = obj.custom;\n var u = obj.UUID;\n return c || u;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "touch", + "param": 0 + }, + "expect": null + }, + { + "id": "silence-ducktyped-store-as-address", + "source": "storefront checkoutHelpers.copyCustomerAddressToShipment", + "description": "Store model call site must not win when body also uses address-only fields", + "dwTypes": ["CustomerAddress", "Store"], + "files": { + "/models/store.js": "function StoreModel(storeObject) {\n this.ID = storeObject.ID;\n this.name = storeObject.name;\n this.firstName = 'Shop';\n this.lastName = this.name;\n this.address1 = storeObject.address1;\n this.address2 = storeObject.address2;\n this.city = storeObject.city;\n this.postalCode = storeObject.postalCode;\n this.stateCode = storeObject.stateCode;\n this.countryCode = storeObject.countryCode;\n}\nmodule.exports = StoreModel;\n", + "/helpers/storeHelpers.js": "var StoreModel = require('../models/store');\nfunction getDeliveryStore() {\n return new StoreModel(getApiStore());\n}\nmodule.exports = { getDeliveryStore: getDeliveryStore };\n", + "/checkout/checkoutHelpers.js": "function copyCustomerAddressToShipment(address) {\n use(address.countryCode);\n use(address.firstName || 'X');\n use(address.lastName || address.name);\n use(address.companyName ? address.companyName : '');\n use(address.address1);\n use(address.address2);\n use(address.postBox ? address.postBox : '');\n use(address.city);\n use(address.postalCode);\n use(address.stateCode ? address.stateCode : '');\n use(address.ID ? address.ID : '');\n}\nmodule.exports = { copyCustomerAddressToShipment: copyCustomerAddressToShipment };\n", + "/controllers/Checkout.js": "var COHelpers = require('../checkout/checkoutHelpers');\nvar storeHelpers = require('../helpers/storeHelpers');\nvar preferredAddress;\npreferredAddress = req.currentCustomer.addressBook.preferredAddress;\nCOHelpers.copyCustomerAddressToShipment(preferredAddress);\nCOHelpers.copyCustomerAddressToShipment(storeHelpers.getDeliveryStore());\n" + }, + "globals": " declare function getApiStore(): Store;\n declare const req: any;", + "target": { + "file": "/checkout/checkoutHelpers.js", + "function": "copyCustomerAddressToShipment", + "param": 0 + }, + "expect": null + }, + { + "id": "silence-sfra-customer-raw-only", + "source": "storefront addressHelpers.saveAddress", + "description": "SFRA view-model .raw is not dw.customer.Customer — stay silent", + "dwTypes": ["Customer", "Profile"], + "files": { + "/helpers.js": "function saveAddress(form, customer) {\n return customer.raw.getProfile();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "saveAddress", + "param": 1 + }, + "expect": null + }, + { + "id": "union-string-or-product-call-sites", + "source": "storefront productHelpers getProductType-style helper", + "description": "Call sites passing string IDs and Product objects (with a typeof guard) infer string | Product", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function resolveProductKey(product) {\n if (typeof product === 'string') return product;\n return product.ID;\n}\nresolveProductKey('sku-1');\nresolveProductKey(getSomeProduct());\n" + }, + "globals": " function getSomeProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "resolveProductKey", + "param": 0 + }, + "expect": "string | Product" + }, + { + "id": "silence-unnamed-shared-strong-member", + "source": "synthetic — .profile without Customer name", + "description": "Single strong member shared by multiple classes stays silent without a matching identifier name", + "dwTypes": ["Customer", "ServiceConfig"], + "files": { + "/helpers.js": "function readProfile(obj) {\n return obj.profile;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "readProfile", + "param": 0 + }, + "expect": null + }, + { + "id": "silence-viewmodel-array-length-only", + "source": "storefront hasPreorderableLineItem / hasMissingPrescriptions view-model arrays", + "description": "Array of SFRA view models (only .length) must not become Collection/ProductLineItem", + "dwTypes": ["ProductLineItem", "Basket", "Collection"], + "files": { + "/helpers.js": "function hasPreorderableLineItem(items) {\n return items && items.length > 0;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "hasPreorderableLineItem", + "param": 0 + }, + "expect": null + }, + { + "id": "lineitem-alias-price-adjustments", + "source": "storefront productLineItem decorators / priceTotal", + "description": "SFRA alias lineItem + .priceAdjustments recovers ProductLineItem (name ≠ class)", + "dwTypes": ["ProductLineItem", "ShippingLineItem", "PriceAdjustment"], + "files": { + "/helpers.js": "function getTotalPrice(lineItem) {\n return lineItem.priceAdjustments.getLength();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "getTotalPrice", + "param": 0 + }, + "expect": "ProductLineItem" + }, + { + "id": "pli-alias-set-price-value", + "source": "storefront Order controller handlePliAttributes", + "description": "Short alias pli + strong method recovers ProductLineItem", + "dwTypes": ["ProductLineItem", "ShippingLineItem"], + "files": { + "/helpers.js": "function handlePliAttributes(pli) {\n pli.setPriceValue(0);\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "handlePliAttributes", + "param": 0 + }, + "expect": "ProductLineItem" + }, + { + "id": "pricemodel-alias-price", + "source": "storefront feed DefaultPrice model", + "description": "priceModel alias recovers ProductPriceModel from .price", + "dwTypes": ["ProductPriceModel", "Product"], + "files": { + "/helpers.js": "function DefaultPrice(priceModel) {\n this.price = priceModel.price;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "DefaultPrice", + "param": 0 + }, + "expect": "ProductPriceModel" + }, + { + "id": "instanceof-product-line-item", + "source": "storefront payment/tax lineItemHelper", + "description": "Single instanceof ProductLineItem recovers that class with no call sites", + "dwTypes": ["ProductLineItem", "ShippingLineItem", "PriceAdjustment"], + "files": { + "/helpers.js": "function isProductLine(lineItem) {\n return lineItem instanceof ProductLineItem;\n}\n" + }, + "globals": " const ProductLineItem: { new (): ProductLineItem };\n const ShippingLineItem: { new (): ShippingLineItem };\n const PriceAdjustment: { new (): PriceAdjustment };", + "target": { + "file": "/helpers.js", + "function": "isProductLine", + "param": 0 + }, + "expect": "ProductLineItem" + }, + { + "id": "collections-first-ternary-return", + "source": "storefront collections.first / productImages", + "description": "collections.first ternary return (it.next() : null) recovers element type from typed call-site collection", + "dwTypes": ["Product", "Collection", "Variant"], + "files": { + "/collections.js": "function first(collection) {\n var iterator = collection.iterator();\n return iterator.hasNext() ? iterator.next() : null;\n}\n", + "/helpers.js": "function firstVariant(product) {\n var variant = first(product.getVariants());\n return variant;\n}\nfirstVariant(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "variant" + }, + "expect": "Variant" + }, + { + "id": "collections-first-callback-element", + "source": "storefront calculate.js collections.first(coll, function (adj) …) shape", + "description": "first with predicate callback types the element-first callback parameter", + "files": { + "/types.d.ts": "interface FixtureAdj { promotionID: string; }\ninterface FixtureIterator { hasNext(): boolean; next(): FixtureAdj; }\ninterface FixtureCollection { iterator(): FixtureIterator; }\ndeclare function getAdjustments(): FixtureCollection;\ndeclare function first(collection: FixtureCollection, callback: (item: any) => boolean): FixtureAdj | null;\n", + "/helpers.js": "function hasOrderLevelAdjustment() {\n return first(getAdjustments(), function (priceAdjustment) {\n return priceAdjustment.promotionID === 'x';\n });\n}\n" + }, + "target": { + "file": "/helpers.js", + "kind": "callbackParam", + "param": 0 + }, + "expect": "FixtureAdj" + }, + { + "id": "bonus-discount-line-item-subclass", + "source": "storefront promotion helper bonusDiscountLineItem", + "description": "bonusDiscountLineItem name + distinctive members recovers BonusDiscountLineItem, not the ProductLineItem the bare LineItem suffix would force", + "dwTypes": ["BonusDiscountLineItem", "ProductLineItem", "CouponLineItem"], + "files": { + "/helpers.js": "function countBonusChoices(bonusDiscountLineItem) {\n var max = bonusDiscountLineItem.maxBonusItems;\n return bonusDiscountLineItem.getBonusProducts().length + max;\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "countBonusChoices", + "param": 0 + }, + "expect": "BonusDiscountLineItem" + }, + { + "id": "bonus-discount-line-item-shared-member-silence", + "source": "storefront promotion helper bonusDiscountLineItem (generic usage)", + "description": "bonusDiscountLineItem touching only a base member ProductLineItem also exposes stays silent (named class does not match) rather than mis-resolving to ProductLineItem", + "dwTypes": ["BonusDiscountLineItem", "ProductLineItem"], + "files": { + "/helpers.js": "function bonusQty(bonusDiscountLineItem) {\n return bonusDiscountLineItem.getQuantity();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "bonusQty", + "param": 0 + }, + "expect": null + }, + { + "id": "product-shipping-line-item-subclass", + "source": "storefront shipping helper productShippingLineItem", + "description": "productShippingLineItem name + shared LineItem member resolves to ProductShippingLineItem, not the ShippingLineItem the shorter suffix would force", + "dwTypes": ["ProductShippingLineItem", "ShippingLineItem", "ProductLineItem"], + "files": { + "/helpers.js": "function surcharge(productShippingLineItem) {\n var q = productShippingLineItem.getQuantity();\n return productShippingLineItem.getProductLineItem();\n}\n" + }, + "target": { + "file": "/helpers.js", + "function": "surcharge", + "param": 0 + }, + "expect": "ProductShippingLineItem" + }, + { + "id": "call-this-model-inheritance", + "source": "SFRA product decorators: base.call(this, product, ...)", + "description": "`base.call(this, product)` counts as a call site, with the arguments after thisArg", + "dwTypes": ["Product"], + "files": { + "/base.js": "function BaseModel(product, quantity) {\n this.id = product.ID;\n this.quantity = quantity;\n}\nmodule.exports = BaseModel;\n", + "/full.js": "var BaseModel = require('./base');\nfunction FullModel(product) {\n BaseModel.call(this, product, 1);\n}\nnew FullModel(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": { + "file": "/base.js", + "function": "BaseModel", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "apply-arguments-forwarding", + "source": "storefront wrappers: helper.apply(this, arguments)", + "description": "`helper.apply(this, arguments)` forwards the wrapper's own call-site arguments", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function helper(product) {\n return product.ID;\n}\nfunction logged(product) {\n return helper.apply(this, arguments);\n}\nlogged(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "helper", + "param": 0 + }, + "expect": "Product" + }, + { + "id": "contextual-type-without-call-sites", + "source": "storefront productHelpers: ProductMgr.getProduct(pid)", + "description": "A never-called helper's parameter passed on to a typed API takes that API's parameter type", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function lookup(pid) {\n return findProduct(pid);\n}\nmodule.exports = {lookup: lookup};\n" + }, + "globals": " function findProduct(productID: string): Product;", + "target": { + "file": "/helpers.js", + "function": "lookup", + "param": 0 + }, + "expect": "string" + }, + { + "id": "logical-or-initializer-ignores-literal-fallback", + "source": "SFRA models: var safeOptions = options || {}", + "description": "`x || {}` initializes to x's type; the literal fallback adds nothing", + "dwTypes": ["Basket"], + "files": { + "/helpers.js": "function totals(lineItemContainer) {\n var container = lineItemContainer || {};\n return container;\n}\ntotals(getBasket());\n" + }, + "globals": " function getBasket(): Basket;", + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "container" + }, + "expect": "Basket" + }, + { + "id": "reassigned-variable-union", + "source": "storefront helpers: var target; if (...) target = a; else target = b;", + "description": "A variable declared without a value takes the union of what it is assigned", + "dwTypes": ["Product", "Category"], + "files": { + "/helpers.js": "function pick(useCategory) {\n var target;\n if (useCategory) {\n target = getCategory();\n } else {\n target = getProduct();\n }\n return target;\n}\n" + }, + "globals": " function getProduct(): Product;\n function getCategory(): Category;", + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "target" + }, + "expect": "Category | Product" + }, + { + "id": "named-element-callback", + "source": "storefront helpers: collections.forEach(variants, handleVariant)", + "description": "A named function passed by reference as an element-first callback takes the element type", + "dwTypes": ["Product", "Collection", "Variant"], + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/helpers.js": "var collections = require('./collections');\nfunction handleVariant(variant) {\n return variant.ID;\n}\nfunction allVariants(product) {\n collections.forEach(product.getVariants(), handleVariant);\n}\nallVariants(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": { + "file": "/helpers.js", + "function": "handleVariant", + "param": 0 + }, + "expect": "Variant" + }, + { + "id": "contextual-class-type-without-call-sites", + "source": "storefront cartHelpers: ShippingMgr.applyShippingCost(basket)", + "description": "A never-called helper's parameter passed on as a typed dw.* class takes that class", + "dwTypes": ["Basket"], + "files": { + "/helpers.js": "function recalculate(currentBasket) {\n applyShippingCost(currentBasket);\n}\nmodule.exports = {recalculate: recalculate};\n" + }, + "globals": " function applyShippingCost(basket: Basket): void;", + "target": { + "file": "/helpers.js", + "function": "recalculate", + "param": 0 + }, + "expect": "Basket" + }, + { + "id": "sfra-collections-foreach-many-element-types", + "source": "SFRA scripts/util/collections.forEach", + "description": "Collections of more element types than a union shows read as Collection instead of silence", + "dwTypes": ["Collection", "Product", "Category", "Shipment", "ProductLineItem"], + "globals": "function getProducts(): Collection;\nfunction getCategories(): Collection;\nfunction getShipments(): Collection;\nfunction getLineItems(): Collection;", + "files": { + "/collections.js": "function forEach(collection, callback, scope) {\n var iterator = Object.hasOwnProperty.call(collection, 'iterator') ? collection.iterator() : collection;\n var index = 0;\n while (iterator.hasNext()) {\n var item = iterator.next();\n if (scope) callback.call(scope, item, index++, collection);\n else callback(item, index++, collection);\n }\n}\nforEach(getProducts(), function () {});\nforEach(getCategories(), function () {});\nforEach(getShipments(), function () {});\nforEach(getLineItems(), function () {});\nmodule.exports = {forEach: forEach};\n" + }, + "target": { + "file": "/collections.js", + "function": "forEach", + "param": 0 + }, + "expect": "Collection" + }, + { + "id": "cart-matching-line-items-push", + "source": "SFRA cartHelpers.getMatchingProducts shape", + "description": "An array filled by push() inside collections.forEach and returned in an object reads back as an array of the collection's elements", + "dwTypes": ["Basket", "Collection", "ProductLineItem"], + "globals": "function getBasket(): Basket;", + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/cartHelpers.js": "var collections = require('./collections');\n\nfunction getMatchingProducts(productId, productLineItems) {\n var matchingProducts = [];\n var uuid;\n collections.forEach(productLineItems, function (item) {\n if (item.productID === productId) {\n matchingProducts.push(item);\n uuid = item.UUID;\n }\n });\n return {matchingProducts: matchingProducts, uuid: uuid};\n}\n\nfunction getExistingProductLineItemInCart(productId) {\n var matching = getMatchingProducts(productId, getBasket().productLineItems).matchingProducts;\n return matching[0];\n}\n\nmodule.exports = {getExistingProductLineItemInCart: getExistingProductLineItemInCart};\n" + }, + "target": { + "file": "/cartHelpers.js", + "kind": "variable", + "name": "matching" + }, + "expect": "ProductLineItem[]" + }, + { + "id": "search-refinement-factory-model", + "source": "SFRA scripts/factories/searchRefinements + models/search/attributeRefinementValue shape", + "description": "A refinement model's wrapper is exported, picked by a factory's unbound `return require(...)`, held in a local `Model`, passed on as an argument and finally called with `new Model(...)`; the model keeps the value on `this` and reads it in a prototype method, which rules out the categories the other branch passes (the API declares the values as SearchRefinementValue)", + "dwTypes": ["Category", "Collection", "ProductSearchModel", "ProductSearchRefinementValue"], + "globals": "function getSearch(): ProductSearchModel;", + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/models/boolean.js": "function BooleanAttributeValue(productSearch, refinementDefinition, refinementValue) {\n this.productSearch = productSearch;\n this.refinementDefinition = refinementDefinition;\n this.refinementValue = refinementValue;\n this.initialize();\n}\n\nBooleanAttributeValue.prototype.initialize = function () {\n this.hitCount = this.refinementValue.hitCount;\n this.displayValue = this.refinementValue.displayValue;\n this.selected = this.productSearch.isRefinedByAttributeValue(\n this.refinementDefinition.attributeID,\n this.refinementValue.value\n );\n};\n\nfunction BooleanRefinementValueWrapper(productSearch, refinementDefinition, refinementValue) {\n var value = new BooleanAttributeValue(productSearch, refinementDefinition, refinementValue);\n return {hitCount: value.hitCount, displayValue: value.displayValue, selected: value.selected};\n}\n\nmodule.exports = BooleanRefinementValueWrapper;\n", + "/models/category.js": "function CategoryAttributeValue(productSearch, refinementDefinition, category, selected) {\n this.id = category.ID;\n this.online = category.online;\n this.selected = selected;\n this.subCategories = [];\n}\n\nmodule.exports = CategoryAttributeValue;\n", + "/searchRefinements.js": "var collections = require('./collections');\n\nfunction getAttributeRefinementValueModel(refinementDefinition) {\n if (refinementDefinition.categoryRefinement) {\n return require('./models/category');\n }\n return require('./models/boolean');\n}\n\nfunction createCategorySearchRefinement(productSearch, refinementDefinition, Model) {\n var currentCategory = productSearch.category;\n var topCategory = new Model(productSearch, refinementDefinition, currentCategory, true);\n collections.forEach(currentCategory.subCategories, function (category) {\n topCategory.subCategories.push(new Model(productSearch, refinementDefinition, category, false));\n });\n return [topCategory];\n}\n\nfunction get(productSearch, refinementDefinition, refinementValues) {\n var Model = getAttributeRefinementValueModel(refinementDefinition);\n if (refinementDefinition.categoryRefinement) {\n return createCategorySearchRefinement(productSearch, refinementDefinition, Model);\n }\n var tree = [];\n collections.forEach(refinementValues, function (value) {\n tree.push(new Model(productSearch, refinementDefinition, value));\n });\n return tree;\n}\n\nmodule.exports = {get: get};\n", + "/productSearch.js": "var searchRefinementsFactory = require('./searchRefinements');\n\nfunction getRefinements(productSearch, refinements, refinementDefinitions) {\n return refinementDefinitions.toArray().map(function (definition) {\n var refinementValues = refinements.getAllRefinementValues(definition);\n var values = searchRefinementsFactory.get(productSearch, definition, refinementValues);\n return {displayName: definition.displayName, values: values};\n });\n}\n\nfunction ProductSearch(productSearch) {\n this.refinements = getRefinements(\n productSearch,\n productSearch.refinements,\n productSearch.refinements.refinementDefinitions\n );\n}\n\nmodule.exports = ProductSearch;\n", + "/search.js": "var ProductSearch = require('./productSearch');\n\nnew ProductSearch(getSearch());\n" + }, + "target": { + "file": "/models/boolean.js", + "function": "BooleanRefinementValueWrapper", + "param": 2 + }, + "expect": "SearchRefinementValue" + }, + { + "id": "shipping-collections-find-call-specific", + "source": "SFRA scripts/util/collections.find + scripts/checkout/shippingHelpers.getShipmentByUUID shape", + "description": "collections.find documents its result as the `{Object|null}` placeholder and its collection as a bare `{dw.util.Collection}` whose elements are `any`; across its callers it returns nothing in particular, but for the call getShipmentByUUID makes with `basket.shipments` it returns a Shipment, so the controller's variable holding that result reads as one", + "dwTypes": ["Basket", "Collection"], + "globals": "type DwUtilCollection = Collection;\nnamespace dw.util { type Collection = DwUtilCollection; }\nfunction getCurrentBasket(): Basket;", + "files": { + "/collections.js": "/**\n * Find method for dw.util.Collection subclass instance\n * @param {dw.util.Collection} collection - Collection subclass instance to find value in\n * @param {Function} match - Match function\n * @param {Object} [scope] - Optional execution scope to pass to the match function\n * @returns {Object|null} Single item from the collection\n */\nfunction find(collection, match, scope) {\n var result = null;\n\n if (collection) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n var item = iterator.next();\n if (scope ? match.call(scope, item) : match(item)) {\n result = item;\n break;\n }\n }\n }\n\n return result;\n}\n\nmodule.exports = {find: find};\n", + "/shippingHelpers.js": "var collections = require('./collections');\n\nfunction getShipmentByUUID(basket, uuid) {\n return collections.find(basket.shipments, function (shipment) {\n return shipment.UUID === uuid;\n });\n}\n\nfunction getProductLineItemByUUID(basket, uuid) {\n return collections.find(basket.productLineItems, function (item) {\n return item.UUID === uuid;\n });\n}\n\nmodule.exports = {getShipmentByUUID: getShipmentByUUID, getProductLineItemByUUID: getProductLineItemByUUID};\n", + "/CheckoutShippingServices.js": "var shippingHelpers = require('./shippingHelpers');\n\nfunction selectShippingMethod(req) {\n var currentBasket = getCurrentBasket();\n var shipment = shippingHelpers.getShipmentByUUID(currentBasket, req.form.shipmentUUID);\n return shipment;\n}\n\nmodule.exports = {selectShippingMethod: selectShippingMethod};\n" + }, + "target": { + "file": "/CheckoutShippingServices.js", + "kind": "variable", + "name": "shipment" + }, + "expect": "Shipment" + }, + { + "id": "payment-hook-callhook-prefixed", + "source": "SFRA hooks.json app.payment.processor.* + CheckoutServices SubmitPayment shape", + "description": "nothing calls a payment hook's Handle export by name: the controller dispatches through HookMgr.callHook with an extension point built from the processor ID, which reaches the script hooks.json registers for app.payment.processor.basic_credit, so its basket parameter is the Basket the controller passes as the hook's first argument", + "dwTypes": ["Basket", "HookMgr", "PaymentMgr"], + "globals": "var HookManager: typeof HookMgr;\nvar PaymentManager: typeof PaymentMgr;\nfunction getCurrentBasket(): Basket;", + "hooks": [ + { + "extensionPoint": "app.payment.processor.basic_credit", + "script": "/scripts/hooks/payment/processor/basic_credit.js" + } + ], + "files": { + "/scripts/hooks/payment/processor/basic_credit.js": "var Transaction = require('dw/system/Transaction');\n\nfunction Handle(basket, paymentInformation, paymentMethodID) {\n var currentBasket = basket;\n var error = false;\n Transaction.wrap(function () {\n var existing = currentBasket.getPaymentInstruments(paymentMethodID);\n for (var i = 0; i < existing.length; i++) {\n currentBasket.removePaymentInstrument(existing[i]);\n }\n var paymentInstrument = currentBasket.createPaymentInstrument(paymentMethodID, currentBasket.totalGrossPrice);\n paymentInstrument.setCreditCardHolder(paymentInformation.cardOwner);\n });\n return {fieldErrors: {}, serverErrors: [], error: error};\n}\n\nfunction Authorize(orderNumber, paymentInstrument, paymentProcessor) {\n paymentInstrument.paymentTransaction.setTransactionID(orderNumber);\n paymentInstrument.paymentTransaction.setPaymentProcessor(paymentProcessor);\n return {fieldErrors: {}, serverErrors: [], error: false};\n}\n\nexports.Handle = Handle;\nexports.Authorize = Authorize;\n", + "/controllers/CheckoutServices.js": "function submitPayment(req, paymentForm) {\n var currentBasket = getCurrentBasket();\n var paymentMethodID = paymentForm.paymentMethod.value;\n var processor = PaymentManager.getPaymentMethod(paymentMethodID).getPaymentProcessor();\n var hookName = 'app.payment.processor.' + processor.ID.toLowerCase();\n if (!HookManager.hasHook(hookName)) return {error: true};\n return HookManager.callHook('app.payment.processor.' + processor.ID.toLowerCase(), 'Handle', currentBasket, paymentForm.paymentInformation, paymentMethodID);\n}\n\nmodule.exports = {submitPayment: submitPayment};\n" + }, + "target": { + "file": "/scripts/hooks/payment/processor/basic_credit.js", + "function": "Handle", + "param": 0 + }, + "expect": "Basket" + } +] diff --git a/packages/b2c-script-types/test/corpus/corpus.test.js b/packages/b2c-script-types/test/corpus/corpus.test.js new file mode 100644 index 000000000..622e0d4c6 --- /dev/null +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ts = require('typescript'); + +const { + usageProfileOf, + createInferenceContext, + describeTypes, + inferParameterType, + inferTypeForNode, +} = require('../../src/usage-inference'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('../helpers/fixture-language-service'); +const {realTypesPrelude} = require('../helpers/real-dw-types'); + +const cases = JSON.parse(fs.readFileSync(path.join(__dirname, 'cases.json'), 'utf8')); + +function findCallbackParam(sourceFile, paramIndex = 0) { + let param; + const visit = (node) => { + if (ts.isFunctionExpression(node) && !param) { + param = node.parameters[paramIndex]; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!param) throw new Error('callback parameter not found'); + return param; +} + +function findVariableDeclaration(sourceFile, name) { + let decl; + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) { + decl = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!decl) throw new Error(`variable declaration not found: ${name}`); + return decl; +} + +function buildFiles(corpusCase) { + const files = {...corpusCase.files}; + if (corpusCase.dwTypes?.length) { + files['/types.d.ts'] = realTypesPrelude(corpusCase.dwTypes, corpusCase.globals ?? ''); + } else if (corpusCase.globals) { + files['/types.d.ts'] = `declare global {\n${corpusCase.globals}\n}\n`; + } + return files; +} + +function resolveTarget(ctx, corpusCase) { + const sourceFile = ctx.program.getSourceFile(corpusCase.target.file); + assert.ok(sourceFile, `missing fixture file ${corpusCase.target.file}`); + if (corpusCase.target.kind === 'callbackParam') { + return {kind: 'param', node: findCallbackParam(sourceFile, corpusCase.target.param ?? 0)}; + } + if (corpusCase.target.kind === 'variable') { + return {kind: 'variable', node: findVariableDeclaration(sourceFile, corpusCase.target.name)}; + } + const fn = findFunctionDeclaration(sourceFile, corpusCase.target.function); + return {kind: 'param', node: fn.parameters[corpusCase.target.param ?? 0]}; +} + +describe('usage-inference golden corpus (real-storefront shapes)', () => { + for (const corpusCase of cases) { + it(`${corpusCase.id}: ${corpusCase.description}`, () => { + const languageService = createFixtureLanguageService(buildFiles(corpusCase)); + // A case's `hooks` stand in for the hooks.json registrations the plugin reads from disk. + const host = corpusCase.hooks ? {hookRegistrations: () => corpusCase.hooks} : undefined; + const ctx = createInferenceContext(ts, languageService, host); + assert.ok(ctx, 'expected an inference context'); + const target = resolveTarget(ctx, corpusCase); + + if (corpusCase.expectMembers) { + assert.equal(target.kind, 'param', `${corpusCase.id}: expectMembers requires a parameter target`); + const members = [...usageProfileOf(ctx, target.node).memberNames].sort(); + assert.deepEqual(members, [...corpusCase.expectMembers].sort()); + return; + } + + const types = + target.kind === 'variable' ? inferTypeForNode(ctx, target.node.name) : inferParameterType(ctx, target.node); + if (corpusCase.expect === null) { + // Assert length, never `deepEqual(types, [])`: on an unexpected + // non-empty result `types` holds TS Type objects whose circular + // internal structure makes deepEqual hang instead of failing. + assert.equal(types.length, 0, `expected silence for ${corpusCase.id}, got: ${describeTypes(ctx, types)}`); + return; + } + + assert.equal(describeTypes(ctx, types), corpusCase.expect); + }); + } +}); diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js new file mode 100644 index 000000000..a5b9c6266 --- /dev/null +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +// Ground-truth evaluation of usage inference against real cartridges. +// +// Skipped unless B2C_INFERENCE_CORPUS names one or more directories +// (path.delimiter-separated) that contain cartridges (`.project` markers): +// +// B2C_INFERENCE_CORPUS=/path/to/sfra/cartridges:/path/to/plugin_x/cartridges \ +// pnpm --filter @salesforce/b2c-script-types exec mocha test/eval/ground-truth.test.js +// +// Real cartridges document many helpers with a `dw.*` JSDoc type. Those are +// the answer key: for one file at a time, every `{dw.*}` @param/@returns type +// is blanked out in memory (same length, so offsets stay valid), and the +// plugin is asked — through the real hover path for parameters, the engine +// entry point for returns — what it would infer for the now-undocumented +// value. Each answer is scored against the documented type: +// +// exact same class(es) as documented +// union a union that contains the documented class +// related an ancestor or descendant of the documented class +// (`LineItemCtnr` documented, `Basket` inferred, or vice versa) +// wrong an unrelated class +// silent no inference +// +// Precision = (exact + union + related) / fired, recall = the same / total. +// Every silent parameter is triaged into why it stayed silent (no call sites +// found, budget exhausted, evidence dropped by the policy, no evidence), so a +// change can target the largest bucket. Local variables whose `dw.*` type +// came only from the blanked JSDoc are re-inferred and scored too: that is +// the end-to-end effect a user sees in hovers and completions on locals. +// Parameters that carry no type at all in the original source are hovered +// too, to measure how often inference fires on genuinely undocumented code. +// Cost is reported as wall time and as reference searches per request, +// project-wide and single-file ones apart; the search count is the same on +// every machine and run, so compare that one. +// +// Optional: B2C_INFERENCE_CARTRIDGE_PATH (colon-separated cartridge names) +// sets the cartridge order, B2C_INFERENCE_REPORT writes the full JSON report +// (mismatches, timings, searches, slowest requests, undocumented samples) to +// that file. + +const fs = require('node:fs'); +const path = require('node:path'); + +const ts = require('typescript'); + +const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); +const {collectCallSites} = require('../../src/inference/call-sites'); +const {holderOf} = require('../../src/inference/value-flow'); +const {inferTypeForExpression} = require('../../src/inference/core'); +const {createInferenceContext, describeTypes, inferReturnType, inferTypeForNode} = require('../../src/usage-inference'); +const {createPluginProxy} = require('../helpers/plugin-proxy'); +const {countReferenceSearches} = require('../helpers/reference-searches'); + +const CORPUS = process.env.B2C_INFERENCE_CORPUS; +const INFERRED_NOTE = 'Inferred from usage: '; +const DW_TYPE_EXPRESSION = /^\{[^{}]*\bdw\.[^{}]*\}$/; +const SERVER_SIDE_EXCLUDES = ['**/client/**', '**/static/**', '**/node_modules/**']; + +// `.project` markers are what the plugin itself discovers by, but some +// published cartridges ship without one — any direct child with a +// `cartridge/` folder counts too. +function cartridgesWithoutProjectFile(root, known) { + return fs + .readdirSync(root, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'cartridge'))) + .map((entry) => ({name: entry.name, src: path.join(root, entry.name)})) + .filter((c) => !known.has(c.name)); +} + +function discoverCorpusCartridges(roots) { + const discovered = roots.flatMap((root) => discoverCartridgesOnDisk(ts, root, ts.sys.fileExists)); + const known = new Set(discovered.map((c) => c.name)); + discovered.push(...roots.flatMap((root) => cartridgesWithoutProjectFile(root, known))); + const configured = process.env.B2C_INFERENCE_CARTRIDGE_PATH?.split(':').filter(Boolean); + return orderCartridges(discovered, configured); +} + +function serverScriptFiles(cartridges) { + return cartridges.flatMap((c) => + ts.sys.readDirectory(c.src, ['.js'], SERVER_SIDE_EXCLUDES).map((f) => f.replace(/\\/g, '/')), + ); +} + +/** A disk-backed LanguageServiceHost whose file contents can be overridden in memory. */ +function createCorpusHost(fileNames, currentDirectory) { + const overrides = new Map(); + const versions = new Map(); + const compilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + allowJs: true, + checkJs: false, + noEmit: true, + }; + const readFile = (fileName) => overrides.get(fileName) ?? ts.sys.readFile(fileName); + const host = { + getScriptFileNames: () => [...fileNames, ts.getDefaultLibFilePath(compilerOptions)], + getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0), + getScriptSnapshot: (fileName) => { + const text = readFile(fileName); + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => currentDirectory, + getCompilationSettings: () => compilerOptions, + getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), + fileExists: (fileName) => overrides.has(fileName) || ts.sys.fileExists(fileName), + readFile, + directoryExists: (dir) => ts.sys.directoryExists(dir), + getDirectories: (dir) => ts.sys.getDirectories(dir), + resolveModuleNameLiterals: (literals, containingFile, _redirected, options) => + literals.map((literal) => ts.resolveModuleName(literal.text, containingFile, options, host)), + }; + const setOverride = (fileName, text) => { + if (text === undefined) overrides.delete(fileName); + else overrides.set(fileName, text); + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + }; + return {host, setOverride}; +} + +function createCorpusPlugin(cartridges, fileNames) { + const {host, setOverride} = createCorpusHost(fileNames, cartridges[0]?.src ?? '/'); + const languageService = ts.createLanguageService(host); + // Reference searches are what an inference request spends its time on; + // counting them gives a cost measure that, unlike wall time, is the same + // on every machine and run. A search confined to the one file that can + // refer to its name is counted apart from one that reads the project. + const searches = countReferenceSearches(); + const {proxy} = createPluginProxy({ + host, + languageService, + currentDirectory: cartridges[0]?.src ?? '/', + config: {enabled: true, autoDiscover: false, inferUsage: true, cartridges}, + }); + return {languageService, proxy, setOverride, searches}; +} + +// --------------------------------------------------------------------------- +// Answer key: documented `dw.*` parameters and returns, located in the +// original program. +// --------------------------------------------------------------------------- + +/** The `{...}` span of a JSDoc tag's type when it names a `dw.*` type, else undefined. */ +function dwTypeSpan(tag) { + const expression = tag?.typeExpression; + if (!expression || !DW_TYPE_EXPRESSION.test(expression.getText())) return undefined; + return {start: expression.getStart(), end: expression.getEnd()}; +} + +/** A documented type's simple class name; an array reads as its element's (`dw.order.Basket[]` -> 'Basket[]'). */ +function documentedName(checker, type) { + if (!checker.isArrayType(type)) return (type.aliasSymbol ?? type.getSymbol())?.getName(); + const element = documentedName(checker, checker.getTypeArguments(type)[0]); + return element && `${element}[]`; +} + +/** Simple class names of a documented type's non-nullable constituents (`dw.order.Basket|null` -> ['Basket']). */ +function documentedNames(checker, type) { + const parts = type.isUnion() ? type.types : [type]; + return parts + .filter((t) => !(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))) + .map((t) => documentedName(checker, t)) + .filter(Boolean); +} + +function functionLabel(fn, sourceFile) { + const name = fn.name ?? (fn.parent && 'name' in fn.parent ? fn.parent.name : undefined); + const line = sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1; + return `${path.basename(sourceFile.fileName)}:${line} ${name ? name.getText(sourceFile) : ''}`; +} + +function collectFunctionTargets(checker, fn, sourceFile, out) { + for (const param of fn.parameters) { + if (!ts.isIdentifier(param.name)) continue; + const tag = ts.getJSDocParameterTags(param)[0]; + const span = dwTypeSpan(tag); + const label = `${functionLabel(fn, sourceFile)}(${param.name.text})`; + if (span) { + const names = documentedNames(checker, checker.getTypeAtLocation(param.name)); + out.params.push({label, position: param.name.getStart(sourceFile), names}); + out.blanks.push(span); + } else if (!tag?.typeExpression && !param.type) { + out.undocumented.push({label, position: param.name.getStart(sourceFile)}); + } + } + const returnTag = ts.getJSDocTags(fn).find((t) => ts.isJSDocReturnTag(t)); + const returnSpan = dwTypeSpan(returnTag); + if (returnSpan) { + const signature = checker.getSignatureFromDeclaration(fn); + const names = signature ? documentedNames(checker, checker.getReturnTypeOfSignature(signature)) : []; + out.returns.push({label: `${functionLabel(fn, sourceFile)}()`, position: fn.getStart(sourceFile), names}); + out.blanks.push(returnSpan); + } +} + +/** True when `type` (or a non-nullable part of it) is declared by the vendored Script API. */ +function isScriptApiType(type) { + const parts = type.isUnion() ? type.types : [type]; + return parts.some((part) => + (part.getSymbol()?.declarations ?? []).some((decl) => decl.getSourceFile().fileName.includes('/types/dw/')), + ); +} + +/** A local variable whose checker type is a Script API class: a candidate for the downstream metric. */ +function collectVariableTarget(checker, decl, sourceFile, out) { + if (!ts.isIdentifier(decl.name) || decl.type) return; + const type = checker.getTypeAtLocation(decl.name); + if (!isScriptApiType(type)) return; + const line = sourceFile.getLineAndCharacterOfPosition(decl.getStart(sourceFile)).line + 1; + out.variables.push({ + label: `${path.basename(sourceFile.fileName)}:${line} var ${decl.name.text}`, + position: decl.name.getStart(sourceFile), + names: documentedNames(checker, type), + }); +} + +function collectFileTargets(program, fileName) { + const sourceFile = program.getSourceFile(fileName); + const checker = program.getTypeChecker(); + const out = {fileName, params: [], returns: [], variables: [], undocumented: [], blanks: []}; + if (!sourceFile) return out; + const visit = (node) => { + if (ts.isFunctionLike(node) && 'parameters' in node) collectFunctionTargets(checker, node, sourceFile, out); + if (ts.isVariableDeclaration(node)) collectVariableTarget(checker, node, sourceFile, out); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return out; +} + +/** The file's text with every documented `{dw.*}` type expression blanked to spaces (offsets unchanged). */ +function blankSpans(text, spans) { + let out = text; + for (const {start, end} of spans) out = out.slice(0, start) + ' '.repeat(end - start) + out.slice(end); + return out; +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +/** Maps every top-level class/interface name in the dw.* declarations to the names of all its ancestors. */ +function buildAncestry(program) { + const checker = program.getTypeChecker(); + const ancestry = new Map(); + const ancestorsOf = (type, seen = new Set()) => { + for (const base of type.isClassOrInterface() ? checker.getBaseTypes(type) : []) { + const name = base.getSymbol()?.getName(); + if (!name || seen.has(name)) continue; + seen.add(name); + // A generic base (`List`) is a type reference; its declaration is the target. + ancestorsOf(base.target ?? base, seen); + } + return seen; + }; + for (const sf of program.getSourceFiles()) { + if (!sf.isDeclarationFile || !sf.fileName.includes('/types/dw/')) continue; + for (const stmt of sf.statements) { + if (!(ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) || !stmt.name) continue; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (symbol) ancestry.set(stmt.name.text, ancestorsOf(checker.getDeclaredTypeOfSymbol(symbol))); + } + } + return ancestry; +} + +/** `Product | ProductLineItem` -> ['Product', 'ProductLineItem']. */ +function parseInferredNames(description) { + return description.split(' | ').map((part) => part.replace(/<.*>$/, '').trim()); +} + +function isRelated(ancestry, a, b) { + const arrays = a.endsWith('[]') && b.endsWith('[]'); + if (arrays) return isRelated(ancestry, a.slice(0, -2), b.slice(0, -2)); + return a === b || ancestry.get(a)?.has(b) === true || ancestry.get(b)?.has(a) === true; +} + +function classify(ancestry, documented, inferred) { + if (!inferred) return 'silent'; + const names = parseInferredNames(inferred); + if (names.every((n) => documented.includes(n))) return 'exact'; + if (names.length > 1 && names.some((n) => documented.includes(n))) return 'union'; + if (names.every((n) => documented.some((d) => isRelated(ancestry, n, d)))) return 'related'; + return 'wrong'; +} + +function inferredFromHover(proxy, fileName, position) { + const info = proxy.getQuickInfoAtPosition(fileName, position); + const text = (info?.documentation ?? []).map((p) => p.text).join(''); + const index = text.lastIndexOf(INFERRED_NOTE); + return index === -1 ? undefined : text.slice(index + INFERRED_NOTE.length).trim(); +} + +/** The node starting exactly at `position` that satisfies `predicate`, outermost first. */ +function findNodeAt(sourceFile, position, predicate) { + let found; + const visit = (node) => { + if (found || position < node.getStart(sourceFile) || position >= node.getEnd()) return; + if (predicate(node) && node.getStart(sourceFile) === position) found = node; + else ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; +} + +/** A fresh inference context and the evaluated file in the current (blanked) program. */ +function freshRequest(languageService, fileName) { + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx?.program.getSourceFile(fileName); + return ctx && sourceFile ? {ctx, sourceFile} : undefined; +} + +function inferredReturn(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const fn = request && findNodeAt(request.sourceFile, position, ts.isFunctionLike); + const types = fn ? inferReturnType(request.ctx, fn) : []; + return types.length > 0 ? describeTypes(request.ctx, types) : undefined; +} + +/** + * What inference shows for a local variable, or null when the variable is + * still typed by the checker after blanking (its type never depended on the + * removed JSDoc, so it says nothing about inference). + */ +function inferredVariable(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const name = request && findNodeAt(request.sourceFile, position, ts.isIdentifier); + if (!name || isScriptApiType(request.ctx.checker.getTypeAtLocation(name))) return null; + const types = inferTypeForNode(request.ctx, name); + return types.length > 0 ? describeTypes(request.ctx, types) : undefined; +} + +/** + * Why a parameter stayed silent, re-derived with a fresh request: an + * anonymous callback (nothing to search for), no call sites found, a search + * or reference budget that ran out, call-site evidence the policy dropped + * (fit, union limit), or call sites whose arguments say nothing. + */ +function silentReason(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const param = request && findNodeAt(request.sourceFile, position, ts.isIdentifier)?.parent; + if (!param || !ts.isParameter(param)) return 'not-a-parameter'; + const holder = holderOf(request.ctx, param.parent); + return holder ? callSiteSilence(request.ctx, holder, param) : 'anonymous-callback'; +} + +/** Classifies a silent parameter of a function whose calls can be searched by what its call sites gave. */ +function callSiteSilence(ctx, holder, param) { + const {calls, handoffs} = collectCallSites(ctx, holder); + const index = param.parent.parameters.indexOf(param); + const evidence = calls.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); + if (evidence.length > 0) return 'dropped-by-policy'; + if (ctx.searchBudget <= 0 || ctx.referenceBudget <= 0) return 'budget'; + return calls.length + handoffs.length === 0 ? 'no-call-sites' : 'no-evidence'; +} + +const SLOWEST_REPORTED = 20; +// The live reference-search counter, kept under a symbol so the JSON report leaves it out. +const SEARCH_COUNTER = Symbol('searchCounter'); + +function timed(report, label, compute) { + const counter = report[SEARCH_COUNTER]; + const searchesBefore = counter.projectWide(); + const localBefore = counter.local(); + const start = process.hrtime.bigint(); + const result = compute(); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + const searches = counter.projectWide() - searchesBefore; + const localSearches = counter.local() - localBefore; + report.timings.push(ms); + report.searches.push(searches); + report.localSearches.push(localSearches); + report.slowest.push({target: label, ms, searches, localSearches}); + report.slowest.sort((a, b) => b.ms - a.ms).splice(SLOWEST_REPORTED); + return result; +} + +function scoreTarget(report, ancestry, kind, target, inferred, explainSilence) { + const verdict = classify(ancestry, target.names, inferred); + report[kind][verdict]++; + if (verdict === 'exact' || verdict === 'union') return; + const reason = verdict === 'silent' && explainSilence ? explainSilence() : undefined; + if (reason) report.silentReasons[reason] = (report.silentReasons[reason] ?? 0) + 1; + report.mismatches.push({kind, verdict, target: target.label, documented: target.names.join(' | '), inferred, reason}); +} + +function emptyScore() { + return {exact: 0, union: 0, related: 0, wrong: 0, silent: 0}; +} + +function evaluateFile(plugin, ancestry, targets, report) { + const {proxy, languageService, setOverride} = plugin; + setOverride(targets.fileName, blankSpans(ts.sys.readFile(targets.fileName), targets.blanks)); + try { + for (const target of targets.params) { + const inferred = timed(report, target.label, () => inferredFromHover(proxy, targets.fileName, target.position)); + scoreTarget(report, ancestry, 'params', target, inferred, () => + silentReason(languageService, targets.fileName, target.position), + ); + } + for (const target of targets.returns) { + const inferred = timed(report, target.label, () => + inferredReturn(languageService, targets.fileName, target.position), + ); + scoreTarget(report, ancestry, 'returns', target, inferred); + } + // Untimed, and after the timed requests: the triage and variable + // requests warm this program's reference cache, which must not lower the + // search counts measured above. + for (const target of targets.variables) { + const inferred = inferredVariable(languageService, targets.fileName, target.position); + if (inferred !== null) scoreTarget(report, ancestry, 'variables', target, inferred); + } + } finally { + setOverride(targets.fileName, undefined); + } +} + +function evaluateUndocumented(proxy, allTargets, report) { + for (const targets of allTargets) { + for (const target of targets.undocumented) { + const inferred = timed(report, target.label, () => inferredFromHover(proxy, targets.fileName, target.position)); + report.undocumented.total++; + if (!inferred) continue; + report.undocumented.fired++; + report.undocumented.samples.push({target: target.label, inferred}); + } + } +} + +function percentile(sorted, p) { + return sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +} + +function summarize(report) { + const line = (kind) => { + const s = report[kind]; + const total = s.exact + s.union + s.related + s.wrong + s.silent; + const good = s.exact + s.union + s.related; + const fired = total - s.silent; + const pct = (n, d) => (d === 0 ? 'n/a' : `${((100 * n) / d).toFixed(1)}%`); + return ( + `${kind.padEnd(8)} total=${total} exact=${s.exact} union=${s.union} related=${s.related} ` + + `wrong=${s.wrong} silent=${s.silent} | precision=${pct(good, fired)} ` + + `strict=${pct(s.exact, fired)} recall=${pct(good, total)}` + ); + }; + const sorted = [...report.timings].sort((a, b) => a - b); + const searches = [...report.searches].sort((a, b) => a - b); + const localSearches = [...report.localSearches].sort((a, b) => a - b); + const u = report.undocumented; + const reasons = Object.entries(report.silentReasons) + .sort((a, b) => b[1] - a[1]) + .map(([reason, count]) => `${reason}=${count}`); + return [ + line('params'), + line('returns'), + line('variables'), + `silent params: ${reasons.join(' ') || 'none'}`, + `undocumented params fired on ${u.fired}/${u.total}`, + `latency ms p50=${percentile(sorted, 50).toFixed(1)} p95=${percentile(sorted, 95).toFixed(1)} ` + + `max=${(sorted.at(-1) ?? 0).toFixed(1)} over ${sorted.length} requests`, + `project-wide searches p50=${percentile(searches, 50)} p95=${percentile(searches, 95)} ` + + `max=${searches.at(-1) ?? 0} total=${searches.reduce((sum, n) => sum + n, 0)}`, + `single-file searches p95=${percentile(localSearches, 95)} max=${localSearches.at(-1) ?? 0} ` + + `total=${localSearches.reduce((sum, n) => sum + n, 0)}`, + ].join('\n'); +} + +(CORPUS ? describe : describe.skip)('usage inference ground truth (B2C_INFERENCE_CORPUS)', function () { + this.timeout(30 * 60 * 1000); + + it('scores inference against documented dw.* types', () => { + const cartridges = discoverCorpusCartridges(CORPUS.split(path.delimiter).filter(Boolean)); + const fileNames = serverScriptFiles(cartridges); + const plugin = createCorpusPlugin(cartridges, fileNames); + const program = plugin.languageService.getProgram(); + const ancestry = buildAncestry(program); + const allTargets = fileNames.map((f) => collectFileTargets(program, f)); + const report = { + params: emptyScore(), + returns: emptyScore(), + variables: emptyScore(), + silentReasons: {}, + undocumented: {total: 0, fired: 0, samples: []}, + mismatches: [], + timings: [], + searches: [], + localSearches: [], + slowest: [], + [SEARCH_COUNTER]: plugin.searches, + }; + + try { + evaluateUndocumented(plugin.proxy, allTargets, report); + for (const targets of allTargets) { + if (targets.params.length > 0 || targets.returns.length > 0) evaluateFile(plugin, ancestry, targets, report); + } + } finally { + plugin.searches.stop(); + } + + const summary = summarize(report); + process.stdout.write(`\n${cartridges.length} cartridges, ${fileNames.length} files\n${summary}\n`); + if (process.env.B2C_INFERENCE_REPORT) { + fs.writeFileSync(process.env.B2C_INFERENCE_REPORT, JSON.stringify({summary, ...report}, null, 2)); + } + }); +}); diff --git a/packages/b2c-script-types/test/helpers/assert-inference.js b/packages/b2c-script-types/test/helpers/assert-inference.js new file mode 100644 index 000000000..19a0991fd --- /dev/null +++ b/packages/b2c-script-types/test/helpers/assert-inference.js @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const ts = require('typescript'); + +// Word-based completion entries from tsserver use ScriptElementKind.warning +// (VS Code maps them to CompletionItemKind.Text). Typed API members use +// memberFunctionElement, memberVariableElement, etc. — filtering warning kind +// avoids false greens when a name also appears as literal text in the fixture. +const WORD_BASED_COMPLETION_KIND = ts.ScriptElementKind.warning; + +/** + * Collects the human-readable hover text from a QuickInfo response. + * Inferred-usage notes live in `documentation`; type/display text may also + * appear in `displayParts`, so both are joined for pattern matching. + * + * @param {import('typescript').QuickInfo | undefined} info + * @returns {string} + */ +function quickInfoText(info) { + return [...(info?.displayParts ?? []), ...(info?.documentation ?? [])].map((p) => p.text).join(''); +} + +/** + * @param {import('typescript').LanguageService} languageService or proxy + * @param {string} fileName + * @param {number} position + * @param {RegExp|string} expectedTypePattern - must appear in hover text + */ +function assertInferredHover(languageService, fileName, position, expectedTypePattern) { + const info = languageService.getQuickInfoAtPosition(fileName, position); + const text = quickInfoText(info); + assert.ok( + text.includes('Inferred from usage'), + `expected an "Inferred from usage" hover note at ${fileName}:${position}, got: ${text}`, + ); + const matches = + expectedTypePattern instanceof RegExp ? expectedTypePattern.test(text) : text.includes(expectedTypePattern); + assert.ok(matches, `expected hover to match ${expectedTypePattern}, got: ${text}`); +} + +/** + * Assert hover has NO "Inferred from usage" note (silence). + * + * @param {import('typescript').LanguageService} languageService or proxy + * @param {string} fileName + * @param {number} position + */ +function assertNoInferredHover(languageService, fileName, position) { + const info = languageService.getQuickInfoAtPosition(fileName, position); + const docText = (info?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + !docText.includes('Inferred from usage'), + `expected no inferred-usage hover note at ${fileName}:${position}, got: ${docText}`, + ); +} + +/** + * Returns completion entry names, optionally excluding word-based suggestions. + * + * @param {import('typescript').CompletionEntry[]} entries + * @param {boolean} typedOnly + * @returns {string[]} + */ +function completionNames(entries, typedOnly) { + const filtered = typedOnly ? entries.filter((e) => e.kind !== WORD_BASED_COMPLETION_KIND) : entries; + return filtered.map((e) => e.name); +} + +/** + * @param {import('typescript').LanguageService} languageService or proxy + * @param {string} fileName + * @param {number} position + * @param {object} opts + * @param {string[]} opts.required - member names that must be present + * @param {string[]} [opts.forbidden] - must not be present + * @param {boolean} [opts.typedOnly=true] - if true, ignore word-based (warning kind) entries + */ +function assertTypedCompletions(languageService, fileName, position, opts) { + const {required, forbidden = [], typedOnly = true} = opts; + const completions = languageService.getCompletionsAtPosition(fileName, position, undefined); + const names = completionNames(completions?.entries ?? [], typedOnly); + + for (const name of required) { + assert.ok(names.includes(name), `expected ${name} among typed completions, got: ${names.join(', ')}`); + } + for (const name of forbidden) { + assert.ok(!names.includes(name), `expected ${name} to be absent from typed completions, got: ${names.join(', ')}`); + } +} + +/** + * Finds the byte offset of `needle` in `sourceText`. + * + * @param {string} sourceText + * @param {string} needle + * @returns {number} + */ +function positionOf(sourceText, needle) { + const idx = sourceText.indexOf(needle); + if (idx === -1) { + throw new Error(`needle not found in source: ${needle}`); + } + return idx; +} + +module.exports = { + assertInferredHover, + assertNoInferredHover, + assertTypedCompletions, + completionNames, + positionOf, + quickInfoText, + WORD_BASED_COMPLETION_KIND, +}; diff --git a/packages/b2c-script-types/test/helpers/cartridge-fixture.js b/packages/b2c-script-types/test/helpers/cartridge-fixture.js new file mode 100644 index 000000000..99fafce38 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/cartridge-fixture.js @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const {realTypesPrelude} = require('./real-dw-types'); + +/** + * Builds the absolute in-memory path for a file inside a named cartridge. + * + * @param {string} cartridgeName + * @param {string} relativePath - path relative to cartridge root, e.g. `cartridge/scripts/helpers/foo.js` + * @returns {string} + */ +function absoluteCartridgePath(cartridgeName, relativePath) { + const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+/, ''); + return `/cartridges/${cartridgeName}/${normalized}`; +} + +/** + * Builds a cartridge `src` root used in plugin cartridge config. + * + * @param {string} cartridgeName + * @returns {string} + */ +function cartridgeSrcRoot(cartridgeName) { + return `/cartridges/${cartridgeName}/`; +} + +/** + * @param {object} opts + * @param {Array<{name: string, files: Record}>} opts.cartridges + * - `files` keys are paths relative to cartridge root, e.g. `cartridge/scripts/helpers/foo.js` + * - Absolute virtual paths will be `/cartridges//` + * @param {string[]} [opts.dwTypes] - passed to realTypesPrelude if provided + * @param {string} [opts.globals] - body of `declare global { ... }` when dwTypes is set + * @param {string} [opts.extraDts] - full `/types.d.ts` content when dwTypes is omitted + * @param {string[]} [opts.cartridgeOrder] - order for dw.json cartridges field; default = opts.cartridges map name + * @returns {{ + * files: Record, + * dwJsonPath: string, + * cartridgeConfigs: Array<{name: string, src: string}>, + * createHostFiles: () => Record, + * }} + */ +function createCartridgeFixture(opts) { + const {cartridges, dwTypes, globals, extraDts, cartridgeOrder} = opts; + + const files = {}; + const cartridgeConfigs = []; + const jsIncludePaths = []; + + for (const cartridge of cartridges) { + cartridgeConfigs.push({name: cartridge.name, src: cartridgeSrcRoot(cartridge.name)}); + + for (const [relativePath, content] of Object.entries(cartridge.files)) { + const absPath = absoluteCartridgePath(cartridge.name, relativePath); + files[absPath] = content; + if (relativePath.endsWith('.js')) { + // jsconfig paths are relative to project root (/). + jsIncludePaths.push(absPath.slice(1)); + } + } + } + + const dwJsonPath = '/dw.json'; + const order = cartridgeOrder ?? cartridges.map((c) => c.name); + files[dwJsonPath] = JSON.stringify( + { + hostname: 'test-fixture.invalid', + username: 'fixture-user', + password: 'not-a-real-password', + 'code-version': 'version1', + cartridges: order.join(':'), + }, + null, + 2, + ); + + if (dwTypes && dwTypes.length > 0) { + files['/types.d.ts'] = realTypesPrelude(dwTypes, globals ?? extraDts ?? ''); + } else if (extraDts) { + files['/types.d.ts'] = extraDts; + } + + if (jsIncludePaths.length > 0) { + files['/jsconfig.json'] = JSON.stringify( + { + compilerOptions: { + target: 'es5', + module: 'commonjs', + moduleResolution: 'node', + allowJs: true, + checkJs: false, + noEmit: true, + }, + include: jsIncludePaths, + }, + null, + 2, + ); + } + + return { + files, + dwJsonPath, + cartridgeConfigs, + createHostFiles: () => ({...files}), + }; +} + +module.exports = { + absoluteCartridgePath, + cartridgeSrcRoot, + createCartridgeFixture, +}; diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js new file mode 100644 index 000000000..1508faa6d --- /dev/null +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const ts = require('typescript'); + +// Builds a LanguageServiceHost backed entirely by in-memory sources. Lib files +// (lib.es2020.d.ts, etc.) still resolve through the real ts.sys since we only +// care about controlling the fixture's own files — but the default lib file +// must be listed explicitly, since (unlike ts.createProgram) a LanguageService +// never adds it automatically; without it, primitive types (string, number) +// have no members at all, which would make apparent-type-dependent inference +// impossible to test. +function createFixtureHost(files, options) { + const compilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + allowJs: true, + checkJs: false, + strict: false, + ...options, + }; + const libFileName = ts.getDefaultLibFilePath(compilerOptions); + const fileNames = () => [...Object.keys(files), libFileName]; + + // Directories implied by the in-memory file map. TS's module resolver + // probes directoryExists before trying file candidates + // (directoryProbablyExists), and the ts.sys fallback answers against the + // real filesystem — where these virtual directories don't exist — so + // without this, a nested relative require ('./helpers/productHelpers') + // inside the fixture silently fails to resolve. + const impliedDirs = new Set(['/']); + for (const fileName of Object.keys(files)) { + let dir = fileName; + while (dir.includes('/') && (dir = dir.slice(0, dir.lastIndexOf('/'))) !== '') { + impliedDirs.add(dir); + } + } + + const fileExists = (fileName) => fileName in files || ts.sys.fileExists(fileName); + const readFile = (fileName) => files[fileName] ?? ts.sys.readFile(fileName); + + const host = { + getScriptFileNames: fileNames, + // The shared DocumentRegistry below (see createFixtureLanguageService) + // only reuses a cached parse when both the file path AND this version + // string match a previous request — so the version must reflect actual + // content, not a constant, or two different tests that happen to reuse + // the same in-memory path (very common: '/types.d.ts', '/helper.js') but + // with different content would silently serve each other's stale parsed + // SourceFile. Using the in-memory file's own text as its version makes + // that impossible (identical content -> identical version -> safe reuse; + // different content -> different version -> correctly reparsed) while + // real on-disk files (the vendored dw/* tree, lib.*.d.ts — which never + // change across a test run) keep a constant version, so THEY get parsed + // once total and reused by every subsequent fixture — the expensive part + // this cache exists to short-circuit. + getScriptVersion: (fileName) => files[fileName] ?? 'on-disk', + getScriptSnapshot: (fileName) => { + const text = readFile(fileName); + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => '/', + getCompilationSettings: () => compilerOptions, + getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), + fileExists, + readFile, + directoryExists: (dir) => impliedDirs.has(dir) || ts.sys.directoryExists(dir), + getDirectories: (dir) => ts.sys.getDirectories(dir), + }; + + // The tsserver plugin only *wraps* existing host resolution hooks — it + // won't install cartridge `~/` / `*/` / `dw/` resolution when these are + // absent. Real tsserver hosts always provide them. Delegate to + // ts.resolveModuleName for ordinary relative/node resolution so existing + // engine tests keep working; the plugin's wrapper then fills in cartridge + // specifiers the default resolver leaves unresolved. + host.resolveModuleNameLiterals = (moduleLiterals, containingFile, _redirected, options) => + moduleLiterals.map((literal) => ts.resolveModuleName(literal.text, containingFile, options, host)); + host.resolveModuleNames = (moduleNames, containingFile, _reused, _redirected, options) => + moduleNames.map((name) => ts.resolveModuleName(name, containingFile, options, host).resolvedModule); + + return host; +} + +// Shared across every fixture LanguageService created in this process — both +// via createFixtureLanguageService below and by test files (e.g. +// index.test.js) that build a LanguageService directly with +// createFixtureHost(). A DocumentRegistry is TypeScript's built-in mechanism +// for reusing an already-parsed-and-bound SourceFile across multiple +// LanguageServices that request the same (path, version, compilation +// settings) — exactly the case for the real vendored dw/* declaration tree, +// which is identical across every test in a run. A fresh per-call registry +// (the previous behavior) defeated this entirely, forcing every single test +// to re-parse and re-bind hundreds of real .d.ts files from scratch — the +// dominant cost behind this suite's real-world runtime. +const sharedDocumentRegistry = ts.createDocumentRegistry(); + +// Builds a real ts.LanguageService on top of createFixtureHost(), so +// usage-inference tests can exercise findReferences/checker behavior without +// touching disk. +function createFixtureLanguageService(files, options) { + const host = createFixtureHost(files, options); + return ts.createLanguageService(host, sharedDocumentRegistry); +} + +// Finds a top-level `function name(...) {...}` declaration in a fixture +// source file, so tests can locate the node to run inference against without +// computing offsets by hand. +function findFunctionDeclaration(sourceFile, name) { + let found; + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && node.name && node.name.text === name) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!found) throw new Error(`function ${name} not found`); + return found; +} + +module.exports = { + createFixtureHost, + createFixtureLanguageService, + findFunctionDeclaration, + sharedDocumentRegistry, +}; diff --git a/packages/b2c-script-types/test/helpers/plugin-proxy.js b/packages/b2c-script-types/test/helpers/plugin-proxy.js new file mode 100644 index 000000000..efa8e41f3 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/plugin-proxy.js @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const ts = require('typescript'); + +const init = require('../../src/index'); +const {createFixtureHost, sharedDocumentRegistry} = require('./fixture-language-service'); + +/** + * Installs the plugin on a language service the way tsserver does, passing + * only the part of PluginCreateInfo the plugin touches (logger, current + * directory, project version, config, host, language service). + * + * @param {object} options + * @param {object} options.config - the plugin configuration + * @param {Record} [options.files] - in-memory fixture files (when no `host` is given) + * @param {object} [options.compilerOptions] - compiler options for the fixture host + * @param {object} [options.host] - a prepared LanguageServiceHost to use instead of a fixture host + * @param {object} [options.languageService] - the language service to decorate (default: one over `host`) + * @param {() => string} [options.projectVersion] - the project version tsserver reports + * @param {string} [options.currentDirectory] - the project root auto-discovery starts from + * @param {(message: string) => void} [options.log] - receives the plugin's log lines + * @returns {{proxy: object, plugin: {create: Function, onConfigurationChanged: Function}, host: object, languageService: object}} + */ +function createPluginProxy({ + config, + files, + compilerOptions, + host = createFixtureHost(files, compilerOptions), + languageService = ts.createLanguageService(host, sharedDocumentRegistry), + projectVersion = () => '1', + currentDirectory = '/', + log = () => {}, +}) { + const plugin = init({typescript: ts}); + const proxy = plugin.create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: log}}, + getCurrentDirectory: () => currentDirectory, + getProjectVersion: projectVersion, + }, + config, + }); + return {proxy, plugin, host, languageService}; +} + +module.exports = {createPluginProxy}; diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js new file mode 100644 index 000000000..9626b581e --- /dev/null +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const path = require('node:path'); + +// Absolute paths to the real, bundled dw.* type declarations (not stand-in +// ambient shapes), so tests exercise the actual Product/Order API surface a +// cartridge author sees, including its real generics, overloads, and +// nullability. `createFixtureHost`'s ts.sys fallback resolves these directly +// by absolute path — no dw/* module-resolution wiring needed for these tests. +const TYPES_DIR = path.resolve(__dirname, '../../types').replace(/\\/g, '/'); + +const dtsPath = (...segments) => path.join(TYPES_DIR, ...segments).replace(/\\/g, '/'); + +const REAL_DW_TYPES = { + Product: dtsPath('dw', 'catalog', 'Product'), + ProductMgr: dtsPath('dw', 'catalog', 'ProductMgr'), + ProductPriceModel: dtsPath('dw', 'catalog', 'ProductPriceModel'), + Category: dtsPath('dw', 'catalog', 'Category'), + Collection: dtsPath('dw', 'util', 'Collection'), + Variant: dtsPath('dw', 'catalog', 'Variant'), + VariationGroup: dtsPath('dw', 'catalog', 'VariationGroup'), + Money: dtsPath('dw', 'value', 'Money'), + Order: dtsPath('dw', 'order', 'Order'), + OrderMgr: dtsPath('dw', 'order', 'OrderMgr'), + Customer: dtsPath('dw', 'customer', 'Customer'), + Profile: dtsPath('dw', 'customer', 'Profile'), + Shipment: dtsPath('dw', 'order', 'Shipment'), + ProductLineItem: dtsPath('dw', 'order', 'ProductLineItem'), + BonusDiscountLineItem: dtsPath('dw', 'order', 'BonusDiscountLineItem'), + ProductShippingLineItem: dtsPath('dw', 'order', 'ProductShippingLineItem'), + AddressBook: dtsPath('dw', 'customer', 'AddressBook'), + CustomerAddress: dtsPath('dw', 'customer', 'CustomerAddress'), + ProductListRegistrant: dtsPath('dw', 'customer', 'ProductListRegistrant'), + Store: dtsPath('dw', 'catalog', 'Store'), + ServiceConfig: dtsPath('dw', 'svc', 'ServiceConfig'), + Basket: dtsPath('dw', 'order', 'Basket'), + OrderAddress: dtsPath('dw', 'order', 'OrderAddress'), + ProductSearchModel: dtsPath('dw', 'catalog', 'ProductSearchModel'), + ProductSearchRefinementValue: dtsPath('dw', 'catalog', 'ProductSearchRefinementValue'), + ProductAvailabilityModel: dtsPath('dw', 'catalog', 'ProductAvailabilityModel'), + OrderPaymentInstrument: dtsPath('dw', 'order', 'OrderPaymentInstrument'), + ShippingMethod: dtsPath('dw', 'order', 'ShippingMethod'), + ShippingLineItem: dtsPath('dw', 'order', 'ShippingLineItem'), + PriceAdjustment: dtsPath('dw', 'order', 'PriceAdjustment'), + Transaction: dtsPath('dw', 'system', 'Transaction'), + HookMgr: dtsPath('dw', 'system', 'HookMgr'), + PaymentMgr: dtsPath('dw', 'order', 'PaymentMgr'), + PaymentInstrument: dtsPath('dw', 'order', 'PaymentInstrument'), + CustomerPaymentInstrument: dtsPath('dw', 'customer', 'CustomerPaymentInstrument'), +}; + +/** + * Builds a `/types.d.ts` fixture file that imports the requested real dw.* + * classes and re-declares the given globals inside `declare global {}`. + * + * A `.d.ts` file with top-level `import ... = require(...)` statements + * becomes a *module*, which would otherwise scope plain `declare function` + * statements to that module instead of making them true ambient globals + * visible from the consuming `.js` fixture — `declare global` is what keeps + * them globally visible despite the imports. + * + * @param {string[]} imports - dw.* class names to import, e.g. `['Product', 'ProductMgr']`. + * @param {string} globals - body of the `declare global { ... }` block (function/var declarations). + * @returns {string} the `/types.d.ts` file content. + */ +function realTypesPrelude(imports, globals) { + const importLines = imports.map((name) => `import ${name} = require('${REAL_DW_TYPES[name]}');`).join('\n'); + return `${importLines}\ndeclare global {\n${globals}\n}\n`; +} + +module.exports = {REAL_DW_TYPES, realTypesPrelude}; diff --git a/packages/b2c-script-types/test/helpers/reference-searches.js b/packages/b2c-script-types/test/helpers/reference-searches.js new file mode 100644 index 000000000..3f9410541 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/reference-searches.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const diagnosticsChannel = require('node:diagnostics_channel'); + +// Usage inference publishes every reference search it runs on this channel +// as `{name, projectWide}` (see src/inference/reference-search.ts). A search +// served from the per-Program cache is not a run and is not published. +const REFERENCE_SEARCH_CHANNEL = '@salesforce/b2c-script-types:reference-search'; + +/** + * Starts counting the reference searches usage inference runs: the + * deterministic cost measure the performance baselines assert on. `total()` + * counts every search, `projectWide()` those that read every file of the + * program, and `local()` those confined to the one file that can refer to + * the searched name. Call `stop()` when done. + */ +function countReferenceSearches() { + const counts = {projectWide: 0, local: 0}; + const onSearch = (message) => { + if (message.projectWide) counts.projectWide++; + else counts.local++; + }; + diagnosticsChannel.subscribe(REFERENCE_SEARCH_CHANNEL, onSearch); + return { + total: () => counts.projectWide + counts.local, + projectWide: () => counts.projectWide, + local: () => counts.local, + reset() { + counts.projectWide = 0; + counts.local = 0; + }, + stop: () => diagnosticsChannel.unsubscribe(REFERENCE_SEARCH_CHANNEL, onSearch), + }; +} + +module.exports = {countReferenceSearches}; diff --git a/packages/b2c-script-types/test/hook-registry.test.js b/packages/b2c-script-types/test/hook-registry.test.js new file mode 100644 index 000000000..b23f21f9a --- /dev/null +++ b/packages/b2c-script-types/test/hook-registry.test.js @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const {readHookRegistrations} = require('../src/resolver/hook-registry'); +const {createPathContainment} = require('../src/resolver/module-resolution'); + +const {isWithinRoot} = createPathContainment(ts, true); + +const CARTRIDGE = {name: 'app_custom', root: '/ws/app_custom/', rawRoot: '/ws/app_custom/'}; + +/** The registrations `CARTRIDGE` declares when the workspace holds `files` (path -> text). */ +function registrationsOf(files, cartridges = [CARTRIDGE]) { + return readHookRegistrations(cartridges, { + readFile: (fileName) => files[fileName], + fileExists: (fileName) => fileName in files, + isWithinRoot, + }); +} + +const json = (value) => JSON.stringify(value); + +describe('readHookRegistrations', () => { + it('reads the hooks.json a cartridge package.json names, resolving scripts like a require()', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({ + hooks: [ + {name: 'dw.order.calculate', script: './cartridge/scripts/hooks/cart/calculate.js'}, + {name: 'app.payment.processor.basic_credit', script: './cartridge/scripts/hooks/payment/basic_credit'}, + {name: 'app.legacy', script: './cartridge/scripts/hooks/legacy'}, + ], + }), + '/ws/app_custom/cartridge/scripts/hooks/cart/calculate.js': '', + '/ws/app_custom/cartridge/scripts/hooks/payment/basic_credit.js': '', + '/ws/app_custom/cartridge/scripts/hooks/legacy.ds': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/cartridge/scripts/hooks/cart/calculate.js'}, + { + extensionPoint: 'app.payment.processor.basic_credit', + script: '/ws/app_custom/cartridge/scripts/hooks/payment/basic_credit.js', + }, + {extensionPoint: 'app.legacy', script: '/ws/app_custom/cartridge/scripts/hooks/legacy.ds'}, + ]); + }); + + it('resolves scripts relative to a hooks.json kept inside the cartridge folder', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './cartridge/scripts/hooks.json'}), + '/ws/app_custom/cartridge/scripts/hooks.json': json({ + hooks: [{name: 'dw.order.calculate', script: './cart/calculate'}], + }), + '/ws/app_custom/cartridge/scripts/cart/calculate.js': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/cartridge/scripts/cart/calculate.js'}, + ]); + }); + + it('declares nothing without a package.json hooks entry, or when either file is missing, unreadable or malformed', () => { + const hooksJson = json({hooks: [{name: 'dw.order.calculate', script: './calculate.js'}]}); + const cases = [ + {'/ws/app_custom/hooks.json': hooksJson, '/ws/app_custom/calculate.js': ''}, + {'/ws/app_custom/package.json': json({name: 'app_custom'}), '/ws/app_custom/hooks.json': hooksJson}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'})}, + {'/ws/app_custom/package.json': '{"hooks": ', '/ws/app_custom/hooks.json': hooksJson}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'}), '/ws/app_custom/hooks.json': 'not json'}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'}), '/ws/app_custom/hooks.json': json({hooks: {}})}, + {'/ws/app_custom/package.json': json({hooks: 42}), '/ws/app_custom/hooks.json': hooksJson}, + ]; + for (const files of cases) assert.deepEqual(registrationsOf(files), [], json(files)); + const unreadable = readHookRegistrations([CARTRIDGE], { + readFile: () => { + throw new Error('EACCES'); + }, + fileExists: () => true, + isWithinRoot, + }); + assert.deepEqual(unreadable, []); + }); + + it('skips entries without a name, a script, or a script file that exists', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({ + hooks: [ + null, + 'dw.order.calculate', + {name: 'dw.order.calculate'}, + {script: './calculate.js'}, + {name: 7, script: './calculate.js'}, + {name: 'dw.order.missing', script: './missing.js'}, + {name: 'dw.order.calculate', script: './calculate.js'}, + ], + }), + '/ws/app_custom/calculate.js': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/calculate.js'}, + ]); + }); + + it('never reads or registers a path outside the cartridge that declares it', () => { + const outside = { + '/ws/other/hooks.json': json({hooks: [{name: 'dw.order.calculate', script: './calculate.js'}]}), + '/ws/other/calculate.js': '', + }; + const readOutside = []; + const files = {...outside, '/ws/app_custom/package.json': json({hooks: '../other/hooks.json'})}; + readHookRegistrations([CARTRIDGE], { + readFile: (fileName) => { + if (fileName.startsWith('/ws/other/')) readOutside.push(fileName); + return files[fileName]; + }, + fileExists: (fileName) => fileName in files, + isWithinRoot, + }); + assert.deepEqual(readOutside, []); + + const escapingScript = { + ...outside, + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({hooks: [{name: 'dw.order.calculate', script: '../other/calculate.js'}]}), + }; + assert.deepEqual(registrationsOf(escapingScript), []); + }); + + it('reads every configured cartridge, in cartridge path order', () => { + const base = {name: 'app_base', root: '/ws/app_base/', rawRoot: '/ws/app_base/'}; + const declare = (root, script) => ({ + [`${root}package.json`]: json({hooks: './hooks.json'}), + [`${root}hooks.json`]: json({hooks: [{name: 'dw.order.calculate', script}]}), + [`${root}${script.slice(2)}`]: '', + }); + const files = {...declare('/ws/app_custom/', './custom.js'), ...declare('/ws/app_base/', './base.js')}; + assert.deepEqual( + registrationsOf(files, [CARTRIDGE, base]).map((registration) => registration.script), + ['/ws/app_custom/custom.js', '/ws/app_base/base.js'], + ); + }); +}); diff --git a/packages/b2c-script-types/test/index.security.test.js b/packages/b2c-script-types/test/index.security.test.js new file mode 100644 index 000000000..afc180e56 --- /dev/null +++ b/packages/b2c-script-types/test/index.security.test.js @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +// Security regression tests for the plugin's module resolvers. Every input a +// resolver consumes (import specifiers, cartridge names, a cartridge's +// package.json `main`) is attacker-controlled the moment a developer opens a +// cloned repository, so a crafted `require()` must never resolve to a file +// outside the intended root (the bundled types dir for `dw/*`, a cartridge +// root for cartridge-relative requires). These drive the REAL plugin through +// its wrapped `resolveModuleNameLiterals` host hook against on-disk fixtures — +// no mocks of the code under audit — because the resolvers probe the real +// filesystem via ts.sys. + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ts = require('typescript'); + +const {createPluginProxy} = require('./helpers/plugin-proxy'); + +const TYPES_DIR = path.resolve(__dirname, '..', 'types'); + +// Builds an on-disk workspace: an `app` cartridge and a `modules` cartridge +// under /workspace, plus secret files OUTSIDE every root that traversal +// must never reach. +function buildWorkspace() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sec-')); + const ws = path.join(tmp, 'workspace'); + const appRoot = path.join(ws, 'app_cartridge'); + fs.mkdirSync(path.join(appRoot, 'cartridge', 'scripts'), {recursive: true}); + // A legitimate in-cartridge module, so we can assert normal requires still resolve. + fs.writeFileSync(path.join(appRoot, 'cartridge', 'scripts', 'util.js'), 'module.exports = {};'); + const containingFile = path.join(appRoot, 'cartridge', 'scripts', 'controller.js'); + fs.writeFileSync(containingFile, 'require("x");'); + + const modRoot = path.join(ws, 'modules'); + fs.mkdirSync(path.join(modRoot, 'pkg'), {recursive: true}); + // A directory package whose `main` traverses out of the modules root. + fs.writeFileSync(path.join(modRoot, 'pkg', 'package.json'), JSON.stringify({main: '../../../secret_outside.js'})); + // A benign directory package, so we can assert legitimate `main` still resolves. + fs.mkdirSync(path.join(modRoot, 'goodpkg'), {recursive: true}); + fs.writeFileSync(path.join(modRoot, 'goodpkg', 'package.json'), JSON.stringify({main: './lib.js'})); + fs.writeFileSync(path.join(modRoot, 'goodpkg', 'lib.js'), 'module.exports = {};'); + + // A directory package whose package.json is valid JSON with a valid in-root + // `main`, but padded past the 1 MiB parse ceiling — must be refused rather + // than parsed synchronously on tsserver's thread. + fs.mkdirSync(path.join(modRoot, 'bigpkg'), {recursive: true}); + fs.writeFileSync( + path.join(modRoot, 'bigpkg', 'package.json'), + JSON.stringify({main: './lib.js', _pad: 'A'.repeat(2 * 1024 * 1024)}), + ); + fs.writeFileSync(path.join(modRoot, 'bigpkg', 'lib.js'), 'module.exports = {};'); + + // Secrets outside any root. + fs.writeFileSync(path.join(tmp, 'secret_outside.js'), 'module.exports = {SECRET: "leaked"};'); + fs.writeFileSync(path.join(tmp, 'leak.d.ts'), 'export const SECRET: string;'); + + return {tmp, appRoot, modRoot, containingFile}; +} + +function makeResolver({appRoot, modRoot, containingFile, tmp}) { + const host = { + getScriptFileNames: () => [containingFile], + getScriptVersion: () => '0', + getScriptSnapshot: (f) => (fs.existsSync(f) ? ts.ScriptSnapshot.fromString(fs.readFileSync(f, 'utf8')) : undefined), + getCurrentDirectory: () => tmp, + getCompilationSettings: () => ({allowJs: true}), + getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o), + fileExists: (f) => fs.existsSync(f), + readFile: (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : undefined), + directoryExists: (d) => fs.existsSync(d), + getDirectories: (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []), + // Provide the hook so the plugin wraps it; return all-unresolved so the + // plugin's cartridge/dw fallback resolution runs for every specifier. + resolveModuleNameLiterals: (lits) => lits.map(() => ({resolvedModule: undefined})), + }; + const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + createPluginProxy({ + host, + languageService, + currentDirectory: tmp, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'app_cartridge', src: appRoot}, + {name: 'modules', src: modRoot}, + ], + }, + }); + // host.resolveModuleNameLiterals is now the plugin's wrapped version. + return (spec, from = containingFile) => { + const res = host.resolveModuleNameLiterals([{text: spec}], from, undefined, {}, undefined, undefined); + return res[0] && res[0].resolvedModule ? res[0].resolvedModule.resolvedFileName : undefined; + }; +} + +// Canonical containment check mirroring the security property under test: +// a resolved path must sit at or beneath `root` once symlinks and `..` are +// resolved. +function isWithin(resolved, root) { + const real = (p) => { + try { + return fs.realpathSync(p); + } catch { + return path.resolve(p); + } + }; + const c = real(resolved); + const r = real(root); + return c === r || c.startsWith(r + path.sep); +} + +describe('module resolver path-traversal containment', () => { + let workspace; + let resolve; + + before(() => { + workspace = buildWorkspace(); + resolve = makeResolver(workspace); + }); + + after(() => { + if (workspace) fs.rmSync(workspace.tmp, {recursive: true, force: true}); + }); + + // --- resolveDwModule: must stay inside the bundled types dir --- + + it('does not let a crafted dw/ specifier escape the bundled types directory', () => { + const spec = 'dw/../' + path.relative(TYPES_DIR, path.join(workspace.tmp, 'leak')).replace(/\\/g, '/'); + const resolved = resolve(spec); + assert.equal(resolved, undefined, `dw traversal resolved to ${resolved}`); + }); + + it('still resolves a legitimate dw/ module to the bundled types directory', () => { + const resolved = resolve('dw/catalog/Product'); + assert.ok(resolved, 'expected dw/catalog/Product to resolve'); + assert.ok(isWithin(resolved, TYPES_DIR), `dw module resolved outside types dir: ${resolved}`); + }); + + // --- resolveCartridgeModule: ~/, */, / subpaths must stay in-cartridge --- + + it('does not let ~/.. escape the owning cartridge root', () => { + const resolved = resolve('~/../../secret_outside'); + assert.equal(resolved, undefined, `~/.. traversal resolved to ${resolved}`); + }); + + it('does not let */.. escape a cartridge root', () => { + const resolved = resolve('*/../../secret_outside'); + assert.equal(resolved, undefined, `*/.. traversal resolved to ${resolved}`); + }); + + it('does not let /.. escape the named cartridge root', () => { + const resolved = resolve('app_cartridge/../../secret_outside'); + assert.equal(resolved, undefined, `/.. traversal resolved to ${resolved}`); + }); + + it('still resolves a legitimate ~/ cartridge require', () => { + const resolved = resolve('~/cartridge/scripts/util'); + assert.ok(resolved, 'expected ~/cartridge/scripts/util to resolve'); + assert.ok(isWithin(resolved, workspace.appRoot), `resolved outside cartridge: ${resolved}`); + }); + + // --- resolveModulesCartridge: bare specifiers + package.json main --- + + it('does not let a modules-cartridge specifier escape the modules root', () => { + const resolved = resolve('pkg/../../../secret_outside'); + assert.equal(resolved, undefined, `modules traversal resolved to ${resolved}`); + }); + + it("does not let a cartridge package.json 'main' traverse out of the modules root", () => { + // require('pkg') -> reads modules/pkg/package.json whose main is '../../../secret_outside.js'. + const resolved = resolve('pkg'); + assert.equal(resolved, undefined, `package.json main traversal resolved to ${resolved}`); + }); + + it("still resolves a benign cartridge package.json 'main'", () => { + const resolved = resolve('goodpkg'); + assert.ok(resolved, 'expected goodpkg to resolve via package.json main'); + assert.ok(isWithin(resolved, workspace.modRoot), `resolved outside modules root: ${resolved}`); + }); + + it('refuses to parse a cartridge package.json larger than the size ceiling', () => { + // Valid JSON with a valid in-root `main`, but > 1 MiB — the size cap must + // skip it (a real package.json is a few KB) rather than parse it. + const resolved = resolve('bigpkg'); + assert.equal(resolved, undefined, `oversized package.json was parsed and resolved to ${resolved}`); + }); + + // --- symlink escape: an in-cartridge symlink pointing outside --- + + it('does not follow an in-cartridge symlink that points outside the cartridge root', () => { + const link = path.join(workspace.appRoot, 'cartridge', 'scripts', 'link.js'); + try { + fs.symlinkSync(path.join(workspace.tmp, 'secret_outside.js'), link); + } catch { + return; // filesystem without symlink support — skip + } + const resolved = resolve('~/cartridge/scripts/link'); + fs.rmSync(link, {force: true}); + assert.equal(resolved, undefined, `symlink escape resolved to ${resolved}`); + }); +}); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js new file mode 100644 index 000000000..2caf1fe5f --- /dev/null +++ b/packages/b2c-script-types/test/index.test.js @@ -0,0 +1,1073 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ts = require('typescript'); + +const {INFERRED_COMPLETION_SOURCE} = require('../src/usage-inference'); +const {createFixtureHost, sharedDocumentRegistry} = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); + +const AMBIENT_TYPES = ` +declare function getProduct(): {ID: string; name: string}; +`; + +const FIXTURE_FILES = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, +}; + +// The plugin installed over FIXTURE_FILES with the given configuration. +function fixtureProxy(config) { + return createPluginProxy({files: FIXTURE_FILES, config}).proxy; +} + +// Parses the fixture source once to locate exact AST offsets, rather than +// computing them by hand from the template string (fragile to whitespace). +function fixtureOffsets() { + const source = FIXTURE_FILES['/helper.js']; + const sourceFile = ts.createSourceFile('/helper.js', source, ts.ScriptTarget.ES2020, true); + let paramPos; + let dotPos; + const visit = (node) => { + if (ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === 'product') { + paramPos = node.name.getStart(sourceFile); + } + if (ts.isPropertyAccessExpression(node) && node.name.text === 'ID') { + dotPos = node.expression.getEnd() + 1; // right after `product.` + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return {paramPos, dotPos}; +} + +// `/helper.js` only counts as a cartridge file once a cartridge root +// containing it is configured — matches how the real plugin scopes every +// other feature (require resolution, ambient globals) to cartridge files. +const CARTRIDGE_CONFIG = [{name: 'test_cartridge', src: '/'}]; + +describe('create() proxy — usage inference wiring', () => { + const {paramPos, dotPos} = fixtureOffsets(); + + it('leaves hover untouched when inferUsage is off (default)', () => { + const proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const docText = (info?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(!docText.includes('Inferred from usage')); + }); + + it('appends an inferred-usage hover note when inferUsage is on', () => { + const proxy = fixtureProxy({ + enabled: true, + autoDiscover: false, + cartridges: CARTRIDGE_CONFIG, + inferUsage: true, + }); + const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const docText = (info?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(docText.includes('Inferred from usage: { ID: string; name: string; }')); + }); + + it('leaves completions untouched when inferUsage is off (default)', () => { + const proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const completions = proxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(!names.includes('ID')); + }); + + it('synthesizes member completions from inferred usage when inferUsage is on', () => { + const proxy = fixtureProxy({ + enabled: true, + autoDiscover: false, + cartridges: CARTRIDGE_CONFIG, + inferUsage: true, + }); + const completions = proxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('ID')); + assert.ok(names.includes('name')); + }); + + it('preserves every other CompletionInfo field from the original result when merging in inferred entries', () => { + const plainProxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const original = plainProxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + + const inferProxy = fixtureProxy({ + enabled: true, + autoDiscover: false, + cartridges: CARTRIDGE_CONFIG, + inferUsage: true, + }); + const merged = inferProxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + + const originalRest = {...original}; + delete originalRest.entries; + const mergedRest = {...merged}; + delete mergedRest.entries; + assert.deepEqual(mergedRest, originalRest); + }); + + it('does not run inference outside a configured cartridge root, even when inferUsage is on', () => { + // No cartridges configured -> /helper.js isn't recognized as a cartridge + // file, matching every other feature in this plugin (require resolution, + // ambient globals) that only applies inside known cartridge roots. + const proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: [], inferUsage: true}); + const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const docText = (info?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(!docText.includes('Inferred from usage')); + + const completions = proxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(!names.includes('ID')); + }); + + it('does not run inference when the parent scriptTypes feature is disabled, even when inferUsage is on', () => { + const proxy = fixtureProxy({ + enabled: false, + autoDiscover: false, + cartridges: CARTRIDGE_CONFIG, + inferUsage: true, + }); + const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const docText = (info?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(!docText.includes('Inferred from usage')); + + const completions = proxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(!names.includes('ID')); + }); + + it('forwards the maximumLength parameter to the underlying getQuickInfoAtPosition call', () => { + // A documented (explicitly-typed) parameter with a long inline object + // type, so a small maximumLength actually truncates the display text — + // proves getQuickInfoAtPosition's 3rd argument reaches the real + // language service rather than being silently dropped by the wrapper. + const files = { + '/typed.ts': `function helper(x: {aVeryLongPropertyNameHere: string; anotherVeryLongPropertyName: number; yetAnotherLongOne: boolean}) { return x; }`, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: []}, + }); + const pos = files['/typed.ts'].indexOf('x:'); + + const full = proxy.getQuickInfoAtPosition('/typed.ts', pos); + const truncated = proxy.getQuickInfoAtPosition('/typed.ts', pos, 10); + + const fullText = full.displayParts.map((p) => p.text).join(''); + const truncatedText = truncated.displayParts.map((p) => p.text).join(''); + assert.ok(truncatedText.length < fullText.length); + }); + + it('does not serve a stale inferred type after the underlying file changes and the project version bumps', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES + 'declare function getInventory(): {quantity: number};\n', + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const versions = {'/types.d.ts': 0, '/helper.js': 0}; + let projectVersion = 1; + const host = createFixtureHost(files); + // Prefix createFixtureHost's content-derived version with an edit counter + // so this test can simulate a real edit bumping a file's version, while + // the content part keeps the shared DocumentRegistry from ever serving + // another test's parse of the same in-memory path. + const origGetScriptVersion = host.getScriptVersion; + host.getScriptVersion = (fileName) => `${versions[fileName] ?? 0}:${origGetScriptVersion(fileName)}`; + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {proxy} = createPluginProxy({ + host, + languageService, + projectVersion: () => String(projectVersion), + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + const paramPos = files['/helper.js'].indexOf('product)'); // start of the `product` identifier + + const before = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const beforeText = (before?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(beforeText.includes('{ ID: string; name: string; }')); + assert.ok(!beforeText.includes('quantity')); + + // Simulate an edit: the body now reads a member only the inventory shape + // has, and a new call site passes one. Bump both the file's script + // version and the project version (as a real host would) so the cache + // can't keep serving the old Product-shaped answer. + files['/helper.js'] = files['/helper.js'].replace('product.ID', 'product.quantity') + '\nhelper(getInventory());\n'; + versions['/helper.js'] += 1; + projectVersion += 1; + + const after = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + const afterText = (after?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + afterText.includes('Inferred from usage: { quantity: number; }'), + `expected the edited inference, got: ${afterText}`, + ); + assert.ok(!afterText.includes('{ ID: string; name: string; }')); + }); + + it('hovers and completes against the real, nullable dw.catalog.ProductMgr.getProduct() shape end-to-end', () => { + // Regression test for the exact production bug this feature shipped + // with: ProductMgr.getProduct() really does return `Product | null`, + // and getPropertiesOfType on that union (before stripping the nullable + // part) returns zero members — completions silently fell back to plain + // global suggestions while hover kept working, since describeTypes just + // stringifies the union instead of walking its members. + const files = { + '/priceHelper.js': ` + function getDisplayName(product) { + return product.getName(); + } + function useHelper() { + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); + return getDisplayName(product); + } + module.exports = {getDisplayName}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + const paramPos = files['/priceHelper.js'].indexOf('product)'); + const dotPos = files['/priceHelper.js'].indexOf('product.getName()') + 'product.'.length; + + const hover = proxy.getQuickInfoAtPosition('/priceHelper.js', paramPos); + const hoverText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok(hoverText.includes('Inferred from usage')); + assert.ok(/Product/.test(hoverText)); + + const completions = proxy.getCompletionsAtPosition('/priceHelper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('getID')); + assert.ok(names.includes('getName')); + }); + + it('offers inferred completions when the receiver is a chained call, not just a bare identifier', () => { + // `product.getPriceModel().|` — the receiver is a CallExpression. The + // completion wiring used to require a plain identifier base, so chains + // got no synthesized entries even though hover-driven return inference + // could resolve them. + const files = { + '/priceHelper.js': ` + function resolveProductPrice(product) { + return product.getPriceModel().getPrice(); + } + function useHelper() { + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); + return resolveProductPrice(product); + } + module.exports = {resolveProductPrice}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + const dotPos = files['/priceHelper.js'].indexOf('.getPrice()') + 1; + + const completions = proxy.getCompletionsAtPosition('/priceHelper.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + // Real dw.catalog.ProductPriceModel members. + assert.ok(names.includes('getPrice'), `expected getPrice among completions, got: ${names.join(', ')}`); + assert.ok(names.includes('getMinPrice'), `expected getMinPrice among completions, got: ${names.join(', ')}`); + + // Methods and properties get distinct completion icons. + const entryByName = new Map((completions?.entries ?? []).map((e) => [e.name, e])); + assert.equal(entryByName.get('getPrice').kind, ts.ScriptElementKind.memberFunctionElement); + assert.equal(entryByName.get('maxPrice').kind, ts.ScriptElementKind.memberVariableElement); + }); + + it('shows an inferred-usage hover note when hovering the member name of a property access, not just the bare receiver', () => { + // Regression test: hovering `shipment` itself in `shipment.productLineItems` + // worked (inferTypeForNode resolves a bare identifier's own declaration), + // but hovering `productLineItems` — the member name — didn't, because + // `productLineItems` has no declaration of its own to look up until the + // receiver's type is known, and the hover handler only ever tried + // inferTypeForNode on the exact hovered identifier. It now falls back to + // resolving the whole access expression, the same way completions do. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + shipment.custom.fromStoreId = null; + var items = shipment.productLineItems; + } + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const source = files['/shippingHelpers.js']; + const receiverPos = source.indexOf('shipment.productLineItems') + 1; + const memberPos = source.indexOf('productLineItems', receiverPos) + 1; + + const receiverHover = proxy.getQuickInfoAtPosition('/shippingHelpers.js', receiverPos); + const receiverDoc = (receiverHover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + receiverDoc.includes('Inferred from usage: Shipment'), + `expected the receiver hover to infer Shipment, got: ${receiverDoc}`, + ); + + const memberHover = proxy.getQuickInfoAtPosition('/shippingHelpers.js', memberPos); + const memberDoc = (memberHover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + memberDoc.includes('Inferred from usage: Collection'), + `expected the member-name hover to infer Collection, got: ${memberDoc}`, + ); + }); + + it("hover borrows the real declaration's display parts and doc comment instead of just noting the inferred type", () => { + // Regression test covering two things together: + // 1. Hover should read like a native, fully-resolved hover — the bolded + // header should read "(parameter) shipment: Shipment" (not "... : + // any"), and the documentation should include Shipment's own real + // doc comment ("Represents an order shipment."), not just our bare + // "Inferred from usage: X" note. + // 2. The vendored dw.* Script API nests each class's custom-attributes + // interface under the exact same simple name as the class itself + // (`declare global { module ICustomAttributes { interface Shipment + // extends CustomAttributes {} } }`, alongside the top-level `class + // Shipment`). Plain checker.typeToString() prints only the innermost + // name for both, so hovering `shipment.custom` used to show the + // misleading "Inferred from usage: Shipment" — identical to hovering + // `shipment` itself — instead of the real, distinct + // "ICustomAttributes.Shipment". + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + shipment.custom.fromStoreId = null; + var items = shipment.productLineItems; + } + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const source = files['/shippingHelpers.js']; + const paramPos = source.indexOf('markShipmentForShipping(shipment)') + 'markShipmentForShipping('.length; + const memberPos = source.indexOf('shipment.custom') + 'shipment.'.length + 1; + + const paramHover = proxy.getQuickInfoAtPosition('/shippingHelpers.js', paramPos); + const paramHeader = (paramHover?.displayParts ?? []).map((p) => p.text).join(''); + assert.equal(paramHeader, '(parameter) shipment: Shipment'); + const paramDoc = (paramHover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + paramDoc.includes('Represents an order shipment.'), + `expected the class's own doc comment, got: ${paramDoc}`, + ); + + const memberHover = proxy.getQuickInfoAtPosition('/shippingHelpers.js', memberPos); + const memberHeader = (memberHover?.displayParts ?? []).map((p) => p.text).join(''); + assert.equal(memberHeader, 'ICustomAttributes.Shipment'); + const memberDoc = (memberHover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + memberDoc.includes('Returns the custom attributes for this object'), + `expected the property's own doc comment, got: ${memberDoc}`, + ); + assert.ok( + memberDoc.includes('Inferred from usage: ICustomAttributes.Shipment'), + `expected the correctly-qualified type in the note, got: ${memberDoc}`, + ); + }); + + it('runs inference through the hover/completion gate for a weak `@param {Object}` placeholder', () => { + // Regression test for the entry gate (isOpenForUsageInference): checkJs + // resolves the ubiquitous SFRA `@param {Object}` placeholder to the global + // `Object` interface — NOT `any` and NOT the lowercase `object` + // non-primitive — so the gate used to reject the hover/completion before + // inference ran, even though the rest of the engine already treats + // `{Object}` JSDoc as a weak placeholder. The unit/corpus suites call + // inferParameterType() directly and never exercised the gate, so this + // only surfaced in the real VS Code host (and its integration suite). + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'Profile'], ''), + '/accountHelpers.js': ` + /** + * @param {Object} resettingCustomer + */ + function sendPasswordResetEmail(resettingCustomer) { + var last = resettingCustomer.profile.lastName; + return resettingCustomer.profile.firstName + last; + } + module.exports = {sendPasswordResetEmail}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const source = files['/accountHelpers.js']; + const paramPos = source.indexOf('sendPasswordResetEmail(resettingCustomer)') + 'sendPasswordResetEmail('.length; + const dotPos = source.indexOf('resettingCustomer.profile') + 'resettingCustomer.'.length; + + const hover = proxy.getQuickInfoAtPosition('/accountHelpers.js', paramPos); + const hoverText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + hoverText.includes('Inferred from usage: Customer'), + `weak {Object} JSDoc must not close the gate; got: ${hoverText || '(no inferred note)'}`, + ); + + const completions = proxy.getCompletionsAtPosition('/accountHelpers.js', dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok( + names.includes('getProfile'), + `expected Customer members after the {Object} receiver, got: ${names.join(', ')}`, + ); + }); + + it('rewrites a weak `{Object}` hover header, not only a trailing `any`', () => { + // Under strict checkJs TypeScript renders `@param {Object}` as the + // `Object` interface in the bold hover header rather than as `any`. + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'Profile'], ''), + '/accountHelpers.js': ` + /** @param {Object} resettingCustomer */ + function sendPasswordResetEmail(resettingCustomer) { + return resettingCustomer.profile.firstName + resettingCustomer.profile.lastName; + } + module.exports = {sendPasswordResetEmail}; + `, + }; + const {proxy} = createPluginProxy({ + files, + compilerOptions: {checkJs: true, strict: true}, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const paramPos = files['/accountHelpers.js'].indexOf('resettingCustomer)'); + const hover = proxy.getQuickInfoAtPosition('/accountHelpers.js', paramPos); + const header = (hover?.displayParts ?? []).map((p) => p.text).join(''); + assert.equal(header, '(parameter) resettingCustomer: Customer'); + }); + + it('offers no inferred members inside an argument list further along a chain', () => { + // At `helper(|).name` the cursor completes an argument, not a member of + // `helper(...)`, even though that call is the receiver of the enclosing + // `.name` access. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product; + } + var label = helper(getProduct()).name; + module.exports = {helper}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + const source = files['/helper.js']; + const inferredNames = (position) => + (proxy.getCompletionsAtPosition('/helper.js', position, undefined)?.entries ?? []) + .filter((entry) => entry.source === INFERRED_COMPLETION_SOURCE) + .map((entry) => entry.name); + + assert.deepEqual(inferredNames(source.indexOf('helper(getProduct())') + 'helper('.length), []); + assert.ok(inferredNames(source.indexOf('.name') + 1).includes('ID')); + }); + + it('drops cached hovers when the configuration changes, so a new cartridge path takes effect', () => { + // Same Program before and after: only the cartridge path, and with it + // the module `module.superModule` refers to, changes. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/a/cartridge/scripts/price.js': `module.exports = {fromA: getProduct()};`, + '/b/cartridge/scripts/price.js': `module.exports = {fromB: getProduct()};`, + '/top/cartridge/scripts/price.js': ` + var base = module.superModule; + module.exports = base; + `, + }; + const configOver = (below) => ({ + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'top', src: '/top/'}, + {name: below, src: `/${below}/`}, + ], + inferUsage: true, + }); + const {proxy, plugin} = createPluginProxy({files, config: configOver('a')}); + const topFile = '/top/cartridge/scripts/price.js'; + const basePos = files[topFile].indexOf('base ='); + const hoverText = () => + (proxy.getQuickInfoAtPosition(topFile, basePos)?.documentation ?? []).map((p) => p.text).join(''); + + assert.match(hoverText(), /fromA/); + plugin.onConfigurationChanged(configOver('b')); + assert.match(hoverText(), /fromB/); + }); + + it('offers completions for a dangling mid-edit `shipment.` immediately followed by more code on later lines', () => { + // Regression test for a real dogfooding find: `.` never gets automatic + // semicolon insertion (it always demands a following identifier), so a + // dangling `shipment.` — the exact state while a developer is mid-typing, + // before finishing the line — parses as ONE expression together with + // whatever identifier comes next, however many lines later: + // `shipment.\n\nTransaction.wrap(...)` becomes `shipment.Transaction.wrap(...)`. + // Left unhandled, that phantom `Transaction` "member" would count as real + // usage evidence for `shipment` (no dw.* class has it), poisoning the + // match and silently producing zero completions for the very position + // asking for them — even though `shipment` is used correctly (`.custom`, + // `.productLineItems`) later in the same function. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + var Transaction = require('dw/system/Transaction'); + + shipment. + + Transaction.wrap(function () { + shipment.custom.fromStoreId = null; + var items = shipment.productLineItems; + }); + } + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const source = files['/shippingHelpers.js']; + const dotPos = source.indexOf('shipment.\n') + 'shipment.'.length; + + const completions = proxy.getCompletionsAtPosition('/shippingHelpers.js', dotPos, undefined); + const inferredNames = (completions?.entries ?? []) + .filter((e) => e.source === INFERRED_COMPLETION_SOURCE) + .map((e) => e.name); + // "custom" itself is deduped out of the inferred set here because it's + // also a plain word-completion (it appears as literal text elsewhere in + // the fixture) — setShippingMethod isn't, so it's the reliable signal + // that real Shipment members were actually synthesized. + assert.ok( + inferredNames.includes('setShippingMethod'), + `expected real Shipment members despite the dangling dot, got: ${inferredNames.join(', ')}`, + ); + assert.ok(inferredNames.includes('getUUID'), `expected getUUID, got: ${inferredNames.join(', ')}`); + assert.ok( + !inferredNames.includes('Transaction'), + 'the phantom merged "Transaction" member must not leak in as a synthesized (inferred-usage) entry', + ); + }); + + it('resolves module.superModule along the configured cartridge path for hover and completions', () => { + // Two cartridge roots in path order (custom overrides base). The overlay + // reaches its base module via module.superModule; the plugin must map + // that to the same-subpath file in the next cartridge down and infer the + // base module's export members. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/base/cartridge/scripts/helpers/priceHelpers.js': ` + function getSalePrice(product) { + return product.ID; + } + getSalePrice(getProduct()); + module.exports = { + getSalePrice: getSalePrice + }; + `, + '/custom/cartridge/scripts/helpers/priceHelpers.js': ` + var base = module.superModule; + function getMemberPrice(product) { + var basePrice = base.getSalePrice(product); + return basePrice; + } + module.exports = base; + module.exports.getMemberPrice = getMemberPrice; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'custom', src: '/custom/'}, + {name: 'base', src: '/base/'}, + ], + inferUsage: true, + }, + }); + const overlaySource = files['/custom/cartridge/scripts/helpers/priceHelpers.js']; + const overlayFile = '/custom/cartridge/scripts/helpers/priceHelpers.js'; + + // Hover on `basePrice` — its value flows through superModule into the + // base module's undocumented helper, which is only typed by its own + // call site in the base cartridge. + const hoverPos = overlaySource.indexOf('basePrice;'); + const hover = proxy.getQuickInfoAtPosition(overlayFile, hoverPos); + const hoverText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + hoverText.includes('Inferred from usage: string'), + `expected the base helper's inferred return type (string), got: ${hoverText}`, + ); + + // Completion after `base.` offers the base module's exported members. + const dotPos = overlaySource.indexOf('base.getSalePrice') + 'base.'.length; + const completions = proxy.getCompletionsAtPosition(overlayFile, dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('getSalePrice'), `expected getSalePrice among completions, got: ${names.join(', ')}`); + }); + + it('types server.append middleware parameters contextually via the injected SFRA ambient declarations', () => { + // No inference involved: with a `modules` cartridge configured the + // plugin injects types/sfra/server.d.ts, whose typed + // `append(name, ...middleware: Middleware[])` signature lets TypeScript + // itself type `req`/`res`/`next` contextually. The plugin must inject + // the ambient file and then stay out of the way. + const files = { + '/c/cartridge/controllers/Product.js': ` + var server = require('server'); + server.append('Show', function (req, res, next) { + var qs = req.querystring; + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const source = files['/c/cartridge/controllers/Product.js']; + const controllerFile = '/c/cartridge/controllers/Product.js'; + const reqParamPos = source.indexOf('req, res'); + + const hover = proxy.getQuickInfoAtPosition(controllerFile, reqParamPos); + const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); + assert.ok(/Request/.test(hoverText), `expected req to be typed as Request, got: ${hoverText}`); + assert.ok( + !hoverText.includes('Inferred from usage'), + `req is contextually typed — inference must not decorate it: ${hoverText}`, + ); + + const dotPos = source.indexOf('req.querystring') + 'req.'.length; + const completions = proxy.getCompletionsAtPosition(controllerFile, dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok( + names.includes('httpParameterMap'), + `expected httpParameterMap among completions, got: ${names.join(', ')}`, + ); + assert.ok(names.includes('geolocation'), `expected geolocation among completions, got: ${names.join(', ')}`); + }); + + it('types a middleware module function from the server.get() routes it is handed to', () => { + // SFRA's own middleware (cache.applyDefaultCache, csrf.validateRequest, + // ...) is written as plain exported functions and only ever handed to + // server.get/post/use by reference, where nothing types its parameters + // contextually; the Middleware type those take still says what they get. + const files = { + '/c/cartridge/scripts/middleware/cache.js': ` + function applyDefaultCache(req, res, next) { + res.cachePeriod = 24; + next(); + } + module.exports = {applyDefaultCache: applyDefaultCache}; + `, + '/c/cartridge/controllers/Home.js': ` + var server = require('server'); + var cache = require('*/cartridge/scripts/middleware/cache'); + server.get('Show', cache.applyDefaultCache, function (req, res, next) { + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const middlewareFile = '/c/cartridge/scripts/middleware/cache.js'; + const source = files[middlewareFile]; + + const hover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('res, next')); + const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); + assert.match(hoverText, /res: Response/); + assert.match(hoverText, /Inferred from usage: Response/); + }); + + it('types the middleware a factory returns from the routes its calls are handed to', () => { + const files = { + '/c/cartridge/scripts/middleware/jsonResponse.js': ` + function responseHandler(fieldMap) { + return function (req, res, next) { + res.json(fieldMap); + next(); + }; + } + module.exports = {responseHandler: responseHandler}; + `, + '/c/cartridge/controllers/Order.js': ` + var server = require('server'); + var jsonResponse = require('*/cartridge/scripts/middleware/jsonResponse'); + server.append('Confirm', jsonResponse.responseHandler({orderNo: 'orderNo'})); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const middlewareFile = '/c/cartridge/scripts/middleware/jsonResponse.js'; + const source = files[middlewareFile]; + + const hover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('res, next')); + const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); + assert.match(hoverText, /res: Response/); + assert.match(hoverText, /Inferred from usage: Response/); + + // `next` is typed by the declared callback's function type alias. + const nextHover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('next)')); + const nextText = (nextHover?.documentation ?? []).map((p) => p.text).join(''); + assert.match(nextText, /Inferred from usage: NextFunction/); + }); + + it('leaves a parameter the checker types as a function to the checker', () => { + // A function type has no properties, like the empty `{}` the gate treats + // as open, but its call signature is real type information: hovering the + // contextually typed `next` must not run inference or repeat its type. + const files = { + '/c/cartridge/controllers/Home.js': ` + var server = require('server'); + server.get('Show', function (req, res, next) { + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const controller = '/c/cartridge/controllers/Home.js'; + + const hover = proxy.getQuickInfoAtPosition(controller, files[controller].indexOf('next)')); + const header = (hover?.displayParts ?? []).map((p) => p.text).join(''); + const docText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.equal(header, '(parameter) next: NextFunction'); + assert.ok(!docText.includes('Inferred from usage'), `got: ${docText}`); + }); + + it("types a route event listener's parameters through the route a middleware step runs on", () => { + // SFRA calls each middleware step with the route as `this`, so the + // listeners a step registers with `this.on(...)` are typed by Route.on. + const files = { + '/c/cartridge/controllers/Cart.js': ` + var server = require('server'); + server.get('Show', function (req, res, next) { + this.on('route:BeforeComplete', function (request, response) { + var data = response.getViewData(); + }); + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const controllerFile = '/c/cartridge/controllers/Cart.js'; + const source = files[controllerFile]; + + const hover = proxy.getQuickInfoAtPosition(controllerFile, source.indexOf('response)')); + const hoverText = (hover?.displayParts ?? []).map((p) => p.text).join(''); + assert.match(hoverText, /response: Response/); + const dotPos = source.indexOf('response.getViewData') + 'response.'.length; + const names = (proxy.getCompletionsAtPosition(controllerFile, dotPos, undefined)?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('setViewData'), `expected setViewData among completions, got: ${names.join(', ')}`); + }); + + it('resolves members across a multi-cartridge superModule stack, including intermediate augmentations', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/base/cartridge/scripts/helpers/priceHelpers.js': ` + function getSalePrice(product) { + return product.ID; + } + getSalePrice(getProduct()); + module.exports = { getSalePrice: getSalePrice }; + `, + '/mid/cartridge/scripts/helpers/priceHelpers.js': ` + var base = module.superModule; + function getMemberPrice(product) { + return 'member-price'; + } + module.exports = base; + module.exports.getMemberPrice = getMemberPrice; + `, + '/top/cartridge/scripts/helpers/priceHelpers.js': ` + var base = module.superModule; + function getPromoPrice(product) { + var memberPrice = base.getMemberPrice(product); + return memberPrice; + } + module.exports = base; + module.exports.getPromoPrice = getPromoPrice; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'top', src: '/top/'}, + {name: 'mid', src: '/mid/'}, + {name: 'base', src: '/base/'}, + ], + inferUsage: true, + }, + }); + const source = files['/top/cartridge/scripts/helpers/priceHelpers.js']; + const topFile = '/top/cartridge/scripts/helpers/priceHelpers.js'; + + // Hover on `memberPrice` — flows through a member augmented at the MID + // level, invisible to any candidate type. + const hoverPos = source.indexOf('memberPrice;'); + const hover = proxy.getQuickInfoAtPosition(topFile, hoverPos); + const hoverText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.ok( + hoverText.includes('Inferred from usage: string'), + `expected string via mid augmentation, got: ${hoverText}`, + ); + + // Completion after `base.` offers both the deep base member and the + // mid-level augmentation. + const dotPos = source.indexOf('base.getMemberPrice') + 'base.'.length; + const completions = proxy.getCompletionsAtPosition(topFile, dotPos, undefined); + const names = (completions?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('getSalePrice'), `expected deep base member getSalePrice, got: ${names.join(', ')}`); + assert.ok(names.includes('getMemberPrice'), `expected mid augmentation getMemberPrice, got: ${names.join(', ')}`); + }); + + it('lets a non-cancellation exception from the underlying call propagate instead of degrading it to an empty result', () => { + // The `guarded` wrapper exists to protect tsserver from bugs in this + // plugin's own inference additions — never to change how errors from the + // real language service behave. Swallowing those would turn a genuine TS + // crash into a silent "hover stopped working" for every file. + const host = createFixtureHost(FIXTURE_FILES); + const realLanguageService = ts.createLanguageService(host, sharedDocumentRegistry); + const languageService = new Proxy(realLanguageService, { + get(target, prop) { + if (prop === 'getQuickInfoAtPosition' || prop === 'getCompletionsAtPosition') { + return () => { + throw new Error('underlying language service failure'); + }; + } + return target[prop]; + }, + }); + const {proxy} = createPluginProxy({ + host, + languageService, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + assert.throws(() => proxy.getQuickInfoAtPosition('/helper.js', 0), /underlying language service failure/); + assert.throws( + () => proxy.getCompletionsAtPosition('/helper.js', 0, undefined), + /underlying language service failure/, + ); + }); + + it('rethrows ts.OperationCanceledException from the underlying call instead of swallowing it as a failure', () => { + // TS throws this cooperatively whenever the host's CancellationToken + // fires (e.g. the user kept typing while this request was in flight) — + // ordinary, frequent behavior that tsserver's request pipeline handles + // very differently from a completed-but-empty response. A plugin + // override that swallows it and returns undefined instead would + // misreport "cancelled" as "resolved to nothing" on every fast edit. + const host = createFixtureHost({ + '/helper.js': ` + function helper(product) { return product.ID; } + helper(getProduct()); + module.exports = {helper}; + `, + }); + const realLanguageService = ts.createLanguageService(host, sharedDocumentRegistry); + const languageService = new Proxy(realLanguageService, { + get(target, prop) { + if (prop === 'getQuickInfoAtPosition' || prop === 'getCompletionsAtPosition') { + return () => { + throw new ts.OperationCanceledException(); + }; + } + return target[prop]; + }, + }); + const {proxy} = createPluginProxy({ + host, + languageService, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + assert.throws(() => proxy.getQuickInfoAtPosition('/helper.js', 0), ts.OperationCanceledException); + assert.throws(() => proxy.getCompletionsAtPosition('/helper.js', 0, undefined), ts.OperationCanceledException); + }); +}); + +describe('create() proxy — ambient declarations', () => { + // The plugin adds the bundled SFCC globals (and, with a `modules` + // cartridge, the SFRA typings) to a cartridge project's file list. + const TYPES_DIR = path.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); + const GLOBAL_DTS = `${TYPES_DIR}/global.d.ts`; + const SFRA_SERVER_DTS = `${TYPES_DIR}/sfra/server.d.ts`; + const ambientIn = (list) => list.filter((f) => f.startsWith(TYPES_DIR)); + + function pluginOver(files, cartridges) { + const host = createFixtureHost(files); + const {plugin} = createPluginProxy({host, config: {enabled: true, autoDiscover: false, cartridges}}); + return {host, plugin}; + } + + it('adds the SFCC globals to a cartridge project once, and the SFRA typings only with a modules cartridge', () => { + const files = {'/app/cartridge/a.js': 'module.exports = 1;'}; + assert.deepEqual(ambientIn(pluginOver(files, [{name: 'app', src: '/app'}]).host.getScriptFileNames()), [ + GLOBAL_DTS, + ]); + const withModules = pluginOver({...files, '/modules/server.js': ''}, [ + {name: 'app', src: '/app'}, + {name: 'modules', src: '/modules'}, + ]); + assert.deepEqual(ambientIn(withModules.host.getScriptFileNames()), [GLOBAL_DTS, SFRA_SERVER_DTS]); + const included = pluginOver({...files, [GLOBAL_DTS]: ''}, [{name: 'app', src: '/app'}]); + assert.deepEqual(ambientIn(included.host.getScriptFileNames()), [GLOBAL_DTS]); + }); + + it('answers repeated requests from an unchanged file list and recomputes when the files or configuration change', () => { + const files = {'/other/a.js': ''}; + const {host, plugin} = pluginOver(files, [{name: 'app', src: '/app'}]); + assert.deepEqual(ambientIn(host.getScriptFileNames()), []); + assert.equal(host.getScriptFileNames(), host.getScriptFileNames()); + + files['/app/cartridge/a.js'] = 'module.exports = 1;'; + assert.deepEqual(ambientIn(host.getScriptFileNames()), [GLOBAL_DTS]); + + plugin.onConfigurationChanged({enabled: false}); + assert.deepEqual(ambientIn(host.getScriptFileNames()), []); + plugin.onConfigurationChanged({enabled: true, cartridges: [{name: 'other', src: '/other'}]}); + assert.deepEqual(ambientIn(host.getScriptFileNames()), [GLOBAL_DTS]); + }); +}); + +describe('create() proxy — hook registrations', () => { + // A hook script's exports are only invoked through HookMgr.callHook(). The + // plugin reads which scripts implement which extension points from each + // configured cartridge's package.json -> hooks.json, through the host. + const ROOT = '/ws/app_custom'; + const HOOK_FILES = { + [`${ROOT}/package.json`]: JSON.stringify({hooks: './hooks.json'}), + [`${ROOT}/hooks.json`]: JSON.stringify({ + hooks: [{name: 'app.payment.processor.basic_credit', script: './cartridge/scripts/hooks/payment/basic_credit'}], + }), + [`${ROOT}/cartridge/scripts/hooks/payment/basic_credit.js`]: ` + function Handle(basket, paymentInformation) { + return {error: false}; + } + exports.Handle = Handle; + `, + [`${ROOT}/cartridge/controllers/Checkout.js`]: ` + var BasketMgr = require('dw/order/BasketMgr'); + var HookMgr = require('dw/system/HookMgr'); + function submitPayment(processor) { + var currentBasket = BasketMgr.getCurrentBasket(); + return HookMgr.callHook('app.payment.processor.' + processor.ID.toLowerCase(), 'Handle', currentBasket, {}); + } + module.exports = {submitPayment: submitPayment}; + `, + }; + const HOOK_SCRIPT = `${ROOT}/cartridge/scripts/hooks/payment/basic_credit.js`; + + function hoverOnHandleBasket(files, inferUsage = true) { + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: [{name: 'app_custom', src: ROOT}], inferUsage}, + }); + const position = files[HOOK_SCRIPT].indexOf('Handle(basket') + 'Handle('.length; + const hover = proxy.getQuickInfoAtPosition(HOOK_SCRIPT, position); + return (hover?.documentation ?? []).map((p) => p.text).join(''); + } + + it('infers a hook function parameter from the callHook calls dispatched to it', () => { + const hoverText = hoverOnHandleBasket(HOOK_FILES); + assert.ok(hoverText.includes('Inferred from usage: Basket'), `got: ${hoverText || '(no inferred note)'}`); + }); + + it('infers nothing for a hook script hooks.json does not register', () => { + const files = {...HOOK_FILES, [`${ROOT}/hooks.json`]: JSON.stringify({hooks: []})}; + const hoverText = hoverOnHandleBasket(files); + assert.ok(!hoverText.includes('Inferred from usage'), `got: ${hoverText}`); + }); +}); diff --git a/packages/b2c-script-types/test/reference-search.test.js b/packages/b2c-script-types/test/reference-search.test.js new file mode 100644 index 000000000..333f5fe0d --- /dev/null +++ b/packages/b2c-script-types/test/reference-search.test.js @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const {createInferenceContext} = require('../src/usage-inference'); +const {getNodeAtPosition} = require('../src/inference/ast-helpers'); +const {searchReferences} = require('../src/inference/reference-search'); +const {createFixtureHost, createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {countReferenceSearches} = require('./helpers/reference-searches'); + +const BACKSLASH = '\\'; + +/** The identifier `text` starts with, in `fileName` (`'helper(p)'` names the `helper` before `(p)`). */ +function nameAt(ctx, fileName, text) { + const file = ctx.program.getSourceFile(fileName); + const position = file.text.indexOf(text); + assert.ok(position >= 0, `${text} not found in ${fileName}`); + const node = getNodeAtPosition(file, ts, position); + assert.ok(ts.isIdentifier(node), `${text} does not start with an identifier`); + return node; +} + +/** Each reference as ` `, so a failure shows where it was found. */ +function describeReferences(references) { + return references + .map((node) => { + const context = ts.isShorthandPropertyAssignment(node.parent) ? node.parent.parent : node.parent; + return `${node.getSourceFile().fileName} ${context.getText()}`; + }) + .sort(); +} + +/** Runs one search on a fresh Program, counting the searches it runs. */ +function search(files, fileName, text, languageService = createFixtureLanguageService(files)) { + const searches = countReferenceSearches(); + try { + const ctx = createInferenceContext(ts, languageService); + const references = searchReferences(ctx, nameAt(ctx, fileName, text)); + return {references: describeReferences(references), projectWide: searches.projectWide(), runs: searches.total()}; + } finally { + searches.stop(); + } +} + +describe('reference search', () => { + it("reads only a CommonJS module's own file for its top-level function, and keeps a same-named one elsewhere out", () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nhelper(1);\nmodule.exports = {helper: helper};', + '/other.js': 'function helper(x) { return x; }\nhelper(2);\nmodule.exports = helper;', + }, + '/helpers.js', + 'helper(p)', + ); + + assert.deepEqual(result.references, [ + '/helpers.js function helper(p) { return p; }', + '/helpers.js helper(1)', + '/helpers.js helper: helper', + ]); + assert.equal(result.projectWide, 0, 'nothing outside a CommonJS module names its own function'); + }); + + it('reads the whole project for a global of a plain script', () => { + const result = search( + { + '/globals.js': 'function formatMoney(value) { return value; }', + '/page.js': "var util = require('./util');\nformatMoney(util.price);", + '/util.js': 'module.exports = {price: 1};', + }, + '/globals.js', + 'formatMoney(value)', + ); + + assert.deepEqual(result.references, [ + '/globals.js function formatMoney(value) { return value; }', + '/page.js formatMoney(util.price)', + ]); + assert.equal(result.projectWide, 1); + }); + + it("finds the require() calls that load a module through its `module.exports`, an index.js through its folder's name", () => { + const files = { + '/models/product.js': 'function Product() {}\nmodule.exports = Product;', + '/models/index.js': "module.exports = {product: require('./product')};", + '/productUtils.js': 'module.exports = {};', + '/a.js': "var Product = require('./models/product');\nnew Product();", + '/b.js': + "var P = require('./models/product');\nvar models = require('./models');\nvar utils = require('./productUtils');", + }; + + assert.deepEqual(search(files, '/models/product.js', 'exports = Product').references, [ + "/a.js require('./models/product')", + "/b.js require('./models/product')", + "/models/index.js require('./product')", + '/models/product.js module.exports', + ]); + assert.deepEqual(search(files, '/models/index.js', 'exports = {').references, [ + "/b.js require('./models')", + '/models/index.js module.exports', + ]); + }); + + it('reaches an export through the aliases that read it: a destructured require, a property access, a local call', () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nexports.helper = helper;', + '/c.js': "var {helper} = require('./helpers');\nvar h = require('./helpers');\nh.helper(1);\nhelper(2);", + }, + '/helpers.js', + 'helper = helper', + ); + + assert.deepEqual(result.references, [ + '/c.js h.helper', + '/c.js helper', + '/c.js helper(2)', + '/helpers.js exports.helper', + ]); + }); + + it('searches a shorthand export as both the property and the value it copies', () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nmodule.exports = {helper};', + '/c.js': "var h = require('./helpers');\nh.helper(1);", + }, + '/helpers.js', + 'helper}', + ); + + assert.deepEqual(result.references, [ + '/c.js h.helper', + '/helpers.js function helper(p) { return p; }', + '/helpers.js {helper}', + ]); + }); + + it('finds a use whose name is spelled with a unicode escape, and one deep inside nested code', () => { + // `helper` with its `e` written as a unicode escape sequence. + const e = `${BACKSLASH}u0065`; + const result = search( + { + '/helpers.js': [ + 'function helper(p) { return p; }', + `h${e}lper(1);`, + 'function outer() { return [{run: function () { if (true) { return helper(2); } }}]; }', + 'module.exports = {};', + ].join('\n'), + }, + '/helpers.js', + 'helper(p)', + ); + + assert.deepEqual(result.references, [ + '/helpers.js function helper(p) { return p; }', + `/helpers.js h${e}lper(1)`, + '/helpers.js helper(2)', + ]); + }); + + it('reports no uses in declaration files', () => { + const result = search( + { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/a.js': 'getProduct();\nmodule.exports = {};', + }, + '/a.js', + 'getProduct()', + ); + + assert.deepEqual(result.references, ['/a.js getProduct()']); + }); + + it('runs each search once per Program, whichever request asks', () => { + const languageService = createFixtureLanguageService({ + '/helpers.js': 'function helper(p) { return p; }\nhelper(1);\nmodule.exports = {helper: helper};', + }); + const searches = countReferenceSearches(); + try { + const first = createInferenceContext(ts, languageService); + const second = createInferenceContext(ts, languageService); + const references = searchReferences(first, nameAt(first, '/helpers.js', 'helper(p)')); + + assert.equal(searchReferences(second, nameAt(second, '/helpers.js', 'helper(p)')), references); + assert.equal(searches.total(), 1); + } finally { + searches.stop(); + } + }); + + it("finds the same references when a SourceFile lacks the parser's name table", () => { + const files = { + '/helpers.js': 'function helper(p) { return p; }\nmodule.exports = {helper};', + '/c.js': "var h = require('./helpers');\nh.helper(1);", + }; + // A private registry: the SourceFiles altered here are shared with no other test. + const languageService = ts.createLanguageService(createFixtureHost(files)); + for (const file of languageService.getProgram().getSourceFiles()) delete file.identifiers; + + assert.deepEqual(search(files, '/helpers.js', 'helper}', languageService).references, [ + '/c.js h.helper', + '/helpers.js function helper(p) { return p; }', + '/helpers.js {helper}', + ]); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.hardening.test.js b/packages/b2c-script-types/test/usage-inference.hardening.test.js new file mode 100644 index 000000000..08adc6dd8 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + matchAmbientTypesByUsage, +} = require('../src/usage-inference'); +const {assertInferredHover, assertNoInferredHover, positionOf} = require('./helpers/assert-inference'); +const {absoluteCartridgePath, createCartridgeFixture} = require('./helpers/cartridge-fixture'); +const { + createFixtureHost, + createFixtureLanguageService, + findFunctionDeclaration, + sharedDocumentRegistry, +} = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); +const {realTypesPrelude} = require('./helpers/real-dw-types'); + +describe('usage-inference hardening', () => { + describe('mid-inference cancellation', () => { + it('throws OperationCanceledException once the host cancels the request mid-search', () => { + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/helper.js': ` + function helper(product) { return product.ID; } + helper(getProduct()); + helper(getProduct()); + module.exports = {helper: helper}; + `, + }; + let polls = 0; + // Cancelled from the second poll on: the search has started. + const host = {isCancellationRequested: () => ++polls > 1}; + const ctx = createInferenceContext(ts, createFixtureLanguageService(files), host); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + + assert.throws(() => inferParameterType(ctx, fn.parameters[0]), ts.OperationCanceledException); + assert.ok(polls > 1, 'the search polls the host while it runs'); + }); + + it('plugin guarded() rethrows cancellation from inference (does not degrade to empty hover)', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ''), + '/helper.js': ` + function helper(product) { return product.getID(); } + helper(ProductMgr.getProduct('x')); + module.exports = {helper: helper}; + `, + }; + const host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const source = files['/helper.js']; + const paramPos = positionOf(source, 'product)'); + const {proxy} = createPluginProxy({ + host, + languageService, + config: { + enabled: true, + inferUsage: true, + cartridges: [{name: 'test_cartridge', src: '/'}], + }, + }); + // TypeScript read the host's token when the service was created, so the + // plain hover underneath still completes; inference polls it itself. + host.getCancellationToken = () => ({isCancellationRequested: () => true}); + + assert.throws(() => proxy.getQuickInfoAtPosition('/helper.js', paramPos), ts.OperationCanceledException); + }); + }); + + describe('negative / silence cases', () => { + it('matchAmbientTypesByUsage stays silent for a weak-only custom+UUID signature', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment', 'ProductLineItem', 'Profile', 'Customer'], ''), + '/helpers.js': ` + function touch(obj) { + return obj.custom || obj.UUID; + } + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const types = matchAmbientTypesByUsage(ctx, new Set(['custom', 'UUID'])); + assert.equal(describeTypes(ctx, types), ''); + }); + + it('does not let an identifier-name match rescue a signature that matches zero classes', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['Profile'], ''), + '/helpers.js': 'function f(profile) { return profile.notARealMember; }\n', + }); + const ctx = createInferenceContext(ts, languageService); + const types = matchAmbientTypesByUsage(ctx, new Set(['notARealMember']), 'profile'); + assert.equal(describeTypes(ctx, types), ''); + }); + + it('stays silent when a duck-typed Store model call site resolves but the body also uses address-only fields (a storefront cartridge copyCustomerAddressToShipment)', () => { + // Controllers pass both an untyped preferredAddress (req is any) and a + // Store *model* from getDeliveryStore(). Only the model resolves to a + // concrete type named Store — without a body-usage consistency check + // the hover wrongly claims the address parameter is Store. + const files = { + '/types.d.ts': realTypesPrelude( + ['CustomerAddress', 'Store'], + ` + declare function getApiStore(): Store; + declare const req: any; + `, + ), + '/models/store.js': ` + function Store(storeObject) { + this.ID = storeObject.ID; + this.name = storeObject.name; + this.firstName = 'Shop'; + this.lastName = this.name; + this.address1 = storeObject.address1; + this.address2 = storeObject.address2; + this.city = storeObject.city; + this.postalCode = storeObject.postalCode; + this.stateCode = storeObject.stateCode; + this.countryCode = storeObject.countryCode; + } + module.exports = Store; + `, + '/helpers/reserveAndGoHelpers.js': ` + var StoreModel = require('../models/store'); + function getDeliveryStore() { + return new StoreModel(getApiStore()); + } + module.exports = { getDeliveryStore: getDeliveryStore }; + `, + '/checkout/checkoutHelpers.js': ` + function copyCustomerAddressToShipment(address) { + use(address.countryCode); + use(address.firstName || 'X'); + use(address.lastName || address.name); + use(address.companyName ? address.companyName : ''); + use(address.address1); + use(address.address2); + use(address.postBox ? address.postBox : ''); + use(address.city); + use(address.postalCode); + use(address.stateCode ? address.stateCode : ''); + use(address.ID ? address.ID : ''); + } + module.exports = { copyCustomerAddressToShipment: copyCustomerAddressToShipment }; + `, + '/controllers/Checkout.js': ` + var COHelpers = require('../checkout/checkoutHelpers'); + var reserveAndGoHelpers = require('../helpers/reserveAndGoHelpers'); + var preferredAddress; + if (req.currentCustomer.addressBook && req.currentCustomer.addressBook.preferredAddress) { + preferredAddress = req.currentCustomer.addressBook.preferredAddress; + COHelpers.copyCustomerAddressToShipment(preferredAddress); + } + var storeModel = reserveAndGoHelpers.getDeliveryStore(); + COHelpers.copyCustomerAddressToShipment(storeModel); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration( + ctx.program.getSourceFile('/checkout/checkoutHelpers.js'), + 'copyCustomerAddressToShipment', + ); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + }); + + describe('multi-cartridge require call sites (cartridge-fixture factory)', () => { + it('infers a helper parameter from a call site reached through require("~/...")', () => { + // Mirrors a storefront cartridge/a storefront cartridge: helpers consumed via cartridge-relative + // require from another file in the same cartridge. Uses the shared + // createCartridgeFixture factory so path layout stays consistent with + // the VS Code E2E workspace. + const fixture = createCartridgeFixture({ + dwTypes: ['Product'], + globals: ' function getSomeProduct(): Product;', + cartridges: [ + { + name: 'test_cartridge', + files: { + 'cartridge/scripts/helpers/productHelpers.js': ` + function getDisplayName(product) { + return product.getID(); + } + module.exports = { getDisplayName: getDisplayName }; + `, + 'cartridge/scripts/cartService.js': ` + var productHelpers = require('~/cartridge/scripts/helpers/productHelpers'); + productHelpers.getDisplayName(getSomeProduct()); + module.exports = {}; + `, + }, + }, + ], + }); + + const host = createFixtureHost(fixture.files); + // Real tsserver installs the plugin before the first program build. Our + // test creates the LanguageService first, then create() wraps the host's + // resolvers — bump script + project versions so the next getProgram() + // re-resolves `~/` requires through the wrapped hooks. + const versions = Object.fromEntries(Object.keys(fixture.files).map((f) => [f, 0])); + let projectVersion = 1; + const origGetScriptVersion = host.getScriptVersion; + host.getScriptVersion = (f) => `${versions[f] ?? 0}:${origGetScriptVersion(f)}`; + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {proxy} = createPluginProxy({ + host, + languageService, + projectVersion: () => String(projectVersion), + config: { + enabled: true, + inferUsage: true, + cartridges: fixture.cartridgeConfigs, + }, + }); + for (const f of Object.keys(versions)) versions[f] += 1; + projectVersion += 1; + + const helperPath = absoluteCartridgePath('test_cartridge', 'cartridge/scripts/helpers/productHelpers.js'); + const source = fixture.files[helperPath]; + const paramPos = positionOf(source, 'product)'); + assertInferredHover(proxy, helperPath, paramPos, /Product/); + }); + }); + + describe('assert helpers smoke', () => { + it('assertNoInferredHover passes when inferUsage is off', () => { + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/helper.js': ` + function helper(product) { return product.ID; } + helper(getProduct()); + module.exports = {helper: helper}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, inferUsage: false, cartridges: [{name: 'c', src: '/'}]}, + }); + assertNoInferredHover(proxy, '/helper.js', positionOf(files['/helper.js'], 'product)')); + }); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js new file mode 100644 index 000000000..e1a5043d1 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -0,0 +1,694 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const {createInferenceContext, describeTypes, inferParameterType, inferReturnType} = require('../src/usage-inference'); +const { + createFixtureHost, + createFixtureLanguageService, + findFunctionDeclaration, + sharedDocumentRegistry, +} = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); +const {countReferenceSearches} = require('./helpers/reference-searches'); +const {realTypesPrelude} = require('./helpers/real-dw-types'); + +// --------------------------------------------------------------------------- +// Performance baselines. +// +// The engine runs synchronously inside tsserver on every hover/completion +// keystroke, so its worst-case cost has to stay bounded. The dominant cost by +// far is reference searches (each checks every occurrence of a name across +// the files that can refer to it), so the STRICT baselines below are +// deterministic *counters* of how many searches a pathological input may +// trigger, read off the engine's diagnostics channel. Counters don't flake +// on slow CI runners and pinpoint exactly which cap stopped working. +// +// The wall-clock ceilings are deliberately generous (they'd pass on a very +// slow machine) and exist only as tripwires for catastrophic regressions — +// an accidental exponential blowup, a lost cap, an infinite loop that the +// counters can't see. If one of these fails, the engine got MUCH slower, not +// slightly slower. Do not "fix" a failure by raising a ceiling without +// understanding which bound was lost. +// --------------------------------------------------------------------------- +const WALL_CLOCK_CEILING_MS = 5000; + +// Baseline: how many reference searches each scenario is allowed to trigger. +// These trace directly to the engine's caps (MAX_REFERENCES_PER_CALL, +// MAX_REFERENCE_HOPS, MAX_CHAIN_HOPS, MAX_INFERENCE_DEPTH, request memo). +const BASELINE = { + // One search from the function name plus at most a couple of indirection + // hops (export binding / property name) — independent of call-site count. + widelyReferencedHelper: 4, + // The chain cap aborts before ever reaching the parameter at the receiver + // end, so an overlong method chain must trigger NO reference search. + overlongMethodChain: 0, + // A no-parameter helper chain is resolved natively by TypeScript — the + // engine's direct-type check returns it without any search. + nativeHelperChain: 0, + // Return-direction chasing of a parameter-forwarding chain is bounded by + // the depth cap without any search. Parameter-direction chasing follows a + // parameter passed on as is at no depth cost (it is the same value all the + // way down), one search per level, so the request's search budget + // (MAX_SEARCHES_PER_REQUEST) is what stops it. + deepForwardingChainReturns: 0, + deepForwardingChainParam: 12, + // Twenty sibling branches through the same sub-helper must share ONE + // memoized search set, not repeat it per branch. + wideFanOutMemoized: 4, + // A repeated identical request at the same project version must be served + // entirely from the plugin's position cache. + repeatedHoverCached: 0, + // A helper whose call sites feed it results of many DISTINCT sub-helpers: + // the memo can't collapse anything (every name is different) and each + // sub-helper costs its own full-project scan while draining the result + // budget by only 2-3 — so the SEARCH budget (MAX_SEARCHES_PER_REQUEST) is + // the bound that has to engage. Before that cap existed this scenario ran + // 76 scans (~115ms measured on an SFRA-sized program). + distinctSubHelperTree: 12, + // Two implicit-any parameters of the same function must share one + // reference-search set (the request-scoped call-site memo), not re-run the + // identical searches once per parameter. + multiParamHelper: 2, + // A later request on the same, unchanged Program (hovering one parameter + // after another while reading code) re-runs none of the reference searches + // an earlier request already ran. + repeatedRequestSameProgram: 0, + // Thousands of call sites packed into one generated file: one scan, and + // the per-call result budget must bound how many of its hits get processed. + hugeGeneratedFile: 2, + // A project-version bump WITHOUT a program change (the version string moves + // on events that don't produce a new Program) must not evict the inference + // cache — invalidation keys on Program identity. + versionBumpSameProgram: 0, + // Candidate types propagating up a forwarding chain get deduplicated (by + // display string) at every recursion level; the request-scoped + // typeToString memo must keep that to ONE stringification per unique type + // per request. 30 unique candidates + slack — without the memo this + // scenario stringifies each candidate once per level (4x, 120 calls). + nestedForwardingStringifications: 32, + // The no-call-site usage-match fallback's ambient-class index (every + // dw.* class's member-name set) is cached per declaration SourceFile, + // which tsserver reuses across Programs while the file is unchanged — so + // a request after an edit to a cartridge .js file (a new Program) must add + // zero getPropertiesOfType calls, not re-index the Script API. + ambientClassIndexRebuildAfterJsEdit: 0, + // A hook function's parameters: its own name and its export binding, plus + // ONE project-wide search for every HookMgr.callHook call, shared by all + // the hook parameters a request infers. + hookParameters: 3, +}; + +// Every reference-search counter a test starts, stopped when it finishes. +const searchCounters = []; + +/** Counts the reference searches run from here on (see ./helpers/reference-searches). */ +function countSearches() { + const counter = countReferenceSearches(); + searchCounters.push(counter); + return counter; +} + +/** + * Counts calls to `checker.getPropertiesOfType` — the per-class cost of + * building the ambient-class index the no-call-site usage-match fallback + * (matchAmbientTypesByUsage) matches against. That index is cached per + * declaration SourceFile (see ./src/inference/ambient-index), so a warm + * cache must add zero further calls. + */ +function withPropertiesOfTypeCounter(checker) { + let count = 0; + const original = checker.getPropertiesOfType.bind(checker); + checker.getPropertiesOfType = (type) => { + count++; + return original(type); + }; + return {count: () => count}; +} + +function timed(fn) { + const start = process.hrtime.bigint(); + const result = fn(); + const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6; + return {result, elapsedMs}; +} + +describe('usage-inference — performance baselines', () => { + afterEach(() => { + for (const counter of searchCounters.splice(0)) counter.stop(); + }); + + it(`caps the cost of a widely-referenced helper (300 call sites, <= ${BASELINE.widelyReferencedHelper} searches)`, () => { + const callSites = Array.from({length: 300}, () => 'helper(getProduct());').join('\n'); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/helper.js': ` + function helper(product) { + return product.ID; + } + ${callSites} + module.exports = {helper}; + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + + const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); + + // Correctness must survive the cap: the first <=50 processed references + // are more than enough to type this parameter. + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + assert.ok( + counter.total() <= BASELINE.widelyReferencedHelper, + `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.total()}`, + ); + // The per-call reference budget must actually engage (not short-circuit to + // 0 searches / 0 hits while still somehow typing the parameter). + const spent = 200 - ctx.referenceBudget; + assert.equal(spent, 50, `expected the per-call cap (50) to fully engage on 300 call sites, spent ${spent}`); + assert.ok(counter.total() >= 1, 'expected at least one reference search for a named helper'); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('aborts an overlong method chain before any reference search', () => { + const chain = '.next()'.repeat(60); + const files = { + '/types.d.ts': ` + interface Chainable { next(): Chainable; value: string; } + declare function getChainable(): Chainable; + `, + '/chain.js': ` + function resolveChain(x) { + return x${chain}.value; + } + function useHelper() { return resolveChain(getChainable()); } + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/chain.js'), 'resolveChain'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(types.length, 0); + assert.equal( + counter.total(), + BASELINE.overlongMethodChain, + 'the chain cap must fire before the receiver parameter is ever reference-searched', + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it("delegates a no-parameter helper chain to TypeScript's own inference (0 searches)", () => { + // h1() -> h2() -> ... -> h12() -> getProduct(): with no implicit-any + // parameters involved, TypeScript resolves the whole chain natively and + // the engine's direct-type check returns it without any work of its own. + const helpers = Array.from( + {length: 12}, + (_, i) => `function h${i + 1}() { return ${i + 1 < 12 ? `h${i + 2}()` : 'getProduct()'}; }`, + ).join('\n'); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/deep.js': `${helpers}\nmodule.exports = {h1};`, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/deep.js'), 'h1'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + assert.equal(counter.total(), BASELINE.nativeHelperChain); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('bounds a 12-level parameter-forwarding chain by the depth cap and the search budget', () => { + // f1(x) -> f2(x) -> ... -> f12(x) -> x: parameters are implicit any, so + // TypeScript can't resolve this natively — the engine's own caps are all + // that bounds the cost, and it must stay flat no matter how deep the + // forwarding stack goes. + const N = 12; + const helpers = Array.from({length: N}, (_, i) => { + const n = i + 1; + return n < N ? `function f${n}(x) { return f${n + 1}(x); }` : `function f${n}(x) { return x; }`; + }).join('\n'); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/deep.js': `${helpers}\nf1(getProduct());\nmodule.exports = {f1};`, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + + // Return direction (hover on f1): the depth cap truncates before the + // chain's deep end ever resolves a parameter, so no search happens. + const ctx = createInferenceContext(ts, base); + const sourceFile = ctx.program.getSourceFile('/deep.js'); + const {result: returnTypes, elapsedMs: returnMs} = timed(() => + inferReturnType(ctx, findFunctionDeclaration(sourceFile, 'f1')), + ); + assert.equal(returnTypes.length, 0, 'truncated by the depth cap — flat cost regardless of chain length'); + assert.equal(counter.total(), BASELINE.deepForwardingChainReturns); + assert.ok(returnMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(returnMs)}ms`); + + // Parameter direction (hover on f12's x): each level passes `x` on as is, + // which costs no depth, so one search per level until the search budget + // stops the climb. + counter.reset(); + const ctx2 = createInferenceContext(ts, base); + const {elapsedMs: paramMs} = timed(() => + inferParameterType(ctx2, findFunctionDeclaration(sourceFile, 'f12').parameters[0]), + ); + assert.ok( + counter.total() <= BASELINE.deepForwardingChainParam, + `expected <= ${BASELINE.deepForwardingChainParam} searches from the deep end, got ${counter.total()}`, + ); + assert.ok(paramMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(paramMs)}ms`); + }); + + it(`memoizes a shared sub-helper across 20 sibling branches (<= ${BASELINE.wideFanOutMemoized} searches total)`, () => { + const branches = Array.from({length: 20}, (_, i) => `if (mode === ${i}) { return shared(getProduct()); }`).join( + '\n ', + ); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/fanout.js': ` + function shared(x) { + return x; + } + function caller(mode) { + ${branches} + return null; + } + shared(getProduct()); + module.exports = {caller, shared}; + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/fanout.js'), 'caller'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.ok(describeTypes(ctx, types).includes('ID'), 'fan-out must still infer the shared type'); + assert.ok( + counter.total() <= BASELINE.wideFanOutMemoized, + `expected the request memo to collapse 20 branches into <= ${BASELINE.wideFanOutMemoized} searches, got ${counter.total()}`, + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('serves a repeated identical hover from the position cache (0 new searches at the same project version)', () => { + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const host = createFixtureHost(files); + const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); + const counter = countSearches(); + const {proxy} = createPluginProxy({ + host, + languageService: baseLs, + config: {enabled: true, autoDiscover: false, cartridges: [{name: 'c', src: '/'}], inferUsage: true}, + }); + const paramPos = files['/helper.js'].indexOf('product)'); + + const first = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + assert.ok((first?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); + counter.reset(); + + const {result: second, elapsedMs} = timed(() => proxy.getQuickInfoAtPosition('/helper.js', paramPos)); + + assert.ok((second?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); + assert.equal( + counter.total(), + BASELINE.repeatedHoverCached, + 'an unchanged project version must be served from the inference cache without re-searching', + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it(`caps full-project scans when call sites route through many DISTINCT sub-helpers (<= ${BASELINE.distinctSubHelperTree} searches)`, () => { + // hot(x) is called 40 times, each time with the result of a DIFFERENT + // exported sub-helper that just returns its own parameter. Nothing here + // repeats, so neither the request memo nor the call-site memo can help, + // and every sub-helper's reference search returns only 2-3 results — + // draining the result budget far too slowly to bound the number of + // project scans. Only the dedicated search budget stops this one. + const N = 40; + const subs = Array.from({length: N}, (_, i) => `function sub${i}(a${i}) { return a${i}; }`).join('\n'); + const calls = Array.from({length: N}, (_, i) => `hot(sub${i}(getProduct()));`).join('\n'); + const exportsMap = Array.from({length: N}, (_, i) => ` sub${i}: sub${i},`).join('\n'); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/tree.js': ` + function hot(x) { + return x.ID; + } + ${subs} + ${calls} + module.exports = { + hot: hot, + ${exportsMap} + }; + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/tree.js'), 'hot'); + + const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); + + // Correctness must survive the cap: the first in-budget sub-helpers are + // enough to resolve the parameter's type. + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + assert.ok( + counter.total() <= BASELINE.distinctSubHelperTree, + `expected the search budget to bound project scans at <= ${BASELINE.distinctSubHelperTree}, got ${counter.total()}`, + ); + // The scenario must genuinely pressure the cap — if it stops needing to, + // it no longer guards anything and needs rebuilding. + assert.equal(ctx.searchBudget, 0, 'expected the search budget to be fully consumed by this scenario'); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it(`shares one reference-search set across sibling parameters of the same function (<= ${BASELINE.multiParamHelper} searches)`, () => { + // Return-type inference of pick() chases BOTH parameters; without the + // request-scoped call-site memo each parameter re-ran the identical + // searches (4 total instead of 2: the function name plus its alias-map + // property, twice). + const files = { + '/types.d.ts': + 'declare function getProduct(): {ID: string}; declare function getCategory(): {displayName: string};', + '/pick.js': ` + function pick(a, b) { + if (a) { return a; } + return b; + } + pick(getProduct(), getCategory()); + module.exports = {pick: pick}; + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/pick.js'), 'pick'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(describeTypes(ctx, types), '{ ID: string; } | { displayName: string; }'); + assert.ok( + counter.total() <= BASELINE.multiParamHelper, + `expected the call-site memo to dedupe sibling-parameter searches to <= ${BASELINE.multiParamHelper}, got ${counter.total()}`, + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it(`reuses reference searches across requests on an unchanged Program, with the same answer and budget (${BASELINE.repeatedRequestSameProgram} new searches)`, () => { + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/helper.js': ` + function helper(product) { + return product.ID; + } + module.exports = {helper: helper}; + `, + '/consumer.js': `var helpers = require('./helper'); helpers.helper(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const counter = countSearches(); + const infer = () => { + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + return {ctx, description: describeTypes(ctx, inferParameterType(ctx, fn.parameters[0]))}; + }; + + const cold = infer(); + const coldSearches = counter.total(); + counter.reset(); + const warm = infer(); + + assert.ok(coldSearches > 0, 'the first request must search for call sites'); + assert.equal(counter.total(), BASELINE.repeatedRequestSameProgram); + assert.equal(warm.description, '{ ID: string; }'); + assert.equal(warm.description, cold.description); + // A cached search still spends budget, so a warm request reaches exactly + // the call sites a cold one would. + assert.equal(warm.ctx.searchBudget, cold.ctx.searchBudget); + }); + + it(`bounds a helper with thousands of call sites in one generated file (<= ${BASELINE.hugeGeneratedFile} searches, <= 50 hits processed)`, () => { + // A generated data file whose big array literal contains a call site per + // row. One scan finds all of them; the per-call result budget must stop + // processing at 50, and each processed hit's root-to-position AST walk + // must not degrade on the huge sibling list (getNodeAtPosition stops + // scanning a sibling list once past the target position). + const rows = Array.from({length: 2000}, (_, i) => ` {sku: 'sku-${i}', price: hotPrice(getProduct())},`).join('\n'); + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/huge.js': ` + function hotPrice(product) { + return product.ID; + } + var ROWS = [ + ${rows} + ]; + module.exports = {ROWS: ROWS, hotPrice: hotPrice}; + `, + }; + const base = createFixtureLanguageService(files); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/huge.js'), 'hotPrice'); + + const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + assert.ok( + counter.total() <= BASELINE.hugeGeneratedFile, + `expected <= ${BASELINE.hugeGeneratedFile} searches, got ${counter.total()}`, + ); + const spent = 200 - ctx.referenceBudget; + assert.ok(spent <= 50, `expected the per-call cap (50) to bound processed hits, spent ${spent}`); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('keeps the inference cache across a project-version bump that produces no new program (0 new searches)', () => { + // tsserver bumps the project version string on events that don't change + // the program. Invalidation keys on Program identity, so such a bump must + // NOT force a full re-inference. + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string; name: string};', + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const host = createFixtureHost(files); + const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); + const counter = countSearches(); + let projectVersion = 1; + const {proxy} = createPluginProxy({ + host, + languageService: baseLs, + projectVersion: () => String(projectVersion), + config: {enabled: true, autoDiscover: false, cartridges: [{name: 'c', src: '/'}], inferUsage: true}, + }); + const paramPos = files['/helper.js'].indexOf('product)'); + + const first = proxy.getQuickInfoAtPosition('/helper.js', paramPos); + assert.ok((first?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); + counter.reset(); + projectVersion++; // bump WITHOUT any host/script change — same program + + const {result: second, elapsedMs} = timed(() => proxy.getQuickInfoAtPosition('/helper.js', paramPos)); + + assert.ok((second?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); + assert.equal( + counter.total(), + BASELINE.versionBumpSameProgram, + 'a version bump with an unchanged program must be served from the inference cache', + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it(`stringifies each unique candidate type at most once per request (<= ${BASELINE.nestedForwardingStringifications} typeToString calls)`, () => { + // fmtOpts forwards its parameter; wrap1/wrap2 forward through it. Hover + // on wrap2 pulls all 30 distinct large object-literal candidates into + // fmtOpts's parameter, where they are deduped by display string and then, + // being more than MAX_UNION_TYPES unrelated shapes, collapse to silence. + // Before the per-request typeToString memo every dedupe level re-rendered + // every type (120 calls, measured at 13ms of a 34ms request with + // 50x150-property literals). + const N = 30; + const literal = (i) => '{' + Array.from({length: 40}, (_, p) => `k${i}_${p}: ${p}`).join(', ') + '}'; + const calls = Array.from({length: N}, (_, i) => `fmtOpts(${literal(i)});`).join('\n'); + const files = { + '/opts.js': ` + function fmtOpts(opts) { + return opts; + } + function wrap1(o) { return fmtOpts(o); } + function wrap2(o) { return wrap1(o); } + ${calls} + module.exports = {fmtOpts: fmtOpts, wrap1: wrap1, wrap2: wrap2}; + `, + }; + const base = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, base); + // Count typeToString calls made THROUGH the public checker method — the + // deterministic cost proxy for dedupe/render work (checker-internal + // rendering doesn't route through this, so the counter is exactly ours). + let stringifications = 0; + const origTypeToString = ctx.checker.typeToString.bind(ctx.checker); + ctx.checker.typeToString = (...args) => { + stringifications++; + return origTypeToString(...args); + }; + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/opts.js'), 'wrap2'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(types.length, 0, 'more than MAX_UNION_TYPES unrelated shapes must collapse to silence'); + assert.ok( + stringifications <= BASELINE.nestedForwardingStringifications, + `expected the typeToString memo to bound stringifications at <= ${BASELINE.nestedForwardingStringifications}, got ${stringifications}`, + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('terminates promptly on mutual recursion combined with heavy call-site fan-in', () => { + // Worst of both worlds: a cycle whose members are also widely referenced. + const calls = Array.from({length: 100}, (_, i) => `a(${i}); b(${i});`).join('\n'); + const files = { + '/recursive.js': ` + function a(x) { return b(x); } + function b(y) { return a(y); } + ${calls} + module.exports = {a, b}; + `, + }; + const base = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, base); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/recursive.js'), 'a'); + + const {elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.ok(ctx.referenceBudget >= 0, 'the shared reference budget must never go negative'); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it('reuses the ambient-class index across Programs when only a cartridge .js file changes (addressBook.addresses)', () => { + // Real-world shape from a storefront cartridge's addressHelpers.js: an uncalled + // (from this file's perspective) helper whose only parameter usage is a + // single, globally-unique member access — the ambient-class matching + // fallback this scenario exercises, against the real bundled dw.* types + // rather than a small stand-in shape. + const files = { + '/types.d.ts': realTypesPrelude(['AddressBook'], ''), + '/addressHelpers.js': ` + function getAddressBookAddressByForm(addressBook, form) { + var collections = require('*/cartridge/scripts/util/collections'); + return collections.find(addressBook.addresses, function (address) { + return address.postalCode === form.postalCode.value; + }); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const before = createInferenceContext(ts, languageService); + const fnBefore = findFunctionDeclaration( + before.program.getSourceFile('/addressHelpers.js'), + 'getAddressBookAddressByForm', + ); + assert.equal(describeTypes(before, inferParameterType(before, fnBefore.parameters[0])), 'AddressBook'); + + // Typing in the cartridge file produces a new Program over the same, + // unchanged declaration SourceFiles. + files['/addressHelpers.js'] += '\n// edited\n'; + const after = createInferenceContext(ts, languageService); + assert.notEqual(after.program, before.program, 'expected the edit to produce a new Program'); + const counter = withPropertiesOfTypeCounter(after.checker); + const fnAfter = findFunctionDeclaration( + after.program.getSourceFile('/addressHelpers.js'), + 'getAddressBookAddressByForm', + ); + + const {result, elapsedMs} = timed(() => inferParameterType(after, fnAfter.parameters[0])); + + assert.equal(describeTypes(after, result), 'AddressBook'); + assert.equal( + counter.count(), + BASELINE.ambientClassIndexRebuildAfterJsEdit, + `expected the warm ambient-class index to add ${BASELINE.ambientClassIndexRebuildAfterJsEdit} getPropertiesOfType calls, got ${counter.count()}`, + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + it(`searches for HookMgr.callHook calls once per request, whichever hook parameters it infers (<= ${BASELINE.hookParameters} searches)`, () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['Basket', 'HookMgr', 'Customer'], + 'var HookManager: typeof HookMgr; function getBasket(): Basket; function getCustomer(): Customer;', + ), + '/hooks/payment.js': ` + function Handle(basket, customer) { + return {error: false}; + } + exports.Handle = Handle; + `, + '/checkout.js': Array.from( + {length: 20}, + (_, i) => `HookManager.callHook('app.payment.processor.p${i}', 'Handle', getBasket(), getCustomer());`, + ).join('\n'), + }; + const registrations = Array.from({length: 20}, (_, i) => ({ + extensionPoint: `app.payment.processor.p${i}`, + script: '/hooks/payment.js', + })); + const languageService = createFixtureLanguageService(files); + const counter = countSearches(); + let hostCalls = 0; + const ctx = createInferenceContext(ts, languageService, { + hookRegistrations: () => { + hostCalls++; + return registrations; + }, + }); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/hooks/payment.js'), 'Handle'); + + const {result, elapsedMs} = timed(() => fn.parameters.map((parameter) => inferParameterType(ctx, parameter))); + + assert.deepEqual( + result.map((types) => describeTypes(ctx, types)), + ['Basket', 'Customer'], + ); + assert.ok( + counter.total() <= BASELINE.hookParameters, + `expected <= ${BASELINE.hookParameters} reference searches, got ${counter.total()}`, + ); + assert.equal(hostCalls, 1, 'the host is asked for its registrations once per request'); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.real-types.test.js b/packages/b2c-script-types/test/usage-inference.real-types.test.js new file mode 100644 index 000000000..03602e83b --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -0,0 +1,651 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, + inferTypeForNode, + typesToCompletionEntries, +} = require('../src/usage-inference'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); + +// Builds the fixture LanguageService + inference context + target function +// node in one call, so each test body only has to state its fixture and its +// assertion. +function setupInference(files, jsFileName, fnName) { + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile(jsFileName); + const fn = findFunctionDeclaration(sourceFile, fnName); + return {ctx, fn}; +} + +function completionNames(ts_, checker, types) { + return typesToCompletionEntries(ts_, checker, types) + .map((e) => e.name) + .sort(); +} + +// This whole suite exercises the engine against the *real*, bundled dw.* +// type declarations (Product, Order, Money, ...) rather than small stand-in +// ambient shapes, since that's what a real cartridge actually resolves +// against — the tests in usage-inference.test.js cover the engine's +// mechanics in isolation; these cover it against a realistic SFCC Script API +// surface: real generics, real overloads, real nullability, real deep +// chains, matching how b2c-vs-extension#script-types-infer-usage.test.ts +// exercises the same feature end-to-end in VS Code. +describe('usage-inference — real dw.* Script API types (Product, Order)', () => { + describe('happy paths', () => { + const PRODUCT_HELPER_FILES = { + '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ' function getSomeProduct(): Product;'), + '/productHelpers.js': ` + function getDisplayName(product) { + return product.getName(); + } + function useHelper() { + var product = getSomeProduct(); + return getDisplayName(product); + } + `, + }; + + it('infers dw.catalog.Product for an undocumented parameter from a single ProductMgr.getProduct() call site', () => { + const {ctx, fn} = setupInference(PRODUCT_HELPER_FILES, '/productHelpers.js', 'getDisplayName'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product'); + }); + + it('offers real dw.catalog.Product members (getID, getName, getPriceModel) as synthesized completions', () => { + const {ctx, fn} = setupInference(PRODUCT_HELPER_FILES, '/productHelpers.js', 'getDisplayName'); + + const types = inferParameterType(ctx, fn.parameters[0]); + const names = completionNames(ts, ctx.checker, types); + + assert.ok(names.includes('getID')); + assert.ok(names.includes('getName')); + assert.ok(names.includes('getPriceModel')); + }); + + it('infers dw.catalog.Product for a constructor-function model parameter, invoked via `new` (StoreModel/ProductLineItem shape)', () => { + // Real-world shape from real storefront cartridges: SFRA "class" models + // are plain constructor functions (`function StoreModel(storeObject) { + // this.id = storeObject.getID(); ... }`) invoked with `new`, never a + // plain call — a widely-used idiom across both surveyed codebases + // (StoreModel, ProductLineItem, CartModel, AccountModel, AddressModel, + // Contact, ...) that plain call-site collection previously missed + // entirely, since `new Foo(x)` is a NewExpression, not a CallExpression. + const files = { + '/types.d.ts': realTypesPrelude(['Product'], ' function getSomeProduct(): Product;'), + '/productModel.js': ` + function ProductModel(apiProduct) { + this.id = apiProduct.getID(); + this.name = apiProduct.getName(); + } + function useModel() { + return new ProductModel(getSomeProduct()); + } + module.exports = ProductModel; + `, + }; + const {ctx, fn} = setupInference(files, '/productModel.js', 'ProductModel'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product'); + }); + + it('infers dw.order.Order for an undocumented parameter from an OrderMgr.getOrder() call site', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Order', 'OrderMgr'], ' function getSomeOrder(): Order;'), + '/orderHelpers.js': ` + function getOrderNumber(order) { + return order.getOrderNo(); + } + function useHelper() { + var order = getSomeOrder(); + return getOrderNumber(order); + } + `, + }; + const {ctx, fn} = setupInference(files, '/orderHelpers.js', 'getOrderNumber'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Order'); + }); + }); + + describe('deep nesting', () => { + const PRICING_HELPER_FILES = { + '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ' function getSomeProduct(): Product;'), + '/pricingHelpers.js': ` + function resolveProductPrice(product) { + return product.getPriceModel().getPrice(); + } + function useHelper() { + var product = getSomeProduct(); + return resolveProductPrice(product); + } + `, + }; + + it("resolves an undocumented helper's own return type through a real two-hop method chain (product.getPriceModel().getPrice())", () => { + const {ctx, fn} = setupInference(PRICING_HELPER_FILES, '/pricingHelpers.js', 'resolveProductPrice'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'Money'); + }); + + it('offers real dw.value.Money members for the deep-chain-inferred return type', () => { + const {ctx, fn} = setupInference(PRICING_HELPER_FILES, '/pricingHelpers.js', 'resolveProductPrice'); + + const types = inferReturnType(ctx, fn); + const names = completionNames(ts, ctx.checker, types); + + assert.ok(names.includes('getValue')); + assert.ok(names.includes('getCurrencyCode')); + }); + + it('resolves a method-chain return type when the receiver traces back to a real nullable getter (ProductMgr.getProduct(): Product | null)', () => { + // Regression test: resolveExpressionTypes's chain-hop branch looked up + // each method directly on the receiver's apparent type without + // stripping nullability first, so a receiver inferred from a real, + // nullable SFCC getter (the common shape — nearly every dw.*Mgr getter + // returns `T | null`) made `getPropertyOfType` return nothing for every + // hop, silently reducing the whole chain to `[]` instead of `Money`. + const files = { + '/pricingHelpers.js': ` + function resolveProductPrice(product) { + return product.getPriceModel().getPrice(); + } + function useHelper() { + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); + return resolveProductPrice(product); + } + `, + }; + const {ctx, fn} = setupInference(files, '/pricingHelpers.js', 'resolveProductPrice'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'Money'); + }); + + it('resolves the same chain split across an intermediate local variable — the idiomatic SFCC style', () => { + // Same result as the inline `product.getPriceModel().getPrice()` test + // above, but written the way real SFRA helpers are: chain hops assigned + // to `var`s along the way. Regression test — variable indirection used + // to dead-end inference entirely while the inline version worked. + const files = { + '/pricingHelpers.js': ` + function resolveProductPrice(product) { + var priceModel = product.getPriceModel(); + return priceModel.getPrice(); + } + function useHelper() { + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); + return resolveProductPrice(product); + } + `, + }; + const {ctx, fn} = setupInference(files, '/pricingHelpers.js', 'resolveProductPrice'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'Money'); + }); + + it('resolves a three-hop chain (order.getCustomer().getProfile().getEmail()) through an undocumented helper', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Order', 'OrderMgr'], ' function getSomeOrder(): Order;'), + '/customerHelpers.js': ` + function resolveCustomerEmail(order) { + return order.getCustomer().getProfile().getEmail(); + } + function useHelper() { + var order = getSomeOrder(); + return resolveCustomerEmail(order); + } + `, + }; + const {ctx, fn} = setupInference(files, '/customerHelpers.js', 'resolveCustomerEmail'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('chases a chain through two forwarding undocumented helpers before reaching the real method call', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ' function getSomeProduct(): Product;'), + '/pricingHelpers.js': ` + function resolveProductPrice(product) { + return getPriceInternal(product); + } + function getPriceInternal(p) { + return p.getPriceModel().getPrice(); + } + function useHelper() { + var product = getSomeProduct(); + return resolveProductPrice(product); + } + `, + }; + const {ctx, fn} = setupInference(files, '/pricingHelpers.js', 'resolveProductPrice'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'Money'); + }); + }); + + describe('callback parameters and iterator loops', () => { + const VARIANT_FILES = { + '/types.d.ts': realTypesPrelude( + ['Product', 'ProductMgr', 'Collection', 'Variant'], + ' function getSomeProduct(): Product;', + ), + '/util/collections.js': ` + function forEach(collection, callback) { + var it = collection.iterator(); + while (it.hasNext()) { callback(it.next()); } + } + module.exports = { forEach: forEach }; + `, + '/variantHelpers.js': ` + var collections = require('./util/collections'); + function eachVariant(product) { + collections.forEach(product.getVariants(), function (variant) { + return variant.getID(); + }); + } + function firstVariantName(product) { + var iter = product.getVariants().iterator(); + while (iter.hasNext()) { + var candidate = iter.next(); + return candidate.getName(); + } + return null; + } + function useHelper() { + eachVariant(getSomeProduct()); + firstVariantName(getSomeProduct()); + } + `, + }; + + function findIdentifierUse(sourceFile, text) { + let found; + const visit = (node) => { + if ( + ts.isIdentifier(node) && + node.text === text && + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node + ) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; + } + + it('infers Variant for a collections.forEach callback parameter fed by an inferred Collection', () => { + // The full SFRA shape: an untyped collections util, a callback with no + // name to search references for, and a collection argument that is + // itself only typed by inferring the enclosing helper's parameter. + const languageService = createFixtureLanguageService(VARIANT_FILES, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/variantHelpers.js'); + let cbParam; + const visit = (node) => { + if (ts.isFunctionExpression(node) && !cbParam) cbParam = node.parameters[0]; + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferParameterType(ctx, cbParam); + const names = completionNames(ts, ctx.checker, types); + + assert.equal(describeTypes(ctx, types), 'Variant'); + assert.ok(names.includes('getID')); + assert.ok(names.includes('getUPC')); + }); + + it('infers Variant through a manual iterator loop (iterator()/hasNext()/next())', () => { + const languageService = createFixtureLanguageService(VARIANT_FILES, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/variantHelpers.js'); + + const iterTypes = inferTypeForNode(ctx, findIdentifierUse(sourceFile, 'iter')); + assert.equal(describeTypes(ctx, iterTypes), 'Iterator'); + + const ctx2 = createInferenceContext(ts, languageService); + const candidateTypes = inferTypeForNode(ctx2, findIdentifierUse(sourceFile, 'candidate')); + assert.equal(describeTypes(ctx2, candidateTypes), 'Variant'); + }); + }); + + describe('module.superModule overlays', () => { + it('infers Money through an overlay calling an undocumented base helper (superModule + alias map + var chain)', () => { + // Full SFRA plugin composition: the overlay reaches its base module via + // module.superModule, calls an undocumented base helper whose own + // parameter is only typed by a call site in a third file (reached + // through the alias-map export), with every hop parked in a local var. + const files = { + '/base/cartridge/scripts/helpers/productHelpers.js': ` + function getSalePrice(product) { + var priceModel = product.getPriceModel(); + var price = priceModel.getPrice(); + return price; + } + module.exports = { + getSalePrice: getSalePrice + }; + `, + '/base/cartridge/scripts/cartService.js': ` + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var productHelpers = require('./helpers/productHelpers'); + function buildInfo(productId) { + var product = ProductMgr.getProduct(productId); + return productHelpers.getSalePrice(product); + } + module.exports = {buildInfo: buildInfo}; + `, + '/custom/cartridge/scripts/helpers/productHelpers.js': ` + var base = module.superModule; + function getMemberPrice(product) { + var basePrice = base.getSalePrice(product); + return basePrice; + } + module.exports = base; + module.exports.getMemberPrice = getMemberPrice; + `, + }; + const resolver = (f) => + f === '/custom/cartridge/scripts/helpers/productHelpers.js' + ? '/base/cartridge/scripts/helpers/productHelpers.js' + : undefined; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: resolver}); + const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/productHelpers.js'); + const fn = findFunctionDeclaration(overlay, 'getMemberPrice'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'Money'); + }); + }); + + describe('edge cases', () => { + it('still offers real member completions when the inferred type is nullable (ProductMgr.getProduct(): Product | null)', () => { + // ProductMgr.getProduct's real signature returns `Product | null` — + // this is the exact shape that regressed completions in production + // (getPropertiesOfType on a union only returns members common to every + // constituent, and null contributes none). + const files = { + '/consumer.js': ` + function getDisplayName(product) { + return product.getName(); + } + function useHelper() { + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); + return getDisplayName(product); + } + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'getDisplayName'); + + const types = inferParameterType(ctx, fn.parameters[0]); + const names = completionNames(ts, ctx.checker, types); + + assert.ok(names.includes('getID')); + assert.ok(names.includes('getName')); + }); + + it('infers a union when call sites pass different real dw.* classes (Product | Category)', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['Product', 'ProductMgr', 'Category'], + ' function getSomeProduct(): Product;\n function getSomeCategory(): Category;', + ), + '/consumer.js': ` + function describe(thing) { + return thing; + } + describe(getSomeProduct()); + describe(getSomeCategory()); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'describe'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product | Category'); + }); + + describe('object literals standing in for a class', () => { + const STAND_IN_TYPES = realTypesPrelude(['Order'], ' function getSomeOrder(): Order;'); + + function inferTotalOf(callers) { + const files = { + '/types.d.ts': STAND_IN_TYPES, + '/consumer.js': ` + function totalOf(container) { + return container.getTotalGrossPrice(); + } + totalOf(getSomeOrder()); + ${callers} + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'totalOf'); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + it('drops a test double that has only members of the class another call site passes', () => { + const types = inferTotalOf(` + totalOf({getTotalGrossPrice: function () { return null; }}); + var order = {orderNo: '00001', getTotalGrossPrice: function () { return null; }}; + totalOf(order); + `); + + assert.equal(types, 'Order'); + }); + + it('keeps an object literal with a member the class lacks', () => { + const types = inferTotalOf('totalOf({getTotalGrossPrice: function () { return null; }, giftWrap: true});'); + + assert.equal(types, 'Order | { getTotalGrossPrice: () => null; giftWrap: boolean; }'); + }); + }); + + describe('members read only as a test or an alternative', () => { + const BASKET_TYPES = realTypesPrelude(['Order', 'Basket'], ' function getSomeBasket(): Basket;'); + + function inferContainerOf(body) { + const files = { + '/types.d.ts': BASKET_TYPES, + '/consumer.js': ` + function describeContainer(container) { + ${body} + return container.getTotalGrossPrice(); + } + describeContainer(getSomeBasket()); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'describeContainer'); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + for (const [title, body] of [ + ['an alternative of `||`', 'var id = container.orderNo || container.UUID;'], + ['a guarded alternative', 'var id = (container && container.orderNo) || container.UUID;'], + ['an `if` condition', "if (container.orderNo) { log('order'); }"], + ['a negated test', "if (!container.orderNo) { log('basket'); }"], + ]) { + it(`keeps a Basket whose body reads orderNo only as ${title}`, () => { + assert.equal(inferContainerOf(body), 'Basket'); + }); + } + + it('drops the Basket once the body relies on orderNo, leaving the class that has it', () => { + assert.equal(inferContainerOf('var id = container.orderNo.toUpperCase();'), 'Order'); + }); + }); + + it('drops a call-site argument of a different kind than the Product the body passes the parameter on as', () => { + // SFRA's product bonusUnitPrice decorator is called once with the + // discount line item UUID in the product slot; the body hands the + // parameter to BonusDiscountLineItem.getBonusProductPrice(Product). + const files = { + '/types.d.ts': realTypesPrelude( + ['Product', 'BonusDiscountLineItem'], + ' function getSomeProduct(): Product;\n function getBonusLineItem(): BonusDiscountLineItem;', + ), + '/consumer.js': ` + function getBonusUnitPrice(apiProduct) { + return getBonusLineItem().getBonusProductPrice(apiProduct); + } + getBonusUnitPrice(getSomeProduct()); + getBonusUnitPrice('discount-line-item-uuid'); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'getBonusUnitPrice'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product'); + }); + + it('keeps a sibling class passed to a helper documented with a narrower class of the same family (Order | Basket)', () => { + // SFRA documents many LineItemCtnr helpers as taking a Basket; an Order + // passed to one is a too-narrow JSDoc, not a reason to drop the Order. + const files = { + '/types.d.ts': realTypesPrelude( + ['Order', 'Basket'], + ' function getSomeOrder(): Order;\n function getSomeBasket(): Basket;\n function ensureValidShipments(basket: Basket): boolean;', + ), + '/consumer.js': ` + function getCheckoutStepInformation(lineItemContainer) { + return ensureValidShipments(lineItemContainer); + } + getCheckoutStepInformation(getSomeOrder()); + getCheckoutStepInformation(getSomeBasket()); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'getCheckoutStepInformation'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Order | Basket'); + }); + + it('infers Product from ambient usage when a never-called helper is named product and uses Product API methods', () => { + // Generic Product is indexed for ambient matching (hover shows Product). + // A parameter conventionally named `product` plus a Product-only method is + // exactly the IntelliJ/JSDoc-less case storefront helpers hit constantly. + const files = { + '/types.d.ts': realTypesPrelude(['Product', 'Variant', 'Category'], ''), + '/productHelpers.js': ` + function getDisplayName(product) { + return product.getName(); + } + `, + }; + const {ctx, fn} = setupInference(files, '/productHelpers.js', 'getDisplayName'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product'); + }); + + it('chases a var-of-var deep property chain with a real nullable middle step (availabilityModel.inventoryRecord)', () => { + // Product.availabilityModel is non-null but + // ProductAvailabilityModel.inventoryRecord is `ProductInventoryRecord | + // null` in the real dw types — the property-access branch must strip + // the nullable part before looking up members on the next hop. + const files = { + '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ' function getSomeProduct(): Product;'), + '/stockHelpers.js': ` + function isOrderable(product, quantity) { + var availabilityModel = product.availabilityModel; + var inventoryRecord = availabilityModel.inventoryRecord; + return inventoryRecord.ATS.value >= quantity; + } + function useHelper() { + var product = getSomeProduct(); + return isOrderable(product, 2); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/stockHelpers.js'); + let recordIdentifier; + const visit = (node) => { + if ( + ts.isIdentifier(node) && + node.text === 'inventoryRecord' && + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node + ) { + recordIdentifier = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferTypeForNode(ctx, recordIdentifier); + + // `| null` is dropped like any other uninformative part: the hover names + // the class, the way TypeScript shows it without strictNullChecks. + assert.equal(describeTypes(ctx, types), 'ProductInventoryRecord'); + }); + + it('synthesizes real members for a generic collection candidate type (Collection) without special-casing generics', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['Product', 'ProductMgr', 'Collection', 'Variant'], + ' function getSomeProduct(): Product;', + ), + '/variantHelpers.js': ` + function countVariants(variants) { + return variants.getLength(); + } + function useHelper() { + var product = getSomeProduct(); + return countVariants(product.getVariants()); + } + `, + }; + const {ctx, fn} = setupInference(files, '/variantHelpers.js', 'countVariants'); + + const types = inferParameterType(ctx, fn.parameters[0]); + const names = completionNames(ts, ctx.checker, types); + + assert.ok(describeTypes(ctx, types).startsWith('Collection<')); + assert.ok(names.includes('getLength')); + assert.ok(names.includes('toArray')); + }); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js new file mode 100644 index 000000000..3bdd8097e --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -0,0 +1,1887 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, + inferTypeForNode, + memberCompletionAccess, + typesToCompletionEntries, +} = require('../src/usage-inference'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); +const {realTypesPrelude} = require('./helpers/real-dw-types'); + +const AMBIENT_TYPES = ` +declare function getProduct(): {ID: string; name: string}; +declare function getInventory(): {quantity: number}; +`; + +describe('usage-inference', () => { + describe('inferParameterType', () => { + it('infers a parameter type from a single call site', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('infers a parameter type from a `new Helper(x)` constructor call site (SFRA constructor-function model pattern)', () => { + // Real-world shape from real storefront cartridges: `function StoreModel(storeObject, location) {...}` + // invoked as `new StoreModel(store, location)`, never a plain call — a + // widely-used SFRA idiom for "class" models that plain call-site + // collection (which only recognized ordinary CallExpressions) missed + // entirely. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function StoreModel(storeObject) { + this.id = storeObject.ID; + } + new StoreModel(getProduct()); + module.exports = StoreModel; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'StoreModel'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('infers a union when plain-call and `new` constructor call sites pass different types', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function Wrapper(input) { + this.value = input; + } + Wrapper(getProduct()); + new Wrapper(getInventory()); + module.exports = Wrapper; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'Wrapper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; } | { quantity: number; }'); + }); + + it('infers a constructor function or class instance from a `this` argument', () => { + // The checker types `this` in a constructor as an open `this` type; + // the value passed is the instance under construction. + const files = { + '/helper.js': ` + function store(target, key, value) { + return target[key] ? [target[key], value] : value; + } + function label(model) { + return model.id; + } + var Params = function (raw) { + var pair = raw.split('='); + this.raw = raw; + this[pair[0]] = store(this, pair[0], pair[1]); + }; + class Model { + constructor(id) { + this.id = id; + } + describe() { + return label(this); + } + } + module.exports = {Params: Params, Model: Model}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + + const target = findFunctionDeclaration(sourceFile, 'store').parameters[0]; + const model = findFunctionDeclaration(sourceFile, 'label').parameters[0]; + + assert.equal(describeTypes(ctx, inferParameterType(ctx, target)), 'Params'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, model)), 'Model'); + }); + + it('stays silent on a `this` argument outside a constructor', () => { + const files = { + '/helper.js': ` + function label(model) { + return model.id; + } + function plain() { + return label(this); + } + function Model(ids) { + this.id = ids[0]; + ids.forEach(function () { + label(this); + }, this); + } + module.exports = {plain: plain, Model: Model}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const model = findFunctionDeclaration(sourceFile, 'label').parameters[0]; + + assert.equal(inferParameterType(ctx, model).length, 0); + }); + + it('infers through a mix of plain-call and `new` when every site passes the same type', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function Wrapper(input) { + this.value = input; + } + Wrapper(getProduct()); + new Wrapper(getProduct()); + module.exports = Wrapper; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'Wrapper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('does not throw on a bare `new Helper` constructor call with no parentheses/arguments', () => { + // `new Helper` (no parens) is valid JS whose `arguments` is `undefined`, + // unlike a plain call's — always-present, possibly-empty — array. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function Helper(input) { + this.value = input; + } + new Helper; + module.exports = Helper; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'Helper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), ''); + }); + + it('skips a reference that sits in a top-level JSDoc comment (SFRA shipping model shape)', () => { + // A constructor with an expando member can be used as a JSDoc type, so + // its references include `{ProductLineItemsModel}` inside the doc + // comment — a position the AST walk resolves to the source file itself, + // which has no parent to inspect. The `new` call after it must still count. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/productLineItems.js': ` + function ProductLineItems(items) { + this.id = items.ID; + } + ProductLineItems.count = function () { return 0; }; + module.exports = ProductLineItems; + `, + '/shipping.js': `var ProductLineItemsModel = require('./productLineItems'); + +/** + * @returns {ProductLineItemsModel} the shipment's line items + */ +function getLineItems() { + return new ProductLineItemsModel(getProduct()); +} +module.exports = getLineItems; +`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/productLineItems.js'); + const fn = findFunctionDeclaration(sourceFile, 'ProductLineItems'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('keeps only the call-site types that have every member the body uses', () => { + // One call site passes a look-alike the body can't actually use: the + // body reads `.quantity`, which only the inventory shape has. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(input) { + return input.quantity; + } + helper(getProduct()); + helper(getInventory()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ quantity: number; }'); + }); + + it('keeps a single converged type when every call site agrees', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(input) { + return input; + } + helper(getProduct()); + helper(getProduct()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves references through CommonJS `exports.foo = function(){}` assignment', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + exports.helper = function (product) { + return product.ID; + }; + exports.helper(getProduct()); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + let param; + const visit = (node) => { + if (ts.isFunctionExpression(node)) { + param = node.parameters[0]; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('returns no candidates when the function is never called', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(types.length, 0); + }); + }); + + describe('inferParameterType — cross-file export patterns', () => { + /** The first parameter of the first function-like node `isTarget` accepts. */ + function findFirstParam(sourceFile, isTarget) { + let param; + const visit = (node) => { + if (param) return; + if (isTarget(node)) param = node.parameters[0]; + else ts.forEachChild(node, visit); + }; + visit(sourceFile); + return param; + } + + const findFunctionExpressionParam = (sourceFile) => findFirstParam(sourceFile, ts.isFunctionExpression); + + it('resolves a bare `module.exports = function(){}` called via `require(...)` in another file', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': `module.exports = function (product) { return product.ID; };`, + '/consumer.js': `var helper = require('./helper'); helper(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/helper.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves an immediately-invoked `require(...)(x)` call', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': `module.exports = function (product) { return product.ID; };`, + '/consumer.js': `require('./helper')(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/helper.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a destructured `const {helper} = require(...)` call site', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': `module.exports = { helper: function (product) { return product.ID; } };`, + '/consumer.js': `var { helper } = require('./helper'); helper(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/helper.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a renamed destructure `const {helper: h} = require(...)`', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': `module.exports = { helper: function (product) { return product.ID; } };`, + '/consumer.js': `var { helper: h } = require('./helper'); h(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/helper.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves the SFRA-canonical alias-map export (`module.exports = {helper: helper}`) called from another file', () => { + // References on the *function* name dead-end at the alias-map + // initializer; reaching the cross-file `productHelpers.helper(x)` call + // requires hopping to the property *name* and searching from there. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + module.exports = { + helper: helper + }; + `, + '/consumer.js': `var productHelpers = require('./helper'); productHelpers.helper(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a module required into an export map (`{images: require(...)}`) and called through it', () => { + // SFRA's decorators/index.js shape: each decorator module is required + // into one object and consumers call `decorators.images(...)`. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/images.js': `module.exports = function (product) { return product.ID; };`, + '/index.js': `module.exports = { images: require('./images') };`, + '/consumer.js': `var decorators = require('./index'); decorators.images(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/images.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a call through a local alias of the function (`var run = helper`)', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + var run = helper; + run(getProduct()); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves an ES6 method-shorthand export called via property access', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': `module.exports = { helper(product) { return product.ID; } };`, + '/consumer.js': `var helper = require('./helper'); helper.helper(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFirstParam(ctx.program.getSourceFile('/helper.js'), ts.isMethodDeclaration); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + const ES6_EXPORT_SHAPES = [ + { + shape: 'an arrow-function module export (`module.exports = (product) => ...`)', + helper: `module.exports = (product) => product.ID;`, + consumer: `var helper = require('./helper'); helper(getProduct());`, + isTarget: ts.isArrowFunction, + }, + { + shape: 'an arrow function inside an export map', + helper: `module.exports = { helper: (product) => product.ID };`, + consumer: `var helpers = require('./helper'); helpers.helper(getProduct());`, + isTarget: ts.isArrowFunction, + }, + { + shape: 'an ES6 class constructor reached through `new`', + helper: `class Model { constructor(product) { this.id = product.ID; } } module.exports = Model;`, + consumer: `var Model = require('./helper'); new Model(getProduct());`, + isTarget: ts.isConstructorDeclaration, + }, + { + shape: 'an anonymous class expression export reached through `new`', + helper: `module.exports = class { constructor(product) { this.id = product.ID; } };`, + consumer: `var Model = require('./helper'); new Model(getProduct());`, + isTarget: ts.isConstructorDeclaration, + }, + { + shape: 'an ES6 class method called on an instance', + helper: `class Formatter { format(product) { return product.ID; } } module.exports = Formatter;`, + consumer: `var Formatter = require('./helper'); new Formatter().format(getProduct());`, + isTarget: ts.isMethodDeclaration, + }, + ]; + + for (const {shape, helper, consumer, isTarget} of ES6_EXPORT_SHAPES) { + it(`resolves ${shape} called from another file`, () => { + const files = {'/types.d.ts': AMBIENT_TYPES, '/helper.js': helper, '/consumer.js': consumer}; + const ctx = createInferenceContext(ts, createFixtureLanguageService(files)); + const param = findFirstParam(ctx.program.getSourceFile('/helper.js'), isTarget); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + }); + } + }); + + describe('inferParameterType — fitting call sites to how the body uses the value', () => { + // Infers the first parameter of `fnName` in /helper.js. + function inferHelperParam(files, fnName) { + const ctx = createInferenceContext(ts, createFixtureLanguageService(files)); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), fnName); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + it('drops a call-site argument the body could not pass on to the typed helper it calls', () => { + const files = { + '/types.d.ts': `${AMBIENT_TYPES}declare function priceOf(product: {ID: string; name: string}): number;`, + '/helper.js': ` + function unitPrice(product) { + return priceOf(product); + } + unitPrice(getProduct()); + unitPrice('a-line-item-uuid'); + `, + }; + + assert.equal(inferHelperParam(files, 'unitPrice'), '{ ID: string; name: string; }'); + }); + + it('counts the members a constructor defines with Object.defineProperty(this, ...)', () => { + // `locale` and `currency` are members a Script API class declares, so + // the body's use of them is checked against the argument's type. + const files = { + '/types.d.ts': 'declare class SiteLocale { locale: string; currency: string; }', + '/helper.js': ` + function Request(raw) { + this.path = raw.path; + Object.defineProperty(this, 'locale', { + get: function () { + Object.defineProperty(this, 'currency', {value: raw.currency}); + return raw.locale; + }, + }); + } + function pageTitle(req) { + return req.path + req.locale; + } + function priceLabel(req) { + return req.path + req.currency; + } + pageTitle(new Request({path: '/', locale: 'en'})); + priceLabel(new Request({path: '/', currency: 'EUR'})); + module.exports = Request; + `, + }; + + assert.equal(inferHelperParam(files, 'pageTitle'), 'Request'); + // A nested function's `this` is another value. + assert.equal(inferHelperParam(files, 'priceLabel'), ''); + }); + + it('keeps a call-site argument whose use only a branch that narrowed it constrains', () => { + const files = { + '/types.d.ts': `${AMBIENT_TYPES}declare function lookup(id: string): {ID: string; name: string};`, + '/helper.js': ` + function resolve(productOrId) { + return typeof productOrId === 'string' ? lookup(productOrId) : productOrId; + } + resolve(getProduct()); + resolve('some-id'); + `, + }; + + assert.equal(inferHelperParam(files, 'resolve'), '{ ID: string; name: string; } | string'); + }); + + it('uses an argument to an overloaded function only when every overload of that arity agrees on it', () => { + const files = { + '/types.d.ts': ` + declare function parse(text: string): number; + declare function parse(text: string, radix: number): number; + declare function show(value: string): string; + declare function show(value: number): string; + declare function render(id: string, params: string): string; + declare function render(id: string, attributes: {size: number}, params: string): string; + `, + '/helper.js': ` + function parsed(text) { return parse(text); } + function shown(value) { return show(value); } + function rendered(attributes) { return render('page', attributes, '{}'); } + `, + }; + + assert.equal(inferHelperParam(files, 'parsed'), 'string'); + assert.equal(inferHelperParam(files, 'shown'), ''); + assert.equal(inferHelperParam(files, 'rendered'), '{ size: number; }'); + }); + + it('does not require a member the body tests for before using it', () => { + const files = { + '/types.d.ts': ` + declare function getOrderAddress(): {city: string}; + declare function getCustomerAddress(): {ID: string; city: string}; + `, + '/helper.js': ` + function label(address) { + var id = Object.prototype.hasOwnProperty.call(address, 'ID') ? address.ID : ''; + return id + address.city; + } + label(getOrderAddress()); + label(getCustomerAddress()); + `, + }; + + assert.equal(inferHelperParam(files, 'label'), '{ city: string; } | { ID: string; city: string; }'); + }); + + it('does not require a member used only in one branch of a presence test (`if ("m" in x) … else …`)', () => { + // SFRA's SearchPhraseSuggestions takes either a suggestions object or a + // bare phrase iterator and tells them apart with an `in` test. + const files = { + '/types.d.ts': ` + declare function getPhraseIterator(): {hasNext(): boolean}; + declare function getBrandSuggestions(): {searchPhraseSuggestions: {hasNext(): boolean}}; + `, + '/helper.js': ` + function available(suggestions) { + if ('searchPhraseSuggestions' in suggestions) { + return suggestions.searchPhraseSuggestions.hasNext(); + } + return suggestions.hasNext(); + } + available(getPhraseIterator()); + available(getBrandSuggestions()); + `, + }; + + assert.equal( + inferHelperParam(files, 'available'), + '{ hasNext(): boolean; } | { searchPhraseSuggestions: { hasNext(): boolean; }; }', + ); + }); + + it('still requires a member used outside the branches of a presence test', () => { + const files = { + '/types.d.ts': ` + declare function getOrderAddress(): {city: string}; + declare function getLookAlike(): {ID: string}; + `, + '/helper.js': ` + function label(address) { + var id = 'ID' in address && address.ID; + return id + address.city; + } + label(getOrderAddress()); + label(getLookAlike()); + `, + }; + + assert.equal(inferHelperParam(files, 'label'), '{ city: string; }'); + }); + + it('treats `x instanceof Object` as no evidence of what x is', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function valueOf(field) { + return field instanceof Object ? field.value : field; + } + `, + }; + + assert.equal(inferHelperParam(files, 'valueOf'), ''); + }); + }); + + describe('inferParameterType — explicit `any` is left alone', () => { + it('does not infer a type for a parameter with an explicit `@param {any}` JSDoc tag', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + /** @param {any} product */ + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(types.length, 0); + }); + + it('does not infer a type for a parameter with an explicit `: any` TS annotation', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.ts': ` + function helper(product: any) { + return product.ID; + } + helper(getProduct()); + export {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.ts'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(types.length, 0); + }); + }); + + describe('inferParameterType — weak SFRA placeholder JSDoc does not block inference', () => { + // Real storefronts (and IntelliJ's happy path) often write `@param {Object}` + // / `{obj}` / `{*}` instead of a real dw.* type. Those placeholders must + // not permanently silence usage inference the way a deliberate `{any}` does, + // also when they are written nullable or optional. + for (const annotation of ['Object', 'obj', '*', '{}', 'Object|null', '?Object', 'Object=', '(obj|undefined)']) { + it(`infers through @param {${annotation}} on a named customer parameter`, () => { + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), + '/helper.js': ` + /** + * @param {${annotation}} customer + */ + function getPasswordResetToken(customer) { + return customer.profile.credentials.createResetPasswordToken(); + } + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'getPasswordResetToken'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + }); + } + + // An array of placeholders says no more about its elements than the + // placeholder does; a deliberate `{any[]}` still blocks inference. + for (const [annotation, expected] of [ + ['Array', 'ProductLineItem[]'], + ['Object[]', 'ProductLineItem[]'], + ['Array.<*>', 'ProductLineItem[]'], + ['?Array', 'ProductLineItem[]'], + ['any[]', ''], + ['Array', ''], + ]) { + it(`${expected ? 'infers through' : 'respects'} @param {${annotation}}`, () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['ProductLineItem'], ' function getLineItems(): ProductLineItem[];'), + '/helper.js': ` + /** + * @param {${annotation}} lineItems + */ + function countItems(lineItems) { + return lineItems.length; + } + countItems(getLineItems()); + `, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'countItems'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), expected); + }); + } + + it('still respects a nullable real type mixed with a placeholder (`{Customer|Object|null}`)', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['Customer'], ''), + '/helper.js': ` + /** + * @param {Customer|Object|null} customer + */ + function getPasswordResetToken(customer) { + return customer.profile.credentials.createResetPasswordToken(); + } + `, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'getPasswordResetToken'); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + + it('still respects a real @param {Customer} annotation (does not second-guess dw.* JSDoc)', () => { + // If we ignored the Customer annotation we'd chase the Product call site. + // Respecting dw.* JSDoc matches IntelliJ and leaves the author's type alone. + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['Customer', 'Product'], ' function getSomeProduct(): Product;'), + '/helper.js': ` + /** + * @param {Customer} product + */ + function misnamed(product) { + return product.getID(); + } + misnamed(getSomeProduct()); + `, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'misnamed'); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + }); + + describe('inferParameterType — reference budget', () => { + it('stops collecting call sites once the request-scoped reference budget runs out', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + helper(getInventory()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + // Exhausted up front — even though the helper has usable call sites, + // none should be processed once the shared budget is gone. + ctx.referenceBudget = 0; + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(types.length, 0); + }); + + it('caps how much of the shared budget a single call can spend, so one widely-referenced helper cannot starve sibling branches', () => { + const callSites = Array.from({length: 60}, () => 'helper(1);').join('\n'); + const files = { + '/helper.js': ` + function helper(product) { + return product; + } + ${callSites} + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + const budgetBefore = ctx.referenceBudget; + + inferParameterType(ctx, fn.parameters[0]); + + const spent = budgetBefore - ctx.referenceBudget; + assert.ok(spent <= 50, `expected at most 50 references spent by one call, got ${spent}`); + assert.ok(spent < 60, 'expected the per-call cap to actually engage given 60+ available references'); + }); + }); + + describe('inferReturnType', () => { + it('chases a multi-hop undocumented call chain through a forwarding helper', () => { + // `identity` forwards its own (undocumented, `any`) parameter, so TS's + // own inference gives `identity` an `any` return type too. `caller` + // calls `identity(getProduct())` — this only resolves if inference + // recurses from caller -> identity's return -> identity's parameter. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/chain.js': ` + function identity(x) { + return x; + } + function caller() { + return identity(getProduct()); + } + identity(getProduct()); + module.exports = {caller, identity}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/chain.js'); + const caller = findFunctionDeclaration(sourceFile, 'caller'); + + const types = inferReturnType(ctx, caller); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('chases both branches of a ternary return (collections.first shape)', () => { + // Stock SFRA `collections.first`: `return it.hasNext() ? it.next() : null`. + // Without ConditionalExpression chasing the whole helper stays `any` + // even when the call-site collection is fully typed. + const files = { + '/types.d.ts': ` + interface FixtureIterator { + hasNext(): boolean; + next(): {ID: string}; + } + interface FixtureCollection { + iterator(): FixtureIterator; + } + declare function getCollection(): FixtureCollection; + `, + '/collections.js': ` + function first(collection) { + var iterator = collection.iterator(); + return iterator.hasNext() ? iterator.next() : null; + } + function caller() { + return first(getCollection()); + } + first(getCollection()); + module.exports = {first: first, caller: caller}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/collections.js'); + const caller = findFunctionDeclaration(sourceFile, 'caller'); + + const described = describeTypes(ctx, inferReturnType(ctx, caller)); + assert.ok(described.includes('{ ID: string; }'), `expected element type, got: ${described}`); + }); + + it('does not infinitely recurse on mutually recursive undocumented helpers', () => { + const files = { + '/recursive.js': ` + function a(x) { + return b(x); + } + function b(y) { + return a(y); + } + a(1); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/recursive.js'); + const fnA = findFunctionDeclaration(sourceFile, 'a'); + + // Must return (not hang) even though a() and b() call each other. + const types = inferReturnType(ctx, fnA); + assert.ok(Array.isArray(types)); + }); + + it('chases a property access on an undocumented parameter (`return x.prop`)', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/chain.js': ` + function shared(x) { + return x.ID; + } + function caller() { + return shared(getProduct()); + } + shared(getProduct()); + module.exports = {caller, shared}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/chain.js'); + const caller = findFunctionDeclaration(sourceFile, 'caller'); + + const types = inferReturnType(ctx, caller); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('chases a method-chain (`x.next().next()...`) within MAX_CHAIN_HOPS', () => { + const files = { + '/types.d.ts': ` + interface Chainable { + next(): Chainable; + value: string; + } + declare function getChainable(): Chainable; + `, + '/chain.js': ` + function resolveChain(x) { + return x.next().next().next().next().next().value; + } + function useHelper() { + return resolveChain(getChainable()); + } + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/chain.js'); + const fn = findFunctionDeclaration(sourceFile, 'resolveChain'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('gives up (without hanging) on a method-chain longer than MAX_CHAIN_HOPS, rather than chasing it unbounded', () => { + // MAX_CHAIN_HOPS bounds in-expression chain-hopping (a.b().c().d()...) + // separately from MAX_INFERENCE_DEPTH, which only bounds crossing into + // another undocumented helper's own return-type inference — a chain + // never crosses a function boundary, so without its own cap this would + // be bounded only by how long an expression happens to be written. + const files = { + '/types.d.ts': ` + interface Chainable { + next(): Chainable; + value: string; + } + declare function getChainable(): Chainable; + `, + '/chain.js': ` + function resolveChain(x) { + return x.next().next().next().next().next().next().next().next().next().next().next().next().value; + } + function useHelper() { + return resolveChain(getChainable()); + } + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/chain.js'); + const fn = findFunctionDeclaration(sourceFile, 'resolveChain'); + + const types = inferReturnType(ctx, fn); + + assert.equal(types.length, 0); + }); + + it('reuses a memoized result computed at a shallower depth even when a later call is over MAX_INFERENCE_DEPTH', () => { + // `shared` is reached at depth 1 via `short` (well within budget, gets + // memoized), then again at depth 4 via a longer forwarding chain, which + // is over MAX_INFERENCE_DEPTH (3). The memoized, fully-resolved result + // must still be returned rather than discarded just because this + // particular path to it happens to run over the depth cap. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/chain.js': ` + function shared(x) { return x; } + function short() { return shared(getProduct()); } + function longChain0() { return longChain1(); } + function longChain1() { return longChain2(); } + function longChain2() { return shared(getProduct()); } + function top() { return longChain0(); } + short(); + top(); + module.exports = {short, top}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/chain.js'); + const shortFn = findFunctionDeclaration(sourceFile, 'short'); + const topFn = findFunctionDeclaration(sourceFile, 'top'); + + const shortTypes = inferReturnType(ctx, shortFn); + assert.equal(describeTypes(ctx, shortTypes), '{ ID: string; name: string; }'); + + const topTypes = inferReturnType(ctx, topFn); + assert.equal(describeTypes(ctx, topTypes), '{ ID: string; name: string; }'); + }); + }); + + describe('local-variable indirection', () => { + it('chases a return value through an intermediate local variable, same as the inline expression', () => { + // Idiomatic SFCC style: the chain is split across a `var` instead of + // written inline. Regression test — the identifier branch of + // resolveExpressionTypes used to dead-end on anything that wasn't a + // parameter declaration, so this inferred nothing while the inline + // one-liner version inferred fine. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function pick(input) { + var intermediate = input; + return intermediate; + } + pick(getProduct()); + module.exports = {pick}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'pick'); + + const types = inferReturnType(ctx, fn); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('infers the type of a variable initialized from a property access on an undocumented parameter', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function pick(product) { + var id = product.ID; + return id; + } + pick(getProduct()); + module.exports = {pick}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + let idIdentifier; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === 'id' && ts.isReturnStatement(node.parent)) { + idIdentifier = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferTypeForNode(ctx, idIdentifier); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('leaves a variable with an explicit `@type {any}` JSDoc annotation alone', () => { + // Same rule as parameters and return types: an annotated `any` is a + // deliberate choice, not an inference failure. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function pick(product) { + /** @type {any} */ + var id = product.ID; + return id; + } + pick(getProduct()); + module.exports = {pick}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + let idIdentifier; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === 'id' && ts.isReturnStatement(node.parent)) { + idIdentifier = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferTypeForNode(ctx, idIdentifier); + + assert.equal(types.length, 0); + }); + + it('does not hang on mutually-referencing variable initializers (`var a = b; var b = a;`)', () => { + const files = { + '/cycle.js': ` + function g() { + var a = b; + var b = a; + return a; + } + g(); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/cycle.js'); + let aIdentifier; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === 'a' && ts.isReturnStatement(node.parent)) { + aIdentifier = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + // Must return promptly (not hang) with no candidates. + const types = inferTypeForNode(ctx, aIdentifier); + assert.equal(types.length, 0); + }); + }); + + describe('module.superModule overlays', () => { + // The SFRA plugin-cartridge pattern: an overlay module at the same path + // as a base-cartridge module reaches the base via `module.superModule`. + // The engine resolves it through ctx.host.resolveSuperModulePath (supplied by + // the plugin host, which owns the cartridge order). + const SUPER_RESOLVER = (containingFile) => + containingFile === '/custom/cartridge/scripts/helpers/x.js' ? '/base/cartridge/scripts/helpers/x.js' : undefined; + + const OVERLAY_FILES = { + '/types.d.ts': AMBIENT_TYPES, + '/base/cartridge/scripts/helpers/x.js': ` + function getThing(input) { + return input; + } + getThing(getProduct()); + module.exports = { + getThing: getThing + }; + `, + '/custom/cartridge/scripts/helpers/x.js': ` + var base = module.superModule; + function wrapped() { + return base.getThing(getProduct()); + } + module.exports = base; + module.exports.wrapped = wrapped; + `, + }; + + function findIdentifier(sourceFile, text, parentPredicate) { + let found; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === text && parentPredicate(node)) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; + } + + it("resolves `module.superModule` to the overridden module's export type", () => { + const languageService = createFixtureLanguageService(OVERLAY_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: SUPER_RESOLVER}); + const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); + // `base` in `base.getThing(...)` — an identifier whose declaration is + // the `var base = module.superModule` initializer. + const baseUse = findIdentifier(overlay, 'base', (n) => ts.isPropertyAccessExpression(n.parent)); + + const types = inferTypeForNode(ctx, baseUse); + const entries = typesToCompletionEntries(ts, ctx.checker, types); + + assert.deepEqual( + entries.map((e) => e.name), + ['getThing'], + ); + }); + + it("chases a call through superModule into the base module's own undocumented helper", () => { + // base.getThing's declared return type is `any` (undocumented), so the + // member lookup alone isn't enough — the engine must recurse into the + // base function's declaration and infer its return from usage. + const languageService = createFixtureLanguageService(OVERLAY_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: SUPER_RESOLVER}); + const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); + const wrapped = findFunctionDeclaration(overlay, 'wrapped'); + + const types = inferReturnType(ctx, wrapped); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('returns no candidates when no lower cartridge provides the module', () => { + const languageService = createFixtureLanguageService(OVERLAY_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: () => undefined}); + const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); + const baseUse = findIdentifier(overlay, 'base', (n) => ts.isPropertyAccessExpression(n.parent)); + + assert.equal(inferTypeForNode(ctx, baseUse).length, 0); + }); + + it('returns no candidates without a resolver (plain LSP host that never supplied one)', () => { + const languageService = createFixtureLanguageService(OVERLAY_FILES); + const ctx = createInferenceContext(ts, languageService); + const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); + const baseUse = findIdentifier(overlay, 'base', (n) => ts.isPropertyAccessExpression(n.parent)); + + assert.equal(inferTypeForNode(ctx, baseUse).length, 0); + }); + + it('recurses through a pass-through overlay (`module.exports = base`) to the cartridge below it', () => { + // Three-cartridge path: top -> mid -> base. mid re-exports its own + // superModule untouched, so resolving top's `module.superModule` must + // chase through mid's `module.exports = base` to base's concrete type. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/base/cartridge/scripts/helpers/x.js': ` + function getThing(input) { + return input; + } + module.exports = { + getThing: getThing + }; + `, + '/mid/cartridge/scripts/helpers/x.js': ` + var base = module.superModule; + module.exports = base; + `, + '/top/cartridge/scripts/helpers/x.js': ` + var base = module.superModule; + function useIt() { + return base; + } + module.exports = base; + `, + }; + const order = { + '/top/cartridge/scripts/helpers/x.js': '/mid/cartridge/scripts/helpers/x.js', + '/mid/cartridge/scripts/helpers/x.js': '/base/cartridge/scripts/helpers/x.js', + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => order[f]}); + const top = ctx.program.getSourceFile('/top/cartridge/scripts/helpers/x.js'); + const baseUse = findIdentifier(top, 'base', (n) => ts.isReturnStatement(n.parent)); + + const types = inferTypeForNode(ctx, baseUse); + const entries = typesToCompletionEntries(ts, ctx.checker, types); + + assert.deepEqual( + entries.map((e) => e.name), + ['getThing'], + ); + }); + }); + + describe('callback parameters (function expression in argument position)', () => { + // A callback has no name to run a reference search on; its first + // parameter is instead inferred from the element type of a + // collection-like sibling argument (something with iterator()/next()). + const COLLECTION_TYPES = ` + interface FixtureIterator { + hasNext(): boolean; + next(): {ID: string; name: string}; + } + interface FixtureCollection { + iterator(): FixtureIterator; + } + declare function getCollection(): FixtureCollection; + `; + + function findCallbackParam(sourceFile, paramIndex = 0) { + let param; + const visit = (node) => { + if (ts.isFunctionExpression(node) && !param) { + param = node.parameters[paramIndex]; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return param; + } + + it('infers the element type from a collection sibling argument (collections.forEach style)', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function forEach(collection, callback) { + var it = collection.iterator(); + while (it.hasNext()) { callback(it.next()); } + } + forEach(getCollection(), function (item) { + return item.ID; + }); + module.exports = {forEach: forEach}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('infers the element type for collections.first(coll, function (item) …) predicates', () => { + // Stock SFRA `first` takes only the collection, but calculate.js ports + // call it with a find-style predicate — still element-first. + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function first(collection, callback) {} + first(getCollection(), function (item) { + return item.ID === 'x'; + }); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + }); + + it('resolves the collection argument through inference when it is itself undocumented', () => { + // The collection travels through an undocumented parameter — the + // sibling argument must be resolved by the engine, not just read off + // the checker. + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function eachItem(coll) { + forEach(coll, function (item) { + return item.name; + }); + } + function forEach(collection, callback) {} + eachItem(getCollection()); + module.exports = {eachItem: eachItem}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('does not apply the element heuristic to reduce-style callbacks (accumulator comes first)', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function reduce(collection, callback, initial) {} + reduce(getCollection(), function (acc) { + return acc; + }, 0); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + assert.equal(inferParameterType(ctx, param).length, 0); + }); + + it('does not apply the element heuristic to unknown callees outside the element-first allowlist', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function each(collection, callback) {} + each(getCollection(), function (item) { + return item.ID; + }); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + assert.equal(inferParameterType(ctx, param).length, 0); + }); + + for (const callee of ['map', 'filter', 'every', 'some', 'find']) { + it(`infers the element type for collections.${callee}-style callbacks`, () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function ${callee}(collection, callback) {} + ${callee}(getCollection(), function (item) { + return item.ID; + }); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + }); + } + + it('only maps the first callback parameter to the element type', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/consumer.js': ` + function forEach(collection, callback) {} + forEach(getCollection(), function (item, index) { + return index; + }); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const indexParam = findCallbackParam(sourceFile, 1); + + assert.equal(inferParameterType(ctx, indexParam).length, 0); + }); + + it('infers nothing when no sibling argument is collection-like', () => { + const files = { + '/consumer.js': ` + function run(name, callback) {} + run('label', function (item) { + return item; + }); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const param = findCallbackParam(sourceFile); + + assert.equal(inferParameterType(ctx, param).length, 0); + }); + }); + + describe('module.superModule across multiple cartridges (pass-through + augmentation)', () => { + // The dominant real-world plugin stack: every level does + // `module.exports = base; module.exports.extra = fn;`. Members added at + // an intermediate level live only in those augmentation assignments — + // no candidate type can carry them — so both the member walk and the + // completion listing have dedicated handling. + const STACK_FILES = { + '/types.d.ts': AMBIENT_TYPES, + '/base/x.js': ` + function getSalePrice(p) { return p; } + getSalePrice(getProduct()); + module.exports = { getSalePrice: getSalePrice }; + `, + '/mid/x.js': ` + var base = module.superModule; + function getMemberPrice(p) { return 'member'; } + module.exports = base; + module.exports.getMemberPrice = getMemberPrice; + `, + '/top/x.js': ` + var base = module.superModule; + function promo(p) { + var memberPrice = base.getMemberPrice(p); + var salePrice = base.getSalePrice(p); + return memberPrice; + } + module.exports = base; + module.exports.promo = promo; + `, + }; + const STACK_ORDER = { + '/top/x.js': '/mid/x.js', + '/mid/x.js': '/base/x.js', + }; + + function findVarUse(sourceFile, text) { + let found; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === text && ts.isVariableDeclaration(node.parent)) { + found = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return found; + } + + it("resolves a member augmented at an intermediate overlay level (mid's getMemberPrice from top)", () => { + const languageService = createFixtureLanguageService(STACK_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); + const top = ctx.program.getSourceFile('/top/x.js'); + + const types = inferTypeForNode(ctx, findVarUse(top, 'memberPrice')); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('still resolves a deep base member through the pass-through levels (base getSalePrice from top)', () => { + const languageService = createFixtureLanguageService(STACK_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); + const top = ctx.program.getSourceFile('/top/x.js'); + + const types = inferTypeForNode(ctx, findVarUse(top, 'salePrice')); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('lists augmented members from every pass-through level for completions', () => { + const {collectSuperModuleAugmentedMembers} = require('../src/usage-inference'); + const languageService = createFixtureLanguageService(STACK_FILES); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); + const top = ctx.program.getSourceFile('/top/x.js'); + let baseUse; + const visit = (node) => { + if ( + ts.isIdentifier(node) && + node.text === 'base' && + ts.isPropertyAccessExpression(node.parent) && + node.parent.expression === node && + !baseUse + ) { + baseUse = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(top); + + const members = collectSuperModuleAugmentedMembers(ctx, baseUse); + + assert.deepEqual(members, [{name: 'getMemberPrice', isMethod: true}]); + }); + }); + + describe('cycle-truncated results and the memo', () => { + it('does not memoize a result whose computation hit a cycle guard', () => { + // b's result computed *inside* the a->b->a cycle is truncated by what + // happened to be on the call stack; caching it would let a later, + // out-of-cycle query in the same request get the truncated answer. + const files = { + '/recursive.js': ` + function a(x) { + return b(x); + } + function b(y) { + return a(y); + } + a(1); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/recursive.js'); + const fnA = findFunctionDeclaration(sourceFile, 'a'); + + inferReturnType(ctx, fnA); + + assert.ok(ctx.cycleHits > 0, 'expected the mutual recursion to actually trip a cycle guard'); + assert.equal(ctx.memo.size, 0, 'cycle-truncated results must not be memoized'); + }); + + it('still memoizes results whose computation never hit a cycle guard', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + helper(getProduct()); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + inferParameterType(ctx, fn.parameters[0]); + + assert.equal(ctx.cycleHits, 0); + assert.ok(ctx.memo.has(fn.parameters[0]), 'a clean computation should be memoized'); + }); + }); + + describe('inferParameterType — widening and cycle safety', () => { + it('widens literal call-site arguments to their general type instead of a union of literals', () => { + const files = { + '/helper.js': ` + function helper(input) { + return input; + } + helper('hello'); + helper('world'); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'string'); + }); + + it('does not hang on a self-forwarding helper called with itself as an argument', () => { + const files = { + '/helper.js': ` + function identity(x) { + return x; + } + identity(identity(1)); + module.exports = {identity}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'identity'); + + // Must return promptly (not hang) even though identity's own parameter + // inference re-enters itself via the nested identity(...) argument. + const types = inferParameterType(ctx, fn.parameters[0]); + assert.ok(Array.isArray(types)); + }); + }); + + describe('inferTypeForNode', () => { + it('infers the type of a variable initialized from an undocumented call', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/consumer.js': ` + function getStuff() { + return getProduct(); + } + function useIt() { + var result = getStuff(); + return result.ID; + } + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + let resultIdentifier; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === 'result' && ts.isPropertyAccessExpression(node.parent)) { + resultIdentifier = node; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const types = inferTypeForNode(ctx, resultIdentifier); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + }); + + describe('typesToCompletionEntries', () => { + it('synthesizes deduplicated member completions from candidate types', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/consumer.js': ` + function pick(input) { + return input; + } + pick(getProduct()); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const fn = findFunctionDeclaration(sourceFile, 'pick'); + const types = inferParameterType(ctx, fn.parameters[0]); + + const entries = typesToCompletionEntries(ts, ctx.checker, types); + + assert.deepEqual(entries.map((e) => e.name).sort(), ['ID', 'name']); + }); + + it('offers member completions for a candidate type that is nullable (`T | null`)', () => { + // getPropertiesOfType on a union only returns members common to every + // constituent; `null` contributes none, so a candidate like this one — + // the common shape of an SFCC getter that can return nothing, e.g. + // ProductMgr.getProduct(): Product | null — must have its nullable part + // stripped first, or every entry disappears. Under the default + // `strict: false` fixture settings TS collapses `T | null` down to just + // `T` (strictNullChecks off), which would mask this bug entirely, so + // this test opts into `strictNullChecks: true` — matching VS Code's own + // implicit JS project default (`js/ts.implicitProjectConfig.strictNullChecks`), + // which is what a real cartridge file actually type-checks under. + const files = { + '/types.d.ts': ` + declare function getProductOrNull(): {ID: string; name: string} | null; + `, + '/consumer.js': ` + function pick(input) { + return input; + } + pick(getProductOrNull()); + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const fn = findFunctionDeclaration(sourceFile, 'pick'); + const types = inferParameterType(ctx, fn.parameters[0]); + + const entries = typesToCompletionEntries(ts, ctx.checker, types); + + assert.deepEqual(entries.map((e) => e.name).sort(), ['ID', 'name']); + }); + + it('offers member completions for a primitive candidate type via its apparent (wrapper-object) members', () => { + const files = { + '/helper.js': ` + function helper(input) { + return input; + } + helper('hello'); + module.exports = {helper}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'helper'); + const types = inferParameterType(ctx, fn.parameters[0]); + + const entries = typesToCompletionEntries(ts, ctx.checker, types); + + const names = entries.map((e) => e.name); + assert.ok(names.includes('length')); + assert.ok(names.includes('toUpperCase')); + }); + }); + + describe('memberCompletionAccess', () => { + const source = `var label = helper(product).name; product.ID; basket.getShipment(shipment).ID;`; + const completionAccess = (marker, offset) => { + const sourceFile = ts.createSourceFile('/dotted.js', source, ts.ScriptTarget.ES2020, true); + const access = memberCompletionAccess(sourceFile, ts, source.indexOf(marker) + offset); + return access && access.getText(sourceFile); + }; + + it('finds the access whose dot the cursor follows', () => { + assert.equal(completionAccess('product.ID', 'product.'.length), 'product.ID'); + }); + + it('finds the access whose member name is being typed', () => { + assert.equal(completionAccess('product.ID', 'product.I'.length), 'product.ID'); + }); + + it('ignores a cursor inside the receiver', () => { + assert.equal(completionAccess('product.ID', 'prod'.length), undefined); + }); + + it('ignores a cursor inside an argument list further along the chain', () => { + assert.equal(completionAccess('getShipment(shipment)', 'getShipment('.length), undefined); + assert.equal(completionAccess('helper(product)', 'helper(prod'.length), undefined); + }); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js new file mode 100644 index 000000000..f247eb51c --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -0,0 +1,1297 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +// Values that reach a parameter, variable or return through something other +// than a direct call: arrays built with push() and read back by index or +// array methods, members written into objects and read later, generic +// Script API calls such as Transaction.wrap, downcasts the body makes after +// testing what it holds, and constructors that travel as values (exported, +// returned by a factory, passed as an argument) before they are called. + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, + inferTypeForNode, +} = require('../src/usage-inference'); +const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); +const {countReferenceSearches} = require('./helpers/reference-searches'); + +const CART_TYPES = realTypesPrelude( + ['Basket', 'Collection', 'Product', 'ProductLineItem'], + ` + function getBasket(): Basket; + function getProduct(): Product; + function identity(value: T): T; +`, +); + +// Shared helpers the cases below call: an array filled by push() from a +// Script API iterator, the way SFRA's cart helpers collect line items. +const LINE_ITEM_HELPERS = ` + function lineItemsOf() { + var items = []; + var iterator = getBasket().getProductLineItems().iterator(); + while (iterator.hasNext()) { + items.push(iterator.next()); + } + return items; + } +`; + +// An array filled with two unrelated Script API classes. +const MIXED_ITEMS = ` + function mixedItems() { + var items = []; + items.push(getProduct()); + items.push(getBasket()); + return items; + } +`; + +/** The function a case targets: a declaration, or a function expression assigned to `x.name` / `name:`. */ +function isFunctionNamed(node, name) { + if (ts.isFunctionDeclaration(node)) return node.name?.text === name; + if (!ts.isFunctionExpression(node)) return false; + const parent = node.parent; + if (ts.isBinaryExpression(parent) && ts.isPropertyAccessExpression(parent.left)) + return parent.left.name.text === name; + return ts.isPropertyAssignment(parent) && parent.name.getText() === name; +} + +const FINDERS = { + return: (node, name) => isFunctionNamed(node, name), + param: (node, name) => ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === name, + var: (node, name) => ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name, +}; + +const INFER = { + return: (ctx, node) => inferReturnType(ctx, node), + param: (ctx, node) => inferParameterType(ctx, node), + var: (ctx, node) => inferTypeForNode(ctx, node.name), +}; + +/** The first node in `fileName` that `kind`'s finder accepts for `name`. */ +function findTarget(ctx, fileName, kind, name) { + let target; + const visit = (node) => { + if (!target && FINDERS[kind](node, name)) target = node; + if (!target) ts.forEachChild(node, visit); + }; + visit(ctx.program.getSourceFile(fileName)); + assert.ok(target, `no ${kind} target named ${name} in ${fileName}`); + return target; +} + +/** + * Infers `kind` (`return`, `param`, `var`) for the first node called `name` + * in `file` (default `/helper.js`), with `source` as `/helper.js` and any + * further `files` beside it, and what the plugin `host` supplies. + */ +function infer({types = CART_TYPES, source, files = {}, file = '/helper.js', kind, name, host}) { + const sources = source === undefined ? files : {'/helper.js': source, ...files}; + const languageService = createFixtureLanguageService({'/types.d.ts': types, ...sources}); + const ctx = createInferenceContext(ts, languageService, host); + return describeTypes(ctx, INFER[kind](ctx, findTarget(ctx, file, kind, name))); +} + +function runCases(cases) { + for (const {title, expected, ...target} of cases) { + it(title, () => { + assert.equal(infer(target), expected); + }); + } +} + +describe('usage-inference — arrays and element access', () => { + runCases([ + { + title: 'an array literal filled by push() is an array of what was pushed', + source: LINE_ITEM_HELPERS, + kind: 'return', + name: 'lineItemsOf', + expected: 'ProductLineItem[]', + }, + { + title: 'the variable holding a push()-built array reads as that array', + source: LINE_ITEM_HELPERS, + kind: 'var', + name: 'items', + expected: 'ProductLineItem[]', + }, + { + title: 'indexing an inferred array yields its element', + source: `${LINE_ITEM_HELPERS} function firstLineItem() { return lineItemsOf()[0]; }`, + kind: 'return', + name: 'firstLineItem', + expected: 'ProductLineItem', + }, + { + title: 'indexing a Script API collection (no index signature) yields its element', + source: 'function firstOf(basket) { return basket.productLineItems[0]; } firstOf(getBasket());', + kind: 'return', + name: 'firstOf', + expected: 'ProductLineItem', + }, + { + title: 'filter() on an inferred array keeps the element type, as the lib declares it', + source: `${LINE_ITEM_HELPERS} function giftItems() { return lineItemsOf().filter(function (item) { return item.gift; }); }`, + kind: 'return', + name: 'giftItems', + expected: 'ProductLineItem[]', + }, + { + title: 'find() on an inferred array yields an element', + source: `${LINE_ITEM_HELPERS} function giftItem() { return lineItemsOf().find(function (item) { return item.gift; }); }`, + kind: 'return', + name: 'giftItem', + expected: 'ProductLineItem', + }, + { + title: "a native array method's callback parameter is typed from the inferred receiver", + source: `${LINE_ITEM_HELPERS} function names() { return lineItemsOf().map(function (lineItem) { return lineItem.productName; }); }`, + kind: 'param', + name: 'lineItem', + expected: 'ProductLineItem', + }, + { + title: 'reduce() types the current element but not an accumulator its overloads disagree on', + source: `${LINE_ITEM_HELPERS} function count() { return lineItemsOf().reduce(function (total, line) { return total + line.quantityValue; }, 0); }`, + kind: 'param', + name: 'line', + expected: 'ProductLineItem', + }, + { + title: 'the reduce() accumulator stays silent', + source: `${LINE_ITEM_HELPERS} function count() { return lineItemsOf().reduce(function (total, line) { return total + line.quantityValue; }, 0); }`, + kind: 'param', + name: 'total', + expected: '', + }, + { + title: 'an array literal of one inferred type is an array of it', + source: 'function wrap(product) { return [product]; } wrap(getProduct());', + kind: 'return', + name: 'wrap', + expected: 'Product[]', + }, + { + title: 'a spread copies the element type', + source: `${LINE_ITEM_HELPERS} function copy() { return [...lineItemsOf()]; }`, + kind: 'return', + name: 'copy', + expected: 'ProductLineItem[]', + }, + { + title: 'an array literal mixing unrelated types is an array of their union', + source: 'function pair(product, basket) { return [product, basket]; } pair(getProduct(), getBasket());', + kind: 'return', + name: 'pair', + expected: '(Product | Basket)[]', + }, + { + title: 'an array filled by push() with unrelated types is an array of their union', + source: MIXED_ITEMS, + kind: 'return', + name: 'mixedItems', + expected: '(Product | Basket)[]', + }, + { + title: "a native array method's callback parameter on a mixed array is either element", + source: `${MIXED_ITEMS} function ids() { return mixedItems().map(function (entry) { return entry.UUID; }); }`, + kind: 'param', + name: 'entry', + expected: 'Product | Basket', + }, + { + title: 'an array literal of more unrelated types than a union shows names no array', + source: "function row(product, basket) { return [product, basket, 'x', 1]; } row(getProduct(), getBasket());", + kind: 'return', + name: 'row', + expected: '', + }, + { + title: 'an array of booleans reads as boolean[]', + source: 'function flags(product) { return [product.online, product.searchable]; } flags(getProduct());', + kind: 'return', + name: 'flags', + expected: 'boolean[]', + }, + ]); +}); + +describe('usage-inference — member values', () => { + runCases([ + { + title: "an object literal member reads as the value it was built with (SFRA getConfig's options.apiProduct)", + source: ` + function getConfig(apiProduct) { return {apiProduct: apiProduct, quantity: 1}; } + function show() { var options = getConfig(getProduct()); return options.apiProduct; } + `, + kind: 'return', + name: 'show', + expected: 'Product', + }, + { + title: 'a shorthand member reads as the variable it copies', + source: ` + function getMatching() { var basket = getBasket(); var lineItems = basket.productLineItems; return {lineItems}; } + function matching() { return getMatching().lineItems; } + `, + kind: 'return', + name: 'matching', + expected: 'Collection', + }, + { + title: "a constructor's this.member reads as the argument stored in it", + source: ` + function Model(product) { this.product = product; } + Model.prototype.current = function () { return this.product; }; + new Model(getProduct()); + `, + kind: 'return', + name: 'current', + expected: 'Product', + }, + { + title: 'a member written on an untyped local reads back as the value written', + source: ` + function cachedProduct(viewData) { + var data = viewData && viewData.product; + if (data.apiProduct === undefined) { + data.apiProduct = getProduct() || null; + } + return data.apiProduct; + } + `, + kind: 'return', + name: 'cachedProduct', + expected: 'Product', + }, + ]); +}); + +describe('usage-inference — generic calls', () => { + const TRANSACTION_TYPES = ` + import TransactionClass = require('${REAL_DW_TYPES.Transaction}'); + import Basket = require('${REAL_DW_TYPES.Basket}'); + declare global { + var Transaction: typeof TransactionClass; + function getBasket(): Basket; + } + `; + + runCases([ + { + title: 'Transaction.wrap(callback) returns what its callback returns', + types: TRANSACTION_TYPES, + source: ` + function save(basket) { return Transaction.wrap(function () { return basket; }); } + save(getBasket()); + `, + kind: 'return', + name: 'save', + expected: 'Basket', + }, + { + title: 'a generic identity returns its argument', + source: 'function same(product) { return identity(product); } same(getProduct());', + kind: 'return', + name: 'same', + expected: 'Product', + }, + ]); +}); + +describe('usage-inference — downcasts in the body', () => { + runCases([ + { + title: 'a body calling a member only subclasses have keeps the argument class when several subclasses do', + types: realTypesPrelude(['Product', 'Variant', 'VariationGroup'], 'function getProduct(): Product;'), + source: ` + function describe(apiProduct, productType) { + var category = apiProduct.getPrimaryCategory(); + if (productType === 'variant') { + category = apiProduct.getMasterProduct().getPrimaryCategory(); + } + return category; + } + describe(getProduct(), 'variant'); + `, + kind: 'param', + name: 'apiProduct', + expected: 'Product', + }, + { + title: 'a body relying on members only one subclass has narrows the argument to it', + types: realTypesPrelude( + ['PaymentInstrument', 'OrderPaymentInstrument', 'CustomerPaymentInstrument'], + 'function getPaymentInstrument(): PaymentInstrument;', + ), + source: ` + function amountOf(paymentInstrument) { return paymentInstrument.paymentTransaction.amount; } + amountOf(getPaymentInstrument()); + `, + kind: 'param', + name: 'paymentInstrument', + expected: 'OrderPaymentInstrument', + }, + ]); +}); + +describe('usage-inference — constructors as values', () => { + const REFINEMENT_TYPES = realTypesPrelude( + ['Category', 'ProductSearchModel', 'ProductSearchRefinementValue'], + ` + function getSearch(): ProductSearchModel; + function getCategory(): Category; + function getRefinementValue(): ProductSearchRefinementValue; + `, + ); + + // The shape of SFRA's search refinements: a model keeps its constructor + // arguments on `this` and reads them in prototype methods, a wrapper builds + // it with `new`, the module exports the wrapper, a factory picks the module + // with an unbound `return require(...)`, and the caller passes the chosen + // constructor on to the function that finally calls `new Model(...)`. + const REFINEMENT_FILES = { + '/models/boolean.js': ` + function BooleanValue(productSearch, refinementValue) { + this.productSearch = productSearch; + this.refinementValue = refinementValue; + this.initialize(); + } + BooleanValue.prototype.initialize = function () { + this.hitCount = this.refinementValue.hitCount; + this.displayValue = this.refinementValue.displayValue; + }; + function BooleanValueWrapper(productSearch, rawValue) { + var value = new BooleanValue(productSearch, rawValue); + return {hitCount: value.hitCount, displayValue: value.displayValue}; + } + module.exports = BooleanValueWrapper; + `, + '/models/category.js': ` + function CategoryValue(productSearch, category) { this.id = category.ID; } + module.exports = CategoryValue; + `, + '/factory.js': ` + function getModel(isCategory) { + if (isCategory) { + return require('./models/category'); + } + return require('./models/boolean'); + } + function createCategoryRefinements(productSearch, Model) { + return [new Model(productSearch, productSearch.category)]; + } + function createRefinements(productSearch, isCategory) { + var Model = getModel(isCategory); + if (isCategory) { + return createCategoryRefinements(productSearch, Model); + } + return [new Model(productSearch, getRefinementValue())]; + } + module.exports = {createRefinements: createRefinements}; + `, + '/search.js': ` + var factory = require('./factory'); + factory.createRefinements(getSearch(), false); + `, + }; + + runCases([ + { + title: 'a constructor returned by a factory as an unbound require() is called where the factory result is', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'productSearch', + expected: 'ProductSearchModel', + }, + { + // Category only reaches it through the parameter; the factory may + // return either module, so the direct call adds the refinement value. + title: 'a constructor passed as an argument is called where the receiving parameter is', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/category.js', + kind: 'param', + name: 'category', + expected: 'ProductSearchRefinementValue | Category', + }, + { + title: "members read off this. in prototype methods narrow the model's own parameter", + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'refinementValue', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'a wrapper parameter passed on to new Model(x) is narrowed by what Model does with it', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'rawValue', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'exports.name = fn is called through the requiring module', + files: { + '/helper.js': 'function describeItem(product) { return product.name; } exports.describeItem = describeItem;', + '/consumer.js': "var helper = require('./helper'); helper.describeItem(getProduct());", + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + { + title: 'module.exports = Name is called through every name a require() binds it to', + files: { + '/helper.js': 'function Item(product) { this.name = product.name; } module.exports = Item;', + '/consumer.js': "var LineItemModel = require('./helper'); new LineItemModel(getProduct());", + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + ]); + + // Two call sites pass unrelated classes; only what the value is used for, + // possibly several functions away, tells them apart. + const forwarded = (chain) => ` + ${chain} + first(getRefinementValue()); + first(getCategory()); + `; + + runCases([ + { + title: 'without usage, unrelated call-site classes stay a union', + types: REFINEMENT_TYPES, + source: forwarded('function first(value) { return value; }'), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue | Category', + }, + { + title: 'usage of the parameter it is passed to through Base.call(this, x) narrows a subclass constructor', + types: REFINEMENT_TYPES, + source: forwarded(` + function Base(value) { this.value = value; } + Base.prototype.count = function () { return this.value.hitCount; }; + function first(value) { Base.call(this, value); } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'usage three functions away still narrows the parameter', + types: REFINEMENT_TYPES, + source: forwarded(` + function first(value) { return second(value); } + function second(value) { return third(value); } + function third(value) { return fourth(value); } + function fourth(value) { return value.hitCount; } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'usage four functions away is beyond MAX_USAGE_FORWARDING_HOPS', + types: REFINEMENT_TYPES, + source: forwarded(` + function first(value) { return second(value); } + function second(value) { return third(value); } + function third(value) { return fourth(value); } + function fourth(value) { return fifth(value); } + function fifth(value) { return value.hitCount; } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue | Category', + }, + ]); + + it('follows a function-local name without spending the project-wide search budget', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': CART_TYPES, + '/helper.js': ` + function show(product) { return product.name; } + function run() { var render = show; render(getProduct()); } + `, + }); + const searches = countReferenceSearches(); + const ctx = createInferenceContext(ts, languageService); + ctx.searchBudget = 1; + + try { + const types = inferParameterType(ctx, findTarget(ctx, '/helper.js', 'param', 'product')); + + assert.equal(describeTypes(ctx, types), 'Product'); + assert.equal(searches.total(), 2, 'one search for show, one for the local render'); + assert.equal(ctx.searchBudget, 0, 'only the search for show is charged'); + } finally { + searches.stop(); + } + }); +}); + +describe('usage-inference — functions handed to declared APIs', () => { + // A named function handed to a declared API is not contextually typed the + // way an inline function expression there is; the API's declared callback + // type still says what the function is called with. The parameters below + // have names and usage that say nothing on their own. + const HANDOFF_TYPES = realTypesPrelude( + ['Basket', 'Product'], + ` + function getBasket(): Basket; + function onBasketChange(name: string, ...listeners: Array<(container: Basket, changed: Product) => void>): void; + function later(task: Function): void; + `, + ); + const LISTENER = ` + function recalculate(container, changed) { + return container.getProductLineItems(); + } + module.exports = {recalculate: recalculate}; + `; + const LISTENER_USE = { + '/listeners.js': "var helper = require('./helper'); onBasketChange('cart', helper.recalculate);", + }; + + runCases([ + { + title: "an exported function handed to a declared API's rest parameter receives what its callback type declares", + types: HANDOFF_TYPES, + source: LISTENER, + files: LISTENER_USE, + kind: 'param', + name: 'container', + expected: 'Basket', + }, + { + title: 'a parameter the body never uses is typed by the callback declaration alone', + types: HANDOFF_TYPES, + source: LISTENER, + files: LISTENER_USE, + kind: 'param', + name: 'changed', + expected: 'Product', + }, + { + title: "a function handed to a native array method receives the array's elements", + types: HANDOFF_TYPES, + source: ` + function keep(entry) { return entry; } + getBasket().getProductLineItems().toArray().forEach(keep); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'a callback type that declares no parameters says nothing', + types: HANDOFF_TYPES, + source: 'function task(entry) { return entry; } later(task);', + kind: 'param', + name: 'entry', + expected: '', + }, + { + title: 'an anonymous function a factory returns is handed on wherever the factory’s calls go', + types: HANDOFF_TYPES, + source: ` + function listenerFor(name) { + return function (container, changed) { return name; }; + } + module.exports = {listenerFor: listenerFor}; + `, + files: {'/listeners.js': "var helper = require('./helper'); onBasketChange('cart', helper.listenerFor('cart'));"}, + kind: 'param', + name: 'container', + expected: 'Basket', + }, + { + title: 'an anonymous function a factory returns is called wherever the factory’s result is', + types: HANDOFF_TYPES, + source: ` + function listenerFor(name) { + return function (container) { return name; }; + } + var listener = listenerFor('cart'); + listener(getBasket()); + `, + kind: 'param', + name: 'container', + expected: 'Basket', + }, + ]); +}); + +describe('usage-inference — call-specific returns', () => { + // `{dw.util.Collection}` the way SFRA's JSDoc writes it: no type argument, + // so the checker reads every element as `any`. + const COLLECTION_TYPES = ` + import Basket = require('${REAL_DW_TYPES.Basket}'); + import DwCollection = require('${REAL_DW_TYPES.Collection}'); + declare global { + namespace dw.util { type Collection = DwCollection; } + function getBasket(): Basket; + } + `; + + // SFRA's collections.find, first and map, verbatim but for comments. + const COLLECTIONS = ` + /** + * @param {dw.util.Collection} collection - Collection subclass instance to find value in + * @param {Function} match - Match function + * @returns {Object|null} Single item from the collection + */ + function find(collection, match) { + var result = null; + if (collection) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + var item = iterator.next(); + if (match(item)) { + result = item; + break; + } + } + } + return result; + } + /** + * @param {dw.util.Collection} collection - Collection subclass instance to work with + * @return {Object|null} First element from the collection + */ + function first(collection) { + var iterator = collection.iterator(); + return iterator.hasNext() ? iterator.next() : null; + } + /** + * @param {dw.util.Collection} collection - Collection subclass instance to map over + * @param {Function} callback - Callback function for each item + * @param {Object} [scope] - Optional execution scope to pass to callback + * @returns {Array} Array of results of map + */ + function map(collection, callback, scope) { + var iterator = Object.hasOwnProperty.call(collection, 'iterator') + ? collection.iterator() + : collection; + var index = 0; + var item = null; + var result = []; + while (iterator.hasNext()) { + item = iterator.next(); + result.push(scope ? callback.call(scope, item, index, collection) + : callback(item, index, collection)); + index++; + } + return result; + } + module.exports = {find: find, first: first, map: map}; + `; + + const callers = (source) => ({ + '/collections.js': COLLECTIONS, + '/helper.js': ` + var collections = require('./collections'); + ${source} + shipmentByUUID(getBasket(), 'uuid'); + lineItemByUUID(getBasket(), 'uuid'); + `, + }); + + runCases([ + { + title: 'collections.find over basket.shipments returns a Shipment', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (shipment) { return shipment.UUID === uuid; }); + } + function lineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (item) { return item.UUID === uuid; }); + } + `), + kind: 'return', + name: 'shipmentByUUID', + expected: 'Shipment', + }, + { + title: 'the same helper over basket.productLineItems returns a ProductLineItem', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (shipment) { return shipment.UUID === uuid; }); + } + function lineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (item) { return item.UUID === uuid; }); + } + `), + kind: 'return', + name: 'lineItemByUUID', + expected: 'ProductLineItem', + }, + { + title: 'collections.first returns an element of the collection it is given', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + kind: 'return', + name: 'shipmentByUUID', + expected: 'Shipment', + }, + { + title: 'the helper itself, across all of its callers, stays silent', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + file: '/collections.js', + kind: 'return', + name: 'first', + expected: '', + }, + { + title: 'a wrapper passing its own parameter on binds through both calls', + types: COLLECTION_TYPES, + files: callers(` + function findIn(items, uuid) { + return collections.find(items, function (item) { return item.UUID === uuid; }); + } + function shipmentByUUID(basket, uuid) { return findIn(basket.shipments, uuid); } + function lineItemByUUID(basket, uuid) { return findIn(basket.productLineItems, uuid); } + `), + kind: 'return', + name: 'lineItemByUUID', + expected: 'ProductLineItem', + }, + { + title: 'the wrapper across its callers is the union of what they pass', + types: COLLECTION_TYPES, + files: callers(` + function findIn(items, uuid) { + return collections.find(items, function (item) { return item.UUID === uuid; }); + } + function shipmentByUUID(basket, uuid) { return findIn(basket.shipments, uuid); } + function lineItemByUUID(basket, uuid) { return findIn(basket.productLineItems, uuid); } + `), + kind: 'return', + name: 'findIn', + expected: 'Shipment | ProductLineItem', + }, + { + title: 'a call-specific variable reads as the element it is assigned', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + var shipment = collections.find(basket.shipments, function (s) { return s.UUID === uuid; }); + return shipment; + } + function lineItemByUUID() {} + `), + kind: 'var', + name: 'shipment', + expected: 'Shipment', + }, + { + title: 'an argument the declared parameter type rules out binds nothing', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket); } + function lineItemByUUID() {} + `), + kind: 'return', + name: 'shipmentByUUID', + expected: '', + }, + ]); + + describe('callbacks', () => { + // What `callback(item)` returns inside a helper inferred for one call is + // what the function that call passes returns, for those arguments. + const helpers = (source) => ({'/collections.js': COLLECTIONS, '/helper.js': source}); + + runCases([ + { + title: 'collections.map returns an array of what its callback returns for each element', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function addresses(basket) { + return collections.map(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + kind: 'return', + name: 'addresses', + expected: 'OrderAddress[]', + }, + { + title: 'a model constructed in the callback makes an array of that model', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function ShippingModel(shipment) { this.uuid = shipment.UUID; } + function shippingModels(basket) { + return collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); }); + } + shippingModels(getBasket()); + `), + kind: 'return', + name: 'shippingModels', + expected: 'ShippingModel[]', + }, + { + title: 'a callback a wrapper passes on as is is the one called', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function mapShipments(basket, fn) { return collections.map(basket.shipments, fn); } + function addresses(basket) { + return mapShipments(basket, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + kind: 'return', + name: 'addresses', + expected: 'OrderAddress[]', + }, + { + title: 'the callback’s parameters are what it is called with', + types: COLLECTION_TYPES, + files: helpers(` + function applyTo(value, fn) { return fn(value); } + function defaultShipmentOf() { + return applyTo(getBasket(), function (basket) { return basket.defaultShipment; }); + } + `), + kind: 'return', + name: 'defaultShipmentOf', + expected: 'Shipment', + }, + { + title: 'callback.call(scope, x) passes x, not scope, as the first parameter', + types: COLLECTION_TYPES, + files: helpers(` + function withFirst(collection, fn) { + var iterator = collection.iterator(); + return fn.call(null, iterator.next()); + } + function firstAddress(basket) { + return withFirst(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + firstAddress(getBasket()); + `), + kind: 'return', + name: 'firstAddress', + expected: 'OrderAddress', + }, + { + title: 'collections.map itself, across all of its callers, stays silent', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function addresses(basket) { + return collections.map(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + file: '/collections.js', + kind: 'return', + name: 'map', + expected: '', + }, + ]); + }); + + describe('callback parameters', () => { + // A callback's parameter is what the helper it is passed to calls it + // with, for the arguments of that one call. The helpers are undocumented + // project code, so no name-based shape is assumed, and the callbacks only + // use `.UUID`, which every class has: their usage says nothing. + const ITERATING_HELPERS = ` + function reduce(collection, callback, initial) { + var iterator = collection.iterator(); + var value = arguments.length > 2 ? initial : iterator.next(); + while (iterator.hasNext()) { + value = callback(value, iterator.next()); + } + return value; + } + function forEachIn(collection, fn, scope) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + fn.call(scope, iterator.next()); + } + } + function eachLineItem(basket, fn) { forEachIn(basket.productLineItems, fn); } + function withBasket(fn) { return fn(getBasket()); } + function register(handlers, name, fn) { handlers[name] = fn; } + `; + + runCases([ + { + title: 'a reduce-style helper hands each element to the second parameter', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function lineItemUUIDs(basket) { + return reduce(basket.productLineItems, function (uuids, entry) { return uuids + entry.UUID; }, ''); + } + function shipmentUUIDs(basket) { + return reduce(basket.shipments, function (uuids, other) { return uuids + other.UUID; }, ''); + } + lineItemUUIDs(getBasket()); + shipmentUUIDs(getBasket()); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'an accumulator the callback’s result is fed back into stays silent, like a native reduce’s', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function byUUID(basket) { + return reduce(basket.productLineItems, function (index, entry) { index[entry.UUID] = entry; return index; }, {}); + } + byUUID(getBasket()); + `, + kind: 'param', + name: 'index', + expected: '', + }, + { + title: 'callback.call(scope, x) hands x, not scope, to the first parameter', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function shipmentUUIDs(basket) { forEachIn(basket.shipments, function (entry) { return entry.UUID; }, this); } + shipmentUUIDs(getBasket()); + `, + kind: 'param', + name: 'entry', + expected: 'Shipment', + }, + { + title: 'a wrapper handing its callback on to another helper is followed into it', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + eachLineItem(getBasket(), function (entry) { return entry.UUID; }); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'a callback may be handed any value, not only an element', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + withBasket(function (current) { return current.UUID; }); + `, + kind: 'param', + name: 'current', + expected: 'Basket', + }, + { + title: 'a helper that never calls its callback says nothing about its parameters', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + register({}, 'basket', function (event) { return event; }); + `, + kind: 'param', + name: 'event', + expected: '', + }, + ]); + }); + + it('keeps call-specific results out of the request memo', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': COLLECTION_TYPES, + ...callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + }); + const ctx = createInferenceContext(ts, languageService); + const returnOf = (file, name) => describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, file, 'return', name))); + + assert.equal(returnOf('/helper.js', 'shipmentByUUID'), 'Shipment'); + assert.equal(returnOf('/collections.js', 'first'), '', 'the general answer is not the first call’s'); + assert.equal(returnOf('/helper.js', 'lineItemByUUID'), 'ProductLineItem'); + assert.equal(ctx.bindings.size, 0, 'bindings are released after each call'); + }); + + it('prefers the call’s own answer to a union across callers that missed it', () => { + // SFRA's arrayHelper.find. The reference budget runs out before the + // general answer reaches the last caller, so across its callers find + // returns `string | number`; the line item call still gets its element. + const languageService = createFixtureLanguageService({ + '/types.d.ts': COLLECTION_TYPES, + '/helper.js': ` + /** + * @param {Array} array - Array of elements to find the match in. + * @param {Array} matcher - function that returns true if match is found + * @return {Object|undefined} element that matches provided testing function or undefined. + */ + function find(array, matcher) { + for (var i = 0, l = array.length; i < l; i++) { + if (matcher(array[i], i)) { + return array[i]; + } + } + return undefined; + } + find(['a'], function (value) { return value === 'a'; }); + find([1], function (value) { return value === 1; }); + function lineItemByUUID(uuid) { + return find(getBasket().productLineItems.toArray(), function (item) { return item.UUID === uuid; }); + } + `, + }); + const ctx = createInferenceContext(ts, languageService); + ctx.referenceBudget = 3; + + assert.equal( + describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, '/helper.js', 'return', 'find'))), + 'string | number', + ); + assert.equal( + describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, '/helper.js', 'return', 'lineItemByUUID'))), + 'ProductLineItem', + ); + }); +}); + +describe('usage-inference — values handed down a chain of helpers', () => { + // SFRA's cart helpers pass the basket's line items down four functions + // unchanged before one iterates them with an inline callback. Neither hop + // adds uncertainty, so neither costs inference depth. + const CART_CHAIN = { + '/collections.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + callback(iterator.next()); + } + } + module.exports = {forEach: forEach}; + `, + '/cartHelpers.js': ` + var collections = require('./collections'); + function getMatchingProducts(productId, productLineItems) { + var matchingProducts = []; + collections.forEach(productLineItems, function (item) { + if (item.productID === productId) matchingProducts.push(item); + }); + return {matchingProducts: matchingProducts}; + } + function getExistingProductLineItemsInCart(product, productId, productLineItems) { + var matchingProducts = getMatchingProducts(productId, productLineItems).matchingProducts; + return matchingProducts.filter(function (matchingProduct) { + return product.bundle ? matchingProduct.bundledProductLineItems.length > 0 : true; + }); + } + function getExistingProductLineItemInCart(product, productId, productLineItems) { + return getExistingProductLineItemsInCart(product, productId, productLineItems)[0]; + } + function addProductToCart(currentBasket, productId) { + var productLineItems = currentBasket.productLineItems; + return getExistingProductLineItemInCart(getProduct(), productId, productLineItems); + } + addProductToCart(getBasket(), 'id'); + `, + }; + + runCases([ + { + title: 'the filtered line items of a parameter forwarded three times', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'return', + name: 'getExistingProductLineItemsInCart', + expected: 'ProductLineItem[]', + }, + { + title: 'the first of them, one more helper up', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'return', + name: 'getExistingProductLineItemInCart', + expected: 'ProductLineItem', + }, + { + title: 'the inline callback at the far end of the chain', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'param', + name: 'item', + expected: 'ProductLineItem', + }, + { + title: 'a forwarded parameter documented with a real type keeps that type', + files: { + '/helper.js': ` + function show(product) { return product.name; } + /** @param {Product} product */ + function render(product) { return show(product); } + render(getProduct()); + `, + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + ]); +}); + +describe('usage-inference — hooks', () => { + // Hook scripts are only ever invoked through HookMgr.callHook(extensionPoint, + // functionName, ...args), dispatched by the hooks.json registrations the + // plugin host reads (see test/hook-registry.test.js). + const HOOK_TYPES = realTypesPrelude( + ['Basket', 'HookMgr'], + ` + var HookManager: typeof HookMgr; + function getBasket(): Basket; + `, + ); + const PAYMENT_HOOK = ` + function Handle(basket, paymentInformation) { + return {error: false}; + } + function Authorize(orderNumber, paymentInstrument) { + return {authorized: true}; + } + exports.Handle = Handle; + exports.Authorize = Authorize; + `; + const PAYMENT_HOST = { + hookRegistrations: () => [{extensionPoint: 'app.payment.processor.basic_credit', script: '/hooks/basic_credit.js'}], + }; + + /** The `basket` parameter of the payment hook's Handle, with `/checkout.js` calling it as `checkout`. */ + function handleBasket(checkout, host = PAYMENT_HOST) { + return { + types: HOOK_TYPES, + files: {'/hooks/basic_credit.js': PAYMENT_HOOK, '/checkout.js': checkout}, + file: '/hooks/basic_credit.js', + kind: 'param', + name: 'basket', + host, + }; + } + + runCases([ + { + title: "a hook function's parameter is what callHook passes after the extension point and function name", + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Handle', getBasket(), {});`), + expected: 'Basket', + }, + { + title: 'an extension point built from a literal prefix reaches every registered point it starts', + ...handleBasket(` + function handlePayment(processorId) { + return HookManager.callHook('app.payment.processor.' + processorId.toLowerCase(), 'Handle', getBasket()); + } + `), + expected: 'Basket', + }, + { + title: 'a template literal extension point reaches the points its head starts', + ...handleBasket( + 'function pay(id) { HookManager.callHook(`app.payment.processor.${id}`, "Handle", getBasket()); }', + ), + expected: 'Basket', + }, + { + title: 'an extension point held in a local variable is read through it', + ...handleBasket(` + function handlePayment(processorId) { + var hookName = 'app.payment.processor.' + processorId.toLowerCase(); + return HookManager.callHook(hookName, 'Handle', getBasket()); + } + `), + expected: 'Basket', + }, + { + title: 'a variable assigned again may hold any extension point, so it reaches none', + ...handleBasket(` + function handlePayment(processorId, fallback) { + var hookName = 'app.payment.processor.' + processorId; + if (fallback) hookName = fallback; + return HookManager.callHook(hookName, 'Handle', getBasket()); + } + `), + expected: '', + }, + { + title: 'a call naming another function of the same hook passes this one nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Authorize', getBasket());`), + expected: '', + }, + { + title: 'a call to an extension point the script is not registered for passes it nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.paypal', 'Handle', getBasket());`), + expected: '', + }, + { + title: 'a prefix no registered point starts with reaches nothing', + ...handleBasket(`function pay(id) { HookManager.callHook('app.order.' + id, 'Handle', getBasket()); }`), + expected: '', + }, + { + title: 'without registrations from the host, callHook reaches nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Handle', getBasket());`, {}), + expected: '', + }, + { + title: 'a hook function exported under another name is reached by its export name', + types: HOOK_TYPES, + files: { + '/hooks/default.js': 'function handle(basket) { return {}; } module.exports = {Handle: handle};', + '/checkout.js': `HookManager.callHook('app.payment.processor.default', 'Handle', getBasket());`, + }, + file: '/hooks/default.js', + kind: 'param', + name: 'basket', + host: {hookRegistrations: () => [{extensionPoint: 'app.payment.processor.default', script: '/hooks/default.js'}]}, + expected: 'Basket', + }, + { + title: 'a callHook call returns what the hook functions it dispatches to return', + types: HOOK_TYPES, + files: { + '/hooks/calculate.js': 'exports.calculate = function (basket) { return basket.getTotalGrossPrice(); };', + '/cart.js': `function recalculate() { return HookManager.callHook('dw.order.calculate', 'calculate', getBasket()); }`, + }, + file: '/cart.js', + kind: 'return', + name: 'recalculate', + host: {hookRegistrations: () => [{extensionPoint: 'dw.order.calculate', script: '/hooks/calculate.js'}]}, + expected: 'Money', + }, + ]); +}); diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js new file mode 100644 index 000000000..3c99a5501 --- /dev/null +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -0,0 +1,911 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferTypeForNode, + matchAmbientTypesByUsage, + usageProfileOf, +} = require('../src/usage-inference'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); +const {realTypesPrelude} = require('./helpers/real-dw-types'); + +function setupInference(files, jsFileName, fnName) { + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile(jsFileName); + const fn = findFunctionDeclaration(sourceFile, fnName); + return {ctx, fn}; +} + +// A helper never called from anywhere the reference search can follow (a +// Controller route dispatch, an exports map entry never require()'d in the +// same fixture, or simply dead code) has no call site to infer a parameter's +// type from at all. This suite covers the fallback that kicks in when the +// rest of the engine comes up completely empty: matching how the parameter's +// own body uses it against the program's real dw.* ambient classes. +describe('usage-inference — matching ambient dw.* classes from parameter usage (no call sites)', () => { + const SHIPMENT_HELPER_FILES = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + shipment.custom.fromStoreId = null; + var items = shipment.productLineItems; + return items; + } + `, + }; + + it('infers dw.order.Shipment for an uncalled parameter from its own member usage (.custom, .productLineItems)', () => { + const {ctx, fn} = setupInference(SHIPMENT_HELPER_FILES, '/shippingHelpers.js', 'markShipmentForShipping'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Shipment'); + }); + + it('usageProfileOf sees a member accessed only inside a nested closure', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + doInTransaction(function () { + shipment.custom.fromStoreId = null; + shipment.setShippingMethod(null); + }); + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'markShipmentForShipping'); + + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; + + assert.deepEqual([...members].sort(), ['custom', 'setShippingMethod']); + }); + + it('returns no candidates when the usage signature is a single, too-generic member name', () => { + // Include several ExtensibleObject-like classes so `.custom` is ambiguous + // across the ambient index — and name the parameter `shipment` so the + // identifier-name tiebreak would otherwise rescue Shipment despite the + // weak evidence. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment', 'ProductLineItem', 'Profile', 'Customer'], ''), + '/shippingHelpers.js': ` + function touchCustom(shipment) { + shipment.custom.fromStoreId = null; + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'touchCustom'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), ''); + }); + + it('does not let an identifier-name match rescue a weak-only custom+UUID signature', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment', 'ProductLineItem', 'Profile', 'Customer'], ''), + '/helpers.js': ` + function touch(shipment) { + return shipment.custom || shipment.UUID; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'touch'); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + + it('lets an identifier-name match rescue a single strong member shared by multiple classes (customer + .profile)', () => { + // Real-world shape from a storefront cartridge's accountHelpers.js: + // getPasswordResetToken(customer) { customer.profile.credentials… }. + // One-hop usage collection only sees `.profile`, which Customer shares + // with ServiceConfig — below MIN_USAGE_SIGNATURE_MEMBERS and ambiguous — + // but the parameter name uniquely picks Customer. Contrast the weak-only + // custom+UUID case above: `.profile` is a strong member, so the name may + // break the tie. + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), + '/accountHelpers.js': ` + function getPasswordResetToken(customer) { + return customer.profile.credentials.createResetPasswordToken(); + } + `, + }; + const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'getPasswordResetToken'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + }); + + it('stays silent for a single strong member shared by multiple classes when the identifier name does not disambiguate', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), + '/helpers.js': ` + function readProfile(obj) { + return obj.profile; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'readProfile'); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + + it('infers a single accessed member when it uniquely identifies one ambient class (addressBook.addresses)', () => { + // Real-world shape from a storefront cartridge's addressHelpers.js: + // getAddressBookAddressByForm(addressBook, form) only ever touches + // addressBook.addresses directly — a single member, normally below + // MIN_USAGE_SIGNATURE_MEMBERS. Unlike `.custom` above, `.addresses` is + // declared by exactly one ambient class in the whole program + // (dw.customer.AddressBook), so the signature is weak but unambiguous + // and should still be trusted. + const files = { + '/types.d.ts': realTypesPrelude(['AddressBook'], ''), + '/addressHelpers.js': ` + function getAddressBookAddressByForm(addressBook, form) { + var collections = require('*/cartridge/scripts/util/collections'); + return collections.find(addressBook.addresses, function (address) { + return address.postalCode === form.postalCode.value; + }); + } + `, + }; + const {ctx, fn} = setupInference(files, '/addressHelpers.js', 'getAddressBookAddressByForm'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'AddressBook'); + }); + + it('matchAmbientTypesByUsage returns [] for a single member name that ties across multiple ambient classes', () => { + const {ctx} = setupInference(SHIPMENT_HELPER_FILES, '/shippingHelpers.js', 'markShipmentForShipping'); + + // `.custom` (the SFCC custom-attributes pattern) is shared by many + // ambient classes pulled in transitively — a weak signature that's also + // ambiguous must still be declined, unlike the addressBook.addresses case + // above. + const types = matchAmbientTypesByUsage(ctx, new Set(['custom'])); + + assert.equal(describeTypes(ctx, types), ''); + }); + + it('matchAmbientTypesByUsage returns [] for a usage signature no ambient class satisfies', () => { + const {ctx} = setupInference(SHIPMENT_HELPER_FILES, '/shippingHelpers.js', 'markShipmentForShipping'); + + const types = matchAmbientTypesByUsage(ctx, new Set(['thisMemberDoesNotExistAnywhere', 'norDoesThisOne'])); + + assert.equal(describeTypes(ctx, types), ''); + }); + + it("infers a manual-indexing loop variable's type from its own usage (var item = items[i])", () => { + // Real-world shape from a storefront cartridge's checkoutHelpers.js: an + // undocumented collection parameter iterated with a manual for-loop + // instead of collections.forEach, so items[i]'s type can never come from + // items' own (unknown) type — only lineItem's own usage further down can + // recover it. Three members are accessed rather than two: productID + + // quantity alone tie between dw.order.ProductLineItem and the unrelated, + // smaller dw.customer.ProductListItem (a wishlist entry) which happens to + // expose both too; catalogProduct disambiguates. + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem'], ''), + '/checkoutHelpers.js': ` + function hasBulkProductLineItem(items) { + var result = false; + for (var i = 0; i < items.length; i++) { + var lineItem = items[i]; + if (lineItem && lineItem.productID && lineItem.quantity && lineItem.catalogProduct) { + result = true; + } + } + return result; + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/checkoutHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, 'hasBulkProductLineItem'); + const forStatement = fn.body.statements.find((s) => ts.isForStatement(s)); + const lineItemDecl = forStatement.statement.statements[0].declarationList.declarations[0]; + + const types = inferTypeForNode(ctx, lineItemDecl.name); + + assert.equal(describeTypes(ctx, types), 'ProductLineItem'); + }); + + it('stays quiet for a real-world single-member loop variable (hasPreorderableLineItem shape)', () => { + // Same a storefront cartridge shape, but only one member (`preorderable`) is ever + // accessed on the loop variable — below MIN_USAGE_SIGNATURE_MEMBERS. + // `preorderable` uniquely identifies ProductInventoryRecord in the ambient + // index, but the variable is named `lineItem` (SFRA alias → ProductLineItem), + // so the naming hint wins and we stay silent rather than surprise the + // author with an inventory-record hover. + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem'], ''), + '/checkoutHelpers.js': ` + function hasPreorderableLineItem(item) { + var result = false; + for (var i = 0; i < item.length; i++) { + var lineItem = item[i]; + if (lineItem && lineItem.preorderable) { + result = true; + } + } + return result; + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/checkoutHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, 'hasPreorderableLineItem'); + const forStatement = fn.body.statements.find((s) => ts.isForStatement(s)); + const lineItemDecl = forStatement.statement.statements[0].declarationList.declarations[0]; + + const types = inferTypeForNode(ctx, lineItemDecl.name); + + // Prefer length over deepEqual: Type objects are circular and hang + // assert.deepEqual when a regression accidentally returns a candidate. + assert.equal(types.length, 0, `expected silence, got: ${describeTypes(ctx, types)}`); + }); + + it('still prefers call-site inference over usage matching when a real call site exists', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ` function getSomeShipment(): Shipment;`), + '/shippingHelpers.js': ` + function markShipmentForShipping(shipment) { + shipment.custom.fromStoreId = null; + return shipment.productLineItems; + } + function useHelper() { + var shipment = getSomeShipment(); + return markShipmentForShipping(shipment); + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'markShipmentForShipping'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Shipment'); + }); + + describe("the `'member' in x` existence-check idiom as usage evidence", () => { + // Real-world shape from a storefront cartridge: 261 occurrences across 107 files + // guard an optional/custom attribute with `'Foo' in obj` before reading + // it — sometimes with no direct property-access read anywhere nearby to + // otherwise carry the signal (e.g. a storefront cartridge's productBase.js checking + // `'appliedPromotions' in this` with the read happening only on a later, + // unrelated code path). collectMemberUsageInScope must count this + // idiom, not just direct `x.member` reads. + it("usageProfileOf counts a bare `'member' in param` check", () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function describeShipment(shipment) { + if ('custom' in shipment) { + return 'has custom'; + } + return 'no custom'; + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'describeShipment'); + + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; + + assert.deepEqual([...members], ['custom']); + }); + + it("infers a real-world class purely from `in` checks (getProductSetOrder shape: ('x' in productCustom) ? ... : null)", () => { + // Mirrors a storefront cartridge's productHelpers.js: no direct property-access + // read on the parameter at all near the guard — the ternary's + // consequent reads a *different* expression built from the checked + // name as a string, not `productCustom.custom` itself in this + // simplified repro, so the `in` checks are the only usage evidence. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function describeShipment(shipment) { + var hasCustom = 'custom' in shipment; + var hasLineItems = 'productLineItems' in shipment; + return hasCustom && hasLineItems; + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'describeShipment'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Shipment'); + }); + + it('combines an `in` check with a direct property-access read on the same member without double-counting (category.parent tree-walk shape)', () => { + // Mirrors a storefront cartridge's dynamicAddressHelpers.js/productSearch.js: + // `if (category && 'parent' in category && category.parent.ID !== 'root')`. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function walkUp(shipment) { + if (shipment && 'custom' in shipment && shipment.productLineItems.length > 0) { + return true; + } + return false; + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'walkUp'); + + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; + assert.deepEqual([...members].sort(), ['custom', 'productLineItems']); + + const types = inferParameterType(ctx, fn.parameters[0]); + assert.equal(describeTypes(ctx, types), 'Shipment'); + }); + + it("attributes a chained `'member' in x.y` check to x.y's own one-hop access (`y`), not the checked name itself", () => { + // `'Subsoort' in apiProduct.custom`: `apiProduct.custom` is itself a + // direct, one-hop property access on `apiProduct` (contributing + // `custom`, same as any other `apiProduct.custom` occurrence) — the + // `in` check's right-hand side isn't a bare identifier matching the + // symbol, so `fromStoreId` correctly never gets attributed to + // `apiProduct`'s own signature; it describes `custom`'s shape instead. + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/shippingHelpers.js': ` + function describeShipment(shipment) { + return 'fromStoreId' in shipment.custom; + } + `, + }; + const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'describeShipment'); + + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; + + assert.deepEqual([...members], ['custom']); + }); + }); + + it('infers dw.catalog.Category from mutually-exclusive boolean-flag branches (getProductType shape)', () => { + // Real-world shape from a storefront cartridge's productHelpers.js's getProductType + // (there, checking product.master/variant/variationGroup/productSet/ + // bundle/optionProduct — Category is used here instead of Product so the + // case stays focused on multi-boolean-flag disambiguation rather than the + // Product/Variant family): a chain of if/else-if branches, each + // reading a different boolean flag on the same undocumented parameter — + // the return value is a plain string, so return-expression inference + // alone would learn nothing; only the union of every branch's flag read + // (already handled by the unconditional, control-flow-agnostic AST walk) + // recovers the parameter's real shape. + const files = { + '/types.d.ts': realTypesPrelude(['Category'], ''), + '/categoryHelpers.js': ` + function getCategoryType(category) { + var result; + if (category.root) { + result = 'root'; + } else if (category.topLevel) { + result = 'topLevel'; + } else if (category.online) { + result = 'online'; + } else if (category.onlineFlag) { + result = 'onlineFlag'; + } else { + result = 'standard'; + } + return result; + } + `, + }; + const {ctx, fn} = setupInference(files, '/categoryHelpers.js', 'getCategoryType'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Category'); + }); + + it('infers a parameter from a member-built object literal passed to a call argument, not returned (pushReview shape)', () => { + // Real-world shape from a storefront cartridge's Reviews.js job step: the + // shape-defining object literal is built from the parameter's own + // properties and passed straight into another call's argument + // (`newReviews.unshift({...})`), never returned — the member-access walk + // must recover this the same way it would a returned object literal, + // since it doesn't care about the statement context a read sits in. + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem'], ''), + '/reviewHelpers.js': ` + function pushReview(list, review) { + list.unshift({ + productID: review.productID, + quantity: review.quantity, + catalogProduct: review.catalogProduct, + }); + } + `, + }; + const {ctx, fn} = setupInference(files, '/reviewHelpers.js', 'pushReview'); + + const types = inferParameterType(ctx, fn.parameters[1]); + + assert.equal(describeTypes(ctx, types), 'ProductLineItem'); + }); + + describe('identifier-name tiebreak (prefers the class matching the variable/parameter name)', () => { + // Real-world shape: `var profile = resettingCustomer.profile;` is only + // ever read via email/firstName/lastName/custom — a field subset shared + // by both dw.customer.Profile and the much smaller + // dw.customer.ProductListRegistrant. "Fewest total members" alone used to + // pick ProductListRegistrant; the identifier `profile` breaks the tie in + // favour of Profile. The parameter itself is also recoverable now via the + // PascalCase suffix `resettingCustomer` → Customer (even with weak + // `@param {obj}`), so `.profile` can resolve through Customer's declared + // property as well. + it('infers Profile (not the smaller, equally-matching ProductListRegistrant) for a variable literally named `profile`', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant', 'Customer', 'ServiceConfig'], ''), + '/accountHelpers.js': ` + /** + * @param {obj} resettingCustomer - object that contains user's email address and name information. + */ + function sentAccountActivationEmail(resettingCustomer) { + var profile = resettingCustomer.profile; + return { + email: profile.email, + firstname: profile.firstName, + lastname: profile.lastName, + multico_id__c: profile.custom.multicoID, + }; + } + `, + }; + const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sentAccountActivationEmail'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + + let profileDecl; + const visit = (n) => { + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === 'profile') profileDecl = n; + ts.forEachChild(n, visit); + }; + visit(fn.body); + + const types = inferTypeForNode(ctx, profileDecl.name); + + assert.equal(describeTypes(ctx, types), 'Profile'); + }); + + it('returns the union of every fitting class when no candidate name matches the identifier', () => { + // Same ambiguous member signature, different (unrelated) variable + // name: with no name to break the tie, every class the usage fits is + // shown, the way IntelliJ lists each candidate. + // Deliberate `@param {any}` (not the weak `{obj}` placeholder) blocks + // inference on `resettingCustomer`: without it, the parameter's own + // single-member usage (`.profile`) uniquely matches Customer in this + // fixture and resolves `.profile` through the real declared property, + // never reaching the ambient-fallback path this test means to exercise. + const files = { + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant'], ''), + '/accountHelpers.js': ` + /** + * @param {any} resettingCustomer - deliberately any so contactInfo must use ambient fallback. + */ + function sentAccountActivationEmail(resettingCustomer) { + var contactInfo = resettingCustomer.profile; + return { + email: contactInfo.email, + firstname: contactInfo.firstName, + lastname: contactInfo.lastName, + multico_id__c: contactInfo.custom.multicoID, + }; + } + `, + }; + const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sentAccountActivationEmail'); + + let contactInfoDecl; + const visit = (n) => { + if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === 'contactInfo') { + contactInfoDecl = n; + } + ts.forEachChild(n, visit); + }; + visit(fn.body); + + const types = inferTypeForNode(ctx, contactInfoDecl.name); + + assert.equal(describeTypes(ctx, types), 'ProductListRegistrant | Profile'); + }); + + it('does not let an identifier-name match rescue a signature that matches zero ambient classes', () => { + const {ctx} = setupInference(SHIPMENT_HELPER_FILES, '/shippingHelpers.js', 'markShipmentForShipping'); + + const types = matchAmbientTypesByUsage( + ctx, + new Set(['thisMemberDoesNotExistAnywhere', 'norDoesThisOne']), + 'shipment', + ); + + assert.equal(describeTypes(ctx, types), ''); + }); + + it('maps SFRA alias lineItem → ProductLineItem for a single strong member', () => { + // Real storefront shape: productLineItem decorators name the parameter + // `lineItem` / `pli`, never `productLineItem` — exact name matching alone + // cannot break the tie in favour of ProductLineItem. + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem', 'ShippingLineItem'], ''), + '/priceTotal.js': ` + function getTotalPrice(lineItem) { + return lineItem.priceAdjustments; + } + `, + }; + const {ctx, fn} = setupInference(files, '/priceTotal.js', 'getTotalPrice'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + }); + + it('maps short alias pli → ProductLineItem', () => { + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem', 'ShippingLineItem'], ''), + '/order.js': ` + function handlePliAttributes(pli) { + pli.setPriceValue(0); + } + `, + }; + const {ctx, fn} = setupInference(files, '/order.js', 'handlePliAttributes'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + }); + + it('maps PascalCase suffix resettingCustomer → Customer (SFRA accountHelpers)', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), + '/accountHelpers.js': ` + /** + * @param {Object} resettingCustomer + */ + function sendPasswordResetEmail(email, resettingCustomer) { + return resettingCustomer.profile.credentials.createResetPasswordToken(); + } + `, + }; + const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sendPasswordResetEmail'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[1])), 'Customer'); + }); + + it('maps paymentInstrument alias → OrderPaymentInstrument', () => { + const files = { + '/types.d.ts': realTypesPrelude(['OrderPaymentInstrument'], ''), + '/helpers.js': ` + function amountOf(paymentInstrument) { + return paymentInstrument.capturedAmount; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'amountOf'); + assert.ok(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])).includes('OrderPaymentInstrument')); + }); + + it('maps PascalCase Profile suffix registeredCustomerProfile → Profile', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant'], ''), + '/helpers.js': ` + function greet(registeredCustomerProfile) { + return registeredCustomerProfile.firstName + registeredCustomerProfile.email; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'greet'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Profile'); + }); + + it('maps PascalCase suffixes apiProduct / currentBasket / defaultShipment', () => { + for (const [fnName, param, member, dwType, expect] of [ + ['wrapApiProduct', 'apiProduct', 'getPriceModel', 'Product', 'Product'], + ['useBasket', 'currentBasket', 'billingAddress', 'Basket', 'Basket'], + ['useShipment', 'defaultShipment', 'productLineItems', 'Shipment', 'Shipment'], + ]) { + const files = { + '/types.d.ts': realTypesPrelude([dwType], ''), + '/helpers.js': ` + function ${fnName}(${param}) { + return ${param}.${member}; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', fnName); + assert.ok( + describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])).includes(expect), + `${param} should infer ${expect}`, + ); + } + }); + + it('does not treat all-lowercase names as CamelCase class suffixes (border ≠ Order)', () => { + // `.profile` is shared by Customer and ServiceConfig. A false + // `*order` → Order (or similar) suffix on `border` must not invent a + // unique name match — stay silent like any other uninformative name. + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig', 'Order'], ''), + '/helpers.js': ` + function paint(border) { + return border.profile; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'paint'); + assert.equal(inferParameterType(ctx, fn.parameters[0]).length, 0); + }); + + it('still silences lineItem when the only evidence is weak .custom', () => { + const files = { + '/types.d.ts': realTypesPrelude(['ProductLineItem', 'ShippingLineItem', 'Profile'], ''), + '/helpers.js': ` + function touchCustom(lineItem) { + return lineItem.custom; + } + `, + }; + const {ctx, fn} = setupInference(files, '/helpers.js', 'touchCustom'); + + assert.equal(inferParameterType(ctx, fn.parameters[0]).length, 0); + }); + }); + + describe('instanceof evidence', () => { + it('infers ProductLineItem from a single instanceof check with no call sites', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['ProductLineItem', 'ShippingLineItem'], + ` + const ProductLineItem: { new (): ProductLineItem }; + const ShippingLineItem: { new (): ShippingLineItem }; + `, + ), + '/lineItemHelper.js': ` + function isProductLine(lineItem) { + return lineItem instanceof ProductLineItem; + } + `, + }; + const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'isProductLine'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + }); + + it('infers the union of the classes the body instanceof-checks', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['ProductLineItem', 'ShippingLineItem', 'PriceAdjustment'], + ` + const ProductLineItem: { new (): ProductLineItem }; + const ShippingLineItem: { new (): ShippingLineItem }; + const PriceAdjustment: { new (): PriceAdjustment }; + `, + ), + '/lineItemHelper.js': ` + function describeLine(lineItem) { + if (lineItem instanceof ProductLineItem) return 'product'; + if (lineItem instanceof ShippingLineItem) return 'shipping'; + if (lineItem instanceof PriceAdjustment) return 'adjustment'; + return 'other'; + } + `, + }; + const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'describeLine'); + + assert.equal( + describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), + 'ProductLineItem | ShippingLineItem | PriceAdjustment', + ); + }); + + it('collapses more than three instanceof-checked classes to their closest shared superclass', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['ProductLineItem', 'ShippingLineItem', 'PriceAdjustment', 'ProductShippingLineItem'], + ` + const ProductLineItem: { new (): ProductLineItem }; + const ShippingLineItem: { new (): ShippingLineItem }; + const PriceAdjustment: { new (): PriceAdjustment }; + const ProductShippingLineItem: { new (): ProductShippingLineItem }; + `, + ), + '/lineItemHelper.js': ` + function describeLine(lineItem) { + if (lineItem instanceof ProductLineItem) return 'product'; + if (lineItem instanceof ShippingLineItem) return 'shipping'; + if (lineItem instanceof PriceAdjustment) return 'adjustment'; + if (lineItem instanceof ProductShippingLineItem) return 'product shipping'; + return lineItem.lineItemText; + } + `, + }; + const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'describeLine'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'LineItem'); + }); + }); +}); + +describe('usage-inference — ambient-class index', () => { + it('sees a declaration file added after an earlier request (no index pinned to the first Program)', () => { + const files = { + '/types.d.ts': 'interface Gadget { sprocket: string; widgetize(): void; }\n', + '/helpers.js': 'function noop() {}\n', + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const before = createInferenceContext(ts, languageService); + assert.equal(describeTypes(before, matchAmbientTypesByUsage(before, new Set(['sprocket', 'widgetize']))), 'Gadget'); + assert.equal(describeTypes(before, matchAmbientTypesByUsage(before, new Set(['cog', 'spin']))), ''); + + files['/more.d.ts'] = 'interface Gizmo { cog: number; spin(): void; }\n'; + const after = createInferenceContext(ts, languageService); + + assert.equal(describeTypes(after, matchAmbientTypesByUsage(after, new Set(['cog', 'spin']))), 'Gizmo'); + }); + + it("never matches TypeScript's own library classes (ES / DOM)", () => { + const {ctx} = setupInference({'/helpers.js': 'function noop() {}\n'}, '/helpers.js', 'noop'); + + // Only lib.es*.d.ts's Date has both members. + const types = matchAmbientTypesByUsage(ctx, new Set(['getUTCFullYear', 'toISOString'])); + + assert.equal(describeTypes(ctx, types), ''); + }); +}); + +describe('usage-inference — precision guards', () => { + const inferFirstParam = (files, fnName) => { + const {ctx, fn} = setupInference(files, '/helpers.js', fnName); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + }; + + it('never matches a class describing one global object (TopLevel Module for a lone `.id`)', () => { + const files = { + '/types.d.ts': 'declare class Module { cartridge: string; id: string; exports: any; }\n', + '/helpers.js': 'function getRegionId(regionDefinition) {\n return regionDefinition.id;\n}\n', + }; + + assert.equal(inferFirstParam(files, 'getRegionId'), ''); + }); + + it('stays silent when a JavaScript built-in has every used member too (`msg.replace` is a string, not XML)', () => { + const files = { + '/types.d.ts': 'declare class XML { replace(propertyName: any, value: any): XML; copy(): XML; }\n', + '/helpers.js': "function filterLogMessage(msg) {\n return msg.replace(/secret/g, '***');\n}\n", + }; + + assert.equal(inferFirstParam(files, 'filterLogMessage'), ''); + }); + + it('still lets the identifier name pick a Script API class a built-in also fits (calendar.getTime())', () => { + const files = { + '/types.d.ts': 'declare class Calendar { getTime(): any; setTimeZone(zone: string): void; }\n', + '/helpers.js': 'function toMillis(calendar) {\n return calendar.getTime();\n}\n', + }; + + assert.equal(inferFirstParam(files, 'toMillis'), 'Calendar'); + }); + + describe('members no declared type has (vendored API older than the platform, expando properties)', () => { + const SEARCH_HIT_TYPES = + 'declare class SearchHit { productID: string; getProduct(): any; }\ndeclare function getHit(): SearchHit;\n'; + + it('keep the call-site type the body otherwise fits (getPromotions(searchHit) shape)', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': ` + function getPromotions(searchHit) { + return searchHit.discountedPromotionIDs.concat(searchHit.productID); + } + getPromotions(getHit()); + `, + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), 'SearchHit'); + }); + + it('still drop a candidate when another candidate declares the member', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': ` + function getPromotions(searchHit) { + return searchHit.discountedPromotionIDs.concat(searchHit.productID); + } + getPromotions(getHit()); + getPromotions({productID: 'p1', discountedPromotionIDs: []}); + `, + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), '{ productID: string; discountedPromotionIDs: never[]; }'); + }); + + it('keep ambient matching strict when there is no call site', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': + 'function getPromotions(searchHit) {\n return searchHit.discountedPromotionIDs.concat(searchHit.productID);\n}\n', + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), ''); + }); + }); + + describe('instantiations of one generic class', () => { + const COLLECTION_TYPES = realTypesPrelude( + ['Collection', 'Product', 'Category', 'Shipment', 'Order'], + ` + function getProducts(): Collection; + function getCategories(): Collection; + function getShipments(): Collection; + function getOrders(): Collection; + `, + ); + + it('read as the class itself when they exceed the union limit (SFRA collections.forEach(collection))', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/helpers.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) callback(iterator.next()); + } + forEach(getProducts(), function () {}); + forEach(getCategories(), function () {}); + forEach(getShipments(), function () {}); + forEach(getOrders(), function () {}); + `, + }; + + assert.equal(inferFirstParam(files, 'forEach'), 'Collection'); + }); + + it('never merge arrays: the declared Array would read as `T[]`', () => { + const files = { + '/types.d.ts': '', + '/helpers.js': ` + function wrapList(files) { + return {size: function () { return files.length; }}; + } + wrapList([{a: 1}]); + wrapList([{b: 'x'}]); + wrapList([{c: true}]); + wrapList([{d: null, e: 1}]); + `, + }; + + assert.equal(inferFirstParam(files, 'wrapList'), ''); + }); + + it('keep their element types while the union stays small', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/helpers.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) callback(iterator.next()); + } + forEach(getProducts(), function () {}); + forEach(getCategories(), function () {}); + `, + }; + + assert.equal(inferFirstParam(files, 'forEach'), 'Collection | Collection'); + }); + }); +}); diff --git a/packages/b2c-script-types/types/sfra/server.d.ts b/packages/b2c-script-types/types/sfra/server.d.ts index 046f99dbd..5474e8174 100644 --- a/packages/b2c-script-types/types/sfra/server.d.ts +++ b/packages/b2c-script-types/types/sfra/server.d.ts @@ -30,8 +30,12 @@ declare module 'server' { } declare module 'server/server' { - /** Middleware function executed within a route chain. */ - export type Middleware = (req: Request, res: Response, next: NextFunction) => void; + /** + * Middleware function executed within a route chain. The route calls each + * step with itself as `this`, so a step can register route event listeners + * (`this.on('route:BeforeComplete', ...)`). + */ + export type Middleware = (this: Route, req: Request, res: Response, next: NextFunction) => void; /** Continuation passed to middleware. Pass an Error to abort the chain. */ export type NextFunction = (err?: Error) => void; diff --git a/packages/b2c-tooling-sdk/data/tooling/index.json b/packages/b2c-tooling-sdk/data/tooling/index.json index e7982fcde..b2b4021b3 100644 --- a/packages/b2c-tooling-sdk/data/tooling/index.json +++ b/packages/b2c-tooling-sdk/data/tooling/index.json @@ -1,6 +1,6 @@ { "version": "2.0.0", - "generatedAt": "2026-10-29T20:38:17.710Z", + "generatedAt": "2026-10-31T06:47:23.693Z", "entries": [ { "id": "cli-account-manager", @@ -377,7 +377,7 @@ "category": "tooling", "url": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/ide-integration.html", "sourceUrl": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/ide-integration.md", - "headings": "Script API IntelliSense • Salesforce B2C Commerce VS Code Extension (recommended) • Legacy `.ds` scripts • Standalone VS Code, WebStorm, or IntelliJ Ultimate • Neovim, Helix, Zed, Sublime, or other LSP-based editors • Notes • IntelliJ SFCC Plugin • Setup", + "headings": "Script API IntelliSense • Salesforce B2C Commerce VS Code Extension (recommended) • Legacy `.ds` scripts • Standalone VS Code, WebStorm, or IntelliJ Ultimate • Neovim, Helix, Zed, Sublime, or other LSP-based editors • Inferring types for undocumented helpers (experimental) • Notes • IntelliJ SFCC Plugin • Setup", "preview": "Configure IDE tooling like the IntelliJ SFCC plugin to consume resolved B2C CLI configuration, plus enable Script API IntelliSense via TypeScript definitions." }, { diff --git a/packages/b2c-vs-extension/.prettierignore b/packages/b2c-vs-extension/.prettierignore index 69d37e230..0f14c23ed 100644 --- a/packages/b2c-vs-extension/.prettierignore +++ b/packages/b2c-vs-extension/.prettierignore @@ -4,3 +4,7 @@ src/template/ # Intentionally malformed dw.json fixture (trailing comma) used by the activation # regression test; Prettier would "fix" it into valid JSON and defeat the test. src/test/fixtures/malformed-workspace/dw.json + +# Deliberately ends a line on a dangling `shipment.` (a mid-edit shape the +# completion regression test types into); Prettier would join it onto the next line. +src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js diff --git a/packages/b2c-vs-extension/.vscode-test.mjs b/packages/b2c-vs-extension/.vscode-test.mjs index c213ca9f8..013b8d034 100644 --- a/packages/b2c-vs-extension/.vscode-test.mjs +++ b/packages/b2c-vs-extension/.vscode-test.mjs @@ -74,6 +74,26 @@ export default defineConfig([ timeout: 20000, }, }, + { + // Own workspace folder (not empty-workspace) because cartridge discovery + // walks the open workspace root for .project markers — the scriptTypes + // plugin needs a real cartridge in scope for isCartridgeFile() to let + // scriptTypesInferUsage run at all. + label: 'infer-usage-workspace', + files: 'out/test/integration/script-types-infer-usage.test.js', + version: 'stable', + workspaceFolder: 'src/test/fixtures/infer-usage-workspace', + launchArgs: launchArgs('infer-usage-workspace'), + env: isolatedEnvironment('infer-usage-workspace'), + mocha: { + ui: 'tdd', + timeout: 30000, + // This label names a single compiled file, so running vscode-test + // without the pretest compile step would otherwise report + // "0 passing" and exit 0 — a green run that executed nothing. + failZero: true, + }, + }, { label: 'nested-dw-json', files: 'out/test/config-provider.test.js', diff --git a/packages/b2c-vs-extension/eslint.config.mjs b/packages/b2c-vs-extension/eslint.config.mjs index c064cd27a..487efa81f 100644 --- a/packages/b2c-vs-extension/eslint.config.mjs +++ b/packages/b2c-vs-extension/eslint.config.mjs @@ -17,11 +17,12 @@ headerPlugin.rules.header.meta.schema = false; export default [ includeIgnoreFile(gitignorePath), { - // src/template/** holds raw template assets; test-workspace/** holds sample - // SFCC cartridges used for dev-host testing — their .js controllers/services + // src/template/** holds raw template assets; test-workspace/** and + // src/test/fixtures/*/cartridges/** hold sample SFCC cartridges used for + // dev-host/integration testing — their .js controllers/services // legitimately use CommonJS require() (the B2C Commerce runtime style) and // are not extension source, so they must not be linted by our TS rules. - ignores: ['src/template/**', 'test-workspace/**'], + ignores: ['src/template/**', 'test-workspace/**', 'src/test/fixtures/*/cartridges/**'], }, ...tseslint.configs.recommended, prettierPlugin, diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 84efd949c..4f3acdfa7 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -47,6 +47,11 @@ "workspaceContains:**/dw.json" ], "main": "./dist/extension.cjs", + "capabilities": { + "untrustedWorkspaces": { + "supported": false + } + }, "contributes": { "jsonValidation": [ { @@ -277,6 +282,11 @@ "default": true, "description": "Provide Script API IntelliSense (dw/*) in cartridge JavaScript via the bundled TypeScript Server plugin. No files are written to your workspace." }, + "b2c-dx.features.scriptTypesInferUsage": { + "type": "boolean", + "default": false, + "description": "(Preview) Infer a better type for hover/completion when TypeScript has widened something to `any` (typically an undocumented helper function). Heuristic and may be wrong. In development — off by default." + }, "b2c-dx.auth.usePluginSessionStore": { "type": "boolean", "default": false, diff --git a/packages/b2c-vs-extension/src/script-types/index.ts b/packages/b2c-vs-extension/src/script-types/index.ts index 148257965..fabea7792 100644 --- a/packages/b2c-vs-extension/src/script-types/index.ts +++ b/packages/b2c-vs-extension/src/script-types/index.ts @@ -40,6 +40,10 @@ function isFeatureEnabled(): boolean { return vscode.workspace.getConfiguration('b2c-dx').get('features.scriptTypes', true); } +function isInferUsageEnabled(): boolean { + return vscode.workspace.getConfiguration('b2c-dx').get('features.scriptTypesInferUsage', false); +} + export function registerScriptTypes( context: vscode.ExtensionContext, cartridgeService: CartridgeService, @@ -58,11 +62,24 @@ export function registerScriptTypes( const push = async (): Promise => { const a = await ensureApi(); if (!a) return; - const enabled = isFeatureEnabled(); + // Workspace Trust gate. This extension declares + // `capabilities.untrustedWorkspaces.supported: false`, so VS Code already + // withholds it entirely in an untrusted (Restricted Mode) workspace — but + // gate here too as defense in depth. The feature forwards cartridge names + // and filesystem paths derived from workspace content to the tsserver + // plugin, which resolves require() across the project and (with + // inferUsage) reads sibling files to synthesize hover/completion; none of + // that should act on an unvetted, freshly-cloned repository. If a future + // manifest change ever relaxes the trust requirement, this keeps the + // Script API IntelliSense feature specifically off until the user vouches + // for the workspace. + const trusted = vscode.workspace.isTrusted; + const enabled = trusted && isFeatureEnabled(); + const inferUsage = enabled && isInferUsageEnabled(); const cartridges = enabled ? cartridgeService.getCartridges().map((c) => ({name: c.name, src: c.src})) : []; - a.configurePlugin(PLUGIN_ID, {cartridges, enabled}); + a.configurePlugin(PLUGIN_ID, {cartridges, enabled, inferUsage}); log.appendLine( - `[ScriptTypes] Pushed ${cartridges.length} cartridge(s); enabled=${enabled}; order=[${cartridges.map((c) => c.name).join(', ')}].`, + `[ScriptTypes] Pushed ${cartridges.length} cartridge(s); trusted=${trusted}; enabled=${enabled}; inferUsage=${inferUsage}; order=[${cartridges.map((c) => c.name).join(', ')}].`, ); }; @@ -71,18 +88,26 @@ export function registerScriptTypes( const cartridgesSub = cartridgeService.onDidChange(() => void push()); const configChange = vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration('b2c-dx.features.scriptTypes')) { + if ( + e.affectsConfiguration('b2c-dx.features.scriptTypes') || + e.affectsConfiguration('b2c-dx.features.scriptTypesInferUsage') + ) { void push(); } }); + // Re-push once the user grants trust so the feature turns on without a + // reload (the initial push() while untrusted forwards a disabled config). + const trustChange = vscode.workspace.onDidGrantWorkspaceTrust(() => void push()); + const refreshCmd = registerSafeCommand('b2c-dx.scriptTypes.refresh', async () => { cartridgeService.refresh(); const cartridges = cartridgeService.getCartridges(); + const active = vscode.workspace.isTrusted && isFeatureEnabled(); vscode.window.showInformationMessage( - `B2C DX: Script API IntelliSense — ${isFeatureEnabled() ? 'active' : 'disabled'} (${cartridges.length} cartridge${cartridges.length === 1 ? '' : 's'}).`, + `B2C DX: Script API IntelliSense — ${active ? 'active' : 'disabled'} (${cartridges.length} cartridge${cartridges.length === 1 ? '' : 's'}).`, ); }); - context.subscriptions.push(cartridgesSub, configChange, refreshCmd); + context.subscriptions.push(cartridgesSub, configChange, trustChange, refreshCmd); } diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/.vscode/settings.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/.vscode/settings.json new file mode 100644 index 000000000..743e2d58e --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "b2c-dx.features.scriptTypes": true, + "b2c-dx.features.scriptTypesInferUsage": true, + "b2c-dx.logLevel": "silent", + "b2c-dx.telemetry.enabled": false +} diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/.project b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/.project new file mode 100644 index 000000000..6ede7f578 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/.project @@ -0,0 +1,15 @@ + + + app_custom_cartridge + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/cartridge/scripts/helpers/productHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/cartridge/scripts/helpers/productHelpers.js new file mode 100644 index 000000000..5dbcefdf3 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/cartridge/scripts/helpers/productHelpers.js @@ -0,0 +1,15 @@ +'use strict'; + +// SFRA plugin-cartridge overlay: extends the base cartridge's productHelpers +// at the same path via module.superModule (resolved to the next cartridge +// down the path — see the cartridges order in dw.json) and re-exports it +// with one extra helper, exactly the way real plugin cartridges do. +var base = module.superModule; + +function getMemberPrice(product) { + var basePrice = base.getSalePrice(product); + return basePrice.multiply(0.9); +} + +module.exports = base; +module.exports.getMemberPrice = getMemberPrice; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/modules/.project b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/modules/.project new file mode 100644 index 000000000..788390f42 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/modules/.project @@ -0,0 +1,15 @@ + + + modules + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/.project b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/.project new file mode 100644 index 000000000..9650634e4 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/.project @@ -0,0 +1,15 @@ + + + plugin_promo + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/cartridge/scripts/helpers/productHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/cartridge/scripts/helpers/productHelpers.js new file mode 100644 index 000000000..4d43d8c6a --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/cartridge/scripts/helpers/productHelpers.js @@ -0,0 +1,17 @@ +'use strict'; + +// Third level of the overlay stack (see dw.json's cartridges order: +// plugin_promo : app_custom_cartridge : test_cartridge). getMemberPrice below +// is a member the MIDDLE cartridge added as an export augmentation on top of +// its own pass-through re-export — the hardest superModule shape: no +// candidate type carries it, so resolving it exercises the member walk down +// the cartridge chain. +var base = module.superModule; + +function getPromoPrice(product) { + var memberPrice = base.getMemberPrice(product); + return memberPrice.subtract(base.getSalePrice(product)); +} + +module.exports = base; +module.exports.getPromoPrice = getPromoPrice; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/.project b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/.project new file mode 100644 index 000000000..7761122ff --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/.project @@ -0,0 +1,15 @@ + + + test_cartridge + + + + + com.demandware.studio.core.beehiveElementBuilder + + + + + com.demandware.studio.core.beehiveNature + + diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/controllers/Product.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/controllers/Product.js new file mode 100644 index 000000000..40e380035 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/controllers/Product.js @@ -0,0 +1,18 @@ +'use strict'; + +// SFRA controller shape. The `modules` cartridge in this fixture makes the +// plugin inject its bundled SFRA ambient declarations, whose typed +// `append(name, ...middleware)` signature lets TypeScript type `req`, `res` +// and `next` contextually — no usage inference involved (or wanted) here. +var server = require('server'); + +server.append('Show', function (req, res, next) { + var qs = req.querystring; + if (qs) { + next(); + return; + } + next(); +}); + +module.exports = server.exports(); diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/cartService.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/cartService.js new file mode 100644 index 000000000..c88e0fc5d --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/cartService.js @@ -0,0 +1,26 @@ +'use strict'; + +// The only call sites for the productHelpers functions live here, reached +// through the plugin's own `~/cartridge/...` require resolution — this is +// what makes the cross-file inference tests exercise module resolution, +// project-wide reference search, and inference together, the way a real +// SFRA cartridge is wired. + +var ProductMgr = require('dw/catalog/ProductMgr'); +var productHelpers = require('~/cartridge/scripts/helpers/productHelpers'); +var variantHelpers = require('~/cartridge/scripts/helpers/variantHelpers'); + +function buildLineItemInfo(productId, quantity) { + var product = ProductMgr.getProduct(productId); + return { + price: productHelpers.getSalePrice(product), + priceValue: productHelpers.getListPriceValue(product), + orderable: productHelpers.isOrderable(product, quantity), + variantIds: variantHelpers.collectVariantIds(product), + firstVariant: variantHelpers.firstVariantName(product), + }; +} + +module.exports = { + buildLineItemInfo: buildLineItemInfo, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js new file mode 100644 index 000000000..c61b5c723 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js @@ -0,0 +1,19 @@ +'use strict'; + +var BasketMgr = require('dw/order/BasketMgr'); +var HookMgr = require('dw/system/HookMgr'); +var PaymentMgr = require('dw/order/PaymentMgr'); + +// The extension point is built at runtime from the processor ID, like SFRA's +// checkout helpers; its literal prefix reaches every registered processor. +function handlePayment(paymentMethodID) { + var currentBasket = BasketMgr.getCurrentBasket(); + var processor = PaymentMgr.getPaymentMethod(paymentMethodID).getPaymentProcessor(); + var hookName = 'app.payment.processor.' + processor.ID.toLowerCase(); + var paymentInstrument = HookMgr.callHook(hookName, 'Handle', currentBasket, paymentMethodID); + return paymentInstrument; +} + +module.exports = { + handlePayment: handlePayment, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js new file mode 100644 index 000000000..a042b1151 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js @@ -0,0 +1,32 @@ +'use strict'; + +// Covers the *LineItem naming-alias disambiguation and the "real dw.* JSDoc is +// left alone" negative. Integration tests locate positions via indexOf — keep +// shapes stable. All shapes are synthetic; no real storefront code. + +/** + * A promotion helper whose bonus line item is undocumented (weak {Object} + * placeholder). The parameter is named after a *specific* dw.order line-item + * subclass — it must resolve to BonusDiscountLineItem, NOT the ProductLineItem + * the bare `LineItem` naming suffix would otherwise force. + * @param {Object} bonusDiscountLineItem + */ +function countBonusChoices(bonusDiscountLineItem) { + var max = bonusDiscountLineItem.maxBonusItems; + return bonusDiscountLineItem.getBonusProducts().length + max; +} + +/** + * The parameter carries a real, deliberate dw.* JSDoc type. Usage inference + * must leave it completely alone — no "Inferred from usage" note — deferring + * to the author's annotation and TypeScript's own resolution. + * @param {dw.catalog.Product} catalogProduct + */ +function describeCatalogProduct(catalogProduct) { + return catalogProduct.getID(); +} + +module.exports = { + countBonusChoices: countBonusChoices, + describeCatalogProduct: describeCatalogProduct, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js new file mode 100644 index 000000000..5247a1389 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js @@ -0,0 +1,42 @@ +'use strict'; + +// Evidence IntelliJ also reads for undocumented parameters: `.call(this, ...)` +// call sites (SFRA model inheritance), the typed Script API a value is passed +// on to, and call sites that disagree (shown as a union). Integration tests +// locate positions via indexOf — keep shapes stable. + +var ProductMgr = require('dw/catalog/ProductMgr'); +var CatalogMgr = require('dw/catalog/CatalogMgr'); +var ShippingMgr = require('dw/order/ShippingMgr'); + +function BaseModel(source) { + this.id = source.ID; +} + +function FullModel(source) { + BaseModel.call(this, source); + this.online = source.online; +} + +function recalculate(container) { + ShippingMgr.applyShippingCost(container); +} + +function describeItem(item) { + return item.ID; +} + +function buildModels() { + var product = ProductMgr.getProduct('some-id'); + describeItem(product); + describeItem(CatalogMgr.getCategory('some-category')); + return new FullModel(product); +} + +module.exports = { + BaseModel: BaseModel, + FullModel: FullModel, + recalculate: recalculate, + describeItem: describeItem, + buildModels: buildModels, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js new file mode 100644 index 000000000..a2217e425 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js @@ -0,0 +1,46 @@ +'use strict'; + +// Patterns covered by recent inference work: PascalCase suffixes, weak +// Object JSDoc, instanceof class checks, and collections.first ternary +// returns. Integration tests locate positions via indexOf — keep shapes stable. + +var collections = require('~/cartridge/scripts/util/collections'); + +/** + * @param {string} email + * @param {Object} resettingCustomer + * @param {Object} currentLocale + */ +function sendPasswordResetEmail(email, resettingCustomer, currentLocale) { + var token = resettingCustomer.profile.credentials.createResetPasswordToken(); + return { + email: email, + firstName: resettingCustomer.profile.firstName, + lastName: resettingCustomer.profile.lastName, + locale: currentLocale.ID, + token: token, + }; +} + +/** + * @param {Object} lineItem + */ +function getLineItemAdjustmentCount(lineItem) { + return lineItem.priceAdjustments.getLength(); +} + +function isProductLineItem(lineItem) { + return lineItem instanceof dw.order.ProductLineItem; +} + +function firstVariantId(product) { + var variant = collections.first(product.getVariants()); + return variant ? variant.getID() : null; +} + +module.exports = { + sendPasswordResetEmail: sendPasswordResetEmail, + getLineItemAdjustmentCount: getLineItemAdjustmentCount, + isProductLineItem: isProductLineItem, + firstVariantId: firstVariantId, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/priceHelper.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/priceHelper.js new file mode 100644 index 000000000..c6af0aa46 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/priceHelper.js @@ -0,0 +1,27 @@ +'use strict'; + +// Deliberately undocumented (no JSDoc) — this is the exact gap +// b2c-dx.features.scriptTypesInferUsage fixes. Without it, `product` and this +// function's return value are both implicit `any`, so hovering `product` or +// completing after `product.` inside this function gets nothing useful. +function getDisplayName(product) { + return product.getName(); +} + +// Mirrors how a real user triggers completion: cursor right after `product.`, +// before the member name. +function completionProbe(product) { + return product.getID(); +} + +function useHelper() { + var ProductMgr = require('dw/catalog/ProductMgr'); + var product = ProductMgr.getProduct('some-id'); + completionProbe(product); + return getDisplayName(product); +} + +module.exports = { + getDisplayName: getDisplayName, + completionProbe: completionProbe, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/productHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/productHelpers.js new file mode 100644 index 000000000..fc6c2a63a --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/productHelpers.js @@ -0,0 +1,33 @@ +'use strict'; + +// Modeled on real SFRA helper modules (app_storefront_base scripts/helpers): +// undocumented functions with no JSDoc, chain hops parked in intermediate +// variables, deep property chains with a nullable middle step, and the +// canonical `module.exports = {name: name}` alias map. The integration tests +// locate positions in this file via indexOf on distinctive substrings — keep +// the shapes below stable. + +function getSalePrice(product) { + var priceModel = product.getPriceModel(); + var price = priceModel.getPrice(); + return price; +} + +function getListPriceValue(product) { + return product.getPriceModel().getPrice().getValue(); +} + +function isOrderable(product, quantity) { + var availabilityModel = product.availabilityModel; + var inventoryRecord = availabilityModel.inventoryRecord; + if (!inventoryRecord) { + return false; + } + return inventoryRecord.ATS.value >= quantity; +} + +module.exports = { + getSalePrice: getSalePrice, + getListPriceValue: getListPriceValue, + isOrderable: isOrderable, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js new file mode 100644 index 000000000..08383aa73 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js @@ -0,0 +1,58 @@ +'use strict'; + +var Transaction = require('dw/system/Transaction'); +var collections = require('~/cartridge/scripts/util/collections'); + +// Deliberately undocumented AND never called anywhere in this workspace — +// the exact "no call site at all" scenario usage-based matching exists for +// (a helper only ever reached indirectly, or genuinely dead code). Only +// member/method usage below (.custom, .productLineItems, +// .setShippingMethod) can recover its type. +function markShipmentForShipping(shipment) { + Transaction.wrap(function () { + collections.forEach(shipment.productLineItems, function (lineItem) { + lineItem.custom.fromStoreId = null; + lineItem.setProductInventoryList(null); + }); + shipment.custom.fromStoreId = null; + shipment.setShippingMethod(null); + }); +} + +// Mirrors a real dogfooding find: a dangling `shipment.` immediately +// followed (after a blank line) by more code. `.` never gets automatic +// semicolon insertion, so this parses as ONE expression together with +// whatever identifier comes next — the exact state while a developer is +// mid-typing this line, before finishing it. The integration test locates +// the completion position right after the first `shipment.`. +function danglingDotProbe(shipment) { + var localTransaction = require('dw/system/Transaction'); + + shipment. + + localTransaction.wrap(function () { + shipment.custom.fromStoreId = null; + shipment.setShippingMethod(null); + }); +} + +// A collection iterated with a manual for-loop (index access) instead of +// collections.forEach — `items` is undocumented and never called either, so +// `items[i]` stays `any` no matter what; the loop variable's type can only +// come from ITS OWN usage further down. +function hasBulkProductLineItem(items) { + var result = false; + for (var i = 0; i < items.length; i++) { + var lineItem = items[i]; + if (lineItem && lineItem.productID && lineItem.quantity && lineItem.catalogProduct) { + result = true; + } + } + return result; +} + +module.exports = { + markShipmentForShipping: markShipmentForShipping, + danglingDotProbe: danglingDotProbe, + hasBulkProductLineItem: hasBulkProductLineItem +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js new file mode 100644 index 000000000..aeea40483 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js @@ -0,0 +1,47 @@ +'use strict'; + +// Values followed to where they end up: a model constructor reached only +// through the factory that returns it, and a generic collection helper whose +// result depends on the collection one call passes. Integration tests locate +// positions via indexOf — keep shapes stable. + +var BasketMgr = require('dw/order/BasketMgr'); +var ProductMgr = require('dw/catalog/ProductMgr'); +var collections = require('~/cartridge/scripts/util/collections'); + +function ProductTileModel(source) { + this.id = source.ID; +} + +function getTileModel() { + return ProductTileModel; +} + +function buildTile() { + var TileModel = getTileModel(); + return new TileModel(ProductMgr.getProduct('some-id')); +} + +function getShipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (candidate) { + return candidate.UUID === uuid; + }); +} + +function getLineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (candidate) { + return candidate.UUID === uuid; + }); +} + +function getShippingAddress(uuid) { + var currentBasket = BasketMgr.getCurrentBasket(); + var shipment = getShipmentByUUID(currentBasket, uuid); + var lineItem = getLineItemByUUID(currentBasket, uuid); + return shipment && lineItem ? shipment.shippingAddress : null; +} + +module.exports = { + buildTile: buildTile, + getShippingAddress: getShippingAddress, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/variantHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/variantHelpers.js new file mode 100644 index 000000000..874c1c5a1 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/variantHelpers.js @@ -0,0 +1,28 @@ +'use strict'; + +// Callback and manual-iterator patterns as used all over SFRA. The +// integration tests locate positions in this file via indexOf on distinctive +// substrings — keep the shapes below stable. +var collections = require('~/cartridge/scripts/util/collections'); + +function collectVariantIds(product) { + var ids = []; + collections.forEach(product.getVariants(), function (variant) { + ids.push(variant.getID()); + }); + return ids; +} + +function firstVariantName(product) { + var iter = product.getVariants().iterator(); + while (iter.hasNext()) { + var candidate = iter.next(); + return candidate.getName(); + } + return null; +} + +module.exports = { + collectVariantIds: collectVariantIds, + firstVariantName: firstVariantName, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json new file mode 100644 index 000000000..e77f30654 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json @@ -0,0 +1,8 @@ +{ + "hooks": [ + { + "name": "app.payment.processor.basic_credit", + "script": "./hooks/payment/basicCredit" + } + ] +} diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js new file mode 100644 index 000000000..d13e5662b --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js @@ -0,0 +1,13 @@ +'use strict'; + +// A payment processor hook. Nothing calls Handle by name: the platform +// dispatches HookMgr.callHook('app.payment.processor.', 'Handle', ...) +// here through the hooks.json this cartridge's package.json names, so +// `container` takes the arguments of the callHook call in +// scripts/checkout/paymentHelpers.js. Its own usage alone fits any +// dw.order.LineItemCtnr. +function Handle(container, paymentMethodID) { + return container.createPaymentInstrument(paymentMethodID, container.totalGrossPrice); +} + +exports.Handle = Handle; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js new file mode 100644 index 000000000..7821a4739 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js @@ -0,0 +1,40 @@ +'use strict'; + +// Mirrors SFRA's app_storefront_base scripts/util/collections.js shape: an +// untyped iteration helper over dw.util.Collection. The callback parameter +// deliberately has no JSDoc — inference derives its type from the collection +// argument travelling alongside it. +function forEach(collection, callback) { + var it = collection.iterator(); + while (it.hasNext()) { + callback(it.next()); + } +} + +// Stock SFRA shape: ternary return through iterator.next(). Inference must +// chase both branches so a typed call-site collection yields an element type. +function first(collection) { + var iterator = collection.iterator(); + return iterator.hasNext() ? iterator.next() : null; +} + +// Stock SFRA shape: the matching element, or null. Its result depends on the +// collection each call passes, so inference re-reads it per call. +function find(collection, match) { + var result = null; + var iterator = collection.iterator(); + while (iterator.hasNext()) { + var item = iterator.next(); + if (match(item)) { + result = item; + break; + } + } + return result; +} + +module.exports = { + forEach: forEach, + first: first, + find: find, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json new file mode 100644 index 000000000..ded154b6a --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json @@ -0,0 +1,4 @@ +{ + "name": "test_cartridge", + "hooks": "./cartridge/scripts/hooks.json" +} diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json new file mode 100644 index 000000000..122a2cb96 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json @@ -0,0 +1,7 @@ +{ + "hostname": "test-fixture.invalid", + "username": "fixture-user", + "password": "not-a-real-password", + "code-version": "version1", + "cartridges": "plugin_promo:app_custom_cartridge:test_cartridge:modules" +} diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/jsconfig.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/jsconfig.json new file mode 100644 index 000000000..35ac99aa8 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/jsconfig.json @@ -0,0 +1,17 @@ +{ + // One configured project spanning every cartridge file, so tsserver's + // reference search can see call sites across files — the setup + // `b2c setup ide vscode-types` recommends. Without this, each open file + // gets its own inferred project and cross-file inference has nothing to + // search. dw/* and ~/* requires are resolved by the bundled tsserver + // plugin, not by paths mappings. + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "noEmit": true + }, + "include": ["cartridges/**/cartridge/**/*.js"] +} diff --git a/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts new file mode 100644 index 000000000..9ded2af3e --- /dev/null +++ b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts @@ -0,0 +1,923 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as assert from 'assert'; +import * as path from 'path'; +import {fileURLToPath} from 'url'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'Salesforce.b2c-vs-extension'; + +// Resolves the fixture path from the compiled test location +// (out/test/integration/.js -> src/test/fixtures/... under the source tree). +function fixtureFile(...segments: string[]): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, '..', '..', '..', 'src', 'test', 'fixtures', 'infer-usage-workspace', ...segments); +} + +// Cartridge discovery + pushing config to the TypeScript Server plugin happens +// asynchronously after activation, and the plugin itself needs a moment to +// process it before hover/completion requests reflect the pushed cartridges. +// Poll instead of a single fixed sleep, which is both faster on a healthy run +// and more tolerant of a slow one. +async function waitFor(check: () => Promise, timeoutMs = 15000, intervalMs = 250): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const result = await check(); + if (result !== undefined) return result; + } catch (e) { + lastError = e; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`waitFor() timed out after ${timeoutMs}ms${lastError ? `; last error: ${String(lastError)}` : ''}`); +} + +suite('scriptTypesInferUsage — real hover/completion via the VS Code language feature APIs', () => { + let doc: vscode.TextDocument; + let paramPosition: vscode.Position; + let dotPosition: vscode.Position; + + suiteSetup(async function () { + this.timeout(30000); + + // Cartridge discovery walks the open workspace root, not wherever this + // file happens to live on disk — this suite needs the dedicated + // infer-usage-workspace fixture open, not e.g. empty-workspace, for + // isCartridgeFile() to ever let scriptTypesInferUsage run. Skip + // gracefully rather than fail if some other .vscode-test.mjs label's + // broad file glob picks this test up against the wrong workspace. + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + const uri = vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'priceHelper.js'), + ); + doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + + const text = doc.getText(); + const paramOffset = text.indexOf('product)'); // `function getDisplayName(product)` + assert.ok(paramOffset > -1, 'fixture must declare an undocumented `product` parameter'); + paramPosition = doc.positionAt(paramOffset); + + const probeOffset = text.indexOf('return product.getID()'); + assert.ok(probeOffset > -1, 'fixture must have a completionProbe with a `product.` trigger position'); + dotPosition = doc.positionAt(probeOffset + 'return product.'.length); + }); + + test('hover on the undocumented parameter shows an "Inferred from usage" note with the real dw.catalog.Product type', async () => { + const hovers = await waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + doc.uri, + paramPosition, + ); + const text = result?.flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value))).join('\n'); + return text?.includes('Inferred from usage') ? result : undefined; + }); + + const text = hovers.flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value))).join('\n'); + assert.ok(text.includes('Inferred from usage'), `expected an "Inferred from usage" hover note, got: ${text}`); + assert.ok(/Product/.test(text), `expected the inferred type to mention Product, got: ${text}`); + }); + + test('completion after `product.` offers real dw.catalog.Product members', async () => { + // Completions can settle on VS Code's own generic word-based suggestions + // (every identifier token already in the document) before the plugin's + // inferred entries are merged in — that response is non-empty too, so a + // bare `items.length > 0` wait condition would resolve on it immediately + // and never see the real completions. Wait for the actual members we + // expect instead — and since `getID`/`getName` appear as words in the + // fixture text (word-based suggestions could offer them on their own), + // the condition also requires a Product member that appears nowhere in + // any fixture document, which only inference can produce. + const labels = await waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + dotPosition, + ); + const items = result?.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label)) ?? []; + return items.includes('getID') && items.includes('getName') && items.includes('getLongDescription') + ? items + : undefined; + }, 25000); + + assert.ok(labels.includes('getID'), `expected getID among completions, got: ${labels.join(', ')}`); + assert.ok(labels.includes('getName'), `expected getName among completions, got: ${labels.join(', ')}`); + assert.ok( + labels.includes('getLongDescription'), + `expected getLongDescription (absent from fixture text, so only inference can offer it), got: ${labels.join(', ')}`, + ); + }); +}); + +suite('scriptTypesInferUsage — SFRA-style cross-file and chain patterns', () => { + let helpersDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + // Open the consumer first so its call sites are loaded into the project, + // then the helpers module the tests hover/complete in. The fixture's + // jsconfig.json puts both in one configured project either way — this + // mirrors a developer with the controller and its helper open. + const consumerUri = vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'cartService.js'), + ); + await vscode.workspace.openTextDocument(consumerUri); + const helpersUri = vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'productHelpers.js'), + ); + helpersDoc = await vscode.workspace.openTextDocument(helpersUri); + await vscode.window.showTextDocument(helpersDoc); + }); + + function positionOf(substring: string, offsetWithin = 0): vscode.Position { + const idx = helpersDoc.getText().indexOf(substring); + assert.ok(idx > -1, `fixture must contain: ${substring}`); + return helpersDoc.positionAt(idx + offsetWithin); + } + + async function waitForHoverMatching(position: vscode.Position, expected: RegExp): Promise { + return waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + helpersDoc.uri, + position, + ); + const text = result?.flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value))).join('\n'); + return text && text.includes('Inferred from usage') && expected.test(text) ? text : undefined; + }, 25000); + } + + async function waitForCompletionsIncluding(position: vscode.Position, required: string[]): Promise { + return waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + helpersDoc.uri, + position, + ); + const items = result?.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label)) ?? []; + return required.every((name) => items.includes(name)) ? items : undefined; + }, 25000); + } + + test('infers a parameter type when the only call sites live in another file, reached via a ~/ cartridge require', async () => { + // getSalePrice() is never called inside productHelpers.js — its call + // sites are in cartService.js, linked through the plugin's own + // `~/cartridge/...` module resolution and the SFRA-canonical + // `module.exports = {name: name}` alias map. This exercises module + // resolution, project-wide reference search, and inference together. + const text = await waitForHoverMatching(positionOf('getSalePrice(product', 'getSalePrice('.length), /Product/); + assert.ok(/Product/.test(text), `expected the inferred type to mention Product, got: ${text}`); + }); + + test('infers the chained type of an intermediate local variable (var priceModel = product.getPriceModel())', async () => { + const text = await waitForHoverMatching(positionOf('priceModel.getPrice()'), /ProductPriceModel/); + assert.ok(/ProductPriceModel/.test(text), `expected ProductPriceModel, got: ${text}`); + }); + + test('offers inferred completions after a chained receiver (product.getPriceModel().)', async () => { + // Neither getMinPrice nor getMaxPrice appears as a word anywhere in the + // fixture, so word-based suggestions cannot satisfy this — only the + // plugin's synthesized ProductPriceModel members can. + const labels = await waitForCompletionsIncluding(positionOf('.getPrice().getValue()', 1), [ + 'getMinPrice', + 'getMaxPrice', + ]); + assert.ok(labels.includes('getMinPrice'), `expected getMinPrice among completions, got: ${labels.join(', ')}`); + assert.ok(labels.includes('getMaxPrice'), `expected getMaxPrice among completions, got: ${labels.join(', ')}`); + }); + + test('infers through a deep property chain with a nullable middle step (availabilityModel.inventoryRecord)', async () => { + // Product.availabilityModel -> ProductAvailabilityModel.inventoryRecord + // is `ProductInventoryRecord | null` in the real dw types — the exact + // shape that silently broke member lookup before nullability stripping. + const text = await waitForHoverMatching(positionOf('inventoryRecord.ATS'), /ProductInventoryRecord/); + assert.ok(/ProductInventoryRecord/.test(text), `expected ProductInventoryRecord, got: ${text}`); + }); + + test('offers inferred completions on the nullable chain variable (inventoryRecord.)', async () => { + // getATS and perpetual appear nowhere in the fixture text. + const labels = await waitForCompletionsIncluding(positionOf('inventoryRecord.ATS', 'inventoryRecord.'.length), [ + 'getATS', + 'perpetual', + ]); + assert.ok(labels.includes('getATS'), `expected getATS among completions, got: ${labels.join(', ')}`); + assert.ok(labels.includes('perpetual'), `expected perpetual among completions, got: ${labels.join(', ')}`); + }); +}); + +suite('scriptTypesInferUsage — module.superModule cartridge overlays', () => { + let overlayDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + // app_custom_cartridge sits above test_cartridge in dw.json's cartridges + // order, so module.superModule in its productHelpers.js resolves to + // test_cartridge's module at the same path. + const overlayUri = vscode.Uri.file( + fixtureFile('cartridges', 'app_custom_cartridge', 'cartridge', 'scripts', 'helpers', 'productHelpers.js'), + ); + overlayDoc = await vscode.workspace.openTextDocument(overlayUri); + await vscode.window.showTextDocument(overlayDoc); + }); + + function positionOf(substring: string, offsetWithin = 0): vscode.Position { + const idx = overlayDoc.getText().indexOf(substring); + assert.ok(idx > -1, `overlay fixture must contain: ${substring}`); + return overlayDoc.positionAt(idx + offsetWithin); + } + + // Word-based suggestions draw from every open document, and earlier suites + // leave the base productHelpers.js open — so its member names could appear + // as plain word suggestions here. Filter those out (kind Text) so these + // assertions can only be satisfied by real, typed completion entries. + async function waitForTypedCompletionsIncluding(position: vscode.Position, required: string[]): Promise { + return waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + overlayDoc.uri, + position, + ); + const items = (result?.items ?? []) + .filter((i) => i.kind !== vscode.CompletionItemKind.Text) + .map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); + return required.every((name) => items.includes(name)) ? items : undefined; + }, 25000); + } + + test('infers Money for a value that flows through superModule into an undocumented base helper', async () => { + // basePrice <- base.getSalePrice(product): the base helper is itself + // undocumented, its parameter only typed by a call site in cartService.js + // — the full SFRA plugin composition (superModule + alias-map export + + // intermediate variables + cross-file call site) resolved end-to-end. + const text = await waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + overlayDoc.uri, + positionOf('basePrice.multiply'), + ); + const hoverText = result?.flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value))).join('\n'); + return hoverText && hoverText.includes('Inferred from usage') && /Money/.test(hoverText) ? hoverText : undefined; + }, 25000); + assert.ok(/Money/.test(text), `expected Money, got: ${text}`); + }); + + test("offers the base module's exported members as completions after `base.`", async () => { + const labels = await waitForTypedCompletionsIncluding(positionOf('base.getSalePrice', 'base.'.length), [ + 'getSalePrice', + 'getListPriceValue', + 'isOrderable', + ]); + assert.ok(labels.includes('isOrderable'), `expected isOrderable among completions, got: ${labels.join(', ')}`); + assert.ok( + labels.includes('getListPriceValue'), + `expected getListPriceValue among completions, got: ${labels.join(', ')}`, + ); + }); + + test('offers Money members as completions on the superModule-derived value (basePrice.)', async () => { + // subtract and getCurrencyCode appear nowhere in any fixture document. + const labels = await waitForTypedCompletionsIncluding(positionOf('basePrice.multiply', 'basePrice.'.length), [ + 'subtract', + 'getCurrencyCode', + ]); + assert.ok(labels.includes('subtract'), `expected subtract among completions, got: ${labels.join(', ')}`); + assert.ok( + labels.includes('getCurrencyCode'), + `expected getCurrencyCode among completions, got: ${labels.join(', ')}`, + ); + }); +}); + +// Shared helpers for the suites below, which each work on their own document. +async function hoverTextMatching( + doc: vscode.TextDocument, + position: vscode.Position, + expected: RegExp, + requireInferredNote: boolean, +): Promise { + return waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + doc.uri, + position, + ); + const text = result?.flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value))).join('\n'); + if (!text || !expected.test(text)) return undefined; + if (requireInferredNote && !text.includes('Inferred from usage')) return undefined; + return text; + }, 25000); +} + +async function typedCompletionsIncluding( + doc: vscode.TextDocument, + position: vscode.Position, + required: string[], +): Promise { + return waitFor(async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + position, + ); + // Word-based suggestions (kind Text) draw from every open document and + // could offer these names on their own — only typed entries count. + const items = (result?.items ?? []) + .filter((i) => i.kind !== vscode.CompletionItemKind.Text) + .map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); + return required.every((name) => items.includes(name)) ? items : undefined; + }, 25000); +} + +function offsetPosition(doc: vscode.TextDocument, substring: string, offsetWithin = 0): vscode.Position { + const idx = doc.getText().indexOf(substring); + assert.ok(idx > -1, `fixture ${doc.uri.fsPath} must contain: ${substring}`); + return doc.positionAt(idx + offsetWithin); +} + +suite('scriptTypesInferUsage — callback parameters and iterator loops', () => { + let variantDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + variantDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'variantHelpers.js'), + ), + ); + await vscode.window.showTextDocument(variantDoc); + }); + + test('infers Variant for a collections.forEach callback parameter', async () => { + // The callback has no name to run a reference search on, the collections + // util is untyped JS, and the collection argument's own type only exists + // through inference of the enclosing helper's parameter — the full SFRA + // iteration idiom, resolved end-to-end. + const text = await hoverTextMatching(variantDoc, offsetPosition(variantDoc, 'variant.getID'), /Variant/, true); + assert.ok(/Variant/.test(text), `expected Variant, got: ${text}`); + }); + + test('offers Variant members as completions on the callback parameter (variant.)', async () => { + // getUPC and getLongDescription appear nowhere in any fixture document. + const labels = await typedCompletionsIncluding( + variantDoc, + offsetPosition(variantDoc, 'variant.getID', 'variant.'.length), + ['getUPC', 'getLongDescription'], + ); + assert.ok(labels.includes('getUPC'), `expected getUPC among completions, got: ${labels.join(', ')}`); + }); + + test('infers Variant through a manual iterator loop (iterator()/hasNext()/next())', async () => { + const text = await hoverTextMatching(variantDoc, offsetPosition(variantDoc, 'candidate.getName'), /Variant/, true); + assert.ok(/Variant/.test(text), `expected Variant for iter.next() result, got: ${text}`); + }); +}); + +suite('scriptTypes — server.append controller middleware (contextual, no inference)', () => { + let controllerDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + controllerDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file(fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'controllers', 'Product.js')), + ); + await vscode.window.showTextDocument(controllerDoc); + }); + + test('types req contextually via the injected SFRA ambient declarations — no inference label', async () => { + // The modules cartridge makes the plugin inject types/sfra/server.d.ts; + // its typed append(name, ...middleware) signature lets TypeScript type + // the middleware params itself. The hover must show Request WITHOUT the + // "Inferred from usage" note — this is a real type, not a heuristic. + const text = await hoverTextMatching( + controllerDoc, + offsetPosition(controllerDoc, 'req, res, next'), + /Request/, + false, + ); + assert.ok(/Request/.test(text), `expected req: Request, got: ${text}`); + assert.ok( + !text.includes('Inferred from usage'), + `contextually-typed middleware params must not carry the inference label: ${text}`, + ); + }); + + test('offers Request members as completions after req.', async () => { + // httpParameterMap and geolocation appear nowhere in the fixture text. + const labels = await typedCompletionsIncluding( + controllerDoc, + offsetPosition(controllerDoc, 'req.querystring', 'req.'.length), + ['httpParameterMap', 'geolocation'], + ); + assert.ok(labels.includes('httpParameterMap'), `expected httpParameterMap, got: ${labels.join(', ')}`); + }); +}); + +suite('scriptTypesInferUsage — multi-cartridge superModule stack (plugin_promo -> app_custom -> test)', () => { + let promoDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + promoDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'plugin_promo', 'cartridge', 'scripts', 'helpers', 'productHelpers.js'), + ), + ); + await vscode.window.showTextDocument(promoDoc); + }); + + test('resolves a member augmented at the intermediate overlay level (base.getMemberPrice -> Money)', async () => { + // getMemberPrice exists only as an export augmentation on app_custom's + // pass-through re-export — no candidate type carries it — and its own + // return type needs recursion through the base cartridge's undocumented + // getSalePrice, whose parameter is typed by a call site in cartService. + const text = await hoverTextMatching(promoDoc, offsetPosition(promoDoc, 'memberPrice.subtract'), /Money/, true); + assert.ok(/Money/.test(text), `expected Money through the mid-level augmentation, got: ${text}`); + }); + + test('completions after base. merge deep base members with intermediate augmentations', async () => { + const labels = await typedCompletionsIncluding( + promoDoc, + offsetPosition(promoDoc, 'base.getMemberPrice', 'base.'.length), + ['getMemberPrice', 'isOrderable', 'getListPriceValue'], + ); + assert.ok(labels.includes('getMemberPrice'), `expected mid augmentation getMemberPrice, got: ${labels.join(', ')}`); + assert.ok(labels.includes('isOrderable'), `expected deep base member isOrderable, got: ${labels.join(', ')}`); + }); +}); + +suite('scriptTypesInferUsage — matching ambient classes from usage with no call site at all', () => { + let shippingDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + shippingDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'shippingHelpers.js'), + ), + ); + await vscode.window.showTextDocument(shippingDoc); + }); + + test('infers dw.order.Shipment for an undocumented, never-called parameter purely from its own member usage', async () => { + // markShipmentForShipping is never called anywhere in this workspace — + // call-site inference (the rest of the engine) has nothing to work with + // at all. Only scanning `shipment`'s own body usage (.productLineItems, + // .custom, .setShippingMethod, including inside the nested + // Transaction.wrap/collections.forEach closures) can recover its type. + const text = await hoverTextMatching( + shippingDoc, + offsetPosition(shippingDoc, 'markShipmentForShipping(shipment)', 'markShipmentForShipping('.length), + /Shipment/, + true, + ); + assert.ok(/Shipment/.test(text), `expected Shipment inferred purely from usage, got: ${text}`); + }); + + test("hover shows the real declaration's own display header and doc comment, not just a bare type name", async () => { + const text = await hoverTextMatching( + shippingDoc, + offsetPosition(shippingDoc, 'markShipmentForShipping(shipment)', 'markShipmentForShipping('.length), + /Represents an order shipment/, + true, + ); + assert.ok( + /\(parameter\)\s+shipment:\s+Shipment/.test(text), + `expected a native-looking "(parameter) shipment: Shipment" header, got: ${text}`, + ); + }); + + test('hover on a member name (shipment.custom) resolves the correctly-qualified nested type and its own doc comment', async () => { + // Regression test: dw.*'s vendored custom-attributes interface is nested + // under the exact same simple name as its owning class + // (`module ICustomAttributes { interface Shipment extends + // CustomAttributes {} }`, alongside the top-level `class Shipment`). + // Naive type-to-string would print "Shipment" for both, making this + // hover indistinguishable from hovering `shipment` itself. + const text = await hoverTextMatching( + shippingDoc, + offsetPosition(shippingDoc, 'shipment.custom.fromStoreId', 'shipment.'.length + 1), + /Returns the custom attributes/, + true, + ); + assert.ok(/ICustomAttributes\.Shipment/.test(text), `expected the correctly-qualified nested type, got: ${text}`); + }); + + test('offers real dw.order.Shipment completions after `shipment.` with no call site anywhere', async () => { + // getUUID appears nowhere in the fixture text, so only inference can + // offer it. setShippingMethod/custom/productLineItems etc. are already + // literal text elsewhere in this same file, so the merge logic dedupes + // our real (typed) entry out in favor of the plain-text generic one — + // getUUID is the reliable signal precisely because it has no such + // word-completion competitor anywhere in the fixture. + const labels = await typedCompletionsIncluding( + shippingDoc, + offsetPosition(shippingDoc, 'shipment.setShippingMethod(null)', 'shipment.'.length), + ['getUUID'], + ); + assert.ok(labels.includes('getUUID'), `expected getUUID among completions, got: ${labels.join(', ')}`); + }); + + test('still offers real completions when the cursor sits on a dangling mid-edit `shipment.` merged with later code', async () => { + // Regression test for a real dogfooding find: `.` never gets automatic + // semicolon insertion, so a dangling `shipment.` immediately followed + // (after a blank line) by more code parses as ONE expression together + // with whatever identifier comes next (`shipment.localTransaction.wrap` + // here) — the exact state while actively typing this line. Left + // unhandled, that phantom "localTransaction" member would poison + // usage-based matching and silently produce zero completions for the + // very position asking for them. + const labels = await typedCompletionsIncluding( + shippingDoc, + offsetPosition(shippingDoc, 'shipment.\n\n localTransaction', 'shipment.'.length), + ['getUUID'], + ); + assert.ok(labels.includes('getUUID'), `expected getUUID among completions, got: ${labels.join(', ')}`); + assert.ok(!labels.includes('localTransaction'), 'the phantom merged "localTransaction" member must not leak in'); + }); + + test('infers dw.order.ProductLineItem for a manual-indexing loop variable (var lineItem = items[i])', async () => { + // hasBulkProductLineItem's `items` parameter is undocumented and never + // called either, so `items[i]` stays `any` regardless — only + // `lineItem`'s own downstream usage (.productID, .quantity, + // .catalogProduct) can recover its element type. + const text = await hoverTextMatching( + shippingDoc, + offsetPosition(shippingDoc, 'lineItem.productID'), + /ProductLineItem/, + true, + ); + assert.ok(/ProductLineItem/.test(text), `expected ProductLineItem, got: ${text}`); + }); + + test('offers real dw.order.ProductLineItem completions on the manual-indexing loop variable (lineItem.)', async () => { + // productID/catalogProduct are already literal text in this file (the + // usage that got `lineItem` inferred in the first place), so — same + // dedup reasoning as above — getManufacturerName is the reliable signal: + // it appears nowhere in the fixture text, so only inference can offer it. + const labels = await typedCompletionsIncluding( + shippingDoc, + offsetPosition(shippingDoc, 'lineItem.productID', 'lineItem.'.length), + ['getManufacturerName'], + ); + assert.ok( + labels.includes('getManufacturerName'), + `expected getManufacturerName among completions, got: ${labels.join(', ')}`, + ); + }); +}); + +suite('scriptTypesInferUsage — naming aliases, instanceof, and collections.first', () => { + let namingDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + namingDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'namingHelpers.js'), + ), + ); + await vscode.window.showTextDocument(namingDoc); + }); + + test('infers Customer for PascalCase suffix resettingCustomer despite weak @param {Object}', async () => { + const text = await hoverTextMatching( + namingDoc, + offsetPosition( + namingDoc, + 'sendPasswordResetEmail(email, resettingCustomer', + 'sendPasswordResetEmail(email, '.length, + ), + /Customer/, + true, + ); + assert.ok(/Customer/.test(text), `expected Customer from resettingCustomer suffix, got: ${text}`); + }); + + test('offers Customer members after resettingCustomer. (e.g. getProfile)', async () => { + // getProfile is a Customer method absent from this fixture's literal text. + const labels = await typedCompletionsIncluding( + namingDoc, + offsetPosition(namingDoc, 'resettingCustomer.profile.credentials', 'resettingCustomer.'.length), + ['getProfile'], + ); + assert.ok(labels.includes('getProfile'), `expected getProfile among completions, got: ${labels.join(', ')}`); + }); + + test('infers ProductLineItem for lineItem alias + .priceAdjustments (weak Object JSDoc)', async () => { + const text = await hoverTextMatching( + namingDoc, + offsetPosition(namingDoc, 'getLineItemAdjustmentCount(lineItem)', 'getLineItemAdjustmentCount('.length), + /ProductLineItem/, + true, + ); + assert.ok(/ProductLineItem/.test(text), `expected ProductLineItem from lineItem alias, got: ${text}`); + }); + + test('infers ProductLineItem from a single instanceof dw.order.ProductLineItem check', async () => { + const text = await hoverTextMatching( + namingDoc, + offsetPosition(namingDoc, 'isProductLineItem(lineItem)', 'isProductLineItem('.length), + /ProductLineItem/, + true, + ); + assert.ok(/ProductLineItem/.test(text), `expected ProductLineItem from instanceof, got: ${text}`); + }); + + test('infers Variant through collections.first ternary return (it.next() : null)', async () => { + // Hover the local `variant` holding collections.first(...); inference + // must chase the ternary return of `first` against product.getVariants(). + const text = await hoverTextMatching( + namingDoc, + offsetPosition(namingDoc, 'variant ? variant.getID'), + /Variant/, + true, + ); + assert.ok(/Variant/.test(text), `expected Variant from collections.first, got: ${text}`); + }); +}); + +suite('scriptTypesInferUsage — *LineItem subclass disambiguation and real-JSDoc deference', () => { + let lineItemDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + lineItemDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'lineItemHelpers.js'), + ), + ); + await vscode.window.showTextDocument(lineItemDoc); + }); + + test('infers BonusDiscountLineItem (not ProductLineItem) for a bonusDiscountLineItem parameter', async () => { + // The bare `LineItem` naming suffix would force ProductLineItem; the + // specific-subclass alias must win so the correct sibling class resolves. + const text = await hoverTextMatching( + lineItemDoc, + offsetPosition(lineItemDoc, 'countBonusChoices(bonusDiscountLineItem)', 'countBonusChoices('.length), + /BonusDiscountLineItem/, + true, + ); + assert.ok(/BonusDiscountLineItem/.test(text), `expected BonusDiscountLineItem, got: ${text}`); + }); + + test('offers BonusDiscountLineItem members as completions (getMaxBonusItems absent from fixture text)', async () => { + const labels = await typedCompletionsIncluding( + lineItemDoc, + offsetPosition(lineItemDoc, 'bonusDiscountLineItem.maxBonusItems', 'bonusDiscountLineItem.'.length), + ['getMaxBonusItems'], + ); + assert.ok( + labels.includes('getMaxBonusItems'), + `expected getMaxBonusItems among completions, got: ${labels.join(', ')}`, + ); + }); + + test('leaves a real @param {dw.catalog.Product} annotation alone — Product type, no inference note', async () => { + // Waiting for /Product/ proves the injected dw.* ambient types are live + // (native TS resolves the JSDoc against them). The type must appear + // WITHOUT the "Inferred from usage" note: a deliberate dw.* annotation is + // authoritative and inference must defer to it. + const text = await hoverTextMatching( + lineItemDoc, + offsetPosition(lineItemDoc, 'describeCatalogProduct(catalogProduct)', 'describeCatalogProduct('.length), + /Product/, + false, + ); + assert.ok(/Product/.test(text), `expected the real Product type, got: ${text}`); + assert.ok( + !text.includes('Inferred from usage'), + `a deliberate dw.* JSDoc annotation must not carry the inference label: ${text}`, + ); + }); +}); + +suite('scriptTypesInferUsage — .call() inheritance, typed API uses, and union call sites', () => { + let modelDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + modelDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'modelHelpers.js'), + ), + ); + await vscode.window.showTextDocument(modelDoc); + }); + + test('infers Product for a base model reached only through BaseModel.call(this, source)', async () => { + // `source` is a neutral name and `.ID` alone fits many classes, so only + // the `.call` call site (fed by `new FullModel(product)`) can type it. + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function BaseModel(source', 'function BaseModel('.length), + /Product/, + true, + ); + assert.ok(/Product/.test(text), `expected Product through the .call() call site, got: ${text}`); + }); + + test('infers LineItemCtnr for a never-called parameter passed on to ShippingMgr.applyShippingCost', async () => { + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function recalculate(container', 'function recalculate('.length), + /LineItemCtnr/, + true, + ); + assert.ok(/LineItemCtnr/.test(text), `expected LineItemCtnr from the typed API use, got: ${text}`); + }); + + test('shows the union of conflicting call sites, IntelliJ style (Product | Category)', async () => { + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function describeItem(item', 'function describeItem('.length), + /Product \| Category/, + true, + ); + assert.ok(/Product \| Category/.test(text), `expected the Product | Category union, got: ${text}`); + }); +}); + +suite('scriptTypesInferUsage — factory-returned models, call-specific helpers, and hooks', () => { + let flowDoc: vscode.TextDocument; + let hookDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + flowDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'valueFlowHelpers.js'), + ), + ); + hookDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'hooks', 'payment', 'basicCredit.js'), + ), + ); + await vscode.window.showTextDocument(flowDoc); + }); + + test('infers Product for a constructor reached only through the factory that returns it', async () => { + // Nothing calls ProductTileModel by name: `getTileModel()` returns it and + // `new TileModel(product)` constructs it. + const text = await hoverTextMatching( + flowDoc, + offsetPosition(flowDoc, 'function ProductTileModel(source', 'function ProductTileModel('.length), + /Product/, + true, + ); + assert.ok(/Product/.test(text), `expected Product through the factory, got: ${text}`); + }); + + test('infers Shipment for what collections.find returns for the collection this call passes', async () => { + // Across its callers `find` returns a Shipment or a ProductLineItem; for + // getShipmentByUUID's call with `basket.shipments` it is a Shipment. + const text = await hoverTextMatching( + flowDoc, + offsetPosition(flowDoc, 'var shipment = getShipmentByUUID', 'var '.length), + /Shipment/, + true, + ); + assert.ok(/Shipment/.test(text), `expected Shipment for this call, got: ${text}`); + assert.ok(!/ProductLineItem/.test(text), `the other caller's element type must not leak in: ${text}`); + }); + + test("infers Basket for a hook function's parameter from the HookMgr.callHook call hooks.json routes to it", async () => { + // Its own usage fits any LineItemCtnr; only the prefixed callHook call in + // scripts/checkout/paymentHelpers.js, matched through the cartridge's + // package.json -> hooks.json, says it is a Basket. + const text = await hoverTextMatching( + hookDoc, + offsetPosition(hookDoc, 'function Handle(container', 'function Handle('.length), + /Basket/, + true, + ); + assert.ok(/Basket/.test(text), `expected Basket from the callHook call site, got: ${text}`); + }); +});