diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md new file mode 100644 index 000000000..0af1fa4f5 --- /dev/null +++ b/.changeset/script-types-infer-usage.md @@ -0,0 +1,27 @@ +--- +'@salesforce/b2c-cli': minor +'b2c-vs-extension': minor +--- + +Script API IntelliSense can now infer types for undocumented helper functions from how they're actually called elsewhere in your project, instead of silently falling back to `any` and losing hover/completion for everything downstream. This is off by default — enable it with the `b2c-dx.features.scriptTypesInferUsage` VS Code setting (or `inferUsage: true` in the plugin config for other LSP hosts). Inferred results are clearly labeled ("Inferred from usage") since they're heuristic. `@salesforce/b2c-cli` picks this up too since `b2c setup ide vscode-types`/`tsserver-plugin` bundle the same plugin. + +Beyond call-site and return-expression inference, the engine also recognizes: +- A parameter or local variable's own member/method accesses (e.g. `shipment.custom`, `shipment.productLineItems`) matched against the Script API's ambient classes, when no call site or usable initializer can resolve its type at all — recovering hover/completions for helpers only reached indirectly (e.g. dispatched from a Controller route), and for collection items pulled out with a manual indexing loop (`var item = items[i]`) instead of `collections.forEach`. +- A single accessed member when it uniquely identifies one Script API class (e.g. `addressBook.addresses` only matches `dw.customer.AddressBook`), instead of only ever guessing from two or more accessed members. A lone member name shared by several classes (e.g. the common `.custom` attribute pattern) is still correctly left unresolved. +- A `'member' in obj` existence check (e.g. `'Subsoort' in apiProduct.custom`) as evidence of that member, not just a direct `obj.member` read — a very common SFCC idiom for guarding an optional custom attribute before reading it. +- `new Helper(x)` constructor calls as a call site, not just plain `helper(x)` calls — SFRA's other very common way to invoke an undocumented "class" model (e.g. `new ProductLineItem(...)`, `new StoreModel(...)`). + +When a member signature still matches more than one Script API class, a parameter or variable conventionally named after the class it holds (`profile` for `dw.customer.Profile`, `shipment` for `dw.order.Shipment`) is now preferred over the previous "fewest total members" tiebreak alone — which could otherwise pick a small, unrelated class purely because it exposed less surface area than the large, correct one (e.g. `dw.customer.ProductListRegistrant` over `dw.customer.Profile` for a variable literally named `profile`, since both happen to share a common `email`/`firstName`/`lastName`/`custom` field subset). + +Also fixes several bugs uncovered while dogfooding this against real projects: +- Hover showed nothing when hovering the member name itself in a chained access (e.g. `productLineItems` in `shipment.productLineItems`) even though hovering the receiver worked. +- Completions were slow/unreliable on large real projects because an internal cache was invalidated on every keystroke instead of once per project session. +- Hover now shows the real declaration's own type name, documentation, and JSDoc tags (not just a bare "Inferred from usage: X" note). +- A class's nested custom-attributes interface (`ICustomAttributes.Shipment`) rendered with the same display name as the unrelated top-level class it's attached to. +- A dangling, mid-edit member access (`shipment.` immediately followed by more code on later lines — `.` never gets automatic semicolon insertion) could get parsed together with the next statement, poisoning usage-based matching with a phantom member name and silently producing no completions for the position being typed. +- The most common placeholder JSDoc of all, `@param {Object}`, silently got no inference in the editor: `checkJs` resolves capital-`Object` to the global `Object` interface (not `any` or the lowercase `object`), which the hover/completion entry gate treated as a real type and skipped — so the headline "undocumented SFRA helper" case produced nothing in a live project even though the engine handled it. Weak `{Object}` now opens the gate like the other placeholders. +- A parameter named after a *specific* `dw.order` line-item subclass (`bonusDiscountLineItem`, `productShippingLineItem`) was mis-resolved to `ProductLineItem` by the generic `LineItem` naming heuristic. These now resolve to their own class when the body's usage fits it, and stay silent otherwise, rather than guessing the wrong sibling. + +Also tightens Preview trust: conflicting call-site argument types stay silent instead of unioning a noisy hover; ambient matches rank by member distinctiveness (so ubiquitous `.custom` / `.UUID` don't dominate); and element-first callbacks cover `collections.map` / `filter` / `every` / `some` / `find` / `first` (not only `forEach`). Cartridge `~/` / `*/` require resolution now consults the language-service host filesystem (not only `ts.sys`), so virtualized hosts and tests resolve the same way as a real project. Call-site types that don't expose every member the parameter body actually uses are dropped (so a duck-typed Store model passed into an address helper can't win the hover); a conventionally named parameter with a single strong member (`customer` + `.profile`) is trusted even when that member is shared by another ambient class; SFRA aliases (`lineItem` / `pli` → `ProductLineItem`, `priceModel` → `ProductPriceModel`, …) and CamelCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → `Product`, `currentBasket` → `Basket`) get the same short-circuit; generic Script API classes like `Product` are included in ambient matching (shown as `Product`); ternary returns (stock `collections.first`) and `instanceof` class checks feed inference the same way JetBrains' JS evaluator does; and placeholder SFRA JSDoc (`@param {Object}` / `{obj}` / `{*}` / `{}`) no longer blocks usage inference — only deliberate `{any}` and real `dw.*` annotations stay authoritative, matching how IntelliJ helps when authors write real types while still recovering the common undocumented storefront helpers. + +Includes security hardening against malicious repositories: the tsserver plugin now canonicalizes and contains every resolved `require()` path (including a cartridge `package.json` `main`) so a crafted import specifier or symlink in a cloned repo can no longer resolve to a file outside the bundled types directory or the cartridge roots, bounds the size of `dw.json`/`package.json` it parses, and the VS Code extension now declares that Script API IntelliSense requires a trusted workspace (`capabilities.untrustedWorkspaces`) and refuses to forward cartridge paths or run usage inference until the workspace is trusted. 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 a18c50e38..72587f502 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,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 308a8ae8a..ba2586630 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -113,6 +113,39 @@ 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}`, `{*}`, `{}`): 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 from how the value is actually used elsewhere in the project — call-site arguments for parameters, return statements for return values — chasing 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(); return priceModel.getPrice();`) rather than stopping at the first `any` or placeholder `Object`. Deliberate `@param {any}` / `: any` annotations are still respected and never second-guessed; real `dw.*` JSDoc is left alone too. + +`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** — `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` (element-first callback when a predicate is passed; `reduce` and unknown callees are skipped), e.g. `collections.forEach(product.getVariants(), function (variant) {...})`. A callback in argument position has no name to search references for, so `variant` is typed from the element type of the collection travelling alongside it (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. +- **SFRA naming aliases** — parameters conventionally named `lineItem` / `pli`, `priceModel`, `shippingAddress` / `billingAddress`, `paymentInstrument`, etc. short-circuit ambient matching to the Script API class they hold even when the identifier is not the class's own simple name. CamelCase suffixes are recognized too (`resettingCustomer` → `Customer`, `apiProduct` → `Product`, `currentBasket` → `Basket`), matching the naming style SFRA controllers and helpers use constantly. +- **`instanceof` checks** — a single `param instanceof ProductLineItem` (or `dw.order.ProductLineItem`) in the helper body is treated as concrete class evidence when call sites don't resolve. +- **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. Inference deliberately stays out of the way there. + +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. + +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. +- Conflicting call-site argument types cause inference to stay silent rather than union a noisy hover. + +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: + +- ES6 `class` syntax / arrow-function module exports +- Destructured function parameters (`function f({a, b})`) +- Destructured return values (`var {a, b} = undocumentedFn()`) +- Constructor inheritance via `Foo.prototype = Base.prototype` +- 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.7). 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..6f3f6f658 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -56,4 +56,18 @@ 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 from +how the value is actually used elsewhere in the project — see +[src/usage-inference.ts](./src/usage-inference.ts) (barrel) and the engine +modules under [src/inference/](./src/inference/) — and surface it as an +"Inferred from usage" hover note plus synthesized member completions. It's +heuristic and intentionally conservative: it only kicks in where the checker +has already given up with `any`, never overriding a type TypeScript or JSDoc +already resolved. diff --git a/packages/b2c-script-types/eslint.config.mjs b/packages/b2c-script-types/eslint.config.mjs index d527c8a3d..f75f4872d 100644 --- a/packages/b2c-script-types/eslint.config.mjs +++ b/packages/b2c-script-types/eslint.config.mjs @@ -29,4 +29,13 @@ export default [ ...sharedRules, }, }, + { + // Tests run directly via `node --test` (no bundler/loader), so they're + // plain CommonJS .js files using 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 73f5f4540..51125d858 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -28,7 +28,11 @@ "lint:agent": "eslint --quiet", "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", - "format:check": "prettier --check src" + "format:check": "prettier --check src", + "test": "pnpm run build && node --test", + "test:agent": "pnpm run build && node --test --test-reporter=dot", + "test:unit": "pnpm run test", + "test:watch": "pnpm run build && node --test --watch" }, "devDependencies": { "@eslint/compat": "catalog:", diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 73275cd61..c3c39871e 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -8,7 +8,27 @@ 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 module_resolution_1 = require("./resolver/module-resolution"); +/** + * Swaps the trailing `any` keyword part of a QuickInfo's display parts (the + * shape TS renders for an undocumented parameter/property, e.g. `(parameter) + * shipment: any`) for the inferred type's description, so the bolded hover + * header reads `(parameter) shipment: Shipment` instead of `... : any` — + * while leaving everything else (the `(parameter) shipment: ` prefix TS + * already rendered) untouched. Only ever touches a display exactly ending in + * that keyword; any other shape is returned as-is rather than guessed at. + */ +function replaceTrailingAnyDisplayPart(displayParts, description) { + if (!displayParts || displayParts.length === 0) + return displayParts; + const last = displayParts[displayParts.length - 1]; + if (last.kind !== 'keyword' || last.text !== 'any') + return displayParts; + return [...displayParts.slice(0, -1), { kind: 'text', text: description }]; +} 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,47 +39,17 @@ 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. -const CANDIDATE_EXTENSIONS = ['.js', '.json', '/index.js']; -// 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; 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; @@ -69,21 +59,27 @@ function init({ typescript: ts }) { // 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); - return { name, root: n.endsWith('/') ? n : n + '/' }; + const raw = src.replace(/\\/g, '/'); + return { + name, + root: n.endsWith('/') ? n : n + '/', + rawRoot: raw.endsWith('/') ? raw : raw + '/', + }; }); }; const applyConfig = (config) => { 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. @@ -104,105 +100,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; @@ -218,7 +115,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; }; @@ -230,115 +132,7 @@ 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.root + 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.root + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return { resolved: candidate, source: modulesCart.name }; - } - } - // 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.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - if (fileExists(resolved)) { - return { resolved, source: modulesCart.name }; - } - } - } - } - catch { - // best-effort - } - } - return undefined; - }; - const ownerCartridge = (containingFile) => { - const f = normalize(containingFile); - return cartridges.find((c) => f.startsWith(c.root)); - }; + const ownerCartridge = (containingFile) => (0, module_resolution_1.ownerCartridge)(cartridges, normalize, containingFile); // 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 // map go-to-definition results back to the matching modules/.js file. @@ -346,7 +140,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) @@ -354,35 +148,8 @@ 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 @@ -391,9 +158,9 @@ function init({ typescript: ts }) { const projectRoot = info.project.getCurrentDirectory(); if (projectRoot) { try { - const discovered = discoverCartridgesOnDisk(projectRoot); - const configured = readDwJsonCartridges(projectRoot); - const ordered = orderCartridges(discovered, configured); + const discovered = (0, cartridge_discovery_1.discoverCartridgesOnDisk)(ts, projectRoot, fileExists); + const configured = (0, cartridge_discovery_1.readDwJsonCartridges)(ts, projectRoot, fileExists); + const ordered = (0, cartridge_discovery_1.orderCartridges)(discovered, configured); setCartridges(ordered); log(`auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + (configured ? ` (ordered by dw.json cartridges)` : '')); @@ -404,6 +171,53 @@ function init({ typescript: ts }) { } } const host = info.languageServiceHost; + // 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; + }; // 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, @@ -430,6 +244,36 @@ function init({ typescript: ts }) { } return additions.length > 0 ? [...list, ...additions] : list; }; + // 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 }; + } + const cart = resolveCartridgeModuleOnHost(text, containingFile); + if (cart) { + return { + resolvedFileName: cart.resolved, + extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; + } + const mod = resolveModulesCartridgeOnHost(text); + if (mod) { + return { + resolvedFileName: mod.resolved, + extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; + } + return undefined; + }; const origResolveModuleNameLiterals = host.resolveModuleNameLiterals?.bind(host); if (origResolveModuleNameLiterals) { host.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, options, containingSourceFile, reusedNames) => { @@ -439,41 +283,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 }, + }; }); }; } @@ -487,32 +302,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; }); }; } @@ -538,7 +329,7 @@ function init({ typescript: ts }) { const moduleName = sfraModuleAtOffset(def.textSpan.start); if (!moduleName) return def; - const candidates = [modulesCart.root + moduleName + '.js', modulesCart.root + 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 } }; @@ -564,6 +355,226 @@ function init({ typescript: ts }) { const result = info.languageService.getImplementationAtPosition(fileName, position); return result?.map(remapDefinition); }; + // Usage-based inference (opt-in, `inferUsage`): when hover/completion hits + // a type the checker has already given up on (`any` — typically an + // undocumented helper function), infer a better answer from call sites + // elsewhere in the project instead of leaving the editor with nothing. + // + // Cached per (file, node position). Entries are finished DISPLAY products + // (the hover note string, the synthesized completion entries) rather than + // checker Type objects: a Type pins its checker and, through it, the whole + // program it came from, so caching types would keep an entire stale + // program graph alive from the last edit until the next inference-eligible + // request — potentially forever if the user stops hovering. Strings and + // plain completion entries retain nothing. + // + // HoverInferenceResult is likewise plain data only: `documentation` and + // `tags` are copied out of a real Symbol's own getDocumentationComment()/ + // getJsDocTags() (SymbolDisplayPart[] / JSDocTagInfo[] are just text — + // they don't reference the Symbol, Type, or Node they came from), never + // the Symbol/Type/Node itself. + // + // The whole cache is invalidated when the language service hands back a + // different Program instance (TS builds a new Program object for any + // semantic change, and reuses the same instance otherwise), rather than + // tracking per-entry validity. Program identity is more precise than the + // previously-used project version string, which also bumps on events that + // don't produce a new program — each such bump needlessly re-ran a full + // inference (measured ~13ms per hover on an SFRA-sized project) that the + // cache should have answered. + let inferenceCacheProgram; + const inferenceCache = new Map(); + // Bounds the cache during a long no-edit session (e.g. hours of hovering + // around at the same program): entries are small (strings / plain entry + // arrays), so this is belt-and-braces, and a wholesale clear is honest — + // no LRU bookkeeping for a cache this cheap to refill. + const MAX_INFERENCE_CACHE_ENTRIES = 512; + const getCachedInference = (cacheKey, program, compute) => { + if (program !== inferenceCacheProgram) { + inferenceCache.clear(); + inferenceCacheProgram = program; + } + if (inferenceCache.has(cacheKey)) + return inferenceCache.get(cacheKey); + const result = compute(); + if (inferenceCache.size >= MAX_INFERENCE_CACHE_ENTRIES) + inferenceCache.clear(); + inferenceCache.set(cacheKey, result); + return result; + }; + // Runs our own inference logic and degrades to `fallback` (the untouched + // underlying result) if it throws, so a bug in this plugin's additions + // can't take the whole tsserver request down with it. Deliberately wraps + // ONLY the inference augmentation, never the underlying language-service + // call itself: an exception from vanilla TS must keep propagating to + // tsserver's own error reporting exactly as it would without this plugin + // installed — swallowing it here would turn a real TS crash into a + // silent "hover stopped working" for every file in the project. + // `ts.OperationCanceledException` is exempted and always rethrown: TS + // throws it cooperatively whenever the host's CancellationToken fires + // (e.g. the user kept typing while this hover or completion request was + // still in flight), which is ordinary, frequent behavior, not a real + // failure — tsserver's request pipeline handles a propagated + // cancellation very differently from a completed-but-empty response, so + // swallowing it here would misreport "cancelled" as "resolved to + // nothing" every time. + const guarded = (label, fn, fallback) => { + try { + return fn(); + } + catch (e) { + if (e instanceof ts.OperationCanceledException) + throw e; + log(`usage-inference ${label} failed: ${e.message}`); + return fallback; + } + }; + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) + return original; + return guarded('hover', () => { + const program = info.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + if (!program || !sourceFile) + return original; + const node = (0, usage_inference_1.getNodeAtPosition)(sourceFile, ts, position); + if (!node || !ts.isIdentifier(node)) + return original; + const checker = program.getTypeChecker(); + // superModule-derived expressions get past the open-type gate: the + // checker's type for them is garbage either way (any or an opaque + // circular typeof), never something worth leaving untouched. Weak + // placeholder types (`object` / `{}`) are open too — see + // isOpenForUsageInference. + if (!(0, usage_inference_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(node)) && + !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, node)) { + return original; + } + // `undefined` (inference found nothing) is a cached answer too — + // re-deriving "nothing" costs the same reference searches as + // re-deriving something. + const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath, position); + if (!ctx) + return undefined; + // Hovering the member name of a property access + // (`shipment.productLineItems`, cursor on `productLineItems`) has + // no declaration of its own to look up — `productLineItems` isn't + // a symbol anywhere until the receiver's type is known. Resolve + // the whole access expression the same way completions do, + // rather than restricting to inferTypeForNode's bare-identifier + // (parameter/variable/function) cases. + const propAccess = (0, usage_inference_1.findEnclosingPropertyAccess)(node, ts); + const isMemberName = !!propAccess && propAccess.name === node; + const types = isMemberName ? (0, usage_inference_1.inferTypeForExpression)(ctx, propAccess) : (0, usage_inference_1.inferTypeForNode)(ctx, node); + if (types.length === 0) + return undefined; + const description = (0, usage_inference_1.describeTypes)(checker, types); + // The receiver's type was undocumented, but the *member itself* + // (or the inferred type's own declaration) is real and usually + // documented — borrow its doc comment/tags so hover reads like a + // native, fully-resolved hover instead of just a bare type name. + let symbol; + if (isMemberName && propAccess) { + for (const baseType of (0, usage_inference_1.inferTypeForExpression)(ctx, propAccess.expression)) { + symbol = (0, usage_inference_1.getMemberOfType)(checker, baseType, node.text); + if (symbol) + break; + } + } + else { + symbol = types[0].getSymbol(); + } + const documentation = symbol?.getDocumentationComment(checker); + const tags = symbol?.getJsDocTags(checker); + return { + description, + documentation: documentation && documentation.length > 0 ? documentation : undefined, + tags: tags && tags.length > 0 ? tags : undefined, + }; + }); + if (!inferred) + return original; + const note = { + text: `\n\nInferred from usage: ${inferred.description}`, + kind: 'text', + }; + return { + ...original, + displayParts: replaceTrailingAnyDisplayPart(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags && inferred.tags.length > 0 ? [...inferred.tags] : original.tags, + }; + }, original); + }; + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) + return original; + return guarded('completions', () => { + const program = info.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + if (!program || !sourceFile) + return original; + const node = (0, usage_inference_1.getNodeAtPosition)(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) + return original; + const propAccess = (0, usage_inference_1.findEnclosingPropertyAccess)(node, ts); + if (!propAccess) + return original; + const checker = program.getTypeChecker(); + // See the hover gate above for the superModule / weak-type exception. + if (!(0, usage_inference_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(propAccess.expression)) && + !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, propAccess.expression)) { + return original; + } + // The receiver can be any expression, not just a plain identifier: + // `product.getPriceModel().|` needs the chain resolved the same way + // hover-driven return inference already resolves it. + const baseNode = propAccess.expression; + const typeEntries = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, program, () => { + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath, position); + const types = ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; + return (0, usage_inference_1.typesToCompletionEntries)(ts, checker, types); + }); + // Members added by pass-through superModule overlay levels + // (`module.exports = base; module.exports.extra = fn;`) can't be + // carried by any candidate type — collect them separately. Cheap + // (statement scans only, no reference search), so uncached. + const augmentedCtx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); + const augmentedEntries = (augmentedCtx ? (0, usage_inference_1.collectSuperModuleAugmentedMembers)(augmentedCtx, baseNode) : []).map((m) => ({ + name: m.name, + kind: m.isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: usage_inference_1.INFERRED_COMPLETION_SOURCE, + })); + const inferredEntries = [...typeEntries, ...augmentedEntries]; + if (inferredEntries.length === 0) + return original; + // Dedupe against the original entries AND within the inferred set + // (a name can come from both a candidate type and an overlay + // augmentation). + const seenNames = new Set((original?.entries ?? []).map((e) => e.name)); + const merged = [ + ...(original?.entries ?? []), + ...inferredEntries.filter((e) => !seenNames.has(e.name) && (seenNames.add(e.name), true)), + ]; + // Preserve every other field TS set on the original result (isIncomplete, + // optionalReplacementSpan, metadata, defaultCommitCharacters, flags) — + // only entries actually changed. Only synthesize a fresh CompletionInfo + // in the rare case TS returned nothing at all for this position. + if (original) + return { ...original, entries: merged }; + return { + isGlobalCompletion: false, + isMemberCompletion: true, + isNewIdentifierLocation: false, + entries: merged, + }; + }, original); + }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } 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..08bcb2647 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.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.getNodeAtPosition = getNodeAtPosition; +exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; +exports.hasExplicitParameterType = hasExplicitParameterType; +exports.hasExplicitReturnType = hasExplicitReturnType; +exports.hasExplicitVariableType = hasExplicitVariableType; +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). Without that, every call 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 on every one of the up-to-50 reference hits collectCallSites() + * resolves in that file. + */ +function getNodeAtPosition(sourceFile, ts, pos) { + let result; + const visit = (node) => { + if (pos < node.getStart(sourceFile)) + return true; // walked past pos — later siblings can't contain it + if (pos >= node.getEnd()) + return undefined; // before pos — keep scanning this sibling list + result = node; + ts.forEachChild(node, visit); + return true; // containing child handled — siblings don't overlap + }; + visit(sourceFile); + return result; +} +/** Walks up from `node` to the nearest enclosing PropertyAccessExpression, or `undefined` if there isn't one. */ +function findEnclosingPropertyAccess(node, ts) { + let current = node; + while (current) { + if (ts.isPropertyAccessExpression(current)) + return current; + current = current.parent; + } + return undefined; +} +/** + * SFRA helpers are often "documented" with a placeholder type that carries no + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`. 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. + */ +function isWeakTypeNode(typeNode, ts) { + let node = typeNode; + while (ts.isParenthesizedTypeNode(node)) + node = node.type; + // 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 refName = (() => { + if (ts.isTypeReferenceNode(node)) + return node.typeName.getText(); + if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression)) { + return node.expression.text; + } + return undefined; + })(); + if (!refName) + return false; + const lower = refName.toLowerCase(); + // `Object` / `object` / the SFRA-conventional misspelling `obj`. + return lower === 'object' || lower === 'obj'; +} +/** 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); +} +/** + * 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/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js new file mode 100644 index 000000000..f64c3784f --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -0,0 +1,196 @@ +"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.collectCallSites = collectCallSites; +const constants_1 = require("./constants"); +const ast_helpers_1 = require("./ast-helpers"); +/** + * Identifies the name to run findReferences on for a function-like + * declaration that itself has no `name` (the common CommonJS shapes: + * `const foo = function(){}`, `{foo: function(){}}`, `{foo(){}}`, + * `exports.foo = function(){}`, `module.exports = function(){}`). + */ +function getReferenceNameNode(fn, ts) { + if (ts.isFunctionDeclaration(fn) && fn.name) + return fn.name; + if (ts.isMethodDeclaration(fn) && ts.isIdentifier(fn.name)) + return fn.name; + const parent = fn.parent; + if (!parent) + return undefined; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) + return parent.name; + if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) + return parent.name; + if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const left = parent.left; + // `module.exports = function(){}` / `exports.foo = function(){}` — the + // `.name` identifier (`exports` or `foo`) is what findReferences can + // actually track; for the bare `module.exports` case this resolves to + // the whole module's value, so callers reach it via collectCallSites()'s + // require() indirection rather than a direct property-access call. + if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.name)) + return left.name; + if (ts.isIdentifier(left)) + return left; + } + return undefined; +} +/** + * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing call site if the + * identifier sits in callee/constructor position — one parent up for a + * direct call or `new` expression, two parents up when the identifier is the + * `.name` of a property access. + */ +function findCallInCalleePosition(node, ts) { + const parent = node.parent; + if (!parent) + return undefined; + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) + return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if (grandparent && + (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && + grandparent.expression === parent) { + return grandparent; + } + } + return 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])); +} +/** + * When a reference to our function's name doesn't sit directly in callee + * position, it may still be one hop away from a real call site through a + * binding indirection: the module specifier of a `require(...)` call whose + * result is assigned to a variable (`var helper = require('./helper')`), or + * a destructuring binding element (`const {helper} = require(...)` or + * `const {helper: local} = someObject`). + * + * @returns Either the further name to search references for, or — for an + * immediately-invoked require (`require('./helper')(x)`) — the call site itself. + */ +function resolveIndirectReferenceTarget(node, ts) { + const parent = node.parent; + if (!parent) + return undefined; + if (ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts)) { + const requireCall = parent; + const outer = requireCall.parent; + if (outer && ts.isCallExpression(outer) && outer.expression === requireCall) { + return { kind: 'call', call: outer }; // require('./helper')(x) + } + if (outer && ts.isVariableDeclaration(outer) && outer.initializer === requireCall && ts.isIdentifier(outer.name)) { + return { kind: 'name', name: outer.name }; // var helper = require('./helper') + } + return undefined; + } + if (ts.isBindingElement(parent) && ts.isIdentifier(parent.name)) { + // Covers both `{helper}` (shorthand — name and propertyName are the same + // node) and `{helper: local}` (renamed — redirect to the local binding). + return { kind: 'name', name: parent.name }; + } + // `module.exports = {getSalePrice: getSalePrice}` — SFRA's canonical export + // shape, an alias map from property name to a separately-declared function. + // A reference search on the *function* name dead-ends at the alias-map + // initializer; the actual consumers (`productHelpers.getSalePrice(x)` in + // another file) are references of the property *name*, so redirect the + // search there. Not scoped to module.exports specifically: any + // `{run: helper}` alias whose property is later called is a genuine call + // site of the aliased function. + if (ts.isPropertyAssignment(parent) && parent.initializer === node && ts.isIdentifier(parent.name)) { + return { kind: 'name', name: parent.name }; + } + return undefined; +} +/** + * Finds actual call sites for `nameNode`, following up to + * MAX_REFERENCE_HOPS binding indirections (require() bindings, destructuring) + * when a reference doesn't sit directly in callee position. Stops early once + * ctx.referenceBudget (result count) or ctx.searchBudget (project scans) 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, nameNode) { + const memoized = ctx.callSiteMemo.get(nameNode); + if (memoized) + return memoized; + const calls = []; + const seenNameKeys = new Set(); + let frontier = [nameNode]; + 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 name of frontier) { + if (localBudget <= 0 || ctx.searchBudget <= 0) + break; + const sourceFile = name.getSourceFile(); + const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; + if (seenNameKeys.has(key)) + continue; + seenNameKeys.add(key); + localBudget = collectCallsFromName(ctx, name, calls, nextFrontier, localBudget); + } + frontier = nextFrontier; + } + ctx.callSiteMemo.set(nameNode, calls); + return calls; +} +/** + * Runs one reference search for `name` and sorts each hit into either a + * resolved call site (pushed to `calls`) or a further name to chase on the + * next hop (pushed to `nextFrontier`) via a single binding indirection. + * Consumes one unit of the shared search budget and up to `localBudget` + * result slots, returning the remaining local budget so the caller can stop + * fanning out once it's exhausted. + */ +function collectCallsFromName(ctx, name, calls, nextFrontier, localBudget) { + const { ts, languageService, program } = ctx; + const sourceFile = name.getSourceFile(); + ctx.searchBudget--; + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + for (const ref of refs) { + if (localBudget <= 0) + break; + localBudget--; + ctx.referenceBudget--; + const refFile = program.getSourceFile(ref.fileName); + if (!refFile) + continue; + const node = (0, ast_helpers_1.getNodeAtPosition)(refFile, ts, ref.textSpan.start); + if (!node) + continue; + // Definition sites (the declaration itself) never sit in callee + // position, so this also naturally excludes them. + const call = findCallInCalleePosition(node, ts); + if (call) { + calls.push(call); + continue; + } + const indirect = resolveIndirectReferenceTarget(node, ts); + if (indirect?.kind === 'call') + calls.push(indirect.call); + else if (indirect?.kind === 'name') + nextFrontier.push(indirect.name); + } + return localBudget; +} 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..68178f140 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -0,0 +1,208 @@ +"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.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES = exports.CONVENTIONAL_IDENTIFIER_ALIASES = exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.WEAK_USAGE_MEMBERS = exports.MAX_CALL_SITE_CANDIDATES = exports.MAX_USAGE_MATCH_CANDIDATES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = 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_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 indirection hops (require() binding -> destructuring -> renamed +// re-export, etc.) collectCallSites() will follow from a reference before +// giving up on finding an actual call site. +exports.MAX_REFERENCE_HOPS = 2; +// 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 +// getReferencesAtPosition call still scans the whole program regardless — on +// a large project the dominant cost is that first search, and the real bound +// on it is TS's own cooperative cancellation (rethrown, never swallowed, by +// the plugin's `guarded` wrapper). +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 getReferencesAtPosition SEARCHES one top-level request +// may issue. This is a different axis from MAX_REFERENCES_PER_REQUEST, which +// only bounds how many search *results* get processed: every search is a full +// project scan 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 scans ≈ 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 scans per request. +exports.MAX_SEARCHES_PER_REQUEST = 12; +// 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'; +// Last-resort fallback when call-site/return-expression inference (the whole +// rest of the engine) comes up empty: match the member names a parameter is +// actually accessed by (`shipment.custom`, `shipment.productLineItems`, ...) +// against every ambient class/interface visible in the program, and accept +// the most specific one(s) that expose all of them. A single accessed member +// name (e.g. just `.custom`) is carried by dozens of unrelated business +// objects, so it's too weak a signal on its own to guess from — UNLESS that +// single member happens to be globally unique across every ambient class +// (e.g. `.addresses`, which only `dw.customer.AddressBook` declares), in +// which case there's no ambiguity to be weak about. See +// matchAmbientTypesByUsage's unambiguous-single-member exception. +exports.MIN_USAGE_SIGNATURE_MEMBERS = 2; +// If the member-name signature still ties across more candidates than this +// after ranking by specificity (distinctiveness, then fewest total members), +// the match is too ambiguous to be a useful hint — silence beats a wall of +// unrelated candidates in the hover text. +exports.MAX_USAGE_MATCH_CANDIDATES = 5; +// When call-site arguments don't converge on a single distinct type, silence +// rather than union a noisy hover like `Product | Order`. A two-type union is +// already usually wrong for any given call site; ambient usage-matching is +// also skipped in that case — conflicting evidence is not "no call sites". +exports.MAX_CALL_SITE_CANDIDATES = 1; +// Member names so common across dw.* that they barely discriminate a class +// on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). +// They still count as usage evidence for matching, but contribute far less +// to the distinctiveness score used to rank ambient candidates. +exports.WEAK_USAGE_MEMBERS = new Set(['custom', 'UUID', 'toString', 'valueOf']); +// Callee names whose callbacks lead with the collection element +// (`collections.forEach(coll, function (item) {...})`). Only these get the +// sibling-collection element-type heuristic; `reduce` (accumulator first) +// and 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 / line-item lookups (a storefront cartridge). + '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', +]); +/** + * SFRA/storefront parameter names that conventionally hold a Script API class + * whose declared name does not equal the identifier (case-insensitive). Used + * by ambient usage-matching's identifier short-circuit — `lineItem` must map + * to `ProductLineItem`, not look for a nonexistent ambient class named + * `LineItem`. Keys are lowercase; values are ambient class simple names. + * + * Keep this list conservative: only aliases that are unambiguous in real + * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs + * OrderAddress vs Store address models). Prefer adding PascalCase suffixes to + * {@link CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES} for `resettingCustomer`-style + * names; this map is for short / all-lowercase tokens (`pli`, `pricemodel`). + */ +exports.CONVENTIONAL_IDENTIFIER_ALIASES = new Map([ + ['lineitem', 'ProductLineItem'], + ['pli', 'ProductLineItem'], + ['productlineitem', 'ProductLineItem'], + ['pricemodel', 'ProductPriceModel'], + ['availabilitymodel', 'ProductAvailabilityModel'], + ['shippingaddress', 'OrderAddress'], + ['billingaddress', 'OrderAddress'], + ['paymentinstrument', 'OrderPaymentInstrument'], + ['shippingmethod', 'ShippingMethod'], + ['shippinglineitem', 'ShippingLineItem'], + ['priceadjustment', 'PriceAdjustment'], + ['giftcertificatelineitem', 'GiftCertificateLineItem'], + ['couponlineitem', 'CouponLineItem'], + // Other concrete dw.order line-item subclasses. Without these, the bare + // `LineItem` PascalCase suffix (below) would force an all-lowercase + // `bonusdiscountlineitem` / `productshippinglineitem` to ProductLineItem — + // a wrong guess for a differently-named sibling class (see the matching + // PascalCase suffixes and the *LineItem note there). + ['bonusdiscountlineitem', 'BonusDiscountLineItem'], + ['productshippinglineitem', 'ProductShippingLineItem'], + ['customeraddress', 'CustomerAddress'], + ['orderaddress', 'OrderAddress'], + // High-frequency all-lowercase / compound forms seen across storefronts + // (when authors don't camelCase the class token). + ['currentbasket', 'Basket'], + ['currentcustomer', 'Customer'], + ['currentorder', 'Order'], + ['apiproduct', 'Product'], + ['apiorder', 'Order'], + ['apilineitem', 'ProductLineItem'], +]); +/** + * Trailing PascalCase class tokens → ambient class simple name. Matched with + * `identifierName.endsWith(pascalSuffix)` (case-sensitive on the original + * identifier) so `resettingCustomer` / `apiProduct` / `currentBasket` resolve + * while all-lowercase noise like `border` / `emailaddress` does not. + * + * Ordered longest-first so `productLineItem` hits ProductLineItem rather than + * Product. Generic `Address` is omitted — too many false friends + * (`emailAddress`, `ipAddress`, store address models). + * + * The *LineItem subclasses (`ProductLineItem`, `BonusDiscountLineItem`, + * `CouponLineItem`, `GiftCertificateLineItem`, `ShippingLineItem`, + * `ProductShippingLineItem`) must ALL precede the bare `LineItem` → + * ProductLineItem fallback, and each longer name must precede any shorter one + * it ends with (`ProductShippingLineItem` before `ShippingLineItem`), because + * the matcher stops at the first `endsWith` hit in array order. Without the + * specific entries, a `bonusDiscountLineItem` / `productShippingLineItem` + * parameter would resolve to the wrong sibling class (ProductLineItem / + * ShippingLineItem) whenever its body only touches members shared through the + * common `LineItem` base — the classic silence-vs-wrong-guess trap. + */ +exports.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES = [ + ['GiftCertificateLineItem', 'GiftCertificateLineItem'], + ['BonusDiscountLineItem', 'BonusDiscountLineItem'], + ['CouponLineItem', 'CouponLineItem'], + ['ProductShippingLineItem', 'ProductShippingLineItem'], + ['ProductLineItem', 'ProductLineItem'], + ['ShippingLineItem', 'ShippingLineItem'], + ['OrderPaymentInstrument', 'OrderPaymentInstrument'], + ['PaymentInstrument', 'OrderPaymentInstrument'], + ['ProductAvailabilityModel', 'ProductAvailabilityModel'], + ['AvailabilityModel', 'ProductAvailabilityModel'], + ['ProductPriceModel', 'ProductPriceModel'], + ['PriceModel', 'ProductPriceModel'], + ['ShippingAddress', 'OrderAddress'], + ['BillingAddress', 'OrderAddress'], + ['CustomerAddress', 'CustomerAddress'], + ['OrderAddress', 'OrderAddress'], + ['ShippingMethod', 'ShippingMethod'], + ['PriceAdjustment', 'PriceAdjustment'], + ['LineItem', 'ProductLineItem'], + ['Customer', 'Customer'], + ['Profile', 'Profile'], + ['Product', 'Product'], + ['Basket', 'Basket'], + ['Shipment', 'Shipment'], + ['Category', 'Category'], + ['Order', 'Order'], + ['Store', 'Store'], + ['Variant', 'Variant'], + ['Money', 'Money'], +]; 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..9dcb87d83 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/context.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.createInferenceContext = createInferenceContext; +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, resolveSuperModulePath, triggerPosition) { + const program = languageService.getProgram(); + if (!program) + return undefined; + return { + ts, + program, + checker: program.getTypeChecker(), + languageService, + 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(), + cycleHits: 0, + resolveSuperModulePath, + triggerPosition, + }; +} 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..6ab3c886c --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -0,0 +1,585 @@ +"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 ast_helpers_1 = require("./ast-helpers"); +const call_sites_1 = require("./call-sites"); +const super_module_1 = require("./super-module"); +const type_helpers_1 = require("./type-helpers"); +const usage_match_1 = require("./usage-match"); +/** + * 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 + * — the checker's resolved signature. + */ +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 sig = checker.getResolvedSignature(call); + const sigDecl = sig?.declaration; + if (sigDecl && ts.isFunctionLike(sigDecl)) + return sigDecl; + return undefined; +} +/** + * Chases a local variable's initializer expression — the missing link for 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 reference even though the + * exact same logic written inline resolves fine. + * + * Guarded three ways: an explicit type/JSDoc annotation on the variable means + * its `any` is deliberate (same rule as parameters/returns); the `visiting` + * set breaks initializer cycles (`var a = b; var b = a;`) and records the hit + * in ctx.cycleHits; and the hop is charged to `chainHops` — following a + * variable never crosses a function boundary, so it's an in-expression hop, + * not a recursion-depth step. + * + * Falls back to matching the variable's own usage against ambient classes + * (see {@link collectVariableMemberUsage}) when the initializer itself + * resolves to nothing — the common shape for a manual-indexing loop variable + * (`var item = items[i]`), where `items[i]` stays `any` no matter what since + * `items` itself is undocumented. + */ +function resolveVariableInitializerTypes(ctx, decl, depth, chainHops) { + const { ts } = ctx; + if (!decl.initializer || (0, ast_helpers_1.hasExplicitVariableType)(decl, ts)) + return []; + if (ctx.visiting.has(decl)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(decl); + try { + const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + if (resolved.length > 0) + return resolved; + return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectVariableMemberUsage)(ctx, decl), ts.isIdentifier(decl.name) ? decl.name.text : undefined); + } + finally { + ctx.visiting.delete(decl); + } +} +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. The checker's + * type for the `module.exports` symbol is used when it's concrete — it + * merges the assigned object with any later `module.exports.name = fn` + * augmentations. For a pass-through overlay (`module.exports = base` where + * base is itself `module.superModule`), the right-hand side is resolved via + * resolveExpressionTypes instead, which recurses naturally another cartridge + * down; members such a pass-through level *adds* can't be merged into these + * candidate types — they're handled separately by + * {@link resolveSuperModuleMemberTypes} and + * {@link collectSuperModuleAugmentedMembers}. + */ +function resolveSuperModuleTypes(ctx, expr, depth, chainHops) { + const { ts, checker } = ctx; + const superFile = (0, super_module_1.findSuperModuleFile)(ctx, expr.getSourceFile().fileName); + if (!superFile) + return []; + // Guard against overlay cycles (two cartridges whose modules somehow point + // at each other through a misconfigured cartridge path). + if (ctx.visiting.has(superFile)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(superFile); + try { + const types = []; + for (const bin of (0, super_module_1.collectExportAssignments)(superFile, ts).full) { + const concrete = (0, super_module_1.isConcreteExportAssignment)(ctx, bin); + if (concrete) { + types.push((0, type_helpers_1.widenType)(checker, checker.getTypeAtLocation(bin.left))); + } + // A pass-through assignment (`module.exports = base` where base is + // this level's own module.superModule) needs the RHS recursed even + // when the left-hand type looked concrete: the checker sometimes + // merges this level's augmentations into an opaque `typeof base` type + // that still carries none of the deeper cartridges' members. + if (!concrete || (0, super_module_1.traceSuperModuleAccess)(ts, checker, bin.right)) { + types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + } + } + return (0, type_helpers_1.dedupeTypes)(ctx, types); + } + finally { + ctx.visiting.delete(superFile); + } +} +/** + * Walks the superModule chain of the file containing `superAccess`, one + * cartridge level at a time, and resolves `memberName` from the first level + * that provides it as an export augmentation (`module.exports.name = fn`). + * This is the complement to {@link resolveSuperModuleTypes}: members a + * pass-through overlay level *adds* live only in these assignments, not in + * any candidate type. A level whose `module.exports` type is concrete ends + * the walk (matching runtime semantics — a concrete re-assignment replaces + * everything below unless it deliberately carries the base along). + */ +function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) { + const { ts, checker } = ctx; + const seen = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < constants_1.MAX_SUPERMODULE_HOPS; hop++) { + const superFile = (0, super_module_1.findSuperModuleFile)(ctx, fromFileName); + if (!superFile || seen.has(superFile)) + return []; + seen.add(superFile); + const { full, members } = (0, super_module_1.collectExportAssignments)(superFile, ts); + const matches = members.filter((m) => m.name === memberName); + if (matches.length > 0) { + const types = []; + for (const m of matches) { + types.push(...resolveExpressionTypes(ctx, m.expr, depth, chainHops + 1).filter((t) => !(0, type_helpers_1.isAnyType)(ts, t))); + } + return (0, type_helpers_1.dedupeTypes)(ctx, types); + } + // No augmentation at this level: continue downward only through a + // pass-through (`module.exports = `); a concrete export either + // already carries the member (the type-based lookup found it) or + // genuinely replaces the levels below. + const passesThrough = full.some((bin) => !(0, super_module_1.isConcreteExportAssignment)(ctx, bin) || (0, super_module_1.traceSuperModuleAccess)(ts, checker, bin.right) !== undefined); + if (!passesThrough) + return []; + fromFileName = superFile.fileName; + } + return []; +} +/** + * Infers the type of a callback's first parameter from sibling arguments of + * the call the callback is passed to: `collections.forEach` / `map` / + * `filter` / `every` / `some` / `find` (see {@link ELEMENT_FIRST_CALLBACK_CALLEES}). + * A function expression in argument position has no name to run a reference + * search on, but the collection travelling alongside it names the element + * type. Only the first parameter is mapped (SFRA's collections util passes + * the element first). Unknown callees and `reduce` (accumulator first) are + * left alone — applying the heuristic to an arbitrary helper would guess wrong + * more often than it helps. + */ +function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { + const { ts, checker } = ctx; + if (paramIndex !== 0) + return []; + const call = fn.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.some((arg) => arg === fn)) + return []; + const calleeName = ts.isPropertyAccessExpression(call.expression) + ? call.expression.name.text + : ts.isIdentifier(call.expression) + ? call.expression.text + : undefined; + if (!calleeName || !constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) + return []; + const types = []; + for (const arg of call.arguments) { + if (arg === fn) + continue; + for (const argType of resolveExpressionTypes(ctx, arg, depth)) { + const element = (0, type_helpers_1.collectionElementType)(ctx, argType, arg); + if (element) + types.push((0, type_helpers_1.widenType)(checker, element)); + } + } + return types; +} +/** + * Resolves the candidate type(s) of `expr`. If the checker settles on `any` + * and `expr` is itself a call to a function we can analyze, recurses into + * that function's inferred return type(s) instead of accepting the `any`. + * + * @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 return-type inference — chain-hopping never + * crosses a function boundary, so it needs its own bound + * (`MAX_CHAIN_HOPS`) to keep worst-case cost predictable for a very long + * inline method chain. + * @returns An array (rather than a single unioned Type) because the public + * TypeChecker API exposed via tsserverlibrary has no way to synthesize a + * union Type — callers merge candidates for display/completions themselves. + */ +function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { + const { ts, checker } = ctx; + // module.superModule (or a `var base = module.superModule` alias) first, + // BEFORE trusting the checker's direct type: 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 wrongly satisfy the not-any short-circuit below. + const superAccessAtRoot = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); + if (superAccessAtRoot) { + return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); + } + const direct = checker.getTypeAtLocation(expr); + // Prefer a concrete checker type, but keep chasing through placeholder + // shapes (`any` / `object` / `{}`) the same way — SFRA JSDoc often types + // helpers as `{Object}` which checkJs widens to `any`, and an empty `{}` + // annotation is equally useless as a call-site candidate. + if (!(0, type_helpers_1.isOpenForUsageInference)(ts, direct)) + return [(0, type_helpers_1.widenType)(checker, direct)]; + if (chainHops >= constants_1.MAX_CHAIN_HOPS) + return []; + // The checker gave up. Dispatch on the kind of expression to a focused + // resolver. Each returns [] when it can't do better, so an unhandled kind + // (or an exhausted branch) falls through to []. + 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); + // SFRA helpers often return through a ternary (`return it.hasNext() ? it.next() + // : null` — the body of `collections.first`) or a parenthesized subexpression. + // Without chasing both branches the whole return collapses to `any` even when + // the collection argument at the call site is fully typed. + if (ts.isConditionalExpression(expr)) { + return (0, type_helpers_1.dedupeTypes)(ctx, [ + ...resolveExpressionTypes(ctx, expr.whenTrue, depth, chainHops + 1), + ...resolveExpressionTypes(ctx, expr.whenFalse, depth, chainHops + 1), + ]); + } + if (ts.isParenthesizedExpression(expr)) { + return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + } + return []; +} +/** + * Resolves an `any` call expression: first by inferring the callee's own + * return type, then — for a chained call whose receiver is itself + * undocumented (`x.getPriceModel().getPrice()`) — by resolving the receiver's + * type and looking this method up on its real, documented signature(s). + * Returns [] when neither path improves on `any`. + */ +function resolveCallResultTypes(ctx, expr, depth, chainHops) { + const { ts, checker } = ctx; + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) + return inferred; + } + // resolveCalleeDeclaration can't find a real declaration for a method whose + // receiver base is undocumented (the checker never resolved the method), so + // infer the receiver's type first, then look this method up by name on it. + if (!ts.isPropertyAccessExpression(expr.expression)) + return []; + const methodAccess = expr.expression; + const methodName = methodAccess.name.text; + const returnTypes = []; + const pushSignatureReturns = (methodType) => { + for (const sig of methodType.getCallSignatures()) { + const returnType = checker.getReturnTypeOfSignature(sig); + if (!(0, type_helpers_1.isAnyType)(ts, returnType)) { + returnTypes.push((0, type_helpers_1.widenType)(checker, returnType)); + continue; + } + // The member resolved but its own return type is `any` — the + // superModule case, where the base module's export type carries an + // undocumented function. `any` is never a useful candidate to surface; + // recurse into the function's actual declaration instead, the same + // fallback resolveCalleeDeclaration provides for direct calls. + const sigDecl = sig.declaration; + if (sigDecl && ts.isFunctionLike(sigDecl)) { + returnTypes.push(...inferReturnType(ctx, sigDecl, depth + 1)); + } + } + }; + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { + const methodSymbol = (0, type_helpers_1.getMemberOfType)(checker, receiverType, methodName); + if (!methodSymbol) + continue; + pushSignatureReturns(checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name)); + } + if (returnTypes.length === 0) { + // No candidate type carried this method — but if the receiver is (an + // alias of) module.superModule, the method may be an export + // *augmentation* added by a pass-through overlay level, which no + // candidate type can carry. + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, methodAccess.expression); + if (superAccess) { + for (const memberType of resolveSuperModuleMemberTypes(ctx, superAccess, methodName, depth, chainHops)) { + pushSignatureReturns(memberType); + } + } + } + return returnTypes.length > 0 ? (0, type_helpers_1.dedupeTypes)(ctx, returnTypes) : []; +} +/** + * Resolves an `any` property access (`x.ID`) whose base is itself + * undocumented: infer the base's type first, then look this specific property + * up on it — or, if the base is a superModule alias, as a pass-through overlay + * augmentation. Returns [] when the property can't be resolved. + */ +function resolvePropertyTypes(ctx, expr, depth, chainHops) { + const { ts, checker } = ctx; + const propName = expr.name.text; + const propTypes = []; + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { + const propSymbol = (0, type_helpers_1.getMemberOfType)(checker, baseType, propName); + if (!propSymbol) + continue; + const propType = checker.getTypeOfSymbolAtLocation(propSymbol, expr); + // An `any`-typed member (e.g. an untyped value in an exports map) is + // never a useful candidate — surfacing "Inferred from usage: any" would + // be worse than staying quiet. + if (!(0, type_helpers_1.isAnyType)(ts, propType)) + propTypes.push((0, type_helpers_1.widenType)(checker, propType)); + } + if (propTypes.length === 0) { + // Mirror of the method-chain fallback: the property may be an export + // augmentation added by a pass-through superModule overlay. + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr.expression); + if (superAccess) { + propTypes.push(...resolveSuperModuleMemberTypes(ctx, superAccess, propName, depth, chainHops).map((t) => (0, type_helpers_1.widenType)(checker, t))); + } + } + return propTypes.length > 0 ? (0, type_helpers_1.dedupeTypes)(ctx, propTypes) : []; +} +/** + * Resolves an `any` identifier by chasing what it refers to: an undocumented + * parameter (infer from its call sites) or a local variable holding an + * intermediate result (chase its initializer, so a chain split across `var` + * statements infers exactly like the inline expression would). Returns [] for + * anything else. + */ +function resolveIdentifierTypes(ctx, expr, depth, chainHops) { + const { ts, checker } = ctx; + const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + if (decl && ts.isParameter(decl)) + return inferParameterType(ctx, decl, depth + 1); + if (decl && ts.isVariableDeclaration(decl)) + return resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + return []; +} +/** + * Collects argument types at `paramIndex` across every call/`new` site of + * `nameNode`. A bare `new Helper` (no parens) has `arguments === undefined` + * and contributes nothing. + */ +function collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) { + const types = []; + for (const call of (0, call_sites_1.collectCallSites)(ctx, nameNode)) { + const arg = call.arguments?.[paramIndex]; + if (!arg) + continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + return types; +} +/** + * True when `type` exposes every member name in `memberNames`. Used to drop + * call-site candidates that can't actually support the parameter's own body + * — the classic SFRA duck-typing trap where a Store *model* is passed into a + * helper that also reads CustomerAddress-only fields (`companyName`, + * `postBox`, …). Without this filter the resolvable model wins the hover + * even though the body is not a Store. + */ +function typeSatisfiesMemberUsage(ctx, type, memberNames) { + if (memberNames.size === 0) + return true; + for (const name of memberNames) { + if (!(0, type_helpers_1.getMemberOfType)(ctx.checker, type, name)) + return false; + } + return true; +} +/** + * Turns raw call-site/callback candidates into the final answer for a + * parameter: drop types the body can't use, dedupe, silence conflicting + * top-level unions, otherwise fall back to ambient usage matching when + * nothing resolved. + */ +function finalizeParameterCandidates(ctx, param, types, depth) { + const { ts } = ctx; + const usage = (0, usage_match_1.collectParameterMemberUsage)(ctx, param); + const raw = (0, type_helpers_1.dedupeTypes)(ctx, types); + // Conflicting call-site arguments (e.g. Product at one site, Order at + // another) are not a useful hover — silence rather than a noisy union, + // and do NOT fall through to ambient matching: we already have evidence, + // it just doesn't converge. + // + // Only enforce this at depth 0 (a top-level hover/completion on the + // parameter itself). Recursive callers that chase through a forwarding + // helper still need the full candidate set so return-type inference and + // the typeToString memo baselines keep working; the editor never shows + // those intermediate unions unlabeled. + if (depth === 0 && raw.length > constants_1.MAX_CALL_SITE_CANDIDATES) + return []; + // Keep only call-site types that expose every member the parameter body + // actually touches. A partial resolution (one duck-typed Store model call + // site resolves, an untyped preferredAddress site doesn't) must not surface + // "Store" on a helper whose body also reads address-only fields the model + // never declares. + const result = (0, type_helpers_1.dedupeTypes)(ctx, raw.filter((t) => typeSatisfiesMemberUsage(ctx, t, usage))); + if (result.length > 0) + return result; + // No usable call-site type (none found, none resolved, or none that fit + // the body). Match how the parameter's own body uses it against the + // program's ambient classes instead. + return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, usage, ts.isIdentifier(param.name) ? param.name.text : undefined); +} +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project — a plain call (`helper(x)`) or a + * constructor invocation (`new Helper(x)`, SFRA's other common "class" model + * shape) — since plain un-annotated JS parameters default to `any` with no + * back-inference from call sites. Falls back to matching the parameter's own + * usage (which members it's accessed by) against the program's ambient + * classes when no call site could be found or resolved at all — see + * {@link matchAmbientTypesByUsage}. + * + * @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; + // Check the memo before the depth cap: a result already computed at an + // equal-or-shallower depth is valid regardless of how deep the *current* + // call is — it would be wrong to discard a known-good cached answer just + // because this particular path to it happens to run over budget. + const cached = ctx.memo.get(param); + if (cached && cached.atDepth <= depth) + return cached.types; + if (depth > constants_1.MAX_INFERENCE_DEPTH) + return []; + if ((0, ast_helpers_1.hasExplicitParameterType)(param, ts)) + return []; + // Cycle guard: a self-forwarding helper (e.g. `function id(x){return x}` + // called as `id(id(y))`) could otherwise re-enter inference for this same + // parameter before the first call has finished and memoized its result. + if (ctx.visiting.has(param)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(param); + const cycleHitsBefore = ctx.cycleHits; + try { + const fn = param.parent; + if (!ts.isFunctionLike(fn)) + return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) + return []; + const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); + // `instanceof dw.order.ProductLineItem` (and friends) is concrete class + // evidence from the body itself — merge it with call-site candidates so a + // helper that never sees a typed call site still recovers the class the + // author named. finalizeParameterCandidates still silences multi-type + // unions at depth 0, so a polymorphic Adyen-style branch stays quiet. + const types = [ + ...(nameNode + ? collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) + : // No name to search references for — an anonymous callback passed + // directly in argument position. Its element type may still be + // recoverable from the collection argument travelling alongside it. + inferCallbackParameterTypes(ctx, fn, paramIndex, depth)), + ...(0, usage_match_1.collectParameterInstanceOfTypes)(ctx, param), + ]; + const result = finalizeParameterCandidates(ctx, param, types, depth); + // Don't memoize a result whose computation hit a cycle guard: it was + // truncated by what happened to be on the *current* call stack, and the + // same node queried later in this request from outside the cycle could + // legitimately resolve more. (Depth-cap truncation, by contrast, IS + // safely memoized — the atDepth field encodes exactly how truncated it + // can be, and reuse is restricted accordingly.) + if (ctx.cycleHits === cycleHitsBefore) { + ctx.memo.set(param, { atDepth: depth, types: result }); + } + return result; + } + finally { + ctx.visiting.delete(param); + } +} +/** + * Infers a function's candidate 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) { + const { ts } = ctx; + // See inferParameterType for why the memo is checked before the depth cap. + const cached = ctx.memo.get(fn); + if (cached && cached.atDepth <= depth) + return cached.types; + if (depth > constants_1.MAX_INFERENCE_DEPTH) + return []; + if ((0, ast_helpers_1.hasExplicitReturnType)(fn, ts)) + return []; + if (ctx.visiting.has(fn)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(fn); + const cycleHitsBefore = ctx.cycleHits; + try { + const types = []; + for (const expr of (0, ast_helpers_1.collectReturnExpressions)(fn, ts)) { + types.push(...resolveExpressionTypes(ctx, expr, depth)); + } + const result = (0, type_helpers_1.dedupeTypes)(ctx, types); + // See inferParameterType for why cycle-truncated results skip the memo. + if (ctx.cycleHits === cycleHitsBefore) { + ctx.memo.set(fn, { atDepth: depth, types: result }); + } + return result; + } + finally { + ctx.visiting.delete(fn); + } +} +/** + * Entry point for both hover and completion wiring: given an identifier + * node, figures out what it's worth inferring a better type for (a parameter + * it's declared as, a variable holding an undocumented call's result, or the + * function it names) and returns candidate type(s), if any. + */ +function inferTypeForNode(ctx, node) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(node)) + return []; + const sym = checker.getSymbolAtLocation(node); + const decl = sym?.valueDeclaration; + if (!decl) + return []; + if (ts.isParameter(decl)) + return inferParameterType(ctx, decl); + if (ts.isVariableDeclaration(decl)) { + // Resolve the full initializer expression, not just a direct call's + // callee: `var pm = product.getPriceModel()` (a method call on an + // undocumented parameter) and `var pm = product.priceModel` (a property + // access) both need the same chain-chasing that return-type inference + // already does — resolveVariableInitializerTypes routes through it. + return (0, type_helpers_1.dedupeTypes)(ctx, resolveVariableInitializerTypes(ctx, decl, 0, 0)); + } + if (ts.isFunctionLike(decl)) + return inferReturnType(ctx, decl); + return []; +} +/** + * Like {@link inferTypeForNode}, but for an arbitrary expression in receiver + * position — the completion case `product.getPriceModel().|`, where the thing + * before the dot is a call or chain rather than a plain identifier, so there's + * no declaration to look up; the expression itself is what gets resolved. + */ +function inferTypeForExpression(ctx, expr) { + const { ts } = ctx; + if (ts.isIdentifier(expr)) + return inferTypeForNode(ctx, expr); + return (0, type_helpers_1.dedupeTypes)(ctx, resolveExpressionTypes(ctx, expr, 0)); +} 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..4019f2229 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/super-module.js @@ -0,0 +1,157 @@ +"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.findSuperModuleFile = findSuperModuleFile; +exports.collectExportAssignments = collectExportAssignments; +exports.isConcreteExportAssignment = isConcreteExportAssignment; +exports.traceSuperModuleAccess = traceSuperModuleAccess; +exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; +const constants_1 = require("./constants"); +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.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; + if (!ctx.resolveSuperModulePath) + return undefined; + const superPath = ctx.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) { + if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) + continue; + const bin = stmt.expression; + if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + continue; + const left = bin.left; + if (!ts.isPropertyAccessExpression(left)) + continue; + const base = left.expression; + if (ts.isIdentifier(base) && base.text === 'module' && left.name.text === 'exports') { + full.push(bin); + } + else if (ts.isIdentifier(base) && base.text === 'exports') { + members.push({ name: left.name.text, expr: bin.right }); + } + else if (ts.isPropertyAccessExpression(base) && + ts.isIdentifier(base.expression) && + base.expression.text === 'module' && + base.name.text === 'exports') { + members.push({ name: left.name.text, expr: bin.right }); + } + } + 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; +} +/** + * Collects every member the superModule chain reachable from `expr` + * contributes through export augmentations (`module.exports.name = fn`) at + * pass-through levels — the members {@link resolveSuperModuleTypes}'s + * candidate 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(); + const seenFiles = 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 || seenFiles.has(superFile)) + break; + seenFiles.add(superFile); + const { full, members } = collectExportAssignments(superFile, ts); + for (const m of members) { + if (seenNames.has(m.name)) + continue; + seenNames.add(m.name); + const type = checker.getTypeAtLocation(m.expr); + out.push({ name: m.name, isMethod: type.getCallSignatures().length > 0 }); + } + const passesThrough = full.some((bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined); + if (!passesThrough) + break; + fromFileName = superFile.fileName; + } + return out; +} 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..73c42592e --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -0,0 +1,220 @@ +"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.widenType = widenType; +exports.typeDisplayString = typeDisplayString; +exports.dedupeTypes = dedupeTypes; +exports.getNonNullableApparentType = getNonNullableApparentType; +exports.getMemberOfType = getMemberOfType; +exports.collectionElementType = collectionElementType; +exports.describeTypes = describeTypes; +exports.typesToCompletionEntries = typesToCompletionEntries; +const constants_1 = require("./constants"); +/** 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 the checker's type is too uninformative to prefer over usage + * inference: `any`, the `object` non-primitive, or an empty `{}` type literal. + * Used as the hover/completion gate and when deciding whether a resolved + * expression type is worth keeping versus chasing further. + * + * 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. + * + * The one named type it *does* treat as open is the global `Object` interface, + * which is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O `Object`, distinct from the lowercase `object` + * non-primitive handled above, and from `{*}`/`{}`/`{obj}` which all widen to + * `any` or an empty type). `Object` carries no Script API information, so a + * value typed as bare `Object` is effectively undocumented — exactly the case + * usage inference exists for. Without this, the hover/completion entry gate + * (see index.ts) would reject every `@param {Object}` helper before inference + * even ran, even though {@link hasExplicitParameterType} already correctly + * classifies that JSDoc as a weak placeholder. 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) + return true; + if (symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0) + return true; + return false; +} +/** + * Widens a literal type (e.g. the string literal type of `"hello"`) to its + * general primitive type, so hover text shows `string` rather than a union + * of every literal argument ever passed to a helper. + */ +function widenType(checker, type) { + return checker.getBaseTypeOfLiteralType(type); +} +/** + * `checker.typeToString(type)`, except for a type whose declaration is + * nested inside a namespace/module (e.g. the vendored dw.* Script API's + * `declare global { module ICustomAttributes { interface Shipment extends + * CustomAttributes {} } }`, the type of `someShipment.custom`): plain + * typeToString() prints only the innermost declaration name, which for that + * pattern is the exact same string as the *unrelated* top-level `class + * Shipment` — someone hovering `shipment.custom` right after hovering + * `shipment` itself would see the identical "Shipment" both times, one of + * them silently wrong. `checker.getFullyQualifiedName()` distinguishes them + * ("Shipment" vs "global.ICustomAttributes.Shipment"); the "global." prefix + * (from the `declare global` wrapper, an implementation detail of how these + * types are vendored) is stripped as noise. + * + * Left alone for everything else, notably a generic instantiation + * (`Product`): getFullyQualifiedName() only ever names the class itself + * ("Product"), never its type arguments, so comparing against typeToString() + * directly would wrongly "correct" `Product` down to plain `Product`. + * Comparing against the symbol's own bare name sidesteps that — a + * non-nested symbol's qualified name always equals its own name, so the + * generic-instantiation display is left untouched. + */ +function computeTypeDisplayString(checker, type) { + let simple = checker.typeToString(type); + // Ambient usage-matching indexes generic Script API classes via their + // unsubstituted declared type (`Product`). With no call-site + // instantiation to substitute from, surface the conventional SFCC form + // `Product` instead of a dangling type-parameter name. Only rewrite + // single-letter param slots (`T`, `T, U`) — never real arguments like + // `Product`. + simple = simple.replace(/<([A-Z](?:\s*,\s*[A-Z])*)>/g, (_match, inner) => { + const params = inner.split(/\s*,\s*/); + return `<${params.map(() => 'any').join(', ')}>`; + }); + const symbol = type.getSymbol(); + if (!symbol) + return simple; + const qualified = checker.getFullyQualifiedName(symbol).replace(/^global\./, ''); + return qualified === symbol.getName() ? simple : qualified; +} +/** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ +function typeDisplayString(ctx, type) { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) + return cached; + const str = computeTypeDisplayString(ctx.checker, type); + ctx.typeDisplayStrings.set(type, str); + return str; +} +/** + * Deduplicates candidate types by their display string. Two distinct types + * that happen to render identically (e.g. same-named classes from different + * modules) collapse into one — acceptable here because every consumer of the + * result is display-oriented (hover text, completion-member names). + */ +function dedupeTypes(ctx, types) { + const seen = new Set(); + const out = []; + for (const t of types) { + const key = typeDisplayString(ctx, t); + if (seen.has(key)) + continue; + seen.add(key); + out.push(t); + } + return out; +} +/** + * Strips any nullable part from `type` and computes its apparent type — the + * shared first step for every place in this file (and `typesToCompletionEntries`) + * that walks a candidate type's members. `getPropertyOfType`/`getPropertiesOfType` + * on a union only return members common to *every* constituent, and + * `null`/`undefined` contribute none, so an un-stripped nullable candidate — + * the common shape of an SFCC getter that can return nothing, e.g. + * `ProductMgr.getProduct(): Product | null` — would otherwise never resolve + * any member. `getApparentType` also picks up a primitive candidate's + * wrapper-object members (.length, .toUpperCase(), etc.), which live there + * rather than on the primitive type's own declared members. + */ +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); +} +/** + * 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 = checker.getPropertyOfType(getNonNullableApparentType(checker, t), memberName); + if (!sym) + return undefined; + const memberType = checker.getTypeOfSymbolAtLocation(sym, location); + for (const sig of memberType.getCallSignatures()) { + return checker.getReturnTypeOfSignature(sig); + } + return undefined; + }; + 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; +} +/** + * Renders candidate types as human-readable hover text, e.g. + * `"Product | Category"`. Dedupes by display string in the same pass that + * renders it — the callers hand in already-deduped candidates, so routing + * through dedupeTypes() here would just stringify everything a second time. + */ +function describeTypes(checker, types) { + const seen = new Set(); + for (const t of types) { + seen.add(computeTypeDisplayString(checker, t)); + } + return [...seen].join(' | '); +} +/** 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({ + name, + // Method vs property determines the completion icon the editor shows. + kind: sym.flags & ts.SymbolFlags.Method + ? ts.ScriptElementKind.memberFunctionElement + : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + // '11' mirrors TS's own internal SortText.LocationPriority — the rank + // ordinary resolved members get — so inferred members sort alongside + // real ones rather than above or below them. + sortText: '11', + source: constants_1.INFERRED_COMPLETION_SOURCE, + }); + } + } + return entries; +} diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js new file mode 100644 index 000000000..d80ebd9b8 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -0,0 +1,396 @@ +"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.collectParameterMemberUsage = collectParameterMemberUsage; +exports.collectParameterInstanceOfTypes = collectParameterInstanceOfTypes; +exports.collectVariableMemberUsage = collectVariableMemberUsage; +exports.matchAmbientTypesByUsage = matchAmbientTypesByUsage; +const constants_1 = require("./constants"); +const type_helpers_1 = require("./type-helpers"); +/** + * Resolves a parameter/variable identifier to the ambient class simple name(s) + * it conventionally denotes: exact case-insensitive match (`customer` → + * `Customer`), curated short aliases (`pli` → `ProductLineItem`), and + * PascalCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → + * `Product`). Returns lowercased names for comparison against candidate + * class names. + */ +function conventionalAmbientNames(identifierName) { + const lower = identifierName.toLowerCase(); + const names = new Set([lower]); + const alias = constants_1.CONVENTIONAL_IDENTIFIER_ALIASES.get(lower); + if (alias) { + names.add(alias.toLowerCase()); + return names; + } + for (const [pascalSuffix, className] of constants_1.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES) { + if (identifierName.length > pascalSuffix.length && identifierName.endsWith(pascalSuffix)) { + names.add(className.toLowerCase()); + break; + } + } + return names; +} +// Keyed by LanguageService, NOT by Program: tsserver hands the plugin a +// brand-new Program object on every edit to a file the project contains — +// including every keystroke in the very file someone is actively typing in. +// The ambient class shape (every dw.* class's member set) never changes for +// the life of a project, so a Program-keyed cache would rebuild this index +// (iterate every source file, call getPropertiesOfType on every dw.* class) +// on nearly every completion request while a cartridge file is being edited. +// On a large real project that rebuild is slow enough to blow past a +// completion request's cancellation budget, so completions would silently +// come back empty far more often than hover (a discrete, non-keystroke-driven +// request) — while the *next* Program, once the edit settles, would pay the +// same cost again. `languageService` is stable for as long as the tsserver +// project itself is open, and — just as importantly for tests — distinct +// per fixture, since each test builds its own LanguageService. +const classIndexCache = new WeakMap(); +/** + * Indexes one top-level class/interface declaration into an ambient-class + * candidate, or `undefined` when it's nameless / has no members. + * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. + * + * Generic classes (chiefly `dw.catalog.Product`) are included: skipping + * them left the most common storefront parameter (`product`) matching only + * non-generic subclasses like `Variant` / `VariationGroup`, which is worse + * than showing the unsubstituted generic. Hover display rewrites `Product` + * → `Product` in {@link computeTypeDisplayString} so the editor never + * surfaces a bare type-parameter name with no instantiation context. + */ +function candidateFromDeclaration(checker, ts, stmt) { + const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + if (!isClassOrInterface || !stmt.name) + return undefined; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (!symbol) + return undefined; + const type = checker.getDeclaredTypeOfSymbol(symbol); + const memberNames = new Set(); + for (const prop of checker.getPropertiesOfType(type)) { + memberNames.add(prop.getName()); + } + if (memberNames.size === 0) + return undefined; + return { type, memberNames, name: stmt.name.text }; +} +/** + * Indexes every top-level class/interface declared in a `.d.ts` file visible + * to the program (the vendored dw.* Script API, plus whatever else a + * project's ambient types pull in) by its full member-name set, so + * {@link matchAmbientTypesByUsage} can look candidates up by shape. + */ +function buildAmbientClassIndex(ctx) { + const cached = classIndexCache.get(ctx.languageService); + if (cached) + return cached; + const { ts, checker } = ctx; + const candidates = []; + for (const sourceFile of ctx.program.getSourceFiles()) { + if (!sourceFile.isDeclarationFile) + continue; + for (const stmt of sourceFile.statements) { + const candidate = candidateFromDeclaration(checker, ts, stmt); + if (candidate) + candidates.push(candidate); + } + } + classIndexCache.set(ctx.languageService, candidates); + return candidates; +} +/** + * How many ambient classes declare `memberName`. Built once per + * matchAmbientTypesByUsage call so distinctiveness scoring stays O(matches × + * signature) rather than re-scanning the whole index per member per match. + */ +function buildMemberFrequency(candidates) { + const freq = new Map(); + for (const candidate of candidates) { + for (const name of candidate.memberNames) { + freq.set(name, (freq.get(name) ?? 0) + 1); + } + } + return freq; +} +/** + * Higher = the usage signature's members are rarer across the ambient index + * (and not just ubiquitous `.custom` / `.UUID` noise). Weak members still + * contribute, but at a steep discount so a distinctive co-member dominates. + */ +function distinctivenessScore(memberNames, frequency) { + let score = 0; + for (const name of memberNames) { + const weight = constants_1.WEAK_USAGE_MEMBERS.has(name) ? 0.15 : 1; + score += weight / Math.max(frequency.get(name) ?? 1, 1); + } + return score; +} +/** + * Collects the names of every member accessed directly on whatever `symbol` + * identifies, anywhere in `scope` (including inside nested closures — a + * `Transaction.wrap(function () {...})` callback still reads/writes an outer + * parameter or variable it closes over). Only direct `x.member` accesses + * count; a chained `x.custom.fromStoreId` only contributes `custom` — the + * deeper hop describes `custom`'s shape, not `x`'s. That one-hop rule is also + * what makes the SFRA `product.custom.foo` / `'foo' in product.custom` idiom + * usable as ExtensibleObject-family evidence without guessing attribute names. + * + * Also counts a `'member' in x` existence check as evidence of `member` — + * a very common real-world SFCC idiom for guarding an optional custom + * attribute or a conditionally-present property (`'appliedPromotions' in + * this`, `'Subsoort' in apiProduct.custom`) before reading it, sometimes + * with no direct property-access read anywhere nearby to otherwise carry the + * signal. + * + * Skips the one property access the current request's own cursor sits + * inside of (see {@link InferenceContext.triggerPosition}) — a dangling + * `shipment.` mid-edit, immediately followed by more code, parses as a + * (nonsensical but syntactically valid) access to whatever identifier comes + * next, and that phantom member name must not count as real usage evidence + * for resolving the very completion being asked for. + */ +function collectMemberUsageInScope(ctx, symbol, scope) { + const { ts, checker, triggerPosition } = ctx; + const members = new Set(); + const visit = (node) => { + if (ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + checker.getSymbolAtLocation(node.expression) === symbol && + !(triggerPosition !== undefined && + node.expression.getEnd() <= triggerPosition && + triggerPosition <= node.name.getStart())) { + members.add(node.name.text); + } + else if (ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + checker.getSymbolAtLocation(node.expression) === symbol && + node.argumentExpression && + ts.isStringLiteralLike(node.argumentExpression)) { + // `lineItem['productID']` / `profile['email']` — same evidence as a dot + // read; common when the member name is computed from a form key. + members.add(node.argumentExpression.text); + } + else if (ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.InKeyword && + ts.isStringLiteralLike(node.left) && + ts.isIdentifier(node.right) && + checker.getSymbolAtLocation(node.right) === symbol) { + members.add(node.left.text); + } + ts.forEachChild(node, visit); + }; + visit(scope); + return members; +} +/** Walks up from `node` to the body of the nearest enclosing function-like declaration, if any. */ +function findEnclosingFunctionBody(node, ts) { + let current = node.parent; + while (current) { + if (ts.isFunctionLike(current)) + return current.body; + current = current.parent; + } + return undefined; +} +/** Collects `param`'s own member-usage signature — see {@link collectMemberUsageInScope}. */ +function collectParameterMemberUsage(ctx, param) { + const { ts, checker } = ctx; + const fn = param.parent; + if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) + return new Set(); + const body = fn.body; + if (!body) + return new Set(); + const symbol = checker.getSymbolAtLocation(param.name); + if (!symbol) + return new Set(); + return collectMemberUsageInScope(ctx, symbol, body); +} +/** + * Resolves the *instance* type tested by an `instanceof` right-hand side + * (`dw.order.ProductLineItem`, a local class binding, …). Prefer a construct + * signature's return type; otherwise the declared type of the RHS symbol. + */ +function instanceTypeFromInstanceOfRhs(ctx, rhs) { + const { checker, ts } = ctx; + const rhsType = checker.getTypeAtLocation(rhs); + for (const sig of rhsType.getConstructSignatures()) { + const instance = checker.getReturnTypeOfSignature(sig); + if (!(0, type_helpers_1.isOpenForUsageInference)(ts, instance)) + return instance; + } + const symbol = rhsType.getSymbol() ?? checker.getSymbolAtLocation(rhs); + if (!symbol) + return undefined; + const declared = checker.getDeclaredTypeOfSymbol(symbol); + if ((0, type_helpers_1.isOpenForUsageInference)(ts, declared)) + return undefined; + return declared; +} +/** + * Collects concrete types asserted via `param instanceof SomeType` in the + * parameter's enclosing function body. Real payment/cart helpers (and common + * calculate.js ports) branch on `lineItem instanceof dw.order.ProductLineItem` + * — JetBrains' JS evaluator narrows from that; without collecting it here, a + * polymorphic `lineItem` parameter stays ambient-ambiguous even when the body + * names the class explicitly. + */ +function collectParameterInstanceOfTypes(ctx, param) { + const { ts, checker } = ctx; + const fn = param.parent; + if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) + return []; + const body = fn.body; + if (!body) + return []; + const symbol = checker.getSymbolAtLocation(param.name); + if (!symbol) + return []; + const types = []; + const visit = (node) => { + if (ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && + ts.isIdentifier(node.left) && + checker.getSymbolAtLocation(node.left) === symbol) { + const instance = instanceTypeFromInstanceOfRhs(ctx, node.right); + if (instance) + types.push(instance); + } + ts.forEachChild(node, visit); + }; + visit(body); + return types; +} +/** + * Collects a local variable's own member-usage signature within its + * enclosing function (or the whole file, for a top-level variable) — the + * counterpart to {@link collectParameterMemberUsage} for the common + * manual-indexing loop shape TS can't type at all on its own: + * `for (var i = 0; i < items.length; i++) { var item = items[i]; ...item.foo }`. + * `items[i]` is `any` (items itself is undocumented), so nothing about + * `item`'s initializer helps — but `item`'s own usage further down does. + */ +function collectVariableMemberUsage(ctx, decl) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(decl.name)) + return new Set(); + const symbol = checker.getSymbolAtLocation(decl.name); + if (!symbol) + return new Set(); + const scope = findEnclosingFunctionBody(decl, ts) ?? decl.getSourceFile(); + return collectMemberUsageInScope(ctx, symbol, scope); +} +/** + * Matches a member-name usage signature against every ambient class the + * program knows about, returning the type(s) of whichever candidate(s) expose + * all of them, most-specific first. + * + * Ranking (after an optional identifier-name short-circuit): + * 1. Highest distinctiveness score — rarer used members win over ubiquitous + * ones like `.custom` / `.UUID` (see {@link WEAK_USAGE_MEMBERS}). + * 2. Fewest total members among that top score tier — the tightest-fitting + * shape, not just any rare-member superset. + * + * Returns `[]` when the signature is too weak to be worth guessing from (see + * MIN_USAGE_SIGNATURE_MEMBERS) or when it still ties across too many + * unrelated candidates to be a useful hint (MAX_USAGE_MATCH_CANDIDATES). + * + * Exception: a signature below MIN_USAGE_SIGNATURE_MEMBERS is still trusted + * when it's globally unambiguous — exactly one ambient class in the whole + * program declares all of these members at all (not just tied for + * "tightest"). A member name that's this rare is as strong a signal as a + * multi-member signature; a signature that's both weak AND ambiguous is what + * MIN_USAGE_SIGNATURE_MEMBERS exists to filter out. + * + * @param identifierName - the parameter/variable's own name, when it's a + * plain identifier (`profile`, `shipment`). Real-world bug: a common field + * subset (`email`/`firstName`/`lastName`/`custom`) is shared by both the + * large `dw.customer.Profile` and the much smaller `dw.customer. + * ProductListRegistrant` — "fewest total members" alone picks the small, + * unrelated class every time purely because it has less surface area, never + * the large, contextually correct one. A variable conventionally named after + * the SFCC class it holds is a stronger, more specific signal than raw + * member count, so a name match short-circuits straight to that candidate + * (ambient class names are unique, so at most one can ever match this way) + * before size/distinctiveness ranking even runs — but only after the + * weak-only silence guard below. A parameter literally named `shipment` + * whose only evidence is `.custom` must still stay silent: the name alone + * must not override weak-only evidence. A single *strong* member plus a + * matching name (`customer` + `.profile`) is trusted, since one-hop usage + * collection often yields just the first property of a longer chain. + */ +function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { + if (memberNames.size === 0) + return []; + const candidates = buildAmbientClassIndex(ctx); + const matches = candidates.filter((candidate) => { + for (const name of memberNames) { + if (!candidate.memberNames.has(name)) + return false; + } + return true; + }); + if (matches.length === 0) + return []; + // A signature made only of ubiquitous members (`.custom` / `.UUID` / …) is + // never discriminative enough when more than one ambient class matches — + // distinctiveness scoring alone can't break the tie usefully because every + // match saw the same weak evidence. Silence rather than guessing the + // smallest ExtensibleObject. This guard MUST run before the identifier-name + // short-circuit: a parameter literally named `shipment` whose only evidence + // is `.custom` must stay silent — the name alone must not override + // "too weak" evidence (see usage-match tests). + const strongCount = [...memberNames].filter((n) => !constants_1.WEAK_USAGE_MEMBERS.has(n)).length; + if (strongCount === 0 && matches.length > 1) + return []; + // A conventionally named parameter (`customer`, `profile`, `shipment`, or + // an SFRA alias like `lineItem` / `pli` → ProductLineItem) that uniquely + // matches one of the ambient candidates short-circuits here — even when + // the usage signature is a single strong member. Real SFRA shape: + // `function getPasswordResetToken(customer) { customer.profile.credentials… }` + // only contributes `.profile` (one-hop member collection), which is shared + // by `dw.customer.Customer` and `dw.svc.ServiceConfig`, but the parameter + // name makes the intended class unambiguous. Weak-only signatures never + // reach this point (guard above). + if (identifierName) { + const conventional = conventionalAmbientNames(identifierName); + const byName = matches.filter((m) => conventional.has(m.name.toLowerCase())); + if (byName.length === 1) + return [byName[0].type]; + // The identifier named a real Script API class (or an SFRA alias of one), + // but that class isn't among the usage matches. A thin signature's + // "globally unique member" hit is then almost certainly a *different* + // class that happens to share one property — e.g. `lineItem.preorderable` + // uniquely matching `ProductInventoryRecord` while the author clearly + // meant a line item. Silence rather than override the naming hint. + if (byName.length === 0 && memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS) { + // `conventional.size > 1` means an alias or PascalCase suffix fired + // (`resettingCustomer` → Customer, `lineItem` → ProductLineItem). + const namedIntentionally = conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); + if (namedIntentionally) + return []; + } + } + // Below-minimum signatures that are still ambiguous (no unique name match) + // stay silent — e.g. an unnamed/`obj` parameter that only touches `.profile`. + if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) + return []; + const frequency = buildMemberFrequency(candidates); + const scored = matches.map((m) => ({ + candidate: m, + score: distinctivenessScore(memberNames, frequency), + })); + const bestScore = Math.max(...scored.map((s) => s.score)); + // Floating-point slack: weights are small rationals; equality is fine in + // practice but a tiny epsilon keeps ranking stable if a future weight isn't. + const topTier = scored.filter((s) => bestScore - s.score < 1e-9).map((s) => s.candidate); + const minSize = Math.min(...topTier.map((m) => m.memberNames.size)); + const tightest = topTier.filter((m) => m.memberNames.size === minSize); + if (tightest.length > constants_1.MAX_USAGE_MATCH_CANDIDATES) + return []; + return tightest.map((m) => m.type); +} 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..cbde0d0a1 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js @@ -0,0 +1,169 @@ +"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; +} +/** + * 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) { + 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 })); + // 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; + indexed.sort((a, b) => { + const ar = rankOf(a.c.name); + const br = rankOf(b.c.name); + if (ar !== br) + return ar - br; + return a.i - b.i; + }); + return indexed.map((x) => x.c); +} +/** + * 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..79b932649 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/constants.js @@ -0,0 +1,54 @@ +"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. +exports.CANDIDATE_EXTENSIONS = ['.js', '.json', '/index.js']; +// 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/module-resolution.js b/packages/b2c-script-types/plugin/resolver/module-resolution.js new file mode 100644 index 000000000..5a0c52cd8 --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/module-resolution.js @@ -0,0 +1,190 @@ +"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.reorderForContainingFile = reorderForContainingFile; +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)]; +} +/** + * 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) { + 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(cartridges, deps.normalize, 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, deps.normalize, 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.rawRoot + subpath; + for (const ext of constants_1.CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + // `subpath` comes straight from the import specifier, so a `..` + // segment (or an absolute/symlinked target) can point outside the + // cartridge — resolve and contain before accepting it. + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, c.root)) { + return { resolved: candidate, source: c.name }; + } + } + } + return 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. + */ +function resolveModulesCartridge(ts, cartridges, moduleName, deps) { + 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 (constants_1.SFRA_AMBIENT_MODULES.has(moduleName)) + return undefined; + const modulesCart = cartridges.find((c) => c.name === 'modules'); + if (!modulesCart) + return undefined; + const baseAbs = modulesCart.rawRoot + moduleName; + for (const ext of constants_1.CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + // `moduleName` may carry `..` after its first segment (it only can't + // *start* with `.`/`/`); contain it against the modules root. + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, modulesCart.root)) { + return { resolved: candidate, source: modulesCart.name }; + } + } + // package.json `main` fallback for directories without an index.js. + const pkgPath = baseAbs + '/package.json'; + if (deps.fileExists(pkgPath) && deps.isWithinRoot(pkgPath, modulesCart.root)) { + const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; + if (typeof main === 'string' && main.length > 0) { + 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. + if (deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root)) { + return { resolved, source: modulesCart.name }; + } + } + } + return undefined; +} 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..04dd7ec7e --- /dev/null +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -0,0 +1,46 @@ +"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.matchAmbientTypesByUsage = exports.collectVariableMemberUsage = exports.collectParameterMemberUsage = exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isOpenForUsageInference = exports.isAnyType = exports.getMemberOfType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = 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. Read the modules in this order to understand the engine: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/call-sites - find where a function is called across the project +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/super-module - module.superModule detection and export scanning +// inference/core - the recursive engine that ties it all together +// inference/usage-match - last-resort ambient-class matching from member usage +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, "getNodeAtPosition", { enumerable: true, get: function () { return ast_helpers_1.getNodeAtPosition; } }); +Object.defineProperty(exports, "findEnclosingPropertyAccess", { enumerable: true, get: function () { return ast_helpers_1.findEnclosingPropertyAccess; } }); +var type_helpers_1 = require("./inference/type-helpers"); +Object.defineProperty(exports, "describeTypes", { enumerable: true, get: function () { return type_helpers_1.describeTypes; } }); +Object.defineProperty(exports, "getMemberOfType", { enumerable: true, get: function () { return type_helpers_1.getMemberOfType; } }); +Object.defineProperty(exports, "isAnyType", { enumerable: true, get: function () { return type_helpers_1.isAnyType; } }); +Object.defineProperty(exports, "isOpenForUsageInference", { enumerable: true, get: function () { return type_helpers_1.isOpenForUsageInference; } }); +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; } }); +Object.defineProperty(exports, "traceSuperModuleAccess", { enumerable: true, get: function () { return super_module_1.traceSuperModuleAccess; } }); +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, "inferTypeForExpression", { enumerable: true, get: function () { return core_1.inferTypeForExpression; } }); +Object.defineProperty(exports, "inferTypeForNode", { enumerable: true, get: function () { return core_1.inferTypeForNode; } }); +var usage_match_1 = require("./inference/usage-match"); +Object.defineProperty(exports, "collectParameterMemberUsage", { enumerable: true, get: function () { return usage_match_1.collectParameterMemberUsage; } }); +Object.defineProperty(exports, "collectVariableMemberUsage", { enumerable: true, get: function () { return usage_match_1.collectVariableMemberUsage; } }); +Object.defineProperty(exports, "matchAmbientTypesByUsage", { enumerable: true, get: function () { return usage_match_1.matchAmbientTypesByUsage; } }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 5ef0c9c82..32bf141ee 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -7,30 +7,64 @@ import path from 'node:path'; import type tsserver from 'typescript/lib/tsserverlibrary'; -interface ConfiguredCartridge { - name: string; - src: string; +import { + collectSuperModuleAugmentedMembers, + traceSuperModuleAccess, + createInferenceContext, + describeTypes, + findEnclosingPropertyAccess, + getMemberOfType, + getNodeAtPosition, + INFERRED_COMPLETION_SOURCE, + inferTypeForExpression, + inferTypeForNode, + isOpenForUsageInference, + typesToCompletionEntries, +} 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 { + createPathContainment, + ownerCartridge as ownerCartridgeImpl, + resolveCartridgeModule as resolveCartridgeModuleImpl, + resolveModulesCartridge as resolveModulesCartridgeImpl, +} from './resolver/module-resolution'; + +// Rich hover data borrowed from a real ambient declaration (e.g. the `custom` +// property on `dw.object.ExtensibleObject`) once usage inference has resolved +// which one an undocumented value's usage matches. Plain data only — see the +// caching note where this is produced for why. +interface HoverInferenceResult { + readonly description: string; + readonly documentation?: readonly tsserver.SymbolDisplayPart[]; + readonly tags?: readonly tsserver.JSDocTagInfo[]; } -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; -} - -interface NormalizedCartridge { - name: string; - /** Forward-slash path with trailing '/'. Lowercased on case-insensitive filesystems. */ - root: string; +/** + * Swaps the trailing `any` keyword part of a QuickInfo's display parts (the + * shape TS renders for an undocumented parameter/property, e.g. `(parameter) + * shipment: any`) for the inferred type's description, so the bolded hover + * header reads `(parameter) shipment: Shipment` instead of `... : any` — + * while leaving everything else (the `(parameter) shipment: ` prefix TS + * already rendered) untouched. Only ever touches a display exactly ending in + * that keyword; any other shape is returned as-is rather than guessed at. + */ +function replaceTrailingAnyDisplayPart( + displayParts: tsserver.SymbolDisplayPart[] | undefined, + description: string, +): tsserver.SymbolDisplayPart[] | undefined { + if (!displayParts || displayParts.length === 0) return displayParts; + const last = displayParts[displayParts.length - 1]; + if (last.kind !== 'keyword' || last.text !== 'any') return displayParts; + return [...displayParts.slice(0, -1), {kind: 'text', text: description}]; } -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 @@ -41,52 +75,18 @@ 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. -const CANDIDATE_EXTENSIONS = ['.js', '.json', '/index.js']; - -// 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; 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; @@ -97,16 +97,21 @@ 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}) => { const n = normalize(src); - return {name, root: n.endsWith('/') ? n : n + '/'}; + const raw = src.replace(/\\/g, '/'); + return { + name, + root: n.endsWith('/') ? n : n + '/', + rawRoot: raw.endsWith('/') ? raw : raw + '/', + }; }); }; @@ -114,6 +119,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) @@ -137,102 +143,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); @@ -247,7 +157,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; }; @@ -260,113 +174,8 @@ 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.root + 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.root + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - if (fileExists(candidate)) { - return {resolved: candidate, source: modulesCart.name}; - } - } - - // 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.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - if (fileExists(resolved)) { - return {resolved, source: modulesCart.name}; - } - } - } - } catch { - // best-effort - } - } - return undefined; - }; - - const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => { - const f = normalize(containingFile); - return cartridges.find((c) => f.startsWith(c.root)); - }; + const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => + ownerCartridgeImpl(cartridges, normalize, containingFile); // 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 @@ -382,31 +191,6 @@ 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}`); @@ -420,8 +204,8 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const projectRoot = info.project.getCurrentDirectory(); if (projectRoot) { try { - const discovered = discoverCartridgesOnDisk(projectRoot); - const configured = readDwJsonCartridges(projectRoot); + const discovered = discoverCartridgesOnDisk(ts, projectRoot, fileExists); + const configured = readDwJsonCartridges(ts, projectRoot, fileExists); const ordered = orderCartridges(discovered, configured); setCartridges(ordered); log( @@ -436,6 +220,56 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const host = info.languageServiceHost; + // 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; + }; + // 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, @@ -461,6 +295,40 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return additions.length > 0 ? [...list, ...additions] : list; }; + // 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}; + } + const cart = resolveCartridgeModuleOnHost(text, containingFile); + if (cart) { + return { + resolvedFileName: cart.resolved, + extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; + } + const mod = resolveModulesCartridgeOnHost(text); + if (mod) { + return { + resolvedFileName: mod.resolved, + extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; + } + return undefined; + }; + const origResolveModuleNameLiterals = host.resolveModuleNameLiterals?.bind(host); if (origResolveModuleNameLiterals) { host.resolveModuleNameLiterals = ( @@ -482,41 +350,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; }); }; } @@ -543,32 +381,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; }); }; } @@ -593,7 +407,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.root + moduleName + '.js', modulesCart.root + 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}}; @@ -620,6 +434,234 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return result?.map(remapDefinition); }; + // Usage-based inference (opt-in, `inferUsage`): when hover/completion hits + // a type the checker has already given up on (`any` — typically an + // undocumented helper function), infer a better answer from call sites + // elsewhere in the project instead of leaving the editor with nothing. + // + // Cached per (file, node position). Entries are finished DISPLAY products + // (the hover note string, the synthesized completion entries) rather than + // checker Type objects: a Type pins its checker and, through it, the whole + // program it came from, so caching types would keep an entire stale + // program graph alive from the last edit until the next inference-eligible + // request — potentially forever if the user stops hovering. Strings and + // plain completion entries retain nothing. + // + // HoverInferenceResult is likewise plain data only: `documentation` and + // `tags` are copied out of a real Symbol's own getDocumentationComment()/ + // getJsDocTags() (SymbolDisplayPart[] / JSDocTagInfo[] are just text — + // they don't reference the Symbol, Type, or Node they came from), never + // the Symbol/Type/Node itself. + // + // The whole cache is invalidated when the language service hands back a + // different Program instance (TS builds a new Program object for any + // semantic change, and reuses the same instance otherwise), rather than + // tracking per-entry validity. Program identity is more precise than the + // previously-used project version string, which also bumps on events that + // don't produce a new program — each such bump needlessly re-ran a full + // inference (measured ~13ms per hover on an SFRA-sized project) that the + // cache should have answered. + let inferenceCacheProgram: tsserver.Program | undefined; + const inferenceCache = new Map(); + // Bounds the cache during a long no-edit session (e.g. hours of hovering + // around at the same program): entries are small (strings / plain entry + // arrays), so this is belt-and-braces, and a wholesale clear is honest — + // no LRU bookkeeping for a cache this cheap to refill. + const MAX_INFERENCE_CACHE_ENTRIES = 512; + const getCachedInference = ( + cacheKey: string, + program: tsserver.Program, + compute: () => T, + ): T => { + if (program !== inferenceCacheProgram) { + inferenceCache.clear(); + inferenceCacheProgram = program; + } + if (inferenceCache.has(cacheKey)) return inferenceCache.get(cacheKey) as T; + const result = compute(); + if (inferenceCache.size >= MAX_INFERENCE_CACHE_ENTRIES) inferenceCache.clear(); + inferenceCache.set(cacheKey, result); + return result; + }; + + // Runs our own inference logic and degrades to `fallback` (the untouched + // underlying result) if it throws, so a bug in this plugin's additions + // can't take the whole tsserver request down with it. Deliberately wraps + // ONLY the inference augmentation, never the underlying language-service + // call itself: an exception from vanilla TS must keep propagating to + // tsserver's own error reporting exactly as it would without this plugin + // installed — swallowing it here would turn a real TS crash into a + // silent "hover stopped working" for every file in the project. + // `ts.OperationCanceledException` is exempted and always rethrown: TS + // throws it cooperatively whenever the host's CancellationToken fires + // (e.g. the user kept typing while this hover or completion request was + // still in flight), which is ordinary, frequent behavior, not a real + // failure — tsserver's request pipeline handles a propagated + // cancellation very differently from a completed-but-empty response, so + // swallowing it here would misreport "cancelled" as "resolved to + // nothing" every time. + const guarded = (label: string, fn: () => T, fallback: T): T => { + try { + return fn(); + } catch (e) { + if (e instanceof ts.OperationCanceledException) throw e; + log(`usage-inference ${label} failed: ${(e as Error).message}`); + return fallback; + } + }; + + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; + return guarded( + 'hover', + () => { + const program = info.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + if (!program || !sourceFile) return original; + const node = getNodeAtPosition(sourceFile, ts, position); + if (!node || !ts.isIdentifier(node)) return original; + const checker = program.getTypeChecker(); + // superModule-derived expressions get past the open-type gate: the + // checker's type for them is garbage either way (any or an opaque + // circular typeof), never something worth leaving untouched. Weak + // placeholder types (`object` / `{}`) are open too — see + // isOpenForUsageInference. + if ( + !isOpenForUsageInference(ts, checker.getTypeAtLocation(node)) && + !traceSuperModuleAccess(ts, checker, node) + ) { + return original; + } + // `undefined` (inference found nothing) is a cached answer too — + // re-deriving "nothing" costs the same reference searches as + // re-deriving something. + const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath, position); + if (!ctx) return undefined; + // Hovering the member name of a property access + // (`shipment.productLineItems`, cursor on `productLineItems`) has + // no declaration of its own to look up — `productLineItems` isn't + // a symbol anywhere until the receiver's type is known. Resolve + // the whole access expression the same way completions do, + // rather than restricting to inferTypeForNode's bare-identifier + // (parameter/variable/function) cases. + const propAccess = findEnclosingPropertyAccess(node, ts); + const isMemberName = !!propAccess && propAccess.name === node; + const types = isMemberName ? inferTypeForExpression(ctx, propAccess) : inferTypeForNode(ctx, node); + if (types.length === 0) return undefined; + const description = describeTypes(checker, types); + // The receiver's type was undocumented, but the *member itself* + // (or the inferred type's own declaration) is real and usually + // documented — borrow its doc comment/tags so hover reads like a + // native, fully-resolved hover instead of just a bare type name. + let symbol: tsserver.Symbol | undefined; + if (isMemberName && propAccess) { + for (const baseType of inferTypeForExpression(ctx, propAccess.expression)) { + symbol = getMemberOfType(checker, baseType, node.text); + if (symbol) break; + } + } else { + symbol = types[0].getSymbol(); + } + const documentation = symbol?.getDocumentationComment(checker); + const tags = symbol?.getJsDocTags(checker); + return { + description, + documentation: documentation && documentation.length > 0 ? documentation : undefined, + tags: tags && tags.length > 0 ? tags : undefined, + }; + }); + if (!inferred) return original; + const note: tsserver.SymbolDisplayPart = { + text: `\n\nInferred from usage: ${inferred.description}`, + kind: 'text', + }; + return { + ...original, + displayParts: replaceTrailingAnyDisplayPart(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags && inferred.tags.length > 0 ? [...inferred.tags] : original.tags, + }; + }, + original, + ); + }; + + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; + return guarded( + 'completions', + () => { + const program = info.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + if (!program || !sourceFile) return original; + const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) return original; + const propAccess = findEnclosingPropertyAccess(node, ts); + if (!propAccess) return original; + const checker = program.getTypeChecker(); + // See the hover gate above for the superModule / weak-type exception. + if ( + !isOpenForUsageInference(ts, checker.getTypeAtLocation(propAccess.expression)) && + !traceSuperModuleAccess(ts, checker, propAccess.expression) + ) { + return original; + } + // The receiver can be any expression, not just a plain identifier: + // `product.getPriceModel().|` needs the chain resolved the same way + // hover-driven return inference already resolves it. + const baseNode = propAccess.expression; + const typeEntries = getCachedInference( + `completions:${fileName}:${baseNode.getStart(sourceFile)}`, + program, + () => { + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath, position); + const types = ctx ? inferTypeForExpression(ctx, baseNode) : []; + return typesToCompletionEntries(ts, checker, types); + }, + ); + // Members added by pass-through superModule overlay levels + // (`module.exports = base; module.exports.extra = fn;`) can't be + // carried by any candidate type — collect them separately. Cheap + // (statement scans only, no reference search), so uncached. + const augmentedCtx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); + const augmentedEntries: tsserver.CompletionEntry[] = ( + augmentedCtx ? collectSuperModuleAugmentedMembers(augmentedCtx, baseNode) : [] + ).map((m) => ({ + name: m.name, + kind: m.isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: INFERRED_COMPLETION_SOURCE, + })); + const inferredEntries = [...typeEntries, ...augmentedEntries]; + if (inferredEntries.length === 0) return original; + // Dedupe against the original entries AND within the inferred set + // (a name can come from both a candidate type and an overlay + // augmentation). + const seenNames = new Set((original?.entries ?? []).map((e) => e.name)); + const merged = [ + ...(original?.entries ?? []), + ...inferredEntries.filter((e) => !seenNames.has(e.name) && (seenNames.add(e.name), true)), + ]; + // Preserve every other field TS set on the original result (isIncomplete, + // optionalReplacementSpan, metadata, defaultCommitCharacters, flags) — + // only entries actually changed. Only synthesize a fresh CompletionInfo + // in the rare case TS returned nothing at all for this position. + if (original) return {...original, entries: merged}; + return { + isGlobalCompletion: false, + isMemberCompletion: true, + isNewIdentifierLocation: false, + entries: merged, + }; + }, + original, + ); + }; + log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } 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..70908f2c9 --- /dev/null +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -0,0 +1,145 @@ +/* + * 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, walking up to an enclosing property access, checking +// whether a parameter/return/variable already has an explicit type, 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). Without that, every call 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 on every one of the up-to-50 reference hits collectCallSites() + * resolves in that file. + */ +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.getStart(sourceFile)) return true; // walked past pos — later siblings can't contain it + if (pos >= node.getEnd()) return undefined; // before pos — keep scanning this sibling list + result = node; + ts.forEachChild(node, visit); + return true; // containing child handled — siblings don't overlap + }; + visit(sourceFile); + return result; +} + +/** Walks up from `node` to the nearest enclosing PropertyAccessExpression, or `undefined` if there isn't one. */ +export function findEnclosingPropertyAccess( + node: tsserver.Node, + ts: typeof tsserver, +): tsserver.PropertyAccessExpression | undefined { + let current: tsserver.Node | undefined = node; + while (current) { + if (ts.isPropertyAccessExpression(current)) return current; + current = current.parent; + } + return undefined; +} + +/** + * SFRA helpers are often "documented" with a placeholder type that carries no + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`. 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. + */ +function isWeakTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): boolean { + let node: tsserver.TypeNode = typeNode; + while (ts.isParenthesizedTypeNode(node)) node = node.type; + + // 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 refName = (() => { + if (ts.isTypeReferenceNode(node)) return node.typeName.getText(); + if (ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression)) { + return node.expression.text; + } + return undefined; + })(); + if (!refName) return false; + const lower = refName.toLowerCase(); + // `Object` / `object` / the SFRA-conventional misspelling `obj`. + return lower === 'object' || lower === 'obj'; +} + +/** 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); +} + +/** + * 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/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts new file mode 100644 index 000000000..39e8dec31 --- /dev/null +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -0,0 +1,225 @@ +/* + * 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, a destructured import, an alias map), so +// this layer follows a bounded number of those indirection hops to reach the +// real call expressions. "Call" includes `new Helper(x)` — SFRA's other very +// common invocation shape, for its constructor-function "class" models. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_REFERENCE_HOPS, MAX_REFERENCES_PER_CALL} from './constants'; +import type {InferenceContext} from './context'; +import {getNodeAtPosition} from './ast-helpers'; + +/** + * A call site is either an ordinary call (`helper(x)`) or a constructor + * invocation (`new Helper(x)`) — SFRA's other very common way to invoke an + * undocumented function, for its "class" models (`new ProductLineItem(...)`, + * `new StoreModel(...)`). Both shapes carry an `arguments` list keyed by the + * same parameter index, so every consumer below treats them uniformly; a + * bare `new Helper` with no parens has `arguments === undefined`, which + * callers must check for since a plain call's `arguments` is never absent. + */ +export type CallSite = tsserver.CallExpression | tsserver.NewExpression; + +/** + * Identifies the name to run findReferences on for a function-like + * declaration that itself has no `name` (the common CommonJS shapes: + * `const foo = function(){}`, `{foo: function(){}}`, `{foo(){}}`, + * `exports.foo = function(){}`, `module.exports = function(){}`). + */ +export function getReferenceNameNode( + fn: tsserver.SignatureDeclaration, + ts: typeof tsserver, +): tsserver.Identifier | undefined { + if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name; + if (ts.isMethodDeclaration(fn) && ts.isIdentifier(fn.name)) return fn.name; + const parent = fn.parent; + if (!parent) return undefined; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name; + if (ts.isPropertyAssignment(parent) && ts.isIdentifier(parent.name)) return parent.name; + if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) { + const left = parent.left; + // `module.exports = function(){}` / `exports.foo = function(){}` — the + // `.name` identifier (`exports` or `foo`) is what findReferences can + // actually track; for the bare `module.exports` case this resolves to + // the whole module's value, so callers reach it via collectCallSites()'s + // require() indirection rather than a direct property-access call. + if (ts.isPropertyAccessExpression(left) && ts.isIdentifier(left.name)) return left.name; + if (ts.isIdentifier(left)) return left; + } + return undefined; +} + +/** + * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing call site if the + * identifier sits in callee/constructor position — one parent up for a + * direct call or `new` expression, two parents up when the identifier is the + * `.name` of a property access. + */ +function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const parent = node.parent; + if (!parent) return undefined; + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if ( + grandparent && + (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && + grandparent.expression === parent + ) { + return grandparent; + } + } + return 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]) + ); +} + +/** + * When a reference to our function's name doesn't sit directly in callee + * position, it may still be one hop away from a real call site through a + * binding indirection: the module specifier of a `require(...)` call whose + * result is assigned to a variable (`var helper = require('./helper')`), or + * a destructuring binding element (`const {helper} = require(...)` or + * `const {helper: local} = someObject`). + * + * @returns Either the further name to search references for, or — for an + * immediately-invoked require (`require('./helper')(x)`) — the call site itself. + */ +function resolveIndirectReferenceTarget( + node: tsserver.Node, + ts: typeof tsserver, +): {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier} | undefined { + const parent = node.parent; + if (!parent) return undefined; + + if (ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts)) { + const requireCall = parent; + const outer = requireCall.parent; + if (outer && ts.isCallExpression(outer) && outer.expression === requireCall) { + return {kind: 'call', call: outer}; // require('./helper')(x) + } + if (outer && ts.isVariableDeclaration(outer) && outer.initializer === requireCall && ts.isIdentifier(outer.name)) { + return {kind: 'name', name: outer.name}; // var helper = require('./helper') + } + return undefined; + } + + if (ts.isBindingElement(parent) && ts.isIdentifier(parent.name)) { + // Covers both `{helper}` (shorthand — name and propertyName are the same + // node) and `{helper: local}` (renamed — redirect to the local binding). + return {kind: 'name', name: parent.name}; + } + + // `module.exports = {getSalePrice: getSalePrice}` — SFRA's canonical export + // shape, an alias map from property name to a separately-declared function. + // A reference search on the *function* name dead-ends at the alias-map + // initializer; the actual consumers (`productHelpers.getSalePrice(x)` in + // another file) are references of the property *name*, so redirect the + // search there. Not scoped to module.exports specifically: any + // `{run: helper}` alias whose property is later called is a genuine call + // site of the aliased function. + if (ts.isPropertyAssignment(parent) && parent.initializer === node && ts.isIdentifier(parent.name)) { + return {kind: 'name', name: parent.name}; + } + + return undefined; +} + +/** + * Finds actual call sites for `nameNode`, following up to + * MAX_REFERENCE_HOPS binding indirections (require() bindings, destructuring) + * when a reference doesn't sit directly in callee position. Stops early once + * ctx.referenceBudget (result count) or ctx.searchBudget (project scans) 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, nameNode: tsserver.Identifier): CallSite[] { + const memoized = ctx.callSiteMemo.get(nameNode); + if (memoized) return memoized; + const calls: CallSite[] = []; + const seenNameKeys = new Set(); + let frontier: tsserver.Identifier[] = [nameNode]; + 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: tsserver.Identifier[] = []; + for (const name of frontier) { + if (localBudget <= 0 || ctx.searchBudget <= 0) break; + const sourceFile = name.getSourceFile(); + const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; + if (seenNameKeys.has(key)) continue; + seenNameKeys.add(key); + localBudget = collectCallsFromName(ctx, name, calls, nextFrontier, localBudget); + } + frontier = nextFrontier; + } + + ctx.callSiteMemo.set(nameNode, calls); + return calls; +} + +/** + * Runs one reference search for `name` and sorts each hit into either a + * resolved call site (pushed to `calls`) or a further name to chase on the + * next hop (pushed to `nextFrontier`) via a single binding indirection. + * Consumes one unit of the shared search budget and 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, + name: tsserver.Identifier, + calls: CallSite[], + nextFrontier: tsserver.Identifier[], + localBudget: number, +): number { + const {ts, languageService, program} = ctx; + const sourceFile = name.getSourceFile(); + ctx.searchBudget--; + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + for (const ref of refs) { + if (localBudget <= 0) break; + localBudget--; + ctx.referenceBudget--; + const refFile = program.getSourceFile(ref.fileName); + if (!refFile) continue; + const node = getNodeAtPosition(refFile, ts, ref.textSpan.start); + if (!node) continue; + // Definition sites (the declaration itself) never sit in callee + // position, so this also naturally excludes them. + const call = findCallInCalleePosition(node, ts); + if (call) { + calls.push(call); + continue; + } + const indirect = resolveIndirectReferenceTarget(node, ts); + if (indirect?.kind === 'call') calls.push(indirect.call); + else if (indirect?.kind === 'name') nextFrontier.push(indirect.name); + } + return localBudget; +} 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..a70e60c4f --- /dev/null +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -0,0 +1,221 @@ +/* + * 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 indirection hops (require() binding -> destructuring -> renamed +// re-export, etc.) collectCallSites() will follow from a reference before +// giving up on finding an actual call site. +export const MAX_REFERENCE_HOPS = 2; + +// 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 +// getReferencesAtPosition call still scans the whole program regardless — on +// a large project the dominant cost is that first search, and the real bound +// on it is TS's own cooperative cancellation (rethrown, never swallowed, by +// the plugin's `guarded` wrapper). +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 getReferencesAtPosition SEARCHES one top-level request +// may issue. This is a different axis from MAX_REFERENCES_PER_REQUEST, which +// only bounds how many search *results* get processed: every search is a full +// project scan 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 scans ≈ 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 scans per request. +export const MAX_SEARCHES_PER_REQUEST = 12; + +// 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'; + +// Last-resort fallback when call-site/return-expression inference (the whole +// rest of the engine) comes up empty: match the member names a parameter is +// actually accessed by (`shipment.custom`, `shipment.productLineItems`, ...) +// against every ambient class/interface visible in the program, and accept +// the most specific one(s) that expose all of them. A single accessed member +// name (e.g. just `.custom`) is carried by dozens of unrelated business +// objects, so it's too weak a signal on its own to guess from — UNLESS that +// single member happens to be globally unique across every ambient class +// (e.g. `.addresses`, which only `dw.customer.AddressBook` declares), in +// which case there's no ambiguity to be weak about. See +// matchAmbientTypesByUsage's unambiguous-single-member exception. +export const MIN_USAGE_SIGNATURE_MEMBERS = 2; + +// If the member-name signature still ties across more candidates than this +// after ranking by specificity (distinctiveness, then fewest total members), +// the match is too ambiguous to be a useful hint — silence beats a wall of +// unrelated candidates in the hover text. +export const MAX_USAGE_MATCH_CANDIDATES = 5; + +// When call-site arguments don't converge on a single distinct type, silence +// rather than union a noisy hover like `Product | Order`. A two-type union is +// already usually wrong for any given call site; ambient usage-matching is +// also skipped in that case — conflicting evidence is not "no call sites". +export const MAX_CALL_SITE_CANDIDATES = 1; + +// Member names so common across dw.* that they barely discriminate a class +// on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). +// They still count as usage evidence for matching, but contribute far less +// to the distinctiveness score used to rank ambient candidates. +export const WEAK_USAGE_MEMBERS: ReadonlySet = new Set(['custom', 'UUID', 'toString', 'valueOf']); + +// Callee names whose callbacks lead with the collection element +// (`collections.forEach(coll, function (item) {...})`). Only these get the +// sibling-collection element-type heuristic; `reduce` (accumulator first) +// and 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 / line-item lookups (a storefront cartridge). + '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', +]); + +/** + * SFRA/storefront parameter names that conventionally hold a Script API class + * whose declared name does not equal the identifier (case-insensitive). Used + * by ambient usage-matching's identifier short-circuit — `lineItem` must map + * to `ProductLineItem`, not look for a nonexistent ambient class named + * `LineItem`. Keys are lowercase; values are ambient class simple names. + * + * Keep this list conservative: only aliases that are unambiguous in real + * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs + * OrderAddress vs Store address models). Prefer adding PascalCase suffixes to + * {@link CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES} for `resettingCustomer`-style + * names; this map is for short / all-lowercase tokens (`pli`, `pricemodel`). + */ +export const CONVENTIONAL_IDENTIFIER_ALIASES: ReadonlyMap = new Map([ + ['lineitem', 'ProductLineItem'], + ['pli', 'ProductLineItem'], + ['productlineitem', 'ProductLineItem'], + ['pricemodel', 'ProductPriceModel'], + ['availabilitymodel', 'ProductAvailabilityModel'], + ['shippingaddress', 'OrderAddress'], + ['billingaddress', 'OrderAddress'], + ['paymentinstrument', 'OrderPaymentInstrument'], + ['shippingmethod', 'ShippingMethod'], + ['shippinglineitem', 'ShippingLineItem'], + ['priceadjustment', 'PriceAdjustment'], + ['giftcertificatelineitem', 'GiftCertificateLineItem'], + ['couponlineitem', 'CouponLineItem'], + // Other concrete dw.order line-item subclasses. Without these, the bare + // `LineItem` PascalCase suffix (below) would force an all-lowercase + // `bonusdiscountlineitem` / `productshippinglineitem` to ProductLineItem — + // a wrong guess for a differently-named sibling class (see the matching + // PascalCase suffixes and the *LineItem note there). + ['bonusdiscountlineitem', 'BonusDiscountLineItem'], + ['productshippinglineitem', 'ProductShippingLineItem'], + ['customeraddress', 'CustomerAddress'], + ['orderaddress', 'OrderAddress'], + // High-frequency all-lowercase / compound forms seen across storefronts + // (when authors don't camelCase the class token). + ['currentbasket', 'Basket'], + ['currentcustomer', 'Customer'], + ['currentorder', 'Order'], + ['apiproduct', 'Product'], + ['apiorder', 'Order'], + ['apilineitem', 'ProductLineItem'], +]); + +/** + * Trailing PascalCase class tokens → ambient class simple name. Matched with + * `identifierName.endsWith(pascalSuffix)` (case-sensitive on the original + * identifier) so `resettingCustomer` / `apiProduct` / `currentBasket` resolve + * while all-lowercase noise like `border` / `emailaddress` does not. + * + * Ordered longest-first so `productLineItem` hits ProductLineItem rather than + * Product. Generic `Address` is omitted — too many false friends + * (`emailAddress`, `ipAddress`, store address models). + * + * The *LineItem subclasses (`ProductLineItem`, `BonusDiscountLineItem`, + * `CouponLineItem`, `GiftCertificateLineItem`, `ShippingLineItem`, + * `ProductShippingLineItem`) must ALL precede the bare `LineItem` → + * ProductLineItem fallback, and each longer name must precede any shorter one + * it ends with (`ProductShippingLineItem` before `ShippingLineItem`), because + * the matcher stops at the first `endsWith` hit in array order. Without the + * specific entries, a `bonusDiscountLineItem` / `productShippingLineItem` + * parameter would resolve to the wrong sibling class (ProductLineItem / + * ShippingLineItem) whenever its body only touches members shared through the + * common `LineItem` base — the classic silence-vs-wrong-guess trap. + */ +export const CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES: ReadonlyArray = [ + ['GiftCertificateLineItem', 'GiftCertificateLineItem'], + ['BonusDiscountLineItem', 'BonusDiscountLineItem'], + ['CouponLineItem', 'CouponLineItem'], + ['ProductShippingLineItem', 'ProductShippingLineItem'], + ['ProductLineItem', 'ProductLineItem'], + ['ShippingLineItem', 'ShippingLineItem'], + ['OrderPaymentInstrument', 'OrderPaymentInstrument'], + ['PaymentInstrument', 'OrderPaymentInstrument'], + ['ProductAvailabilityModel', 'ProductAvailabilityModel'], + ['AvailabilityModel', 'ProductAvailabilityModel'], + ['ProductPriceModel', 'ProductPriceModel'], + ['PriceModel', 'ProductPriceModel'], + ['ShippingAddress', 'OrderAddress'], + ['BillingAddress', 'OrderAddress'], + ['CustomerAddress', 'CustomerAddress'], + ['OrderAddress', 'OrderAddress'], + ['ShippingMethod', 'ShippingMethod'], + ['PriceAdjustment', 'PriceAdjustment'], + ['LineItem', 'ProductLineItem'], + ['Customer', 'Customer'], + ['Profile', 'Profile'], + ['Product', 'Product'], + ['Basket', 'Basket'], + ['Shipment', 'Shipment'], + ['Category', 'Category'], + ['Order', 'Order'], + ['Store', 'Store'], + ['Variant', 'Variant'], + ['Money', 'Money'], +]; 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..4dd976ca8 --- /dev/null +++ b/packages/b2c-script-types/src/inference/context.ts @@ -0,0 +1,140 @@ +/* + * 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 type {CallSite} from './call-sites'; +import {MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; + +export 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; + readonly languageService: tsserver.LanguageService; + /** + * 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() every time it issues a getReferencesAtPosition call + * (a full project scan each). 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 node. 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; + /** + * 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 inferReturnType. + */ + cycleHits: number; + /** + * 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. Supplied by the plugin host (which owns the cartridge order); + * without it, `module.superModule` expressions stay uninferred. + */ + readonly resolveSuperModulePath?: (containingFile: string) => string | undefined; + /** + * The hover/completion request's own cursor position, when there is one. + * Exists so usage-based matching (see ./usage-match) can exclude the + * property access the request is itself sitting inside of from its own + * evidence: 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, + resolveSuperModulePath?: (containingFile: string) => string | undefined, + triggerPosition?: number, +): InferenceContext | undefined { + const program = languageService.getProgram(); + if (!program) return undefined; + return { + ts, + program, + checker: program.getTypeChecker(), + languageService, + visiting: new Set(), + memo: new Map(), + referenceBudget: MAX_REFERENCES_PER_REQUEST, + searchBudget: MAX_SEARCHES_PER_REQUEST, + callSiteMemo: new Map(), + typeDisplayStrings: new Map(), + cycleHits: 0, + resolveSuperModulePath, + triggerPosition, + }; +} 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..6c86f38ae --- /dev/null +++ b/packages/b2c-script-types/src/inference/core.ts @@ -0,0 +1,667 @@ +/* + * 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 work out a plausible type from how it is +// used elsewhere: a parameter from its call-site arguments, a function from +// its return expressions, 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; everything they lean on that ISN'T recursive lives in the small +// leaf modules imported below, giving a clean one-way dependency direction. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import { + ELEMENT_FIRST_CALLBACK_CALLEES, + MAX_CALL_SITE_CANDIDATES, + MAX_CHAIN_HOPS, + MAX_INFERENCE_DEPTH, + MAX_SUPERMODULE_HOPS, +} from './constants'; +import type {InferenceContext} from './context'; +import { + collectReturnExpressions, + hasExplicitParameterType, + hasExplicitReturnType, + hasExplicitVariableType, +} from './ast-helpers'; +import {collectCallSites, getReferenceNameNode} from './call-sites'; +import { + collectExportAssignments, + findSuperModuleFile, + isConcreteExportAssignment, + traceSuperModuleAccess, +} from './super-module'; +import { + collectionElementType, + dedupeTypes, + getMemberOfType, + isAnyType, + isOpenForUsageInference, + widenType, +} from './type-helpers'; +import { + collectParameterInstanceOfTypes, + collectParameterMemberUsage, + collectVariableMemberUsage, + matchAmbientTypesByUsage, +} from './usage-match'; + +/** + * 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 + * — the checker's resolved signature. + */ +function resolveCalleeDeclaration( + ctx: InferenceContext, + call: tsserver.CallExpression, +): 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 sig = checker.getResolvedSignature(call); + const sigDecl = sig?.declaration; + if (sigDecl && ts.isFunctionLike(sigDecl)) return sigDecl; + return undefined; +} + +/** + * Chases a local variable's initializer expression — the missing link for 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 reference even though the + * exact same logic written inline resolves fine. + * + * Guarded three ways: an explicit type/JSDoc annotation on the variable means + * its `any` is deliberate (same rule as parameters/returns); the `visiting` + * set breaks initializer cycles (`var a = b; var b = a;`) and records the hit + * in ctx.cycleHits; and the hop is charged to `chainHops` — following a + * variable never crosses a function boundary, so it's an in-expression hop, + * not a recursion-depth step. + * + * Falls back to matching the variable's own usage against ambient classes + * (see {@link collectVariableMemberUsage}) when the initializer itself + * resolves to nothing — the common shape for a manual-indexing loop variable + * (`var item = items[i]`), where `items[i]` stays `any` no matter what since + * `items` itself is undocumented. + */ +function resolveVariableInitializerTypes( + ctx: InferenceContext, + decl: tsserver.VariableDeclaration, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; + if (!decl.initializer || hasExplicitVariableType(decl, ts)) return []; + if (ctx.visiting.has(decl)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(decl); + try { + const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + if (resolved.length > 0) return resolved; + return matchAmbientTypesByUsage( + ctx, + collectVariableMemberUsage(ctx, decl), + ts.isIdentifier(decl.name) ? decl.name.text : undefined, + ); + } finally { + ctx.visiting.delete(decl); + } +} + +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. The checker's + * type for the `module.exports` symbol is used when it's concrete — it + * merges the assigned object with any later `module.exports.name = fn` + * augmentations. For a pass-through overlay (`module.exports = base` where + * base is itself `module.superModule`), the right-hand side is resolved via + * resolveExpressionTypes instead, which recurses naturally another cartridge + * down; members such a pass-through level *adds* can't be merged into these + * candidate types — they're handled separately by + * {@link resolveSuperModuleMemberTypes} and + * {@link collectSuperModuleAugmentedMembers}. + */ +function resolveSuperModuleTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); + if (!superFile) return []; + // Guard against overlay cycles (two cartridges whose modules somehow point + // at each other through a misconfigured cartridge path). + if (ctx.visiting.has(superFile)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(superFile); + try { + const types: tsserver.Type[] = []; + for (const bin of collectExportAssignments(superFile, ts).full) { + const concrete = isConcreteExportAssignment(ctx, bin); + if (concrete) { + types.push(widenType(checker, checker.getTypeAtLocation(bin.left))); + } + // A pass-through assignment (`module.exports = base` where base is + // this level's own module.superModule) needs the RHS recursed even + // when the left-hand type looked concrete: the checker sometimes + // merges this level's augmentations into an opaque `typeof base` type + // that still carries none of the deeper cartridges' members. + if (!concrete || traceSuperModuleAccess(ts, checker, bin.right)) { + types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + } + } + return dedupeTypes(ctx, types); + } finally { + ctx.visiting.delete(superFile); + } +} + +/** + * Walks the superModule chain of the file containing `superAccess`, one + * cartridge level at a time, and resolves `memberName` from the first level + * that provides it as an export augmentation (`module.exports.name = fn`). + * This is the complement to {@link resolveSuperModuleTypes}: members a + * pass-through overlay level *adds* live only in these assignments, not in + * any candidate type. A level whose `module.exports` type is concrete ends + * the walk (matching runtime semantics — a concrete re-assignment replaces + * everything below unless it deliberately carries the base along). + */ +function resolveSuperModuleMemberTypes( + ctx: InferenceContext, + superAccess: tsserver.PropertyAccessExpression, + memberName: string, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + 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 {full, members} = collectExportAssignments(superFile, ts); + const matches = members.filter((m) => m.name === memberName); + if (matches.length > 0) { + const types: tsserver.Type[] = []; + for (const m of matches) { + types.push(...resolveExpressionTypes(ctx, m.expr, depth, chainHops + 1).filter((t) => !isAnyType(ts, t))); + } + return dedupeTypes(ctx, types); + } + // No augmentation at this level: continue downward only through a + // pass-through (`module.exports = `); a concrete export either + // already carries the member (the type-based lookup found it) or + // genuinely replaces the levels below. + const passesThrough = full.some( + (bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined, + ); + if (!passesThrough) return []; + fromFileName = superFile.fileName; + } + return []; +} + +/** + * Infers the type of a callback's first parameter from sibling arguments of + * the call the callback is passed to: `collections.forEach` / `map` / + * `filter` / `every` / `some` / `find` (see {@link ELEMENT_FIRST_CALLBACK_CALLEES}). + * A function expression in argument position has no name to run a reference + * search on, but the collection travelling alongside it names the element + * type. Only the first parameter is mapped (SFRA's collections util passes + * the element first). Unknown callees and `reduce` (accumulator first) are + * left alone — applying the heuristic to an arbitrary helper would guess wrong + * more often than it helps. + */ +function inferCallbackParameterTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + if (paramIndex !== 0) return []; + const call = fn.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.some((arg) => arg === fn)) return []; + const calleeName = ts.isPropertyAccessExpression(call.expression) + ? call.expression.name.text + : ts.isIdentifier(call.expression) + ? call.expression.text + : undefined; + if (!calleeName || !ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) return []; + const types: tsserver.Type[] = []; + for (const arg of call.arguments) { + if (arg === fn) continue; + for (const argType of resolveExpressionTypes(ctx, arg, depth)) { + const element = collectionElementType(ctx, argType, arg); + if (element) types.push(widenType(checker, element)); + } + } + return types; +} + +/** + * Resolves the candidate type(s) of `expr`. If the checker settles on `any` + * and `expr` is itself a call to a function we can analyze, recurses into + * that function's inferred return type(s) instead of accepting the `any`. + * + * @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 return-type inference — chain-hopping never + * crosses a function boundary, so it needs its own bound + * (`MAX_CHAIN_HOPS`) to keep worst-case cost predictable for a very long + * inline method chain. + * @returns An array (rather than a single unioned Type) because the public + * TypeChecker API exposed via tsserverlibrary has no way to synthesize a + * union Type — callers merge candidates for display/completions themselves. + */ +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, + // BEFORE trusting the checker's direct type: 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 wrongly satisfy the not-any short-circuit below. + const superAccessAtRoot = traceSuperModuleAccess(ts, checker, expr); + if (superAccessAtRoot) { + return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); + } + const direct = checker.getTypeAtLocation(expr); + // Prefer a concrete checker type, but keep chasing through placeholder + // shapes (`any` / `object` / `{}`) the same way — SFRA JSDoc often types + // helpers as `{Object}` which checkJs widens to `any`, and an empty `{}` + // annotation is equally useless as a call-site candidate. + if (!isOpenForUsageInference(ts, direct)) return [widenType(checker, direct)]; + if (chainHops >= MAX_CHAIN_HOPS) return []; + // The checker gave up. Dispatch on the kind of expression to a focused + // resolver. Each returns [] when it can't do better, so an unhandled kind + // (or an exhausted branch) falls through to []. + 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); + // SFRA helpers often return through a ternary (`return it.hasNext() ? it.next() + // : null` — the body of `collections.first`) or a parenthesized subexpression. + // Without chasing both branches the whole return collapses to `any` even when + // the collection argument at the call site is fully typed. + if (ts.isConditionalExpression(expr)) { + return dedupeTypes(ctx, [ + ...resolveExpressionTypes(ctx, expr.whenTrue, depth, chainHops + 1), + ...resolveExpressionTypes(ctx, expr.whenFalse, depth, chainHops + 1), + ]); + } + if (ts.isParenthesizedExpression(expr)) { + return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + } + return []; +} + +/** + * Resolves an `any` call expression: first by inferring the callee's own + * return type, then — for a chained call whose receiver is itself + * undocumented (`x.getPriceModel().getPrice()`) — by resolving the receiver's + * type and looking this method up on its real, documented signature(s). + * Returns [] when neither path improves on `any`. + */ +function resolveCallResultTypes( + ctx: InferenceContext, + expr: tsserver.CallExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) return inferred; + } + // resolveCalleeDeclaration can't find a real declaration for a method whose + // receiver base is undocumented (the checker never resolved the method), so + // infer the receiver's type first, then look this method up by name on it. + if (!ts.isPropertyAccessExpression(expr.expression)) return []; + const methodAccess = expr.expression; + const methodName = methodAccess.name.text; + const returnTypes: tsserver.Type[] = []; + const pushSignatureReturns = (methodType: tsserver.Type) => { + for (const sig of methodType.getCallSignatures()) { + const returnType = checker.getReturnTypeOfSignature(sig); + if (!isAnyType(ts, returnType)) { + returnTypes.push(widenType(checker, returnType)); + continue; + } + // The member resolved but its own return type is `any` — the + // superModule case, where the base module's export type carries an + // undocumented function. `any` is never a useful candidate to surface; + // recurse into the function's actual declaration instead, the same + // fallback resolveCalleeDeclaration provides for direct calls. + const sigDecl = sig.declaration; + if (sigDecl && ts.isFunctionLike(sigDecl)) { + returnTypes.push(...inferReturnType(ctx, sigDecl, depth + 1)); + } + } + }; + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { + const methodSymbol = getMemberOfType(checker, receiverType, methodName); + if (!methodSymbol) continue; + pushSignatureReturns(checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name)); + } + if (returnTypes.length === 0) { + // No candidate type carried this method — but if the receiver is (an + // alias of) module.superModule, the method may be an export + // *augmentation* added by a pass-through overlay level, which no + // candidate type can carry. + const superAccess = traceSuperModuleAccess(ts, checker, methodAccess.expression); + if (superAccess) { + for (const memberType of resolveSuperModuleMemberTypes(ctx, superAccess, methodName, depth, chainHops)) { + pushSignatureReturns(memberType); + } + } + } + return returnTypes.length > 0 ? dedupeTypes(ctx, returnTypes) : []; +} + +/** + * Resolves an `any` property access (`x.ID`) whose base is itself + * undocumented: infer the base's type first, then look this specific property + * up on it — or, if the base is a superModule alias, as a pass-through overlay + * augmentation. Returns [] when the property can't be resolved. + */ +function resolvePropertyTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const propName = expr.name.text; + const propTypes: tsserver.Type[] = []; + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { + const propSymbol = getMemberOfType(checker, baseType, propName); + if (!propSymbol) continue; + const propType = checker.getTypeOfSymbolAtLocation(propSymbol, expr); + // An `any`-typed member (e.g. an untyped value in an exports map) is + // never a useful candidate — surfacing "Inferred from usage: any" would + // be worse than staying quiet. + if (!isAnyType(ts, propType)) propTypes.push(widenType(checker, propType)); + } + if (propTypes.length === 0) { + // Mirror of the method-chain fallback: the property may be an export + // augmentation added by a pass-through superModule overlay. + const superAccess = traceSuperModuleAccess(ts, checker, expr.expression); + if (superAccess) { + propTypes.push( + ...resolveSuperModuleMemberTypes(ctx, superAccess, propName, depth, chainHops).map((t) => + widenType(checker, t), + ), + ); + } + } + return propTypes.length > 0 ? dedupeTypes(ctx, propTypes) : []; +} + +/** + * Resolves an `any` identifier by chasing what it refers to: an undocumented + * parameter (infer from its call sites) or a local variable holding an + * intermediate result (chase its initializer, so a chain split across `var` + * statements infers exactly like the inline expression would). Returns [] for + * anything else. + */ +function resolveIdentifierTypes( + ctx: InferenceContext, + expr: tsserver.Identifier, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + if (decl && ts.isParameter(decl)) return inferParameterType(ctx, decl, depth + 1); + if (decl && ts.isVariableDeclaration(decl)) return resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + return []; +} + +/** + * Collects argument types at `paramIndex` across every call/`new` site of + * `nameNode`. A bare `new Helper` (no parens) has `arguments === undefined` + * and contributes nothing. + */ +function collectArgumentTypesFromCallSites( + ctx: InferenceContext, + nameNode: tsserver.Identifier, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const types: tsserver.Type[] = []; + for (const call of collectCallSites(ctx, nameNode)) { + const arg = call.arguments?.[paramIndex]; + if (!arg) continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + return types; +} + +/** + * True when `type` exposes every member name in `memberNames`. Used to drop + * call-site candidates that can't actually support the parameter's own body + * — the classic SFRA duck-typing trap where a Store *model* is passed into a + * helper that also reads CustomerAddress-only fields (`companyName`, + * `postBox`, …). Without this filter the resolvable model wins the hover + * even though the body is not a Store. + */ +function typeSatisfiesMemberUsage( + ctx: InferenceContext, + type: tsserver.Type, + memberNames: ReadonlySet, +): boolean { + if (memberNames.size === 0) return true; + for (const name of memberNames) { + if (!getMemberOfType(ctx.checker, type, name)) return false; + } + return true; +} + +/** + * Turns raw call-site/callback candidates into the final answer for a + * parameter: drop types the body can't use, dedupe, silence conflicting + * top-level unions, otherwise fall back to ambient usage matching when + * nothing resolved. + */ +function finalizeParameterCandidates( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, + types: tsserver.Type[], + depth: number, +): tsserver.Type[] { + const {ts} = ctx; + const usage = collectParameterMemberUsage(ctx, param); + const raw = dedupeTypes(ctx, types); + // Conflicting call-site arguments (e.g. Product at one site, Order at + // another) are not a useful hover — silence rather than a noisy union, + // and do NOT fall through to ambient matching: we already have evidence, + // it just doesn't converge. + // + // Only enforce this at depth 0 (a top-level hover/completion on the + // parameter itself). Recursive callers that chase through a forwarding + // helper still need the full candidate set so return-type inference and + // the typeToString memo baselines keep working; the editor never shows + // those intermediate unions unlabeled. + if (depth === 0 && raw.length > MAX_CALL_SITE_CANDIDATES) return []; + // Keep only call-site types that expose every member the parameter body + // actually touches. A partial resolution (one duck-typed Store model call + // site resolves, an untyped preferredAddress site doesn't) must not surface + // "Store" on a helper whose body also reads address-only fields the model + // never declares. + const result = dedupeTypes( + ctx, + raw.filter((t) => typeSatisfiesMemberUsage(ctx, t, usage)), + ); + if (result.length > 0) return result; + // No usable call-site type (none found, none resolved, or none that fit + // the body). Match how the parameter's own body uses it against the + // program's ambient classes instead. + return matchAmbientTypesByUsage(ctx, usage, ts.isIdentifier(param.name) ? param.name.text : undefined); +} + +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project — a plain call (`helper(x)`) or a + * constructor invocation (`new Helper(x)`, SFRA's other common "class" model + * shape) — since plain un-annotated JS parameters default to `any` with no + * back-inference from call sites. Falls back to matching the parameter's own + * usage (which members it's accessed by) against the program's ambient + * classes when no call site could be found or resolved at all — see + * {@link matchAmbientTypesByUsage}. + * + * @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; + // Check the memo before the depth cap: a result already computed at an + // equal-or-shallower depth is valid regardless of how deep the *current* + // call is — it would be wrong to discard a known-good cached answer just + // because this particular path to it happens to run over budget. + const cached = ctx.memo.get(param); + if (cached && cached.atDepth <= depth) return cached.types; + if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitParameterType(param, ts)) return []; + // Cycle guard: a self-forwarding helper (e.g. `function id(x){return x}` + // called as `id(id(y))`) could otherwise re-enter inference for this same + // parameter before the first call has finished and memoized its result. + if (ctx.visiting.has(param)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(param); + const cycleHitsBefore = ctx.cycleHits; + try { + const fn = param.parent; + if (!ts.isFunctionLike(fn)) return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) return []; + + const nameNode = getReferenceNameNode(fn, ts); + // `instanceof dw.order.ProductLineItem` (and friends) is concrete class + // evidence from the body itself — merge it with call-site candidates so a + // helper that never sees a typed call site still recovers the class the + // author named. finalizeParameterCandidates still silences multi-type + // unions at depth 0, so a polymorphic Adyen-style branch stays quiet. + const types = [ + ...(nameNode + ? collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) + : // No name to search references for — an anonymous callback passed + // directly in argument position. Its element type may still be + // recoverable from the collection argument travelling alongside it. + inferCallbackParameterTypes(ctx, fn, paramIndex, depth)), + ...collectParameterInstanceOfTypes(ctx, param), + ]; + + const result = finalizeParameterCandidates(ctx, param, types, depth); + // Don't memoize a result whose computation hit a cycle guard: it was + // truncated by what happened to be on the *current* call stack, and the + // same node queried later in this request from outside the cycle could + // legitimately resolve more. (Depth-cap truncation, by contrast, IS + // safely memoized — the atDepth field encodes exactly how truncated it + // can be, and reuse is restricted accordingly.) + if (ctx.cycleHits === cycleHitsBefore) { + ctx.memo.set(param, {atDepth: depth, types: result}); + } + return result; + } finally { + ctx.visiting.delete(param); + } +} + +/** + * Infers a function's candidate 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[] { + const {ts} = ctx; + // See inferParameterType for why the memo is checked before the depth cap. + const cached = ctx.memo.get(fn); + if (cached && cached.atDepth <= depth) return cached.types; + if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitReturnType(fn, ts)) return []; + if (ctx.visiting.has(fn)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(fn); + const cycleHitsBefore = ctx.cycleHits; + try { + const types: tsserver.Type[] = []; + for (const expr of collectReturnExpressions(fn, ts)) { + types.push(...resolveExpressionTypes(ctx, expr, depth)); + } + const result = dedupeTypes(ctx, types); + // See inferParameterType for why cycle-truncated results skip the memo. + if (ctx.cycleHits === cycleHitsBefore) { + ctx.memo.set(fn, {atDepth: depth, types: result}); + } + return result; + } finally { + ctx.visiting.delete(fn); + } +} + +/** + * Entry point for both hover and completion wiring: given an identifier + * node, figures out what it's worth inferring a better type for (a parameter + * it's declared as, a variable holding an undocumented call's result, or the + * function it names) and returns candidate type(s), if any. + */ +export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): tsserver.Type[] { + const {ts, checker} = ctx; + if (!ts.isIdentifier(node)) return []; + const sym = checker.getSymbolAtLocation(node); + const decl = sym?.valueDeclaration; + if (!decl) return []; + if (ts.isParameter(decl)) return inferParameterType(ctx, decl); + if (ts.isVariableDeclaration(decl)) { + // Resolve the full initializer expression, not just a direct call's + // callee: `var pm = product.getPriceModel()` (a method call on an + // undocumented parameter) and `var pm = product.priceModel` (a property + // access) both need the same chain-chasing that return-type inference + // already does — resolveVariableInitializerTypes routes through it. + return dedupeTypes(ctx, resolveVariableInitializerTypes(ctx, decl, 0, 0)); + } + if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); + return []; +} + +/** + * Like {@link inferTypeForNode}, but for an arbitrary expression in receiver + * position — the completion case `product.getPriceModel().|`, where the thing + * before the dot is a call or chain rather than a plain identifier, so there's + * no declaration to look up; the expression itself is what gets resolved. + */ +export function inferTypeForExpression(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type[] { + const {ts} = ctx; + if (ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); + return dedupeTypes(ctx, resolveExpressionTypes(ctx, expr, 0)); +} 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..1cf479fe3 --- /dev/null +++ b/packages/b2c-script-types/src/inference/super-module.ts @@ -0,0 +1,170 @@ +/* + * 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. +// They are all "leaf" operations — they never call back into the recursive +// inference engine — so the engine (./core) can depend on them safely without +// creating an import cycle. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_SUPERMODULE_HOPS} from './constants'; +import type {InferenceContext} from './context'; +import {isAnyType} from './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: 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.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). + */ +export function findSuperModuleFile(ctx: InferenceContext, fromFileName: string): tsserver.SourceFile | undefined { + const {program} = ctx; + if (!ctx.resolveSuperModulePath) return undefined; + const superPath = ctx.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;`). + */ +export function collectExportAssignments( + sf: tsserver.SourceFile, + ts: typeof tsserver, +): {full: tsserver.BinaryExpression[]; members: Array<{name: string; expr: tsserver.Expression}>} { + const full: tsserver.BinaryExpression[] = []; + const members: Array<{name: string; expr: tsserver.Expression}> = []; + for (const stmt of sf.statements) { + if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; + const bin = stmt.expression; + if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue; + const left = bin.left; + if (!ts.isPropertyAccessExpression(left)) continue; + const base = left.expression; + if (ts.isIdentifier(base) && base.text === 'module' && left.name.text === 'exports') { + full.push(bin); + } else if (ts.isIdentifier(base) && base.text === 'exports') { + members.push({name: left.name.text, expr: bin.right}); + } else if ( + ts.isPropertyAccessExpression(base) && + ts.isIdentifier(base.expression) && + base.expression.text === 'module' && + base.name.text === 'exports' + ) { + members.push({name: left.name.text, expr: bin.right}); + } + } + 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. + */ +export 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; +} + +/** + * Collects every member the superModule chain reachable from `expr` + * contributes through export augmentations (`module.exports.name = fn`) at + * pass-through levels — the members {@link resolveSuperModuleTypes}'s + * candidate 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(); + const seenFiles = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < MAX_SUPERMODULE_HOPS; hop++) { + const superFile = findSuperModuleFile(ctx, fromFileName); + if (!superFile || seenFiles.has(superFile)) break; + seenFiles.add(superFile); + const {full, members} = collectExportAssignments(superFile, ts); + for (const m of members) { + if (seenNames.has(m.name)) continue; + seenNames.add(m.name); + const type = checker.getTypeAtLocation(m.expr); + out.push({name: m.name, isMethod: type.getCallSignatures().length > 0}); + } + const passesThrough = full.some( + (bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined, + ); + if (!passesThrough) break; + fromFileName = superFile.fileName; + } + return out; +} 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..c7534940f --- /dev/null +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -0,0 +1,232 @@ +/* + * 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`, +// widening literals, de-duplicating candidates by display string, reaching a +// type's real members (stripping nullability), pulling the element type out of +// a collection, and finally turning candidate types into the hover text and +// completion entries the editor shows. These are the "leaf" operations the +// recursive engine in ./core builds on. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {INFERRED_COMPLETION_SOURCE} from './constants'; +import type {InferenceContext} from './context'; + +/** 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 the checker's type is too uninformative to prefer over usage + * inference: `any`, the `object` non-primitive, or an empty `{}` type literal. + * Used as the hover/completion gate and when deciding whether a resolved + * expression type is worth keeping versus chasing further. + * + * 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. + * + * The one named type it *does* treat as open is the global `Object` interface, + * which is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O `Object`, distinct from the lowercase `object` + * non-primitive handled above, and from `{*}`/`{}`/`{obj}` which all widen to + * `any` or an empty type). `Object` carries no Script API information, so a + * value typed as bare `Object` is effectively undocumented — exactly the case + * usage inference exists for. Without this, the hover/completion entry gate + * (see index.ts) would reject every `@param {Object}` helper before inference + * even ran, even though {@link hasExplicitParameterType} already correctly + * classifies that JSDoc as a weak placeholder. 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) return true; + if (symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0) return true; + return false; +} + +/** + * Widens a literal type (e.g. the string literal type of `"hello"`) to its + * general primitive type, so hover text shows `string` rather than a union + * of every literal argument ever passed to a helper. + */ +export function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { + return checker.getBaseTypeOfLiteralType(type); +} + +/** + * `checker.typeToString(type)`, except for a type whose declaration is + * nested inside a namespace/module (e.g. the vendored dw.* Script API's + * `declare global { module ICustomAttributes { interface Shipment extends + * CustomAttributes {} } }`, the type of `someShipment.custom`): plain + * typeToString() prints only the innermost declaration name, which for that + * pattern is the exact same string as the *unrelated* top-level `class + * Shipment` — someone hovering `shipment.custom` right after hovering + * `shipment` itself would see the identical "Shipment" both times, one of + * them silently wrong. `checker.getFullyQualifiedName()` distinguishes them + * ("Shipment" vs "global.ICustomAttributes.Shipment"); the "global." prefix + * (from the `declare global` wrapper, an implementation detail of how these + * types are vendored) is stripped as noise. + * + * Left alone for everything else, notably a generic instantiation + * (`Product`): getFullyQualifiedName() only ever names the class itself + * ("Product"), never its type arguments, so comparing against typeToString() + * directly would wrongly "correct" `Product` down to plain `Product`. + * Comparing against the symbol's own bare name sidesteps that — a + * non-nested symbol's qualified name always equals its own name, so the + * generic-instantiation display is left untouched. + */ +function computeTypeDisplayString(checker: tsserver.TypeChecker, type: tsserver.Type): string { + let simple = checker.typeToString(type); + // Ambient usage-matching indexes generic Script API classes via their + // unsubstituted declared type (`Product`). With no call-site + // instantiation to substitute from, surface the conventional SFCC form + // `Product` instead of a dangling type-parameter name. Only rewrite + // single-letter param slots (`T`, `T, U`) — never real arguments like + // `Product`. + simple = simple.replace(/<([A-Z](?:\s*,\s*[A-Z])*)>/g, (_match, inner: string) => { + const params = inner.split(/\s*,\s*/); + return `<${params.map(() => 'any').join(', ')}>`; + }); + const symbol = type.getSymbol(); + if (!symbol) return simple; + const qualified = checker.getFullyQualifiedName(symbol).replace(/^global\./, ''); + return qualified === symbol.getName() ? simple : qualified; +} + +/** 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 str = computeTypeDisplayString(ctx.checker, type); + ctx.typeDisplayStrings.set(type, str); + return str; +} + +/** + * Deduplicates candidate types by their display string. Two distinct types + * that happen to render identically (e.g. same-named classes from different + * modules) collapse into one — acceptable here because every consumer of the + * result is display-oriented (hover text, completion-member names). + */ +export function dedupeTypes(ctx: InferenceContext, types: tsserver.Type[]): tsserver.Type[] { + const seen = new Set(); + const out: tsserver.Type[] = []; + for (const t of types) { + const key = typeDisplayString(ctx, t); + if (seen.has(key)) continue; + seen.add(key); + out.push(t); + } + return out; +} + +/** + * Strips any nullable part from `type` and computes its apparent type — the + * shared first step for every place in this file (and `typesToCompletionEntries`) + * that walks a candidate type's members. `getPropertyOfType`/`getPropertiesOfType` + * on a union only return members common to *every* constituent, and + * `null`/`undefined` contribute none, so an un-stripped nullable candidate — + * the common shape of an SFCC getter that can return nothing, e.g. + * `ProductMgr.getProduct(): Product | null` — would otherwise never resolve + * any member. `getApparentType` also picks up a primitive candidate's + * wrapper-object members (.length, .toUpperCase(), etc.), which live there + * rather than on the primitive type's own declared members. + */ +export 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); +} + +/** + * 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. + */ +export 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 = checker.getPropertyOfType(getNonNullableApparentType(checker, t), memberName); + if (!sym) return undefined; + const memberType = checker.getTypeOfSymbolAtLocation(sym, location); + for (const sig of memberType.getCallSignatures()) { + return checker.getReturnTypeOfSignature(sig); + } + return undefined; + }; + 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; +} + +/** + * Renders candidate types as human-readable hover text, e.g. + * `"Product | Category"`. Dedupes by display string in the same pass that + * renders it — the callers hand in already-deduped candidates, so routing + * through dedupeTypes() here would just stringify everything a second time. + */ +export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { + const seen = new Set(); + for (const t of types) { + seen.add(computeTypeDisplayString(checker, t)); + } + return [...seen].join(' | '); +} + +/** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ +export function typesToCompletionEntries( + ts: typeof tsserver, + checker: tsserver.TypeChecker, + types: 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({ + name, + // Method vs property determines the completion icon the editor shows. + kind: + sym.flags & ts.SymbolFlags.Method + ? ts.ScriptElementKind.memberFunctionElement + : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + // '11' mirrors TS's own internal SortText.LocationPriority — the rank + // ordinary resolved members get — so inferred members sort alongside + // real ones rather than above or below them. + sortText: '11', + source: INFERRED_COMPLETION_SOURCE, + }); + } + } + return entries; +} diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts new file mode 100644 index 000000000..5b275944d --- /dev/null +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -0,0 +1,426 @@ +/* + * 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 + */ + +// Last-resort fallback for when the rest of the engine (call-site and +// return-expression driven, see ./core) can't find anything: a parameter that +// is never passed a documented value anywhere in the project — e.g. a +// helper only ever called from a Controller via `require(...)`, which the +// reference search can't see through — still gets used *somewhere* in its own +// function body. Scanning which members it's accessed by (`shipment.custom`, +// `shipment.productLineItems`) and matching that shape against every ambient +// class the program knows about (the vendored dw.* Script API, chiefly) can +// recover a plausible type purely from usage, with no call site at all. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import { + CONVENTIONAL_IDENTIFIER_ALIASES, + CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES, + MAX_USAGE_MATCH_CANDIDATES, + MIN_USAGE_SIGNATURE_MEMBERS, + WEAK_USAGE_MEMBERS, +} from './constants'; +import type {InferenceContext} from './context'; +import {isOpenForUsageInference} from './type-helpers'; + +/** + * Resolves a parameter/variable identifier to the ambient class simple name(s) + * it conventionally denotes: exact case-insensitive match (`customer` → + * `Customer`), curated short aliases (`pli` → `ProductLineItem`), and + * PascalCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → + * `Product`). Returns lowercased names for comparison against candidate + * class names. + */ +function conventionalAmbientNames(identifierName: string): ReadonlySet { + const lower = identifierName.toLowerCase(); + const names = new Set([lower]); + const alias = CONVENTIONAL_IDENTIFIER_ALIASES.get(lower); + if (alias) { + names.add(alias.toLowerCase()); + return names; + } + for (const [pascalSuffix, className] of CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES) { + if (identifierName.length > pascalSuffix.length && identifierName.endsWith(pascalSuffix)) { + names.add(className.toLowerCase()); + break; + } + } + return names; +} + +interface AmbientClassCandidate { + readonly type: tsserver.Type; + readonly memberNames: ReadonlySet; + /** The class/interface's own declared name, e.g. `"Profile"` — see the identifier-name tiebreak in {@link matchAmbientTypesByUsage}. */ + readonly name: string; +} + +// Keyed by LanguageService, NOT by Program: tsserver hands the plugin a +// brand-new Program object on every edit to a file the project contains — +// including every keystroke in the very file someone is actively typing in. +// The ambient class shape (every dw.* class's member set) never changes for +// the life of a project, so a Program-keyed cache would rebuild this index +// (iterate every source file, call getPropertiesOfType on every dw.* class) +// on nearly every completion request while a cartridge file is being edited. +// On a large real project that rebuild is slow enough to blow past a +// completion request's cancellation budget, so completions would silently +// come back empty far more often than hover (a discrete, non-keystroke-driven +// request) — while the *next* Program, once the edit settles, would pay the +// same cost again. `languageService` is stable for as long as the tsserver +// project itself is open, and — just as importantly for tests — distinct +// per fixture, since each test builds its own LanguageService. +const classIndexCache = new WeakMap(); + +/** + * Indexes one top-level class/interface declaration into an ambient-class + * candidate, or `undefined` when it's nameless / has no members. + * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. + * + * Generic classes (chiefly `dw.catalog.Product`) are included: skipping + * them left the most common storefront parameter (`product`) matching only + * non-generic subclasses like `Variant` / `VariationGroup`, which is worse + * than showing the unsubstituted generic. Hover display rewrites `Product` + * → `Product` in {@link computeTypeDisplayString} so the editor never + * surfaces a bare type-parameter name with no instantiation context. + */ +function candidateFromDeclaration( + checker: tsserver.TypeChecker, + ts: typeof tsserver, + stmt: tsserver.Statement, +): AmbientClassCandidate | undefined { + const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + if (!isClassOrInterface || !stmt.name) return undefined; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (!symbol) return undefined; + const type = checker.getDeclaredTypeOfSymbol(symbol); + const memberNames = new Set(); + for (const prop of checker.getPropertiesOfType(type)) { + memberNames.add(prop.getName()); + } + if (memberNames.size === 0) return undefined; + return {type, memberNames, name: stmt.name.text}; +} + +/** + * Indexes every top-level class/interface declared in a `.d.ts` file visible + * to the program (the vendored dw.* Script API, plus whatever else a + * project's ambient types pull in) by its full member-name set, so + * {@link matchAmbientTypesByUsage} can look candidates up by shape. + */ +function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] { + const cached = classIndexCache.get(ctx.languageService); + if (cached) return cached; + const {ts, checker} = ctx; + const candidates: AmbientClassCandidate[] = []; + for (const sourceFile of ctx.program.getSourceFiles()) { + if (!sourceFile.isDeclarationFile) continue; + for (const stmt of sourceFile.statements) { + const candidate = candidateFromDeclaration(checker, ts, stmt); + if (candidate) candidates.push(candidate); + } + } + classIndexCache.set(ctx.languageService, candidates); + return candidates; +} + +/** + * How many ambient classes declare `memberName`. Built once per + * matchAmbientTypesByUsage call so distinctiveness scoring stays O(matches × + * signature) rather than re-scanning the whole index per member per match. + */ +function buildMemberFrequency(candidates: readonly AmbientClassCandidate[]): Map { + const freq = new Map(); + for (const candidate of candidates) { + for (const name of candidate.memberNames) { + freq.set(name, (freq.get(name) ?? 0) + 1); + } + } + return freq; +} + +/** + * Higher = the usage signature's members are rarer across the ambient index + * (and not just ubiquitous `.custom` / `.UUID` noise). Weak members still + * contribute, but at a steep discount so a distinctive co-member dominates. + */ +function distinctivenessScore(memberNames: ReadonlySet, frequency: ReadonlyMap): number { + let score = 0; + for (const name of memberNames) { + const weight = WEAK_USAGE_MEMBERS.has(name) ? 0.15 : 1; + score += weight / Math.max(frequency.get(name) ?? 1, 1); + } + return score; +} + +/** + * Collects the names of every member accessed directly on whatever `symbol` + * identifies, anywhere in `scope` (including inside nested closures — a + * `Transaction.wrap(function () {...})` callback still reads/writes an outer + * parameter or variable it closes over). Only direct `x.member` accesses + * count; a chained `x.custom.fromStoreId` only contributes `custom` — the + * deeper hop describes `custom`'s shape, not `x`'s. That one-hop rule is also + * what makes the SFRA `product.custom.foo` / `'foo' in product.custom` idiom + * usable as ExtensibleObject-family evidence without guessing attribute names. + * + * Also counts a `'member' in x` existence check as evidence of `member` — + * a very common real-world SFCC idiom for guarding an optional custom + * attribute or a conditionally-present property (`'appliedPromotions' in + * this`, `'Subsoort' in apiProduct.custom`) before reading it, sometimes + * with no direct property-access read anywhere nearby to otherwise carry the + * signal. + * + * Skips the one property access the current request's own cursor sits + * inside of (see {@link InferenceContext.triggerPosition}) — a dangling + * `shipment.` mid-edit, immediately followed by more code, parses as a + * (nonsensical but syntactically valid) access to whatever identifier comes + * next, and that phantom member name must not count as real usage evidence + * for resolving the very completion being asked for. + */ +function collectMemberUsageInScope(ctx: InferenceContext, symbol: tsserver.Symbol, scope: tsserver.Node): Set { + const {ts, checker, triggerPosition} = ctx; + const members = new Set(); + const visit = (node: tsserver.Node) => { + if ( + ts.isPropertyAccessExpression(node) && + ts.isIdentifier(node.expression) && + checker.getSymbolAtLocation(node.expression) === symbol && + !( + triggerPosition !== undefined && + node.expression.getEnd() <= triggerPosition && + triggerPosition <= node.name.getStart() + ) + ) { + members.add(node.name.text); + } else if ( + ts.isElementAccessExpression(node) && + ts.isIdentifier(node.expression) && + checker.getSymbolAtLocation(node.expression) === symbol && + node.argumentExpression && + ts.isStringLiteralLike(node.argumentExpression) + ) { + // `lineItem['productID']` / `profile['email']` — same evidence as a dot + // read; common when the member name is computed from a form key. + members.add(node.argumentExpression.text); + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.InKeyword && + ts.isStringLiteralLike(node.left) && + ts.isIdentifier(node.right) && + checker.getSymbolAtLocation(node.right) === symbol + ) { + members.add(node.left.text); + } + ts.forEachChild(node, visit); + }; + visit(scope); + return members; +} + +/** Walks up from `node` to the body of the nearest enclosing function-like declaration, if any. */ +function findEnclosingFunctionBody(node: tsserver.Node, ts: typeof tsserver): tsserver.Node | undefined { + let current: tsserver.Node | undefined = node.parent; + while (current) { + if (ts.isFunctionLike(current)) return (current as tsserver.FunctionLikeDeclaration).body; + current = current.parent; + } + return undefined; +} + +/** Collects `param`'s own member-usage signature — see {@link collectMemberUsageInScope}. */ +export function collectParameterMemberUsage(ctx: InferenceContext, param: tsserver.ParameterDeclaration): Set { + const {ts, checker} = ctx; + const fn = param.parent; + if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) return new Set(); + const body = (fn as tsserver.FunctionLikeDeclaration).body; + if (!body) return new Set(); + const symbol = checker.getSymbolAtLocation(param.name); + if (!symbol) return new Set(); + return collectMemberUsageInScope(ctx, symbol, body); +} + +/** + * Resolves the *instance* type tested by an `instanceof` right-hand side + * (`dw.order.ProductLineItem`, a local class binding, …). Prefer a construct + * signature's return type; otherwise the declared type of the RHS symbol. + */ +function instanceTypeFromInstanceOfRhs(ctx: InferenceContext, rhs: tsserver.Expression): tsserver.Type | undefined { + const {checker, ts} = ctx; + const rhsType = checker.getTypeAtLocation(rhs); + for (const sig of rhsType.getConstructSignatures()) { + const instance = checker.getReturnTypeOfSignature(sig); + if (!isOpenForUsageInference(ts, instance)) return instance; + } + const symbol = rhsType.getSymbol() ?? checker.getSymbolAtLocation(rhs); + if (!symbol) return undefined; + const declared = checker.getDeclaredTypeOfSymbol(symbol); + if (isOpenForUsageInference(ts, declared)) return undefined; + return declared; +} + +/** + * Collects concrete types asserted via `param instanceof SomeType` in the + * parameter's enclosing function body. Real payment/cart helpers (and common + * calculate.js ports) branch on `lineItem instanceof dw.order.ProductLineItem` + * — JetBrains' JS evaluator narrows from that; without collecting it here, a + * polymorphic `lineItem` parameter stays ambient-ambiguous even when the body + * names the class explicitly. + */ +export function collectParameterInstanceOfTypes( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, +): tsserver.Type[] { + const {ts, checker} = ctx; + const fn = param.parent; + if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) return []; + const body = (fn as tsserver.FunctionLikeDeclaration).body; + if (!body) return []; + const symbol = checker.getSymbolAtLocation(param.name); + if (!symbol) return []; + const types: tsserver.Type[] = []; + const visit = (node: tsserver.Node) => { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && + ts.isIdentifier(node.left) && + checker.getSymbolAtLocation(node.left) === symbol + ) { + const instance = instanceTypeFromInstanceOfRhs(ctx, node.right); + if (instance) types.push(instance); + } + ts.forEachChild(node, visit); + }; + visit(body); + return types; +} + +/** + * Collects a local variable's own member-usage signature within its + * enclosing function (or the whole file, for a top-level variable) — the + * counterpart to {@link collectParameterMemberUsage} for the common + * manual-indexing loop shape TS can't type at all on its own: + * `for (var i = 0; i < items.length; i++) { var item = items[i]; ...item.foo }`. + * `items[i]` is `any` (items itself is undocumented), so nothing about + * `item`'s initializer helps — but `item`'s own usage further down does. + */ +export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver.VariableDeclaration): Set { + const {ts, checker} = ctx; + if (!ts.isIdentifier(decl.name)) return new Set(); + const symbol = checker.getSymbolAtLocation(decl.name); + if (!symbol) return new Set(); + const scope = findEnclosingFunctionBody(decl, ts) ?? decl.getSourceFile(); + return collectMemberUsageInScope(ctx, symbol, scope); +} + +/** + * Matches a member-name usage signature against every ambient class the + * program knows about, returning the type(s) of whichever candidate(s) expose + * all of them, most-specific first. + * + * Ranking (after an optional identifier-name short-circuit): + * 1. Highest distinctiveness score — rarer used members win over ubiquitous + * ones like `.custom` / `.UUID` (see {@link WEAK_USAGE_MEMBERS}). + * 2. Fewest total members among that top score tier — the tightest-fitting + * shape, not just any rare-member superset. + * + * Returns `[]` when the signature is too weak to be worth guessing from (see + * MIN_USAGE_SIGNATURE_MEMBERS) or when it still ties across too many + * unrelated candidates to be a useful hint (MAX_USAGE_MATCH_CANDIDATES). + * + * Exception: a signature below MIN_USAGE_SIGNATURE_MEMBERS is still trusted + * when it's globally unambiguous — exactly one ambient class in the whole + * program declares all of these members at all (not just tied for + * "tightest"). A member name that's this rare is as strong a signal as a + * multi-member signature; a signature that's both weak AND ambiguous is what + * MIN_USAGE_SIGNATURE_MEMBERS exists to filter out. + * + * @param identifierName - the parameter/variable's own name, when it's a + * plain identifier (`profile`, `shipment`). Real-world bug: a common field + * subset (`email`/`firstName`/`lastName`/`custom`) is shared by both the + * large `dw.customer.Profile` and the much smaller `dw.customer. + * ProductListRegistrant` — "fewest total members" alone picks the small, + * unrelated class every time purely because it has less surface area, never + * the large, contextually correct one. A variable conventionally named after + * the SFCC class it holds is a stronger, more specific signal than raw + * member count, so a name match short-circuits straight to that candidate + * (ambient class names are unique, so at most one can ever match this way) + * before size/distinctiveness ranking even runs — but only after the + * weak-only silence guard below. A parameter literally named `shipment` + * whose only evidence is `.custom` must still stay silent: the name alone + * must not override weak-only evidence. A single *strong* member plus a + * matching name (`customer` + `.profile`) is trusted, since one-hop usage + * collection often yields just the first property of a longer chain. + */ +export function matchAmbientTypesByUsage( + ctx: InferenceContext, + memberNames: ReadonlySet, + identifierName?: string, +): tsserver.Type[] { + if (memberNames.size === 0) return []; + const candidates = buildAmbientClassIndex(ctx); + const matches = candidates.filter((candidate) => { + for (const name of memberNames) { + if (!candidate.memberNames.has(name)) return false; + } + return true; + }); + if (matches.length === 0) return []; + // A signature made only of ubiquitous members (`.custom` / `.UUID` / …) is + // never discriminative enough when more than one ambient class matches — + // distinctiveness scoring alone can't break the tie usefully because every + // match saw the same weak evidence. Silence rather than guessing the + // smallest ExtensibleObject. This guard MUST run before the identifier-name + // short-circuit: a parameter literally named `shipment` whose only evidence + // is `.custom` must stay silent — the name alone must not override + // "too weak" evidence (see usage-match tests). + const strongCount = [...memberNames].filter((n) => !WEAK_USAGE_MEMBERS.has(n)).length; + if (strongCount === 0 && matches.length > 1) return []; + + // A conventionally named parameter (`customer`, `profile`, `shipment`, or + // an SFRA alias like `lineItem` / `pli` → ProductLineItem) that uniquely + // matches one of the ambient candidates short-circuits here — even when + // the usage signature is a single strong member. Real SFRA shape: + // `function getPasswordResetToken(customer) { customer.profile.credentials… }` + // only contributes `.profile` (one-hop member collection), which is shared + // by `dw.customer.Customer` and `dw.svc.ServiceConfig`, but the parameter + // name makes the intended class unambiguous. Weak-only signatures never + // reach this point (guard above). + if (identifierName) { + const conventional = conventionalAmbientNames(identifierName); + const byName = matches.filter((m) => conventional.has(m.name.toLowerCase())); + if (byName.length === 1) return [byName[0].type]; + // The identifier named a real Script API class (or an SFRA alias of one), + // but that class isn't among the usage matches. A thin signature's + // "globally unique member" hit is then almost certainly a *different* + // class that happens to share one property — e.g. `lineItem.preorderable` + // uniquely matching `ProductInventoryRecord` while the author clearly + // meant a line item. Silence rather than override the naming hint. + if (byName.length === 0 && memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS) { + // `conventional.size > 1` means an alias or PascalCase suffix fired + // (`resettingCustomer` → Customer, `lineItem` → ProductLineItem). + const namedIntentionally = + conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); + if (namedIntentionally) return []; + } + } + + // Below-minimum signatures that are still ambiguous (no unique name match) + // stay silent — e.g. an unnamed/`obj` parameter that only touches `.profile`. + if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; + + const frequency = buildMemberFrequency(candidates); + const scored = matches.map((m) => ({ + candidate: m, + score: distinctivenessScore(memberNames, frequency), + })); + const bestScore = Math.max(...scored.map((s) => s.score)); + // Floating-point slack: weights are small rationals; equality is fine in + // practice but a tiny epsilon keeps ranking stable if a future weight isn't. + const topTier = scored.filter((s) => bestScore - s.score < 1e-9).map((s) => s.candidate); + const minSize = Math.min(...topTier.map((m) => m.memberNames.size)); + const tightest = topTier.filter((m) => m.memberNames.size === minSize); + if (tightest.length > MAX_USAGE_MATCH_CANDIDATES) return []; + return tightest.map((m) => m.type); +} 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..e0c0e91f7 --- /dev/null +++ b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts @@ -0,0 +1,171 @@ +/* + * 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; +} + +/** + * 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[] { + 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})); + // 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; + indexed.sort((a, b) => { + const ar = rankOf(a.c.name); + const br = rankOf(b.c.name); + if (ar !== br) return ar - br; + return a.i - b.i; + }); + return indexed.map((x) => x.c); +} + +/** + * 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..c14484970 --- /dev/null +++ b/packages/b2c-script-types/src/resolver/constants.ts @@ -0,0 +1,100 @@ +/* + * 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. +export const CANDIDATE_EXTENSIONS = ['.js', '.json', '/index.js']; + +// 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/module-resolution.ts b/packages/b2c-script-types/src/resolver/module-resolution.ts new file mode 100644 index 000000000..0efa3b420 --- /dev/null +++ b/packages/b2c-script-types/src/resolver/module-resolution.ts @@ -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 + */ + +// 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)); +} + +export 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; +} + +/** + * 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, +): {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(cartridges, deps.normalize, 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, deps.normalize, 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.rawRoot + subpath; + for (const ext of CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + // `subpath` comes straight from the import specifier, so a `..` + // segment (or an absolute/symlinked target) can point outside the + // cartridge — resolve and contain before accepting it. + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, c.root)) { + return {resolved: candidate, source: c.name}; + } + } + } + return 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. + */ +export function resolveModulesCartridge( + ts: typeof tsserver, + cartridges: NormalizedCartridge[], + moduleName: string, + deps: Pick, +): {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.rawRoot + moduleName; + for (const ext of CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + // `moduleName` may carry `..` after its first segment (it only can't + // *start* with `.`/`/`); contain it against the modules root. + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, modulesCart.root)) { + return {resolved: candidate, source: modulesCart.name}; + } + } + + // package.json `main` fallback for directories without an index.js. + const pkgPath = baseAbs + '/package.json'; + if (deps.fileExists(pkgPath) && deps.isWithinRoot(pkgPath, modulesCart.root)) { + const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; + if (typeof main === 'string' && main.length > 0) { + 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. + if (deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root)) { + return {resolved, source: modulesCart.name}; + } + } + } + return undefined; +} 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..77f672807 --- /dev/null +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -0,0 +1,39 @@ +/* + * 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. Read the modules in this order to understand the engine: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/call-sites - find where a function is called across the project +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/super-module - module.superModule detection and export scanning +// inference/core - the recursive engine that ties it all together +// inference/usage-match - last-resort ambient-class matching from member usage + +export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; +export {createInferenceContext} from './inference/context'; +export {getNodeAtPosition, findEnclosingPropertyAccess} from './inference/ast-helpers'; +export { + describeTypes, + getMemberOfType, + isAnyType, + isOpenForUsageInference, + typesToCompletionEntries, +} from './inference/type-helpers'; +export {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './inference/super-module'; +export {inferParameterType, inferReturnType, inferTypeForExpression, inferTypeForNode} from './inference/core'; +export { + collectParameterMemberUsage, + collectVariableMemberUsage, + matchAmbientTypesByUsage, +} from './inference/usage-match'; + +export type {InferenceContext} from './inference/context'; 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..f42053f33 --- /dev/null +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -0,0 +1,443 @@ +[ + { + "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": "order-address-setter-surface", + "source": "storefront checkoutHelpers.escapeAddress / copyAddress", + "description": "OrderAddress inferred from dense setter + getter surface", + "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" + }, + { + "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" + }, + { + "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": "silence-conflicting-call-sites", + "source": "synthetic — conflicting Product vs Order call sites", + "description": "Must stay silent when call-site argument types do not converge", + "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": null + }, + { + "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": "silence-string-or-product-call-sites", + "source": "storefront productHelpers getProductType-style helper", + "description": "Call sites passing string IDs and Product objects must stay silent", + "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": null + }, + { + "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" + } +] 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..1cacdcbe8 --- /dev/null +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -0,0 +1,114 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const { + collectParameterMemberUsage, + createInferenceContext, + describeTypes, + inferParameterType, + inferTypeForNode, +} = require('../../plugin/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)); + const ctx = createInferenceContext(ts, languageService); + 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 = [...collectParameterMemberUsage(ctx, target.node)].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.checker, types)}`, + ); + return; + } + + const described = describeTypes(ctx.checker, types); + assert.ok( + described.includes(corpusCase.expect), + `expected type mentioning ${corpusCase.expect}, got: ${described || '(empty)'}`, + ); + }); + } +}); 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..995723b6d --- /dev/null +++ b/packages/b2c-script-types/test/helpers/assert-inference.js @@ -0,0 +1,115 @@ +'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..4ed7162ef --- /dev/null +++ b/packages/b2c-script-types/test/helpers/cartridge-fixture.js @@ -0,0 +1,113 @@ +'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/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js new file mode 100644 index 000000000..e47d9291a --- /dev/null +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -0,0 +1,70 @@ +/* + * 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'), + 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'), +}; + +/** + * 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/index.security.test.js b/packages/b2c-script-types/test/index.security.test.js new file mode 100644 index 000000000..ac27ce2f9 --- /dev/null +++ b/packages/b2c-script-types/test/index.security.test.js @@ -0,0 +1,216 @@ +/* + * 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 {after, before, describe, it} = require('node:test'); + +const ts = require('typescript'); + +const init = require('../plugin/index'); + +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()); + const {create} = init({typescript: ts}); + create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => tmp, + getProjectVersion: () => '1', + }, + 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..e359aca39 --- /dev/null +++ b/packages/b2c-script-types/test/index.test.js @@ -0,0 +1,854 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const init = require('../plugin/index'); +const {INFERRED_COMPLETION_SOURCE} = require('../plugin/usage-inference'); +const {createFixtureHost, sharedDocumentRegistry} = require('./helpers/fixture-language-service'); +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}; + `, +}; + +// Builds a plugin instance wired against an in-memory LanguageService, using +// only the subset of tsserver's PluginCreateInfo surface the plugin actually +// touches (logger, project version, config, host, language service). +function createPluginProxy(config) { + const {create} = init({typescript: ts}); + const host = createFixtureHost(FIXTURE_FILES); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const info = { + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + config, + }; + return create(info); +} + +// 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 = createPluginProxy({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 = createPluginProxy({ + 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 = createPluginProxy({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 = createPluginProxy({ + 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 = createPluginProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const original = plainProxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); + + const inferProxy = createPluginProxy({ + 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 = createPluginProxy({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 = createPluginProxy({ + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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); + // createFixtureHost's getScriptVersion is a constant '0' — override it + // here so this test can simulate a real edit bumping a file's version. + host.getScriptVersion = (fileName) => String(versions[fileName] ?? 0); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => 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: add a second call site with a different argument + // type, and 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 answer. + // Conflicting call sites must not keep serving the stale Product-shaped + // inference — and must stay silent rather than union a noisy hover. + files['/helper.js'] += '\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'), `expected silence after conflicting edit, 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 = (() => { + const {create} = init({typescript: ts}); + const host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + return create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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('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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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('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 host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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 {create} = init({typescript: ts}); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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); + }); +}); 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..eb4766047 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -0,0 +1,295 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const init = require('../plugin/index'); +const {createInferenceContext, inferParameterType, matchAmbientTypesByUsage} = require('../plugin/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 {realTypesPrelude} = require('./helpers/real-dw-types'); + +describe('usage-inference hardening', () => { + describe('mid-inference cancellation', () => { + it('rethrows OperationCanceledException raised inside getReferencesAtPosition', () => { + 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}; + `, + }; + const base = createFixtureLanguageService(files); + let searches = 0; + const languageService = new Proxy(base, { + get(target, prop, receiver) { + if (prop === 'getReferencesAtPosition') { + return (fileName, position) => { + searches++; + if (searches >= 1) throw new ts.OperationCanceledException(); + return target.getReferencesAtPosition(fileName, position); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + + assert.throws(() => inferParameterType(ctx, fn.parameters[0]), ts.OperationCanceledException); + }); + + 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 {create} = init({typescript: ts}); + const host = createFixtureHost(files); + const base = ts.createLanguageService(host, sharedDocumentRegistry); + let searches = 0; + const languageService = new Proxy(base, { + get(target, prop, receiver) { + if (prop === 'getReferencesAtPosition') { + return (fileName, position) => { + searches++; + if (searches >= 1) throw new ts.OperationCanceledException(); + return target.getReferencesAtPosition(fileName, position); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + config: { + enabled: true, + inferUsage: true, + cartridges: [{name: 'test_cartridge', src: '/'}], + }, + }); + const source = files['/helper.js']; + const paramPos = positionOf(source, 'product)'); + + 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.deepEqual(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.deepEqual(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 {create} = init({typescript: ts}); + 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) => String(versions[f] ?? origGetScriptVersion(f)); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => 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 {create} = init({typescript: ts}); + const host = createFixtureHost(files); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const proxy = create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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..d5fe5c921 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -0,0 +1,621 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, +} = require('../plugin/usage-inference'); +const init = require('../plugin/index'); +const { + createFixtureHost, + createFixtureLanguageService, + findFunctionDeclaration, + sharedDocumentRegistry, +} = require('./helpers/fixture-language-service'); +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 languageService.getReferencesAtPosition — a project-wide scan per +// call — so the STRICT baselines below are deterministic *counters* of how +// many such searches a pathological input may trigger. 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 performs + // one small search cluster per in-cap level. + deepForwardingChainReturns: 0, + deepForwardingChainParam: 6, + // 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, + // 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 built once per LanguageService and + // cached — a second hover/completion request against the same project + // must add zero further getPropertiesOfType calls, not rebuild the index. + ambientClassIndexRebuildOnRepeatedRequest: 0, +}; + +/** + * Wraps a LanguageService so every getReferencesAtPosition call is counted — + * the deterministic cost proxy the baselines assert on. + */ +function withReferenceCounter(languageService) { + let count = 0; + const proxy = new Proxy(languageService, { + get(target, prop) { + if (prop === 'getReferencesAtPosition') { + return (...args) => { + count++; + return target.getReferencesAtPosition(...args); + }; + } + const value = target[prop]; + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return {languageService: proxy, referenceSearches: () => count, reset: () => (count = 0)}; +} + +/** + * Counts calls to `checker.getPropertiesOfType` — the per-candidate cost of + * building the ambient-class index the no-call-site usage-match fallback + * (matchAmbientTypesByUsage) matches against. That index is cached per + * LanguageService (see buildAmbientClassIndex's WeakMap), so a warm cache + * must add zero further calls on a repeated request. + */ +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', () => { + 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + 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.checker, types), '{ ID: string; name: string; }'); + assert.ok( + counter.referenceSearches() <= BASELINE.widelyReferencedHelper, + `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.referenceSearches()}`, + ); + // 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.referenceSearches() >= 1, + 'expected at least one project-wide 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + 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.referenceSearches(), + 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/deep.js'), 'h1'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(counter.referenceSearches(), 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, in both directions', () => { + // 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 = withReferenceCounter(base); + + // 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, counter.languageService); + 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.referenceSearches(), BASELINE.deepForwardingChainReturns); + assert.ok(returnMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(returnMs)}ms`); + + // Parameter direction (hover on f12's x): each in-cap level costs one + // small reference-search cluster, then the depth cap stops the climb. + counter.reset(); + const ctx2 = createInferenceContext(ts, counter.languageService); + const {elapsedMs: paramMs} = timed(() => + inferParameterType(ctx2, findFunctionDeclaration(sourceFile, 'f12').parameters[0]), + ); + assert.ok( + counter.referenceSearches() <= BASELINE.deepForwardingChainParam, + `expected <= ${BASELINE.deepForwardingChainParam} searches from the deep end, got ${counter.referenceSearches()}`, + ); + 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/fanout.js'), 'caller'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.ok(describeTypes(ctx.checker, types).includes('ID'), 'fan-out must still infer the shared type'); + assert.ok( + counter.referenceSearches() <= BASELINE.wideFanOutMemoized, + `expected the request memo to collapse 20 branches into <= ${BASELINE.wideFanOutMemoized} searches, got ${counter.referenceSearches()}`, + ); + 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 = withReferenceCounter(baseLs); + const {create} = init({typescript: ts}); + const proxy = create({ + languageService: counter.languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => '1', + }, + 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.referenceSearches(), + 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + 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.checker, types), '{ ID: string; name: string; }'); + assert.ok( + counter.referenceSearches() <= BASELINE.distinctSubHelperTree, + `expected the search budget to bound project scans at <= ${BASELINE.distinctSubHelperTree}, got ${counter.referenceSearches()}`, + ); + // 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/pick.js'), 'pick'); + + const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); + + assert.equal(describeTypes(ctx.checker, types), '{ ID: string; } | { displayName: string; }'); + assert.ok( + counter.referenceSearches() <= BASELINE.multiParamHelper, + `expected the call-site memo to dedupe sibling-parameter searches to <= ${BASELINE.multiParamHelper}, got ${counter.referenceSearches()}`, + ); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); + + 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 = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/huge.js'), 'hotPrice'); + + const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); + + assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.ok( + counter.referenceSearches() <= BASELINE.hugeGeneratedFile, + `expected <= ${BASELINE.hugeGeneratedFile} searches, got ${counter.referenceSearches()}`, + ); + 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 = withReferenceCounter(baseLs); + const {create} = init({typescript: ts}); + let projectVersion = 1; + const proxy = create({ + languageService: counter.languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => '/', + getProjectVersion: () => 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.referenceSearches(), + 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 up + // through three dedupe levels — each level re-rendered every type before + // the typeToString memo existed (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, N, 'all candidate object-literal types must survive dedupe'); + 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 counter = withReferenceCounter(base); + const ctx = createInferenceContext(ts, counter.languageService); + 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('caches the ambient-class index across repeated hovers on a real dw.* no-call-site parameter (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 ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/addressHelpers.js'), 'getAddressBookAddressByForm'); + const counter = withPropertiesOfTypeCounter(ctx.checker); + + const first = timed(() => inferParameterType(ctx, fn.parameters[0])); + assert.equal(describeTypes(ctx.checker, first.result), 'AddressBook'); + const scansAfterFirstHover = counter.count(); + assert.ok(scansAfterFirstHover > 0, 'expected the cold ambient-class index build to scan at least one candidate'); + assert.ok(first.elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(first.elapsedMs)}ms`); + + // A second hover at the same position (the cursor lingering, or a + // completion request right after the hover) must reuse the cached index + // instead of re-scanning every ambient class's property list. + const second = timed(() => inferParameterType(ctx, fn.parameters[0])); + assert.equal(describeTypes(ctx.checker, second.result), 'AddressBook'); + assert.equal( + counter.count() - scansAfterFirstHover, + BASELINE.ambientClassIndexRebuildOnRepeatedRequest, + `expected the warm ambient-class index to add ${BASELINE.ambientClassIndexRebuildOnRepeatedRequest} getPropertiesOfType calls, got ${counter.count() - scansAfterFirstHover}`, + ); + assert.ok(second.elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(second.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..d58d8fe09 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -0,0 +1,534 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, + inferTypeForNode, + typesToCompletionEntries, +} = require('../plugin/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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, iterTypes), 'Iterator'); + + const ctx2 = createInferenceContext(ts, languageService); + const candidateTypes = inferTypeForNode(ctx2, findIdentifierUse(sourceFile, 'candidate')); + assert.equal(describeTypes(ctx2.checker, 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, 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.checker, 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('stays silent when call sites pass different real dw.* classes (Product vs 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.deepEqual(types, []); + }); + + 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.checker, 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); + + assert.equal(describeTypes(ctx.checker, types), 'ProductInventoryRecord | null'); + }); + + 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.checker, 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..63373ba50 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -0,0 +1,1493 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + findEnclosingPropertyAccess, + getNodeAtPosition, + inferParameterType, + inferReturnType, + inferTypeForNode, + typesToCompletionEntries, +} = require('../plugin/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.checker, 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.checker, types), '{ ID: string; name: string; }'); + }); + + it('stays silent when plain-call and `new` constructor call sites disagree on the argument type', () => { + 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.deepEqual(types, []); + }); + + 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.checker, 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.deepEqual(types, []); + }); + + it('stays silent when call-site argument types conflict (no noisy union)', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(input) { + return input; + } + 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.deepEqual(types, []); + }); + + 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.checker, 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.checker, 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', () => { + function findFunctionExpressionParam(sourceFile) { + let param; + const visit = (node) => { + if (ts.isFunctionExpression(node)) { + param = node.parameters[0]; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return param; + } + + function findMethodDeclarationParam(sourceFile) { + let param; + const visit = (node) => { + if (ts.isMethodDeclaration(node)) { + param = node.parameters[0]; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return param; + } + + 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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 = findMethodDeclarationParam(ctx.program.getSourceFile('/helper.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + }); + }); + + 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. + for (const annotation of ['Object', 'obj', '*', '{}']) { + 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.checker, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + }); + } + + 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, shortTypes), '{ ID: string; name: string; }'); + + const topTypes = inferReturnType(ctx, topFn); + assert.equal(describeTypes(ctx.checker, 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.checker, 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.checker, 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.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, 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, 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.checker, 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, () => 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, (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.checker, 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.checker, 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.checker, 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.checker, 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, (f) => STACK_ORDER[f]); + const top = ctx.program.getSourceFile('/top/x.js'); + + const types = inferTypeForNode(ctx, findVarUse(top, 'memberPrice')); + + assert.equal(describeTypes(ctx.checker, 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, (f) => STACK_ORDER[f]); + const top = ctx.program.getSourceFile('/top/x.js'); + + const types = inferTypeForNode(ctx, findVarUse(top, 'salePrice')); + + assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + }); + + it('lists augmented members from every pass-through level for completions', () => { + const {collectSuperModuleAugmentedMembers} = require('../plugin/usage-inference'); + const languageService = createFixtureLanguageService(STACK_FILES); + const ctx = createInferenceContext(ts, languageService, (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.checker, 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.checker, 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('getNodeAtPosition / findEnclosingPropertyAccess', () => { + it('locates the property access expression enclosing a dotted completion position', () => { + const files = { + '/dotted.js': `var product = {}; product.ID;`, + }; + const languageService = createFixtureLanguageService(files); + const program = languageService.getProgram(); + const sourceFile = program.getSourceFile('/dotted.js'); + // Position of the `.` right after `product` in `product.ID`. + const dotPos = files['/dotted.js'].indexOf('product.ID') + 'product'.length; + + const node = getNodeAtPosition(sourceFile, ts, dotPos - 1); + const propAccess = findEnclosingPropertyAccess(node, ts); + + assert.ok(propAccess); + assert.equal(propAccess.name.text, 'ID'); + }); + }); +}); 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..284623a82 --- /dev/null +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -0,0 +1,717 @@ +/* + * 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 {describe, it} = require('node:test'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferTypeForNode, + matchAmbientTypesByUsage, + collectParameterMemberUsage, +} = require('../plugin/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.checker, types), 'Shipment'); + }); + + it('collectParameterMemberUsage 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 = collectParameterMemberUsage(ctx, fn.parameters[0]); + + 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 short-circuit 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.deepEqual(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 + // short-circuit is allowed. + 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.checker, 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.checker, 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.deepEqual(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.deepEqual(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.checker, 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.checker, 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.checker, 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("collectParameterMemberUsage 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 = collectParameterMemberUsage(ctx, fn.parameters[0]); + + 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.checker, 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 = collectParameterMemberUsage(ctx, fn.parameters[0]); + assert.deepEqual([...members].sort(), ['custom', 'productLineItems']); + + const types = inferParameterType(ctx, fn.parameters[0]); + assert.equal(describeTypes(ctx.checker, 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 = collectParameterMemberUsage(ctx, fn.parameters[0]); + + 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.checker, 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.checker, 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` short-circuits to + // 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.checker, 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.checker, types), 'Profile'); + }); + + it('still returns the smallest-total-members candidate when no candidate name matches the identifier', () => { + // Same ambiguous member signature, different (unrelated) variable + // name — the size-based tiebreak from before this fix must still + // apply exactly as it did, since there's no name match to prefer. + // 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.checker, types), 'ProductListRegistrant'); + }); + + 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.deepEqual(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 short-circuit to 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, 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.checker, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + }); + + it('stays silent when the body instanceof-checks multiple unrelated classes', () => { + 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(inferParameterType(ctx, fn.parameters[0]).length, 0); + }); + }); +}); diff --git a/packages/b2c-vs-extension/.vscode-test.mjs b/packages/b2c-vs-extension/.vscode-test.mjs index f6c76c9be..9e4aa1ff3 100644 --- a/packages/b2c-vs-extension/.vscode-test.mjs +++ b/packages/b2c-vs-extension/.vscode-test.mjs @@ -49,6 +49,25 @@ 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: ['--user-data-dir', shortUserDataDir('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 edc32e866..7891aa9e8 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": { "typescriptServerPlugins": [ { @@ -268,6 +273,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.telemetry.enabled": { "type": "boolean", "default": true, 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..453b09de0 --- /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/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..6031ffcfd --- /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/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..a4a60fb49 --- /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..35e065728 --- /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/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..f67c73335 --- /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/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..67f6599b7 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js @@ -0,0 +1,24 @@ +'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; +} + +module.exports = { + forEach: forEach, + first: first +}; 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..52354a56b --- /dev/null +++ b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts @@ -0,0 +1,797 @@ +/* + * 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}`, + ); + }); +});