From 73006c46bf7579dd1171c5baff697bbc62a854f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 10:35:46 +0000 Subject: [PATCH 01/43] Infer types for undocumented helper functions in Script API IntelliSense Undocumented helper functions widen to `any`, silencing hover/completion for every caller downstream. Adds an opt-in, heuristic usage-based inference engine to the b2c-script-types tsserver plugin that infers plausible types from call-site arguments and return statements, chasing through undocumented call chains, and surfaces the result as a labeled hover note and synthesized member completions. Off by default via the new b2c-dx.features.scriptTypesInferUsage setting. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- .changeset/script-types-infer-usage.md | 6 + docs/guide/ide-integration.md | 13 + packages/b2c-script-types/README.md | 15 +- packages/b2c-script-types/eslint.config.mjs | 9 + packages/b2c-script-types/package.json | 7 +- packages/b2c-script-types/plugin/index.js | 94 ++++++ .../plugin/usage-inference.js | 275 +++++++++++++++++ packages/b2c-script-types/src/index.ts | 98 ++++++ .../b2c-script-types/src/usage-inference.ts | 284 ++++++++++++++++++ .../test/helpers/fixture-language-service.js | 49 +++ packages/b2c-script-types/test/index.test.js | 102 +++++++ .../test/usage-inference.test.js | 268 +++++++++++++++++ packages/b2c-vs-extension/package.json | 5 + .../src/script-types/index.ts | 14 +- 14 files changed, 1234 insertions(+), 5 deletions(-) create mode 100644 .changeset/script-types-infer-usage.md create mode 100644 packages/b2c-script-types/plugin/usage-inference.js create mode 100644 packages/b2c-script-types/src/usage-inference.ts create mode 100644 packages/b2c-script-types/test/helpers/fixture-language-service.js create mode 100644 packages/b2c-script-types/test/index.test.js create mode 100644 packages/b2c-script-types/test/usage-inference.test.js diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md new file mode 100644 index 000000000..109dc2092 --- /dev/null +++ b/.changeset/script-types-infer-usage.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': minor +'b2c-vs-extension': minor +--- + +Script API IntelliSense can now infer types for undocumented 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. diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 308a8ae8a..4f7cf18cd 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -113,6 +113,19 @@ 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 because TypeScript reads the `@param`/`@returns` annotations directly. Plain, undocumented helper functions don't — an unannotated parameter or return value gets widened to `any`, and that `any` propagates to every caller, silencing completion for anything built on top of it. + +Enable **`b2c-dx.features.scriptTypesInferUsage`** (VS Code extension setting, default off) 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) rather than stopping at the first `any`. + +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. + +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. + ### 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..c717c0729 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -56,4 +56,17 @@ 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) — 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..e6ba7cf3f 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -28,7 +28,12 @@ "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", + "pretest": "pnpm run build", + "test": "node --test", + "test:agent": "node --test --test-reporter=dot", + "test:unit": "node --test", + "test:watch": "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..522bfabf6 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -8,6 +8,7 @@ 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 usage_inference_1 = require("./usage-inference"); const PLUGIN_NAME = '@salesforce/b2c-script-types'; const TYPES_DIR = node_path_1.default.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, @@ -60,6 +61,7 @@ function init({ typescript: ts }) { 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; @@ -84,6 +86,7 @@ function init({ typescript: ts }) { const c = (config ?? {}); enabled = c.enabled !== false; autoDiscoverEnabled = c.autoDiscover !== false; + inferUsageEnabled = c.inferUsage === true; // Only touch the cartridge list if the host explicitly provided one. // This lets onConfigurationChanged() update flags (enabled, autoDiscover) // without wiping a previously auto-discovered list. @@ -564,6 +567,97 @@ 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), invalidated on project version change + // so cost is bounded to "recompute only what's under the cursor, only + // when the program actually changed" rather than a whole-program scan. + const inferenceCache = new Map(); + const getCachedInference = (cacheKey, compute) => { + const projectVersion = info.project.getProjectVersion(); + const cached = inferenceCache.get(cacheKey); + if (cached && cached.projectVersion === projectVersion) + return cached.types; + const types = compute(); + inferenceCache.set(cacheKey, { projectVersion, types }); + return types; + }; + proxy.getQuickInfoAtPosition = (fileName, position) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position); + if (!inferUsageEnabled || !original) + return original; + try { + 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(); + if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node))) + return original; + const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService); + return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; + }); + if (types.length === 0) + return original; + const note = { + text: `\n\nInferred from usage: ${(0, usage_inference_1.describeTypes)(checker, types)}`, + kind: 'text', + }; + return { ...original, documentation: [...(original.documentation ?? []), note] }; + } + catch (e) { + log(`inferUsage hover failed: ${e.message}`); + return original; + } + }; + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + if (!inferUsageEnabled) + return original; + try { + 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 || !ts.isIdentifier(propAccess.expression)) + return original; + const checker = program.getTypeChecker(); + if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(propAccess.expression))) + return original; + const baseNode = propAccess.expression; + const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService); + return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, baseNode) : []; + }); + if (types.length === 0) + return original; + const inferredEntries = (0, usage_inference_1.typesToCompletionEntries)(ts, checker, types); + if (inferredEntries.length === 0) + return original; + const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); + const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + return { + isGlobalCompletion: original?.isGlobalCompletion ?? false, + isMemberCompletion: true, + isNewIdentifierLocation: original?.isNewIdentifierLocation ?? false, + entries: merged, + }; + } + catch (e) { + log(`inferUsage completions failed: ${e.message}`); + return original; + } + }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } 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..f9287a440 --- /dev/null +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -0,0 +1,275 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.INFERRED_COMPLETION_SOURCE = void 0; +exports.createInferenceContext = createInferenceContext; +exports.isAnyType = isAnyType; +exports.getNodeAtPosition = getNodeAtPosition; +exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; +exports.inferParameterType = inferParameterType; +exports.inferReturnType = inferReturnType; +exports.inferTypeForNode = inferTypeForNode; +exports.describeTypes = describeTypes; +exports.typesToCompletionEntries = typesToCompletionEntries; +// Bounds 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. +const MAX_INFERENCE_DEPTH = 3; +exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; +function createInferenceContext(ts, languageService) { + const program = languageService.getProgram(); + if (!program) + return undefined; + return { ts, program, checker: program.getTypeChecker(), languageService, visiting: new Set() }; +} +function isAnyType(ts, type) { + return (type.flags & ts.TypeFlags.Any) !== 0; +} +// 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. +function getNodeAtPosition(sourceFile, ts, pos) { + let result; + const visit = (node) => { + if (pos >= node.getStart(sourceFile) && pos < node.getEnd()) { + result = node; + ts.forEachChild(node, visit); + } + }; + visit(sourceFile); + return result; +} +function findEnclosingPropertyAccess(node, ts) { + let current = node; + while (current) { + if (ts.isPropertyAccessExpression(current)) + return current; + current = current.parent; + } + return undefined; +} +// 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(){}}`, `exports.foo = function(){}`). +function getReferenceNameNode(fn, ts) { + if (ts.isFunctionDeclaration(fn) && 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; + 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 either `helper(x)` or +// `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if +// the identifier sits in callee position — one parent up for a direct call, +// 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) && parent.expression === node) + return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if (grandparent && ts.isCallExpression(grandparent) && grandparent.expression === parent) + return grandparent; + } + return undefined; +} +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; +} +function dedupeTypes(checker, types) { + const seen = new Set(); + const out = []; + for (const t of types) { + const key = checker.typeToString(t); + if (seen.has(key)) + continue; + seen.add(key); + out.push(t); + } + return out; +} +// 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`. +// 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) { + const { ts, checker } = ctx; + const direct = checker.getTypeAtLocation(expr); + if (!isAnyType(ts, direct)) + return [direct]; + if (ts.isCallExpression(expr)) { + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) + return inferred; + } + } + else if (ts.isIdentifier(expr)) { + // `expr` is itself an undocumented parameter reference (e.g. a helper + // that just returns/forwards one of its own params) — chase that + // parameter's inferred type too, rather than stopping at `any`. + const sym = checker.getSymbolAtLocation(expr); + const decl = sym?.valueDeclaration; + if (decl && ts.isParameter(decl)) { + const inferred = inferParameterType(ctx, decl, depth + 1); + if (inferred.length > 0) + return inferred; + } + } + return []; +} +// Infers a parameter's candidate type(s) from the arguments it's actually +// called with across the project, since plain un-annotated JS parameters +// default to `any` with no back-inference from call sites. +function inferParameterType(ctx, param, depth = 0) { + const { ts, languageService, program, checker } = ctx; + if (depth > MAX_INFERENCE_DEPTH) + return []; + const fn = param.parent; + if (!ts.isFunctionLike(fn)) + return []; + const nameNode = getReferenceNameNode(fn, ts); + if (!nameNode) + return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) + return []; + const sourceFile = nameNode.getSourceFile(); + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, nameNode.getStart(sourceFile)) ?? []; + const types = []; + for (const ref of refs) { + 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) + continue; + const arg = call.arguments[paramIndex]; + if (!arg) + continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + return dedupeTypes(checker, types); +} +// 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; +} +// 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`. +function inferReturnType(ctx, fn, depth = 0) { + const { ts, checker } = ctx; + if (depth > MAX_INFERENCE_DEPTH) + return []; + if (ctx.visiting.has(fn)) + return []; + ctx.visiting.add(fn); + try { + const types = []; + for (const expr of collectReturnExpressions(fn, ts)) { + types.push(...resolveExpressionTypes(ctx, expr, depth)); + } + return dedupeTypes(checker, types); + } + 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) && decl.initializer && ts.isCallExpression(decl.initializer)) { + const calleeFn = resolveCalleeDeclaration(ctx, decl.initializer); + if (calleeFn) + return inferReturnType(ctx, calleeFn); + } + if (ts.isFunctionLike(decl)) + return inferReturnType(ctx, decl); + return []; +} +function describeTypes(checker, types) { + return dedupeTypes(checker, types) + .map((t) => checker.typeToString(t)) + .join(' | '); +} +function typesToCompletionEntries(ts, checker, types) { + const seen = new Set(); + const entries = []; + for (const type of types) { + for (const sym of checker.getPropertiesOfType(type)) { + const name = sym.getName(); + if (seen.has(name)) + continue; + seen.add(name); + entries.push({ + name, + kind: ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: exports.INFERRED_COMPLETION_SOURCE, + }); + } + } + return entries; +} diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 5ef0c9c82..dd582f13d 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -7,6 +7,16 @@ import path from 'node:path'; import type tsserver from 'typescript/lib/tsserverlibrary'; +import { + createInferenceContext, + describeTypes, + findEnclosingPropertyAccess, + getNodeAtPosition, + inferTypeForNode, + isAnyType, + typesToCompletionEntries, +} from './usage-inference'; + interface ConfiguredCartridge { name: string; src: string; @@ -22,6 +32,13 @@ interface PluginConfig { * 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; } interface NormalizedCartridge { @@ -87,6 +104,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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; @@ -114,6 +132,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) @@ -620,6 +639,85 @@ 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), invalidated on project version change + // so cost is bounded to "recompute only what's under the cursor, only + // when the program actually changed" rather than a whole-program scan. + const inferenceCache = new Map(); + const getCachedInference = (cacheKey: string, compute: () => tsserver.Type[]): tsserver.Type[] => { + const projectVersion = info.project.getProjectVersion(); + const cached = inferenceCache.get(cacheKey); + if (cached && cached.projectVersion === projectVersion) return cached.types; + const types = compute(); + inferenceCache.set(cacheKey, {projectVersion, types}); + return types; + }; + + proxy.getQuickInfoAtPosition = (fileName, position) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position); + if (!inferUsageEnabled || !original) return original; + try { + 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(); + if (!isAnyType(ts, checker.getTypeAtLocation(node))) return original; + const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { + const ctx = createInferenceContext(ts, info.languageService); + return ctx ? inferTypeForNode(ctx, node) : []; + }); + if (types.length === 0) return original; + const note: tsserver.SymbolDisplayPart = { + text: `\n\nInferred from usage: ${describeTypes(checker, types)}`, + kind: 'text', + }; + return {...original, documentation: [...(original.documentation ?? []), note]}; + } catch (e) { + log(`inferUsage hover failed: ${(e as Error).message}`); + return original; + } + }; + + proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + if (!inferUsageEnabled) return original; + try { + 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 || !ts.isIdentifier(propAccess.expression)) return original; + const checker = program.getTypeChecker(); + if (!isAnyType(ts, checker.getTypeAtLocation(propAccess.expression))) return original; + const baseNode = propAccess.expression; + const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { + const ctx = createInferenceContext(ts, info.languageService); + return ctx ? inferTypeForNode(ctx, baseNode) : []; + }); + if (types.length === 0) return original; + const inferredEntries = typesToCompletionEntries(ts, checker, types); + if (inferredEntries.length === 0) return original; + const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); + const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + return { + isGlobalCompletion: original?.isGlobalCompletion ?? false, + isMemberCompletion: true, + isNewIdentifierLocation: original?.isNewIdentifierLocation ?? false, + entries: merged, + }; + } catch (e) { + log(`inferUsage completions failed: ${(e as Error).message}`); + return original; + } + }; + log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } 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..343c95fac --- /dev/null +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -0,0 +1,284 @@ +/* + * 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 type tsserver from 'typescript/lib/tsserverlibrary'; + +// Bounds 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. +const MAX_INFERENCE_DEPTH = 3; + +export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; + +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; +} + +export function createInferenceContext( + ts: typeof tsserver, + languageService: tsserver.LanguageService, +): InferenceContext | undefined { + const program = languageService.getProgram(); + if (!program) return undefined; + return {ts, program, checker: program.getTypeChecker(), languageService, visiting: new Set()}; +} + +export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { + return (type.flags & ts.TypeFlags.Any) !== 0; +} + +// 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. +export function getNodeAtPosition( + sourceFile: tsserver.SourceFile, + ts: typeof tsserver, + pos: number, +): tsserver.Node | undefined { + let result: tsserver.Node | undefined; + const visit = (node: tsserver.Node) => { + if (pos >= node.getStart(sourceFile) && pos < node.getEnd()) { + result = node; + ts.forEachChild(node, visit); + } + }; + visit(sourceFile); + return result; +} + +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; +} + +// 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(){}}`, `exports.foo = function(){}`). +function getReferenceNameNode(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Identifier | undefined { + if (ts.isFunctionDeclaration(fn) && 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; + 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 either `helper(x)` or +// `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if +// the identifier sits in callee position — one parent up for a direct call, +// two parents up when the identifier is the `.name` of a property access. +function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { + const parent = node.parent; + if (!parent) return undefined; + if (ts.isCallExpression(parent) && parent.expression === node) return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if (grandparent && ts.isCallExpression(grandparent) && grandparent.expression === parent) return grandparent; + } + return undefined; +} + +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; +} + +function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tsserver.Type[] { + const seen = new Set(); + const out: tsserver.Type[] = []; + for (const t of types) { + const key = checker.typeToString(t); + if (seen.has(key)) continue; + seen.add(key); + out.push(t); + } + return out; +} + +// 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`. +// 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): tsserver.Type[] { + const {ts, checker} = ctx; + const direct = checker.getTypeAtLocation(expr); + if (!isAnyType(ts, direct)) return [direct]; + if (ts.isCallExpression(expr)) { + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) return inferred; + } + } else if (ts.isIdentifier(expr)) { + // `expr` is itself an undocumented parameter reference (e.g. a helper + // that just returns/forwards one of its own params) — chase that + // parameter's inferred type too, rather than stopping at `any`. + const sym = checker.getSymbolAtLocation(expr); + const decl = sym?.valueDeclaration; + if (decl && ts.isParameter(decl)) { + const inferred = inferParameterType(ctx, decl, depth + 1); + if (inferred.length > 0) return inferred; + } + } + return []; +} + +// Infers a parameter's candidate type(s) from the arguments it's actually +// called with across the project, since plain un-annotated JS parameters +// default to `any` with no back-inference from call sites. +export function inferParameterType( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, + depth = 0, +): tsserver.Type[] { + const {ts, languageService, program, checker} = ctx; + if (depth > MAX_INFERENCE_DEPTH) return []; + const fn = param.parent; + if (!ts.isFunctionLike(fn)) return []; + const nameNode = getReferenceNameNode(fn, ts); + if (!nameNode) return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) return []; + + const sourceFile = nameNode.getSourceFile(); + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, nameNode.getStart(sourceFile)) ?? []; + + const types: tsserver.Type[] = []; + for (const ref of refs) { + 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) continue; + const arg = call.arguments[paramIndex]; + if (!arg) continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + + return dedupeTypes(checker, types); +} + +// 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: 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; +} + +// 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`. +export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDeclaration, depth = 0): tsserver.Type[] { + const {ts, checker} = ctx; + if (depth > MAX_INFERENCE_DEPTH) return []; + if (ctx.visiting.has(fn)) return []; + ctx.visiting.add(fn); + try { + const types: tsserver.Type[] = []; + for (const expr of collectReturnExpressions(fn, ts)) { + types.push(...resolveExpressionTypes(ctx, expr, depth)); + } + return dedupeTypes(checker, types); + } 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) && decl.initializer && ts.isCallExpression(decl.initializer)) { + const calleeFn = resolveCalleeDeclaration(ctx, decl.initializer); + if (calleeFn) return inferReturnType(ctx, calleeFn); + } + if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); + return []; +} + +export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { + return dedupeTypes(checker, types) + .map((t) => checker.typeToString(t)) + .join(' | '); +} + +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(type)) { + const name = sym.getName(); + if (seen.has(name)) continue; + seen.add(name); + entries.push({ + name, + kind: ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: INFERRED_COMPLETION_SOURCE, + }); + } + } + return entries; +} 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..81c2bce17 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -0,0 +1,49 @@ +/* + * 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. +function createFixtureHost(files, options) { + const fileNames = Object.keys(files); + const compilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + allowJs: true, + checkJs: false, + strict: false, + ...options, + }; + + return { + getScriptFileNames: () => fileNames, + getScriptVersion: () => '0', + getScriptSnapshot: (fileName) => { + const text = files[fileName]; + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => '/', + getCompilationSettings: () => compilerOptions, + getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), + fileExists: (fileName) => fileName in files || ts.sys.fileExists(fileName), + readFile: (fileName) => files[fileName] ?? ts.sys.readFile(fileName), + directoryExists: (dir) => ts.sys.directoryExists(dir), + getDirectories: (dir) => ts.sys.getDirectories(dir), + }; +} + +// 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, ts.createDocumentRegistry()); +} + +module.exports = {createFixtureHost, createFixtureLanguageService}; 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..c17532535 --- /dev/null +++ b/packages/b2c-script-types/test/index.test.js @@ -0,0 +1,102 @@ +/* + * 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 {createFixtureHost} = require('./helpers/fixture-language-service'); + +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, ts.createDocumentRegistry()); + 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}; +} + +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: []}); + 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: [], 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: []}); + 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: [], 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')); + }); +}); 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..e7422f42b --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -0,0 +1,268 @@ +/* + * 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} = require('./helpers/fixture-language-service'); + +const AMBIENT_TYPES = ` +declare function getProduct(): {ID: string; name: string}; +declare function getInventory(): {quantity: number}; +`; + +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; +} + +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('unions candidate types across multiple call sites', () => { + 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.equal(types.length, 2); + const rendered = types.map((t) => ctx.checker.typeToString(t)).sort(); + assert.deepEqual(rendered, ['{ ID: string; name: string; }', '{ quantity: number; }']); + }); + + 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('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('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)); + }); + }); + + 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']); + }); + }); + + 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-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 3690601d4..e269fd2ca 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -268,6 +268,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": "Experimental: when hover/completion hits a type TypeScript has widened to `any` (typically an undocumented helper function with no JSDoc), infer a better type from how it's called elsewhere in the project. Heuristic — inferred results are labeled and may be wrong." + }, "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..af9034388 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, @@ -59,10 +63,11 @@ export function registerScriptTypes( const a = await ensureApi(); if (!a) return; const enabled = isFeatureEnabled(); + const inferUsage = 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); enabled=${enabled}; inferUsage=${inferUsage}; order=[${cartridges.map((c) => c.name).join(', ')}].`, ); }; @@ -71,7 +76,10 @@ 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(); } }); From 10e169982fe36a0f167de7f714f0bbea2af02fb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:03:08 +0000 Subject: [PATCH 02/43] Fix usage-inference gaps found in code audit - Handle the common export patterns that previously produced no inference: bare `module.exports = function(){}`, destructured `const {x} = require(...)` (including renamed bindings), and ES6 method-shorthand exports. Previously only `exports.foo = function(){}` consumed via property access worked. - Restore the `maximumLength` parameter on the getQuickInfoAtPosition override, which was being silently dropped for all users regardless of the inferUsage setting. - Gate the new hover/completion overrides on the main `enabled` flag and `isCartridgeFile()`, matching every other feature in this plugin, and fix the VS Code extension side so `inferUsage` can't stay active once the parent scriptTypes feature is disabled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- packages/b2c-script-types/plugin/index.js | 8 +- .../plugin/usage-inference.js | 113 ++++++++++++++--- packages/b2c-script-types/src/index.ts | 8 +- .../b2c-script-types/src/usage-inference.ts | 119 +++++++++++++++--- packages/b2c-script-types/test/index.test.js | 53 +++++++- .../test/usage-inference.test.js | 103 +++++++++++++++ .../src/script-types/index.ts | 2 +- 7 files changed, 363 insertions(+), 43 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 522bfabf6..0a0f43c7e 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -584,9 +584,9 @@ function init({ typescript: ts }) { inferenceCache.set(cacheKey, { projectVersion, types }); return types; }; - proxy.getQuickInfoAtPosition = (fileName, position) => { - const original = info.languageService.getQuickInfoAtPosition(fileName, position); - if (!inferUsageEnabled || !original) + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; try { const program = info.languageService.getProgram(); @@ -618,7 +618,7 @@ function init({ typescript: ts }) { }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - if (!inferUsageEnabled) + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; try { const program = info.languageService.getProgram(); diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index f9287a440..5d66605fb 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -14,6 +14,10 @@ exports.typesToCompletionEntries = typesToCompletionEntries; // helper...) before giving up. Keeps worst-case cost predictable regardless of // how deep a cartridge's helper stack goes. const MAX_INFERENCE_DEPTH = 3; +// Bounds 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. +const MAX_REFERENCE_HOPS = 2; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; function createInferenceContext(ts, languageService) { const program = languageService.getProgram(); @@ -49,10 +53,13 @@ function findEnclosingPropertyAccess(node, ts) { } // 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(){}}`, `exports.foo = function(){}`). +// `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; @@ -62,6 +69,11 @@ function getReferenceNameNode(fn, ts) { 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)) @@ -86,6 +98,88 @@ function findCallInCalleePosition(node, ts) { } 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`). Resolves to 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 }; + } + 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. +function collectCallSites(ctx, nameNode) { + const { ts, languageService, program } = ctx; + const calls = []; + const seenNameKeys = new Set(); + let frontier = [nameNode]; + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0; hop++) { + const nextFrontier = []; + for (const name of frontier) { + const sourceFile = name.getSourceFile(); + const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; + if (seenNameKeys.has(key)) + continue; + seenNameKeys.add(key); + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + for (const ref of refs) { + 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); + } + } + frontier = nextFrontier; + } + return calls; +} function resolveCalleeDeclaration(ctx, call) { const { checker, ts } = ctx; const sym = checker.getSymbolAtLocation(call.expression); @@ -147,7 +241,7 @@ function resolveExpressionTypes(ctx, expr, depth) { // called with across the project, since plain un-annotated JS parameters // default to `any` with no back-inference from call sites. function inferParameterType(ctx, param, depth = 0) { - const { ts, languageService, program, checker } = ctx; + const { ts, checker } = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; const fn = param.parent; @@ -159,21 +253,8 @@ function inferParameterType(ctx, param, depth = 0) { const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; - const sourceFile = nameNode.getSourceFile(); - const refs = languageService.getReferencesAtPosition(sourceFile.fileName, nameNode.getStart(sourceFile)) ?? []; const types = []; - for (const ref of refs) { - 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) - continue; + for (const call of collectCallSites(ctx, nameNode)) { const arg = call.arguments[paramIndex]; if (!arg) continue; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index dd582f13d..432051d16 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -656,9 +656,9 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return types; }; - proxy.getQuickInfoAtPosition = (fileName, position) => { - const original = info.languageService.getQuickInfoAtPosition(fileName, position); - if (!inferUsageEnabled || !original) return original; + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; try { const program = info.languageService.getProgram(); const sourceFile = program?.getSourceFile(fileName); @@ -685,7 +685,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - if (!inferUsageEnabled) return original; + if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; try { const program = info.languageService.getProgram(); const sourceFile = program?.getSourceFile(fileName); diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 343c95fac..bcf868828 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -10,6 +10,11 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; // how deep a cartridge's helper stack goes. const MAX_INFERENCE_DEPTH = 3; +// Bounds 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. +const MAX_REFERENCE_HOPS = 2; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; export interface InferenceContext { @@ -69,15 +74,22 @@ export function findEnclosingPropertyAccess( // 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(){}}`, `exports.foo = function(){}`). +// `const foo = function(){}`, `{foo: function(){}}`, `{foo(){}}`, +// `exports.foo = function(){}`, `module.exports = function(){}`). 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; } @@ -99,6 +111,96 @@ function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tss 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`). Resolves to 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: tsserver.CallExpression} | {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}; + } + + 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. +function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { + const {ts, languageService, program} = ctx; + const calls: tsserver.CallExpression[] = []; + const seenNameKeys = new Set(); + let frontier: tsserver.Identifier[] = [nameNode]; + + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0; hop++) { + const nextFrontier: tsserver.Identifier[] = []; + for (const name of frontier) { + const sourceFile = name.getSourceFile(); + const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; + if (seenNameKeys.has(key)) continue; + seenNameKeys.add(key); + + const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + for (const ref of refs) { + 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); + } + } + frontier = nextFrontier; + } + + return calls; +} + function resolveCalleeDeclaration( ctx: InferenceContext, call: tsserver.CallExpression, @@ -163,7 +265,7 @@ export function inferParameterType( param: tsserver.ParameterDeclaration, depth = 0, ): tsserver.Type[] { - const {ts, languageService, program, checker} = ctx; + const {ts, checker} = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; const fn = param.parent; if (!ts.isFunctionLike(fn)) return []; @@ -172,19 +274,8 @@ export function inferParameterType( const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; - const sourceFile = nameNode.getSourceFile(); - const refs = languageService.getReferencesAtPosition(sourceFile.fileName, nameNode.getStart(sourceFile)) ?? []; - const types: tsserver.Type[] = []; - for (const ref of refs) { - 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) continue; + for (const call of collectCallSites(ctx, nameNode)) { const arg = call.arguments[paramIndex]; if (!arg) continue; types.push(...resolveExpressionTypes(ctx, arg, depth)); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index c17532535..73c7ba198 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -68,35 +68,80 @@ function fixtureOffsets() { 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: []}); + 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: [], inferUsage: true}); + 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: []}); + 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: [], inferUsage: true}); + 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('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')); + }); }); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index e7422f42b..8a828a438 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -139,6 +139,109 @@ describe('usage-inference', () => { }); }); + 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 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('inferReturnType', () => { it('chases a multi-hop undocumented call chain through a forwarding helper', () => { // `identity` forwards its own (undocumented, `any`) parameter, so TS's diff --git a/packages/b2c-vs-extension/src/script-types/index.ts b/packages/b2c-vs-extension/src/script-types/index.ts index af9034388..ff8f1abf3 100644 --- a/packages/b2c-vs-extension/src/script-types/index.ts +++ b/packages/b2c-vs-extension/src/script-types/index.ts @@ -63,7 +63,7 @@ export function registerScriptTypes( const a = await ensureApi(); if (!a) return; const enabled = isFeatureEnabled(); - const inferUsage = isInferUsageEnabled(); + const inferUsage = enabled && isInferUsageEnabled(); const cartridges = enabled ? cartridgeService.getCartridges().map((c) => ({name: c.name, src: c.src})) : []; a.configurePlugin(PLUGIN_ID, {cartridges, enabled, inferUsage}); log.appendLine( From 574e3df1abd6adfd17eceb521bf5bd034451d7f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:13:37 +0000 Subject: [PATCH 03/43] Address remaining efficiency/correctness findings from code audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound inferenceCache growth: clear the whole cache on project version change instead of leaving stale per-key entries around indefinitely. - Add request-scoped, depth-safe memoization plus a hard reference-count budget per inference request, so a widely-referenced helper can't make a single hover/completion synchronously fan out unboundedly. - Skip inference for parameters/returns that already have an explicit type annotation (TS syntax or JSDoc `@param`/`@returns`), even if that annotation is literally `any` — never override a type the developer or JSDoc already resolved on purpose. - Preserve every other CompletionInfo field (isIncomplete, optionalReplacementSpan, metadata, etc.) from the original completion result when merging in synthesized entries. - Correct a misleading comment claiming plugin state is shared across projects in a multi-root workspace — verified against the tsserver source that the plugin factory is invoked fresh per project, so this was never actually a cross-project leak. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- packages/b2c-script-types/plugin/index.js | 28 +++++--- .../plugin/usage-inference.js | 58 +++++++++++++-- packages/b2c-script-types/src/index.ts | 36 +++++++--- .../b2c-script-types/src/usage-inference.ts | 72 +++++++++++++++++-- packages/b2c-script-types/test/index.test.js | 19 +++++ .../test/usage-inference.test.js | 72 +++++++++++++++++++ 6 files changed, 256 insertions(+), 29 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 0a0f43c7e..fb0aece1b 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -571,17 +571,23 @@ function init({ typescript: ts }) { // 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), invalidated on project version change - // so cost is bounded to "recompute only what's under the cursor, only - // when the program actually changed" rather than a whole-program scan. + // Cached per (file, node position); the whole cache is thrown away on a + // project version change rather than tracking per-entry validity, so it + // can't grow without bound across a long editing session — every entry + // in it is guaranteed fresh for the current program. + let inferenceCacheProjectVersion; const inferenceCache = new Map(); const getCachedInference = (cacheKey, compute) => { const projectVersion = info.project.getProjectVersion(); + if (projectVersion !== inferenceCacheProjectVersion) { + inferenceCache.clear(); + inferenceCacheProjectVersion = projectVersion; + } const cached = inferenceCache.get(cacheKey); - if (cached && cached.projectVersion === projectVersion) - return cached.types; + if (cached) + return cached; const types = compute(); - inferenceCache.set(cacheKey, { projectVersion, types }); + inferenceCache.set(cacheKey, types); return types; }; proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { @@ -646,10 +652,16 @@ function init({ typescript: ts }) { return original; const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + // 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: original?.isGlobalCompletion ?? false, + isGlobalCompletion: false, isMemberCompletion: true, - isNewIdentifierLocation: original?.isNewIdentifierLocation ?? false, + isNewIdentifierLocation: false, entries: merged, }; } diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 5d66605fb..af995523c 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -18,12 +18,27 @@ const MAX_INFERENCE_DEPTH = 3; // renamed re-export, etc.) collectCallSites() will follow from a reference // before giving up on finding an actual call site. 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. +const MAX_REFERENCES_PER_REQUEST = 200; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; function createInferenceContext(ts, languageService) { const program = languageService.getProgram(); if (!program) return undefined; - return { ts, program, checker: program.getTypeChecker(), languageService, visiting: new Set() }; + return { + ts, + program, + checker: program.getTypeChecker(), + languageService, + visiting: new Set(), + memo: new Map(), + referenceBudget: MAX_REFERENCES_PER_REQUEST, + }; } function isAnyType(ts, type) { return (type.flags & ts.TypeFlags.Any) !== 0; @@ -140,15 +155,20 @@ function resolveIndirectReferenceTarget(node, ts) { } // 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. +// when a reference doesn't sit directly in callee position. Stops early once +// ctx.referenceBudget 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. function collectCallSites(ctx, nameNode) { const { ts, languageService, program } = ctx; const calls = []; const seenNameKeys = new Set(); let frontier = [nameNode]; - for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0; hop++) { + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && ctx.referenceBudget > 0; hop++) { const nextFrontier = []; for (const name of frontier) { + if (ctx.referenceBudget <= 0) + break; const sourceFile = name.getSourceFile(); const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; if (seenNameKeys.has(key)) @@ -156,6 +176,9 @@ function collectCallSites(ctx, nameNode) { seenNameKeys.add(key); const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { + if (ctx.referenceBudget <= 0) + break; + ctx.referenceBudget--; const refFile = program.getSourceFile(ref.fileName); if (!refFile) continue; @@ -180,6 +203,17 @@ function collectCallSites(ctx, nameNode) { } return calls; } +// True when the developer already gave this parameter/function an explicit +// type — TS syntax or JSDoc — even if that type is literally `any`. In that +// case the checker's `any` reflects a deliberate choice, not an inference +// failure, so usage inference must never second-guess it. Only genuinely +// implicit `any` (no annotation at all) is fair game. +function hasExplicitParameterType(param, ts) { + return param.type !== undefined || ts.getJSDocType(param) !== undefined; +} +function hasExplicitReturnType(fn, ts) { + return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; +} function resolveCalleeDeclaration(ctx, call) { const { checker, ts } = ctx; const sym = checker.getSymbolAtLocation(call.expression); @@ -244,6 +278,11 @@ function inferParameterType(ctx, param, depth = 0) { const { ts, checker } = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitParameterType(param, ts)) + return []; + const cached = ctx.memo.get(param); + if (cached && cached.atDepth <= depth) + return cached.types; const fn = param.parent; if (!ts.isFunctionLike(fn)) return []; @@ -260,7 +299,9 @@ function inferParameterType(ctx, param, depth = 0) { continue; types.push(...resolveExpressionTypes(ctx, arg, depth)); } - return dedupeTypes(checker, types); + const result = dedupeTypes(checker, types); + ctx.memo.set(param, { atDepth: depth, types: result }); + return result; } // Recursively walks a function body collecting `return` expressions, without // descending into nested function-like boundaries (their returns belong to @@ -292,6 +333,11 @@ function inferReturnType(ctx, fn, depth = 0) { const { ts, checker } = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitReturnType(fn, ts)) + return []; + const cached = ctx.memo.get(fn); + if (cached && cached.atDepth <= depth) + return cached.types; if (ctx.visiting.has(fn)) return []; ctx.visiting.add(fn); @@ -300,7 +346,9 @@ function inferReturnType(ctx, fn, depth = 0) { for (const expr of collectReturnExpressions(fn, ts)) { types.push(...resolveExpressionTypes(ctx, expr, depth)); } - return dedupeTypes(checker, types); + const result = dedupeTypes(checker, types); + ctx.memo.set(fn, { atDepth: depth, types: result }); + return result; } finally { ctx.visiting.delete(fn); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 432051d16..866b9d4b8 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -98,9 +98,12 @@ const DISCOVERY_IGNORE = new Set(['node_modules', '.git', 'dist', 'build', 'cove 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; @@ -643,16 +646,22 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // 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), invalidated on project version change - // so cost is bounded to "recompute only what's under the cursor, only - // when the program actually changed" rather than a whole-program scan. - const inferenceCache = new Map(); + // Cached per (file, node position); the whole cache is thrown away on a + // project version change rather than tracking per-entry validity, so it + // can't grow without bound across a long editing session — every entry + // in it is guaranteed fresh for the current program. + let inferenceCacheProjectVersion: string | undefined; + const inferenceCache = new Map(); const getCachedInference = (cacheKey: string, compute: () => tsserver.Type[]): tsserver.Type[] => { const projectVersion = info.project.getProjectVersion(); + if (projectVersion !== inferenceCacheProjectVersion) { + inferenceCache.clear(); + inferenceCacheProjectVersion = projectVersion; + } const cached = inferenceCache.get(cacheKey); - if (cached && cached.projectVersion === projectVersion) return cached.types; + if (cached) return cached; const types = compute(); - inferenceCache.set(cacheKey, {projectVersion, types}); + inferenceCache.set(cacheKey, types); return types; }; @@ -706,10 +715,15 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (inferredEntries.length === 0) return original; const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + // 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: original?.isGlobalCompletion ?? false, + isGlobalCompletion: false, isMemberCompletion: true, - isNewIdentifierLocation: original?.isNewIdentifierLocation ?? false, + isNewIdentifierLocation: false, entries: merged, }; } catch (e) { diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index bcf868828..7450d71f5 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -15,8 +15,25 @@ const MAX_INFERENCE_DEPTH = 3; // before giving up on finding an actual call site. 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. +const MAX_REFERENCES_PER_REQUEST = 200; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; +interface MemoEntry { + // Remaining recursion budget (MAX_INFERENCE_DEPTH - depth) at the time this + // was computed. A result computed with equal-or-more remaining budget is + // always safe to reuse for a request needing equal-or-less budget, 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; @@ -26,6 +43,14 @@ export interface InferenceContext { // 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; } export function createInferenceContext( @@ -34,7 +59,15 @@ export function createInferenceContext( ): InferenceContext | undefined { const program = languageService.getProgram(); if (!program) return undefined; - return {ts, program, checker: program.getTypeChecker(), languageService, visiting: new Set()}; + return { + ts, + program, + checker: program.getTypeChecker(), + languageService, + visiting: new Set(), + memo: new Map(), + referenceBudget: MAX_REFERENCES_PER_REQUEST, + }; } export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { @@ -162,16 +195,20 @@ function resolveIndirectReferenceTarget( // 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. +// when a reference doesn't sit directly in callee position. Stops early once +// ctx.referenceBudget 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. function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { const {ts, languageService, program} = ctx; const calls: tsserver.CallExpression[] = []; const seenNameKeys = new Set(); let frontier: tsserver.Identifier[] = [nameNode]; - for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0; hop++) { + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && ctx.referenceBudget > 0; hop++) { const nextFrontier: tsserver.Identifier[] = []; for (const name of frontier) { + if (ctx.referenceBudget <= 0) break; const sourceFile = name.getSourceFile(); const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; if (seenNameKeys.has(key)) continue; @@ -179,6 +216,8 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { + if (ctx.referenceBudget <= 0) break; + ctx.referenceBudget--; const refFile = program.getSourceFile(ref.fileName); if (!refFile) continue; const node = getNodeAtPosition(refFile, ts, ref.textSpan.start); @@ -201,6 +240,19 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): return calls; } +// True when the developer already gave this parameter/function an explicit +// type — TS syntax or JSDoc — even if that type is literally `any`. In that +// case the checker's `any` reflects a deliberate choice, not an inference +// failure, so usage inference must never second-guess it. Only genuinely +// implicit `any` (no annotation at all) is fair game. +function hasExplicitParameterType(param: tsserver.ParameterDeclaration, ts: typeof tsserver): boolean { + return param.type !== undefined || ts.getJSDocType(param) !== undefined; +} + +function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): boolean { + return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; +} + function resolveCalleeDeclaration( ctx: InferenceContext, call: tsserver.CallExpression, @@ -267,6 +319,9 @@ export function inferParameterType( ): tsserver.Type[] { const {ts, checker} = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitParameterType(param, ts)) return []; + const cached = ctx.memo.get(param); + if (cached && cached.atDepth <= depth) return cached.types; const fn = param.parent; if (!ts.isFunctionLike(fn)) return []; const nameNode = getReferenceNameNode(fn, ts); @@ -281,7 +336,9 @@ export function inferParameterType( types.push(...resolveExpressionTypes(ctx, arg, depth)); } - return dedupeTypes(checker, types); + const result = dedupeTypes(checker, types); + ctx.memo.set(param, {atDepth: depth, types: result}); + return result; } // Recursively walks a function body collecting `return` expressions, without @@ -312,6 +369,9 @@ function collectReturnExpressions(fn: tsserver.SignatureDeclaration, ts: typeof export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDeclaration, depth = 0): tsserver.Type[] { const {ts, checker} = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; + if (hasExplicitReturnType(fn, ts)) return []; + const cached = ctx.memo.get(fn); + if (cached && cached.atDepth <= depth) return cached.types; if (ctx.visiting.has(fn)) return []; ctx.visiting.add(fn); try { @@ -319,7 +379,9 @@ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDec for (const expr of collectReturnExpressions(fn, ts)) { types.push(...resolveExpressionTypes(ctx, expr, depth)); } - return dedupeTypes(checker, types); + const result = dedupeTypes(checker, types); + ctx.memo.set(fn, {atDepth: depth, types: result}); + return result; } finally { ctx.visiting.delete(fn); } diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 73c7ba198..028038316 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -115,6 +115,25 @@ describe('create() proxy — usage inference wiring', () => { 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, diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 8a828a438..21ce26d9b 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -242,6 +242,78 @@ describe('usage-inference', () => { }); }); + 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 — 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); + }); + }); + describe('inferReturnType', () => { it('chases a multi-hop undocumented call chain through a forwarding helper', () => { // `identity` forwards its own (undocumented, `any`) parameter, so TS's From a57bf6195ff9b2f4479b6076f9fa08b616e3507e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:20:42 +0000 Subject: [PATCH 04/43] Align new code with repo conventions for consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert usage-inference.ts's function-level comments to JSDoc /** */ blocks with @param/@returns where the SDK convention already does this for every top-level function (exported or not); index.ts's own internal closures already use plain // comments consistently, so left as-is. - Match the VS Code setting description to the established "(Preview) ... In development — off by default." wording used by other not-yet-stable features, instead of an ad hoc "Experimental:" prefix. - Match doc phrasing in ide-integration.md to the sibling scriptTypes setting's plain-backtick + "(default: `false`)" convention. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- docs/guide/ide-integration.md | 2 +- packages/b2c-script-types/plugin/index.js | 9 +- .../plugin/usage-inference.js | 151 +++++++++----- .../b2c-script-types/src/usage-inference.ts | 185 ++++++++++++------ packages/b2c-vs-extension/package.json | 2 +- 5 files changed, 227 insertions(+), 122 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 4f7cf18cd..49918459c 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -117,7 +117,7 @@ If your editor's LSP client is launched outside the repo root (for example, open JSDoc-documented functions get full hover/completion support because TypeScript reads the `@param`/`@returns` annotations directly. Plain, undocumented helper functions don't — an unannotated parameter or return value gets widened to `any`, and that `any` propagates to every caller, silencing completion for anything built on top of it. -Enable **`b2c-dx.features.scriptTypesInferUsage`** (VS Code extension setting, default off) 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) rather than stopping at the first `any`. +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) rather than stopping at the first `any`. Inferred results are heuristic and clearly labeled: diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index fb0aece1b..1fa237b5b 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -55,9 +55,12 @@ const BASE_CARTRIDGE_RANK = { 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; diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index af995523c..193bb376b 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -26,6 +26,10 @@ const MAX_REFERENCE_HOPS = 2; // unlimited. const MAX_REFERENCES_PER_REQUEST = 200; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; +/** + * 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) { const program = languageService.getProgram(); if (!program) @@ -40,12 +44,15 @@ function createInferenceContext(ts, languageService) { referenceBudget: MAX_REFERENCES_PER_REQUEST, }; } +/** 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; } -// 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. +/** + * 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. + */ function getNodeAtPosition(sourceFile, ts, pos) { let result; const visit = (node) => { @@ -57,6 +64,7 @@ function getNodeAtPosition(sourceFile, ts, pos) { 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) { @@ -66,10 +74,12 @@ function findEnclosingPropertyAccess(node, ts) { } return undefined; } -// 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(){}`). +/** + * 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; @@ -96,10 +106,12 @@ function getReferenceNameNode(fn, ts) { } return undefined; } -// Given a reference identifier (`helper` in either `helper(x)` or -// `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if -// the identifier sits in callee position — one parent up for a direct call, -// two parents up when the identifier is the `.name` of a property access. +/** + * Given a reference identifier (`helper` in either `helper(x)` or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if + * the identifier sits in callee position — one parent up for a direct call, + * two parents up when the identifier is the `.name` of a property access. + */ function findCallInCalleePosition(node, ts) { const parent = node.parent; if (!parent) @@ -113,9 +125,11 @@ function findCallInCalleePosition(node, ts) { } 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). +/** + * 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) && @@ -123,14 +137,17 @@ function isRequireCallExpression(node, ts) { 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`). Resolves to either the further name -// to search references for, or — for an immediately-invoked require -// (`require('./helper')(x)`) — the call site itself. +/** + * 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) @@ -153,12 +170,14 @@ function resolveIndirectReferenceTarget(node, ts) { } 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 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. +/** + * 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 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. + */ function collectCallSites(ctx, nameNode) { const { ts, languageService, program } = ctx; const calls = []; @@ -203,17 +222,25 @@ function collectCallSites(ctx, nameNode) { } return calls; } -// True when the developer already gave this parameter/function an explicit -// type — TS syntax or JSDoc — even if that type is literally `any`. In that -// case the checker's `any` reflects a deliberate choice, not an inference -// failure, so usage inference must never second-guess it. Only genuinely -// implicit `any` (no annotation at all) is fair game. +/** + * True when the developer already gave this parameter an explicit type — TS + * syntax or JSDoc — even if that type is literally `any`. In that case the + * checker's `any` reflects a deliberate choice, not an inference failure, so + * usage inference must never second-guess it. Only genuinely implicit `any` + * (no annotation at all) is fair game. + */ function hasExplicitParameterType(param, ts) { return param.type !== undefined || ts.getJSDocType(param) !== undefined; } +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ function hasExplicitReturnType(fn, ts) { return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; } +/** + * 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); @@ -226,6 +253,7 @@ function resolveCalleeDeclaration(ctx, call) { return sigDecl; return undefined; } +/** Deduplicates candidate types by their display string. */ function dedupeTypes(checker, types) { const seen = new Set(); const out = []; @@ -238,12 +266,15 @@ function dedupeTypes(checker, types) { } return out; } -// 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`. -// 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. +/** + * 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`. + * + * @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) { const { ts, checker } = ctx; const direct = checker.getTypeAtLocation(expr); @@ -271,9 +302,14 @@ function resolveExpressionTypes(ctx, expr, depth) { } return []; } -// Infers a parameter's candidate type(s) from the arguments it's actually -// called with across the project, since plain un-annotated JS parameters -// default to `any` with no back-inference from call sites. +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project, since plain un-annotated JS parameters + * default to `any` with no back-inference from call sites. + * + * @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, checker } = ctx; if (depth > MAX_INFERENCE_DEPTH) @@ -303,9 +339,11 @@ function inferParameterType(ctx, param, depth = 0) { ctx.memo.set(param, { atDepth: depth, types: result }); return result; } -// Recursively walks a function body collecting `return` expressions, without -// descending into nested function-like boundaries (their returns belong to -// them, not to `fn`). +/** + * 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]; @@ -326,9 +364,14 @@ function collectReturnExpressions(fn, ts) { visit(body); return out; } -// 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`. +/** + * 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, checker } = ctx; if (depth > MAX_INFERENCE_DEPTH) @@ -354,10 +397,12 @@ function inferReturnType(ctx, fn, depth = 0) { 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. +/** + * 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)) @@ -377,11 +422,13 @@ function inferTypeForNode(ctx, node) { return inferReturnType(ctx, decl); return []; } +/** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ function describeTypes(checker, types) { return dedupeTypes(checker, types) .map((t) => checker.typeToString(t)) .join(' | '); } +/** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ function typesToCompletionEntries(ts, checker, types) { const seen = new Set(); const entries = []; diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 7450d71f5..4fae824bc 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -26,10 +26,12 @@ const MAX_REFERENCES_PER_REQUEST = 200; export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; interface MemoEntry { - // Remaining recursion budget (MAX_INFERENCE_DEPTH - depth) at the time this - // was computed. A result computed with equal-or-more remaining budget is - // always safe to reuse for a request needing equal-or-less budget, since - // more budget can only surface the same types or more, never fewer. + /** + * Remaining recursion budget (MAX_INFERENCE_DEPTH - depth) at the time this + * was computed. A result computed with equal-or-more remaining budget is + * always safe to reuse for a request needing equal-or-less budget, since + * more budget can only surface the same types or more, never fewer. + */ readonly atDepth: number; readonly types: tsserver.Type[]; } @@ -39,20 +41,30 @@ export interface InferenceContext { 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()}`. + /** + * 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. + /** + * 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. + /** + * Mutable, shared across the whole request — decremented by + * collectCallSites() every time it processes a reference. + */ referenceBudget: 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, @@ -70,13 +82,16 @@ export function createInferenceContext( }; } +/** 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; } -// 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. +/** + * 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. + */ export function getNodeAtPosition( sourceFile: tsserver.SourceFile, ts: typeof tsserver, @@ -93,6 +108,7 @@ export function getNodeAtPosition( 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, @@ -105,10 +121,12 @@ export function findEnclosingPropertyAccess( return undefined; } -// 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(){}`). +/** + * 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: 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; @@ -129,10 +147,12 @@ function getReferenceNameNode(fn: tsserver.SignatureDeclaration, ts: typeof tsse return undefined; } -// Given a reference identifier (`helper` in either `helper(x)` or -// `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if -// the identifier sits in callee position — one parent up for a direct call, -// two parents up when the identifier is the `.name` of a property access. +/** + * Given a reference identifier (`helper` in either `helper(x)` or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if + * the identifier sits in callee position — one parent up for a direct call, + * two parents up when the identifier is the `.name` of a property access. + */ function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { const parent = node.parent; if (!parent) return undefined; @@ -144,9 +164,11 @@ function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tss 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). +/** + * 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) && @@ -157,14 +179,17 @@ function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node ); } -// 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`). Resolves to either the further name -// to search references for, or — for an immediately-invoked require -// (`require('./helper')(x)`) — the call site itself. +/** + * 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, @@ -193,12 +218,14 @@ function resolveIndirectReferenceTarget( 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 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. +/** + * 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 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. + */ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { const {ts, languageService, program} = ctx; const calls: tsserver.CallExpression[] = []; @@ -240,19 +267,27 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): return calls; } -// True when the developer already gave this parameter/function an explicit -// type — TS syntax or JSDoc — even if that type is literally `any`. In that -// case the checker's `any` reflects a deliberate choice, not an inference -// failure, so usage inference must never second-guess it. Only genuinely -// implicit `any` (no annotation at all) is fair game. +/** + * True when the developer already gave this parameter an explicit type — TS + * syntax or JSDoc — even if that type is literally `any`. In that case the + * checker's `any` reflects a deliberate choice, not an inference failure, so + * usage inference must never second-guess it. Only genuinely implicit `any` + * (no annotation at all) is fair game. + */ function hasExplicitParameterType(param: tsserver.ParameterDeclaration, ts: typeof tsserver): boolean { return param.type !== undefined || ts.getJSDocType(param) !== undefined; } +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): boolean { return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; } +/** + * 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, @@ -267,6 +302,7 @@ function resolveCalleeDeclaration( return undefined; } +/** Deduplicates candidate types by their display string. */ function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tsserver.Type[] { const seen = new Set(); const out: tsserver.Type[] = []; @@ -279,12 +315,15 @@ function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tss return out; } -// 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`. -// 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. +/** + * 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`. + * + * @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): tsserver.Type[] { const {ts, checker} = ctx; const direct = checker.getTypeAtLocation(expr); @@ -309,9 +348,14 @@ function resolveExpressionTypes(ctx: InferenceContext, expr: tsserver.Expression return []; } -// Infers a parameter's candidate type(s) from the arguments it's actually -// called with across the project, since plain un-annotated JS parameters -// default to `any` with no back-inference from call sites. +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project, since plain un-annotated JS parameters + * default to `any` with no back-inference from call sites. + * + * @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, @@ -341,9 +385,11 @@ export function inferParameterType( return result; } -// Recursively walks a function body collecting `return` expressions, without -// descending into nested function-like boundaries (their returns belong to -// them, not to `fn`). +/** + * 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: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Expression[] { if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) { return [fn.body]; @@ -363,9 +409,14 @@ function collectReturnExpressions(fn: tsserver.SignatureDeclaration, ts: typeof return out; } -// 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`. +/** + * 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, checker} = ctx; if (depth > MAX_INFERENCE_DEPTH) return []; @@ -387,10 +438,12 @@ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDec } } -// 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. +/** + * 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 []; @@ -406,12 +459,14 @@ export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): ts return []; } +/** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { return dedupeTypes(checker, types) .map((t) => checker.typeToString(t)) .join(' | '); } +/** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ export function typesToCompletionEntries( ts: typeof tsserver, checker: tsserver.TypeChecker, diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index e269fd2ca..51cbb8f5c 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -271,7 +271,7 @@ "b2c-dx.features.scriptTypesInferUsage": { "type": "boolean", "default": false, - "description": "Experimental: when hover/completion hits a type TypeScript has widened to `any` (typically an undocumented helper function with no JSDoc), infer a better type from how it's called elsewhere in the project. Heuristic — inferred results are labeled and may be wrong." + "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", From e37d11684f324ea8d731ec1f11d7692d8172a7af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:53:00 +0000 Subject: [PATCH 05/43] Fix all findings from second code-review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (empirically verified with repro scripts): - Check ctx.memo before the MAX_INFERENCE_DEPTH cap in inferParameterType/ inferReturnType, so an already-computed result isn't discarded just because the current call path happens to run over the depth budget. - Add PropertyAccessExpression handling to resolveExpressionTypes so `return x.prop` on an undocumented parameter chases the base's inferred type instead of giving up. - Use checker.getApparentType() before getPropertiesOfType() so inferred primitive types (string/number/boolean) get their wrapper-object member completions. - Widen literal call-site argument types to their general type, so hover shows `string` instead of a union of every literal ever passed. - Add a ctx.visiting cycle guard to inferParameterType (inferReturnType already had one), preventing re-entrant recomputation for self-forwarding helpers. - Cap how much of the shared reference budget a single collectCallSites call can spend, so one widely-referenced helper can't starve sibling branches processed later in the same request. Tooling: - Fix test/test:agent/test:unit/test:watch to explicitly rebuild first — pnpm's pretest hook only fires for the literal `test` script, so the other three were silently running against stale or missing compiled output. - Fix the shared test fixture host to include the default lib file (a LanguageService, unlike a Program, never adds it automatically), needed to test apparent-type-dependent behavior at all. Coverage: added regression tests for every fix above, plus previously- untested fixes (maximumLength forwarding, cache invalidation on project version change) — 33 tests total, up from 25. Conventions: clarified in the changeset why @salesforce/b2c-cli is listed despite no direct file changes, and matched log message phrasing / VS Code setting punctuation to this file's established style. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- .changeset/script-types-infer-usage.md | 2 +- packages/b2c-script-types/package.json | 9 +- packages/b2c-script-types/plugin/index.js | 4 +- .../plugin/usage-inference.js | 104 ++++++++---- packages/b2c-script-types/src/index.ts | 4 +- .../b2c-script-types/src/usage-inference.ts | 106 +++++++++---- .../test/helpers/fixture-language-service.js | 13 +- packages/b2c-script-types/test/index.test.js | 79 ++++++++++ .../test/usage-inference.test.js | 148 ++++++++++++++++++ packages/b2c-vs-extension/package.json | 2 +- 10 files changed, 400 insertions(+), 71 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 109dc2092..4cd5426e3 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -3,4 +3,4 @@ '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. +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. diff --git a/packages/b2c-script-types/package.json b/packages/b2c-script-types/package.json index e6ba7cf3f..a595ef139 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -29,11 +29,10 @@ "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", - "pretest": "pnpm run build", - "test": "node --test", - "test:agent": "node --test --test-reporter=dot", - "test:unit": "node --test", - "test:watch": "node --test --watch" + "test": "pnpm run build && node --test", + "test:agent": "pnpm run build && node --test --test-reporter=dot", + "test:unit": "pnpm run build && node --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 1fa237b5b..30987150f 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -621,7 +621,7 @@ function init({ typescript: ts }) { return { ...original, documentation: [...(original.documentation ?? []), note] }; } catch (e) { - log(`inferUsage hover failed: ${e.message}`); + log(`usage-inference hover failed: ${e.message}`); return original; } }; @@ -669,7 +669,7 @@ function init({ typescript: ts }) { }; } catch (e) { - log(`inferUsage completions failed: ${e.message}`); + log(`usage-inference completions failed: ${e.message}`); return original; } }; diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 193bb376b..7407faa5d 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -25,6 +25,12 @@ const MAX_REFERENCE_HOPS = 2; // to cover realistic cartridge helper usage without being effectively // unlimited. 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. +const MAX_REFERENCES_PER_CALL = 50; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; /** * Builds a fresh inference context for one top-level hover/completion @@ -183,10 +189,11 @@ function collectCallSites(ctx, nameNode) { const calls = []; const seenNameKeys = new Set(); let frontier = [nameNode]; - for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && ctx.referenceBudget > 0; hop++) { + 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 = []; for (const name of frontier) { - if (ctx.referenceBudget <= 0) + if (localBudget <= 0) break; const sourceFile = name.getSourceFile(); const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; @@ -195,8 +202,9 @@ function collectCallSites(ctx, nameNode) { seenNameKeys.add(key); const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { - if (ctx.referenceBudget <= 0) + if (localBudget <= 0) break; + localBudget--; ctx.referenceBudget--; const refFile = program.getSourceFile(ref.fileName); if (!refFile) @@ -253,6 +261,14 @@ function resolveCalleeDeclaration(ctx, call) { return sigDecl; return undefined; } +/** + * 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); +} /** Deduplicates candidate types by their display string. */ function dedupeTypes(checker, types) { const seen = new Set(); @@ -279,7 +295,7 @@ function resolveExpressionTypes(ctx, expr, depth) { const { ts, checker } = ctx; const direct = checker.getTypeAtLocation(expr); if (!isAnyType(ts, direct)) - return [direct]; + return [widenType(checker, direct)]; if (ts.isCallExpression(expr)) { const calleeFn = resolveCalleeDeclaration(ctx, expr); if (calleeFn) { @@ -288,6 +304,21 @@ function resolveExpressionTypes(ctx, expr, depth) { return inferred; } } + else if (ts.isPropertyAccessExpression(expr)) { + // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented + // (an untyped parameter, say) — infer the base's type first, then look + // up this specific property on it, rather than giving up on the whole + // access just because the access itself resolved to `any`. + const propName = expr.name.text; + const propTypes = []; + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth)) { + const propSymbol = checker.getPropertyOfType(checker.getApparentType(baseType), propName); + if (propSymbol) + propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); + } + if (propTypes.length > 0) + return propTypes; + } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper // that just returns/forwards one of its own params) — chase that @@ -312,32 +343,47 @@ function resolveExpressionTypes(ctx, expr, depth) { */ function inferParameterType(ctx, param, depth = 0) { const { ts, checker } = ctx; - if (depth > MAX_INFERENCE_DEPTH) - return []; - if (hasExplicitParameterType(param, ts)) - return []; + // 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; - const fn = param.parent; - if (!ts.isFunctionLike(fn)) + if (depth > MAX_INFERENCE_DEPTH) return []; - const nameNode = getReferenceNameNode(fn, ts); - if (!nameNode) + if (hasExplicitParameterType(param, ts)) return []; - const paramIndex = fn.parameters.indexOf(param); - if (paramIndex < 0) + // 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)) return []; - const types = []; - for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; - if (!arg) - continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); + ctx.visiting.add(param); + try { + const fn = param.parent; + if (!ts.isFunctionLike(fn)) + return []; + const nameNode = getReferenceNameNode(fn, ts); + if (!nameNode) + return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) + return []; + const types = []; + for (const call of collectCallSites(ctx, nameNode)) { + const arg = call.arguments[paramIndex]; + if (!arg) + continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + const result = dedupeTypes(checker, types); + ctx.memo.set(param, { atDepth: depth, types: result }); + return result; + } + finally { + ctx.visiting.delete(param); } - const result = dedupeTypes(checker, types); - ctx.memo.set(param, { atDepth: depth, types: result }); - return result; } /** * Recursively walks a function body collecting `return` expressions, without @@ -374,13 +420,14 @@ function collectReturnExpressions(fn, ts) { */ function inferReturnType(ctx, fn, depth = 0) { const { ts, checker } = 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 []; - const cached = ctx.memo.get(fn); - if (cached && cached.atDepth <= depth) - return cached.types; if (ctx.visiting.has(fn)) return []; ctx.visiting.add(fn); @@ -433,7 +480,10 @@ function typesToCompletionEntries(ts, checker, types) { const seen = new Set(); const entries = []; for (const type of types) { - for (const sym of checker.getPropertiesOfType(type)) { + // getApparentType so a primitive candidate (string/number/boolean) picks + // up its wrapper-object members (.length, .toUpperCase(), etc.), which + // live there rather than on the primitive type's own declared members. + for (const sym of checker.getPropertiesOfType(checker.getApparentType(type))) { const name = sym.getName(); if (seen.has(name)) continue; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 866b9d4b8..fd5f044ac 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -687,7 +687,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { }; return {...original, documentation: [...(original.documentation ?? []), note]}; } catch (e) { - log(`inferUsage hover failed: ${(e as Error).message}`); + log(`usage-inference hover failed: ${(e as Error).message}`); return original; } }; @@ -727,7 +727,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { entries: merged, }; } catch (e) { - log(`inferUsage completions failed: ${(e as Error).message}`); + log(`usage-inference completions failed: ${(e as Error).message}`); return original; } }; diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 4fae824bc..96303d900 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -23,14 +23,22 @@ const MAX_REFERENCE_HOPS = 2; // unlimited. 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. +const MAX_REFERENCES_PER_CALL = 50; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; interface MemoEntry { /** - * Remaining recursion budget (MAX_INFERENCE_DEPTH - depth) at the time this - * was computed. A result computed with equal-or-more remaining budget is - * always safe to reuse for a request needing equal-or-less budget, since - * more budget can only surface the same types or more, never fewer. + * 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[]; @@ -231,11 +239,12 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): const calls: tsserver.CallExpression[] = []; 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 && ctx.referenceBudget > 0; hop++) { + for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { const nextFrontier: tsserver.Identifier[] = []; for (const name of frontier) { - if (ctx.referenceBudget <= 0) break; + if (localBudget <= 0) break; const sourceFile = name.getSourceFile(); const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; if (seenNameKeys.has(key)) continue; @@ -243,7 +252,8 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { - if (ctx.referenceBudget <= 0) break; + if (localBudget <= 0) break; + localBudget--; ctx.referenceBudget--; const refFile = program.getSourceFile(ref.fileName); if (!refFile) continue; @@ -302,6 +312,15 @@ function resolveCalleeDeclaration( return undefined; } +/** + * 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: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { + return checker.getBaseTypeOfLiteralType(type); +} + /** Deduplicates candidate types by their display string. */ function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tsserver.Type[] { const seen = new Set(); @@ -327,13 +346,25 @@ function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tss function resolveExpressionTypes(ctx: InferenceContext, expr: tsserver.Expression, depth: number): tsserver.Type[] { const {ts, checker} = ctx; const direct = checker.getTypeAtLocation(expr); - if (!isAnyType(ts, direct)) return [direct]; + if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; if (ts.isCallExpression(expr)) { const calleeFn = resolveCalleeDeclaration(ctx, expr); if (calleeFn) { const inferred = inferReturnType(ctx, calleeFn, depth + 1); if (inferred.length > 0) return inferred; } + } else if (ts.isPropertyAccessExpression(expr)) { + // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented + // (an untyped parameter, say) — infer the base's type first, then look + // up this specific property on it, rather than giving up on the whole + // access just because the access itself resolved to `any`. + const propName = expr.name.text; + const propTypes: tsserver.Type[] = []; + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth)) { + const propSymbol = checker.getPropertyOfType(checker.getApparentType(baseType), propName); + if (propSymbol) propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); + } + if (propTypes.length > 0) return propTypes; } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper // that just returns/forwards one of its own params) — chase that @@ -362,27 +393,40 @@ export function inferParameterType( depth = 0, ): tsserver.Type[] { const {ts, checker} = ctx; - if (depth > MAX_INFERENCE_DEPTH) return []; - if (hasExplicitParameterType(param, ts)) return []; + // 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; - const fn = param.parent; - if (!ts.isFunctionLike(fn)) return []; - const nameNode = getReferenceNameNode(fn, ts); - if (!nameNode) return []; - const paramIndex = fn.parameters.indexOf(param); - if (paramIndex < 0) return []; - - 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)); - } + 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)) return []; + ctx.visiting.add(param); + try { + const fn = param.parent; + if (!ts.isFunctionLike(fn)) return []; + const nameNode = getReferenceNameNode(fn, ts); + if (!nameNode) return []; + const paramIndex = fn.parameters.indexOf(param); + if (paramIndex < 0) return []; - const result = dedupeTypes(checker, types); - ctx.memo.set(param, {atDepth: depth, types: result}); - return result; + 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)); + } + + const result = dedupeTypes(checker, types); + ctx.memo.set(param, {atDepth: depth, types: result}); + return result; + } finally { + ctx.visiting.delete(param); + } } /** @@ -419,10 +463,11 @@ function collectReturnExpressions(fn: tsserver.SignatureDeclaration, ts: typeof */ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDeclaration, depth = 0): tsserver.Type[] { const {ts, checker} = ctx; - if (depth > MAX_INFERENCE_DEPTH) return []; - if (hasExplicitReturnType(fn, ts)) return []; + // 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)) return []; ctx.visiting.add(fn); try { @@ -475,7 +520,10 @@ export function typesToCompletionEntries( const seen = new Set(); const entries: tsserver.CompletionEntry[] = []; for (const type of types) { - for (const sym of checker.getPropertiesOfType(type)) { + // getApparentType so a primitive candidate (string/number/boolean) picks + // up its wrapper-object members (.length, .toUpperCase(), etc.), which + // live there rather than on the primitive type's own declared members. + for (const sym of checker.getPropertiesOfType(checker.getApparentType(type))) { const name = sym.getName(); if (seen.has(name)) continue; seen.add(name); diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js index 81c2bce17..ece997159 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -9,9 +9,12 @@ 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. +// 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 fileNames = Object.keys(files); const compilerOptions = { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, @@ -20,12 +23,14 @@ function createFixtureHost(files, options) { strict: false, ...options, }; + const libFileName = ts.getDefaultLibFilePath(compilerOptions); + const fileNames = () => [...Object.keys(files), libFileName]; return { - getScriptFileNames: () => fileNames, + getScriptFileNames: fileNames, getScriptVersion: () => '0', getScriptSnapshot: (fileName) => { - const text = files[fileName]; + const text = files[fileName] ?? ts.sys.readFile(fileName); return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); }, getCurrentDirectory: () => '/', diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 028038316..081328d72 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -163,4 +163,83 @@ describe('create() proxy — usage inference wiring', () => { 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, ts.createDocumentRegistry()); + 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, ts.createDocumentRegistry()); + 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. + 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('quantity')); + }); }); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 21ce26d9b..c521ee973 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -312,6 +312,30 @@ describe('usage-inference', () => { 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', () => { @@ -364,6 +388,107 @@ describe('usage-inference', () => { 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('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('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', () => { @@ -420,6 +545,29 @@ describe('usage-inference', () => { 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', () => { diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 51cbb8f5c..209924a68 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -271,7 +271,7 @@ "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." + "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", From 6e3eb8c3ef4e32d51082bf89f48c738cba931a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 13:03:20 +0000 Subject: [PATCH 06/43] Fix nullable-union completion bug and deep return-type chain gap; add real dw.* type test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completions silently fell back to plain global suggestions whenever the inferred type was nullable (e.g. ProductMgr.getProduct(): Product | null), since getPropertiesOfType on a union only returns members common to every constituent and null contributes none — strip the nullable part first. Also teach resolveExpressionTypes to chase a return expression that is a multi-hop method chain on the function's own untyped parameter (e.g. `return product.getPriceModel().getPrice();`), so an undocumented helper's own return type resolves instead of stopping at `any`. Add an end-to-end VS Code integration test exercising real hover/completion against a cartridge fixture, and a new test matrix exercising the inference engine against the actual bundled dw.catalog.Product/dw.order.Order/etc. types (not toy ambient stand-ins) across happy paths, deep nesting, and edge cases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- packages/b2c-script-types/plugin/index.js | 24 +- .../plugin/usage-inference.js | 37 +- packages/b2c-script-types/src/index.ts | 22 +- .../b2c-script-types/src/usage-inference.ts | 35 +- .../test/helpers/real-dw-types.js | 52 +++ packages/b2c-script-types/test/index.test.js | 50 +++ .../test/usage-inference.real-types.test.js | 327 ++++++++++++++++++ .../test/usage-inference.test.js | 33 ++ packages/b2c-vs-extension/.vscode-test.mjs | 15 + packages/b2c-vs-extension/eslint.config.mjs | 7 +- .../.vscode/settings.json | 6 + .../cartridges/test_cartridge/.project | 15 + .../cartridge/scripts/helpers/priceHelper.js | 27 ++ .../fixtures/infer-usage-workspace/dw.json | 6 + .../script-types-infer-usage.test.ts | 120 +++++++ 15 files changed, 761 insertions(+), 15 deletions(-) create mode 100644 packages/b2c-script-types/test/helpers/real-dw-types.js create mode 100644 packages/b2c-script-types/test/usage-inference.real-types.test.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/.vscode/settings.json create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/.project create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/priceHelper.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json create mode 100644 packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 30987150f..123741c7c 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -594,7 +594,18 @@ function init({ typescript: ts }) { return types; }; proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + // The underlying call is not ours to trust unconditionally — TS's own + // quick-info resolution can throw on unusual ASTs (e.g. mid-edit syntax + // errors), and a plugin override throwing takes the whole tsserver + // request down with it instead of degrading to no hover. + let original; + try { + original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + } + catch (e) { + log(`usage-inference hover failed: underlying getQuickInfoAtPosition threw: ${e.message}`); + return undefined; + } if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; try { @@ -626,7 +637,16 @@ function init({ typescript: ts }) { } }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + // Same reasoning as getQuickInfoAtPosition above: don't let an + // exception from the underlying call escape uncaught. + let original; + try { + original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + } + catch (e) { + log(`usage-inference completions failed: underlying getCompletionsAtPosition threw: ${e.message}`); + return undefined; + } if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; try { diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 7407faa5d..a87a64bc6 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -303,6 +303,29 @@ function resolveExpressionTypes(ctx, expr, depth) { if (inferred.length > 0) return inferred; } + if (ts.isPropertyAccessExpression(expr.expression)) { + // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the + // receiver's own base is undocumented — resolveCalleeDeclaration can't + // find a real declaration since the checker never got far enough to + // resolve the method itself. Infer the receiver's type first (recursing + // through as many chained calls/property accesses as it takes to reach + // an untyped parameter or undocumented helper), then look up this + // method by name on that resolved type's real, documented signature(s). + const methodAccess = expr.expression; + const methodName = methodAccess.name.text; + const returnTypes = []; + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth)) { + const methodSymbol = checker.getPropertyOfType(checker.getApparentType(receiverType), methodName); + if (!methodSymbol) + continue; + const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); + for (const sig of methodType.getCallSignatures()) { + returnTypes.push(widenType(checker, checker.getReturnTypeOfSignature(sig))); + } + } + if (returnTypes.length > 0) + return returnTypes; + } } else if (ts.isPropertyAccessExpression(expr)) { // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented @@ -480,10 +503,16 @@ function typesToCompletionEntries(ts, checker, types) { const seen = new Set(); const entries = []; for (const type of types) { - // getApparentType so a primitive candidate (string/number/boolean) picks - // up its wrapper-object members (.length, .toUpperCase(), etc.), which - // live there rather than on the primitive type's own declared members. - for (const sym of checker.getPropertiesOfType(checker.getApparentType(type))) { + // getPropertiesOfType on a union only returns members common to *every* + // constituent — since `null`/`undefined` contribute none, a candidate + // like `Product | null` (the real, common shape of an SFCC getter that + // can return nothing) would otherwise always synthesize zero entries. + // Strip the nullable parts first; getApparentType then 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. + const nonNullable = checker.getNonNullableType(type); + for (const sym of checker.getPropertiesOfType(checker.getApparentType(nonNullable))) { const name = sym.getName(); if (seen.has(name)) continue; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index fd5f044ac..d1bc029a6 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -666,7 +666,17 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { }; proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + // The underlying call is not ours to trust unconditionally — TS's own + // quick-info resolution can throw on unusual ASTs (e.g. mid-edit syntax + // errors), and a plugin override throwing takes the whole tsserver + // request down with it instead of degrading to no hover. + let original: tsserver.QuickInfo | undefined; + try { + original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + } catch (e) { + log(`usage-inference hover failed: underlying getQuickInfoAtPosition threw: ${(e as Error).message}`); + return undefined; + } if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; try { const program = info.languageService.getProgram(); @@ -693,7 +703,15 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + // Same reasoning as getQuickInfoAtPosition above: don't let an + // exception from the underlying call escape uncaught. + let original: tsserver.WithMetadata | undefined; + try { + original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); + } catch (e) { + log(`usage-inference completions failed: underlying getCompletionsAtPosition threw: ${(e as Error).message}`); + return undefined; + } if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; try { const program = info.languageService.getProgram(); diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 96303d900..b2acd662d 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -353,6 +353,27 @@ function resolveExpressionTypes(ctx: InferenceContext, expr: tsserver.Expression const inferred = inferReturnType(ctx, calleeFn, depth + 1); if (inferred.length > 0) return inferred; } + if (ts.isPropertyAccessExpression(expr.expression)) { + // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the + // receiver's own base is undocumented — resolveCalleeDeclaration can't + // find a real declaration since the checker never got far enough to + // resolve the method itself. Infer the receiver's type first (recursing + // through as many chained calls/property accesses as it takes to reach + // an untyped parameter or undocumented helper), then look up this + // method by name on that resolved type's real, documented signature(s). + const methodAccess = expr.expression; + const methodName = methodAccess.name.text; + const returnTypes: tsserver.Type[] = []; + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth)) { + const methodSymbol = checker.getPropertyOfType(checker.getApparentType(receiverType), methodName); + if (!methodSymbol) continue; + const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); + for (const sig of methodType.getCallSignatures()) { + returnTypes.push(widenType(checker, checker.getReturnTypeOfSignature(sig))); + } + } + if (returnTypes.length > 0) return returnTypes; + } } else if (ts.isPropertyAccessExpression(expr)) { // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented // (an untyped parameter, say) — infer the base's type first, then look @@ -520,10 +541,16 @@ export function typesToCompletionEntries( const seen = new Set(); const entries: tsserver.CompletionEntry[] = []; for (const type of types) { - // getApparentType so a primitive candidate (string/number/boolean) picks - // up its wrapper-object members (.length, .toUpperCase(), etc.), which - // live there rather than on the primitive type's own declared members. - for (const sym of checker.getPropertiesOfType(checker.getApparentType(type))) { + // getPropertiesOfType on a union only returns members common to *every* + // constituent — since `null`/`undefined` contribute none, a candidate + // like `Product | null` (the real, common shape of an SFCC getter that + // can return nothing) would otherwise always synthesize zero entries. + // Strip the nullable parts first; getApparentType then 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. + const nonNullable = checker.getNonNullableType(type); + for (const sym of checker.getPropertiesOfType(checker.getApparentType(nonNullable))) { const name = sym.getName(); if (seen.has(name)) continue; seen.add(name); 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..bd02be177 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -0,0 +1,52 @@ +/* + * 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'), + 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'), +}; + +/** + * 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.test.js b/packages/b2c-script-types/test/index.test.js index 081328d72..066db5a31 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -12,6 +12,7 @@ const ts = require('typescript'); const init = require('../plugin/index'); const {createFixtureHost} = require('./helpers/fixture-language-service'); +const {REAL_DW_TYPES} = require('./helpers/real-dw-types'); const AMBIENT_TYPES = ` declare function getProduct(): {ID: string; name: string}; @@ -242,4 +243,53 @@ describe('create() proxy — usage inference wiring', () => { const afterText = (after?.documentation ?? []).map((p) => p.text).join(''); assert.ok(afterText.includes('quantity')); }); + + 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, ts.createDocumentRegistry()); + 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')); + }); }); 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..1888226d0 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -0,0 +1,327 @@ +/* + * 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, + typesToCompletionEntries, +} = require('../plugin/usage-inference'); +const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); + +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; +} + +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', () => { + it('infers dw.catalog.Product for an undocumented parameter from a single ProductMgr.getProduct() call site', () => { + const 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); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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 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); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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.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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/orderHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, 'getOrderNumber'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx.checker, types), 'Order'); + }); + }); + + describe('deep nesting', () => { + it("resolves an undocumented helper's own return type through a real two-hop method chain (product.getPriceModel().getPrice())", () => { + const 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); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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 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); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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 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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/customerHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, 'resolveProductPrice'); + + 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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const fn = findFunctionDeclaration(sourceFile, '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('unions candidate types across call sites passing different real dw.* classes (Product and 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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/consumer.js'); + const fn = findFunctionDeclaration(sourceFile, 'describe'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx.checker, types), 'Product | Category'); + }); + + it('does not infer a false-positive type for a Product-shaped helper that is never called', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Product'], ''), + '/productHelpers.js': ` + function getDisplayName(product) { + return product.getName(); + } + `, + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, 'getDisplayName'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(types.length, 0); + }); + + 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 languageService = createFixtureLanguageService(files, {strict: true}); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/variantHelpers.js'); + const fn = findFunctionDeclaration(sourceFile, '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 index c521ee973..27b179c3e 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -546,6 +546,39 @@ describe('usage-inference', () => { 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': ` diff --git a/packages/b2c-vs-extension/.vscode-test.mjs b/packages/b2c-vs-extension/.vscode-test.mjs index 3f8762b23..90762feac 100644 --- a/packages/b2c-vs-extension/.vscode-test.mjs +++ b/packages/b2c-vs-extension/.vscode-test.mjs @@ -49,4 +49,19 @@ 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, + }, + }, ]); 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/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/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/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/dw.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json new file mode 100644 index 000000000..1d38b945b --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json @@ -0,0 +1,6 @@ +{ + "hostname": "test-fixture.invalid", + "username": "fixture-user", + "password": "not-a-real-password", + "code-version": "version1" +} 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..93e5acd83 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +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 member we + // expect instead, the same way the hover test above waits for its + // specific text rather than "any hover content". + 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 : undefined; + }, 25000); + + // eslint-disable-next-line no-console + console.log(`[diagnostic] ${labels.length} completion label(s): ${labels.join(', ')}`); + assert.ok(labels.includes('getID'), `expected getID among completions, got: ${labels.join(', ')}`); + assert.ok(labels.includes('getName'), `expected getName among completions, got: ${labels.join(', ')}`); + }); +}); From fc335ec711347b8cd373a765c10c7877997d0b9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 13:52:51 +0000 Subject: [PATCH 07/43] Fix findings from code review of the nullable-union/deep-chain fix - resolveExpressionTypes's method-chain branch (and the pre-existing property-access branch) didn't strip nullability before looking up a member, so a chain rooted in a real nullable SFCC getter (e.g. ProductMgr.getProduct(): Product | null) silently resolved to nothing. Extracted a shared getNonNullableApparentType()/getMemberOfType() helper used by both branches and typesToCompletionEntries, so the fix applies everywhere a candidate type's members are walked. - The new try/catch wrappers around the underlying language service calls unconditionally swallowed ts.OperationCanceledException (thrown whenever the host's CancellationToken fires, e.g. the user kept typing), turning ordinary request cancellation into a logged failure. Consolidated the four call sites into one guarded() helper that always rethrows cancellation. - The new chain-hop recursion had no cap distinct from MAX_INFERENCE_DEPTH and didn't dedupe per hop; added a dedicated MAX_CHAIN_HOPS bound and dedupeTypes() on the accumulated results. - Deduplicated the findFunctionDeclaration test helper into fixture-language-service.js and collapsed repetitive setup in usage-inference.real-types.test.js via a shared setupInference() helper. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6 --- packages/b2c-script-types/plugin/index.js | 56 +++--- .../plugin/usage-inference.js | 62 +++++-- packages/b2c-script-types/src/index.ts | 163 +++++++++-------- .../b2c-script-types/src/usage-inference.ts | 73 ++++++-- .../test/helpers/fixture-language-service.js | 19 +- packages/b2c-script-types/test/index.test.js | 41 +++++ .../test/usage-inference.real-types.test.js | 164 +++++++----------- .../test/usage-inference.test.js | 77 ++++++-- 8 files changed, 401 insertions(+), 254 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 123741c7c..921339ba0 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -593,22 +593,33 @@ function init({ typescript: ts }) { inferenceCache.set(cacheKey, types); return types; }; - proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - // The underlying call is not ours to trust unconditionally — TS's own - // quick-info resolution can throw on unusual ASTs (e.g. mid-edit syntax - // errors), and a plugin override throwing takes the whole tsserver - // request down with it instead of degrading to no hover. - let original; + // Runs `fn` (either the underlying, un-proxied language service call, or + // our own inference logic on top of it) and degrades to `fallback` if it + // throws, so a plugin override throwing can't take the whole tsserver + // request down with it. `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 { - original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + return fn(); } catch (e) { - log(`usage-inference hover failed: underlying getQuickInfoAtPosition threw: ${e.message}`); - return undefined; + if (e instanceof ts.OperationCanceledException) + throw e; + log(`usage-inference ${label} failed: ${e.message}`); + return fallback; } + }; + proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { + const original = guarded('hover', () => info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength), undefined); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; - try { + return guarded('hover', () => { const program = info.languageService.getProgram(); const sourceFile = program?.getSourceFile(fileName); if (!program || !sourceFile) @@ -630,26 +641,13 @@ function init({ typescript: ts }) { kind: 'text', }; return { ...original, documentation: [...(original.documentation ?? []), note] }; - } - catch (e) { - log(`usage-inference hover failed: ${e.message}`); - return original; - } + }, original); }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - // Same reasoning as getQuickInfoAtPosition above: don't let an - // exception from the underlying call escape uncaught. - let original; - try { - original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - } - catch (e) { - log(`usage-inference completions failed: underlying getCompletionsAtPosition threw: ${e.message}`); - return undefined; - } + const original = guarded('completions', () => info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings), undefined); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; - try { + return guarded('completions', () => { const program = info.languageService.getProgram(); const sourceFile = program?.getSourceFile(fileName); if (!program || !sourceFile) @@ -687,11 +685,7 @@ function init({ typescript: ts }) { isNewIdentifierLocation: false, entries: merged, }; - } - catch (e) { - log(`usage-inference completions failed: ${e.message}`); - return original; - } + }, original); }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index a87a64bc6..87fc2c114 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -31,6 +31,14 @@ const MAX_REFERENCES_PER_REQUEST = 200; // exhaust the whole budget and starve the others processed later in the same // request. const MAX_REFERENCES_PER_CALL = 50; +// Bounds 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. +const MAX_CHAIN_HOPS = 10; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; /** * Builds a fresh inference context for one top-level hover/completion @@ -282,20 +290,49 @@ function dedupeTypes(checker, types) { } 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); +} /** * 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) { +function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const { ts, checker } = ctx; const direct = checker.getTypeAtLocation(expr); if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; + if (chainHops >= MAX_CHAIN_HOPS) + return []; if (ts.isCallExpression(expr)) { const calleeFn = resolveCalleeDeclaration(ctx, expr); if (calleeFn) { @@ -314,8 +351,8 @@ function resolveExpressionTypes(ctx, expr, depth) { const methodAccess = expr.expression; const methodName = methodAccess.name.text; const returnTypes = []; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth)) { - const methodSymbol = checker.getPropertyOfType(checker.getApparentType(receiverType), methodName); + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { + const methodSymbol = getMemberOfType(checker, receiverType, methodName); if (!methodSymbol) continue; const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); @@ -324,7 +361,7 @@ function resolveExpressionTypes(ctx, expr, depth) { } } if (returnTypes.length > 0) - return returnTypes; + return dedupeTypes(checker, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { @@ -334,13 +371,13 @@ function resolveExpressionTypes(ctx, expr, depth) { // access just because the access itself resolved to `any`. const propName = expr.name.text; const propTypes = []; - for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth)) { - const propSymbol = checker.getPropertyOfType(checker.getApparentType(baseType), propName); + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { + const propSymbol = getMemberOfType(checker, baseType, propName); if (propSymbol) propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); } if (propTypes.length > 0) - return propTypes; + return dedupeTypes(checker, propTypes); } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper @@ -503,16 +540,7 @@ function typesToCompletionEntries(ts, checker, types) { const seen = new Set(); const entries = []; for (const type of types) { - // getPropertiesOfType on a union only returns members common to *every* - // constituent — since `null`/`undefined` contribute none, a candidate - // like `Product | null` (the real, common shape of an SFCC getter that - // can return nothing) would otherwise always synthesize zero entries. - // Strip the nullable parts first; getApparentType then 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. - const nonNullable = checker.getNonNullableType(type); - for (const sym of checker.getPropertiesOfType(checker.getApparentType(nonNullable))) { + for (const sym of checker.getPropertiesOfType(getNonNullableApparentType(checker, type))) { const name = sym.getName(); if (seen.has(name)) continue; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index d1bc029a6..06614b2d7 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -665,89 +665,102 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return types; }; - proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - // The underlying call is not ours to trust unconditionally — TS's own - // quick-info resolution can throw on unusual ASTs (e.g. mid-edit syntax - // errors), and a plugin override throwing takes the whole tsserver - // request down with it instead of degrading to no hover. - let original: tsserver.QuickInfo | undefined; + // Runs `fn` (either the underlying, un-proxied language service call, or + // our own inference logic on top of it) and degrades to `fallback` if it + // throws, so a plugin override throwing can't take the whole tsserver + // request down with it. `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 { - original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); + return fn(); } catch (e) { - log(`usage-inference hover failed: underlying getQuickInfoAtPosition threw: ${(e as Error).message}`); - return undefined; + 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 = guarded( + 'hover', + () => info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength), + undefined, + ); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; - try { - 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(); - if (!isAnyType(ts, checker.getTypeAtLocation(node))) return original; - const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { - const ctx = createInferenceContext(ts, info.languageService); - return ctx ? inferTypeForNode(ctx, node) : []; - }); - if (types.length === 0) return original; - const note: tsserver.SymbolDisplayPart = { - text: `\n\nInferred from usage: ${describeTypes(checker, types)}`, - kind: 'text', - }; - return {...original, documentation: [...(original.documentation ?? []), note]}; - } catch (e) { - log(`usage-inference hover failed: ${(e as Error).message}`); - 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(); + if (!isAnyType(ts, checker.getTypeAtLocation(node))) return original; + const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { + const ctx = createInferenceContext(ts, info.languageService); + return ctx ? inferTypeForNode(ctx, node) : []; + }); + if (types.length === 0) return original; + const note: tsserver.SymbolDisplayPart = { + text: `\n\nInferred from usage: ${describeTypes(checker, types)}`, + kind: 'text', + }; + return {...original, documentation: [...(original.documentation ?? []), note]}; + }, + original, + ); }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - // Same reasoning as getQuickInfoAtPosition above: don't let an - // exception from the underlying call escape uncaught. - let original: tsserver.WithMetadata | undefined; - try { - original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - } catch (e) { - log(`usage-inference completions failed: underlying getCompletionsAtPosition threw: ${(e as Error).message}`); - return undefined; - } + const original = guarded( + 'completions', + () => info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings), + undefined, + ); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; - try { - 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 || !ts.isIdentifier(propAccess.expression)) return original; - const checker = program.getTypeChecker(); - if (!isAnyType(ts, checker.getTypeAtLocation(propAccess.expression))) return original; - const baseNode = propAccess.expression; - const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { - const ctx = createInferenceContext(ts, info.languageService); - return ctx ? inferTypeForNode(ctx, baseNode) : []; - }); - if (types.length === 0) return original; - const inferredEntries = typesToCompletionEntries(ts, checker, types); - if (inferredEntries.length === 0) return original; - const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); - const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; - // 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, - }; - } catch (e) { - log(`usage-inference completions failed: ${(e as Error).message}`); - 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 || !ts.isIdentifier(propAccess.expression)) return original; + const checker = program.getTypeChecker(); + if (!isAnyType(ts, checker.getTypeAtLocation(propAccess.expression))) return original; + const baseNode = propAccess.expression; + const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { + const ctx = createInferenceContext(ts, info.languageService); + return ctx ? inferTypeForNode(ctx, baseNode) : []; + }); + if (types.length === 0) return original; + const inferredEntries = typesToCompletionEntries(ts, checker, types); + if (inferredEntries.length === 0) return original; + const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); + const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + // 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})`); diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index b2acd662d..26f702488 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -30,6 +30,15 @@ const MAX_REFERENCES_PER_REQUEST = 200; // request. const MAX_REFERENCES_PER_CALL = 50; +// Bounds 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. +const MAX_CHAIN_HOPS = 10; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; interface MemoEntry { @@ -334,19 +343,58 @@ function dedupeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): tss 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: 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}. */ +function getMemberOfType( + checker: tsserver.TypeChecker, + type: tsserver.Type, + name: string, +): tsserver.Symbol | undefined { + return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); +} + /** * 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): tsserver.Type[] { +function resolveExpressionTypes( + ctx: InferenceContext, + expr: tsserver.Expression, + depth: number, + chainHops = 0, +): tsserver.Type[] { const {ts, checker} = ctx; const direct = checker.getTypeAtLocation(expr); if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; + if (chainHops >= MAX_CHAIN_HOPS) return []; if (ts.isCallExpression(expr)) { const calleeFn = resolveCalleeDeclaration(ctx, expr); if (calleeFn) { @@ -364,15 +412,15 @@ function resolveExpressionTypes(ctx: InferenceContext, expr: tsserver.Expression const methodAccess = expr.expression; const methodName = methodAccess.name.text; const returnTypes: tsserver.Type[] = []; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth)) { - const methodSymbol = checker.getPropertyOfType(checker.getApparentType(receiverType), methodName); + for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { + const methodSymbol = getMemberOfType(checker, receiverType, methodName); if (!methodSymbol) continue; const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); for (const sig of methodType.getCallSignatures()) { returnTypes.push(widenType(checker, checker.getReturnTypeOfSignature(sig))); } } - if (returnTypes.length > 0) return returnTypes; + if (returnTypes.length > 0) return dedupeTypes(checker, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented @@ -381,11 +429,11 @@ function resolveExpressionTypes(ctx: InferenceContext, expr: tsserver.Expression // access just because the access itself resolved to `any`. const propName = expr.name.text; const propTypes: tsserver.Type[] = []; - for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth)) { - const propSymbol = checker.getPropertyOfType(checker.getApparentType(baseType), propName); + for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { + const propSymbol = getMemberOfType(checker, baseType, propName); if (propSymbol) propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); } - if (propTypes.length > 0) return propTypes; + if (propTypes.length > 0) return dedupeTypes(checker, propTypes); } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper // that just returns/forwards one of its own params) — chase that @@ -541,16 +589,7 @@ export function typesToCompletionEntries( const seen = new Set(); const entries: tsserver.CompletionEntry[] = []; for (const type of types) { - // getPropertiesOfType on a union only returns members common to *every* - // constituent — since `null`/`undefined` contribute none, a candidate - // like `Product | null` (the real, common shape of an SFCC getter that - // can return nothing) would otherwise always synthesize zero entries. - // Strip the nullable parts first; getApparentType then 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. - const nonNullable = checker.getNonNullableType(type); - for (const sym of checker.getPropertiesOfType(checker.getApparentType(nonNullable))) { + for (const sym of checker.getPropertiesOfType(getNonNullableApparentType(checker, type))) { const name = sym.getName(); if (seen.has(name)) continue; seen.add(name); diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js index ece997159..128bfcbb6 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -51,4 +51,21 @@ function createFixtureLanguageService(files, options) { return ts.createLanguageService(host, ts.createDocumentRegistry()); } -module.exports = {createFixtureHost, createFixtureLanguageService}; +// 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}; diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 066db5a31..bda5bcdf9 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -292,4 +292,45 @@ describe('create() proxy — usage inference wiring', () => { assert.ok(names.includes('getID')); assert.ok(names.includes('getName')); }); + + 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, ts.createDocumentRegistry()); + 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.real-types.test.js b/packages/b2c-script-types/test/usage-inference.real-types.test.js index 1888226d0..1e813d132 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -17,21 +17,18 @@ const { inferReturnType, typesToCompletionEntries, } = require('../plugin/usage-inference'); -const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); -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; +// 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) { @@ -50,23 +47,21 @@ function completionNames(ts_, checker, types) { // 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 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); - } - `, - }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'getDisplayName'); + const {ctx, fn} = setupInference(PRODUCT_HELPER_FILES, '/productHelpers.js', 'getDisplayName'); const types = inferParameterType(ctx, fn.parameters[0]); @@ -74,22 +69,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); it('offers real dw.catalog.Product members (getID, getName, getPriceModel) as synthesized completions', () => { - const 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); - } - `, - }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'getDisplayName'); + const {ctx, fn} = setupInference(PRODUCT_HELPER_FILES, '/productHelpers.js', 'getDisplayName'); const types = inferParameterType(ctx, fn.parameters[0]); const names = completionNames(ts, ctx.checker, types); @@ -112,10 +92,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/orderHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'getOrderNumber'); + const {ctx, fn} = setupInference(files, '/orderHelpers.js', 'getOrderNumber'); const types = inferParameterType(ctx, fn.parameters[0]); @@ -124,23 +101,21 @@ describe('usage-inference — real dw.* Script API types (Product, 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 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); - } - `, - }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'resolveProductPrice'); + const {ctx, fn} = setupInference(PRICING_HELPER_FILES, '/pricingHelpers.js', 'resolveProductPrice'); const types = inferReturnType(ctx, fn); @@ -148,28 +123,39 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); 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 = { - '/types.d.ts': realTypesPrelude(['Product', 'ProductMgr'], ' function getSomeProduct(): Product;'), '/pricingHelpers.js': ` function resolveProductPrice(product) { return product.getPriceModel().getPrice(); } function useHelper() { - var product = getSomeProduct(); + var ProductMgr = require('${REAL_DW_TYPES.ProductMgr}'); + var product = ProductMgr.getProduct('some-id'); return resolveProductPrice(product); } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'resolveProductPrice'); + const {ctx, fn} = setupInference(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')); + assert.equal(describeTypes(ctx.checker, types), 'Money'); }); it('resolves a three-hop chain (order.getCustomer().getProfile().getEmail()) through an undocumented helper', () => { @@ -185,10 +171,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/customerHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'resolveCustomerEmail'); + const {ctx, fn} = setupInference(files, '/customerHelpers.js', 'resolveCustomerEmail'); const types = inferReturnType(ctx, fn); @@ -211,10 +194,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/pricingHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'resolveProductPrice'); + const {ctx, fn} = setupInference(files, '/pricingHelpers.js', 'resolveProductPrice'); const types = inferReturnType(ctx, fn); @@ -240,10 +220,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/consumer.js'); - const fn = findFunctionDeclaration(sourceFile, 'getDisplayName'); + const {ctx, fn} = setupInference(files, '/consumer.js', 'getDisplayName'); const types = inferParameterType(ctx, fn.parameters[0]); const names = completionNames(ts, ctx.checker, types); @@ -266,10 +243,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = describe(getSomeCategory()); `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/consumer.js'); - const fn = findFunctionDeclaration(sourceFile, 'describe'); + const {ctx, fn} = setupInference(files, '/consumer.js', 'describe'); const types = inferParameterType(ctx, fn.parameters[0]); @@ -285,10 +259,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/productHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'getDisplayName'); + const {ctx, fn} = setupInference(files, '/productHelpers.js', 'getDisplayName'); const types = inferParameterType(ctx, fn.parameters[0]); @@ -311,10 +282,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = } `, }; - const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx.program.getSourceFile('/variantHelpers.js'); - const fn = findFunctionDeclaration(sourceFile, 'countVariants'); + const {ctx, fn} = setupInference(files, '/variantHelpers.js', 'countVariants'); const types = inferParameterType(ctx, fn.parameters[0]); const names = completionNames(ts, ctx.checker, types); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 27b179c3e..f4bd72417 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -20,27 +20,13 @@ const { inferTypeForNode, typesToCompletionEntries, } = require('../plugin/usage-inference'); -const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); const AMBIENT_TYPES = ` declare function getProduct(): {ID: string; name: string}; declare function getInventory(): {quantity: number}; `; -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; -} - describe('usage-inference', () => { describe('inferParameterType', () => { it('infers a parameter type from a single call site', () => { @@ -413,6 +399,67 @@ describe('usage-inference', () => { 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 From b02a9f776dd4121743f624d7d7d9e5fb1a058d50 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:09:29 +0000 Subject: [PATCH 08/43] Fix audit findings in usage-based type inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Chase local-variable indirection: a chain split across intermediate vars (var priceModel = product.getPriceModel(); return priceModel.getPrice();) — the idiomatic SFCC style — now infers the same types as the inline expression, for return inference, hover on the variable, and member completions. Explicit @type annotations on a variable are respected (deliberate `any` is left alone), and mutually-referencing initializers are cycle-guarded. - Offer inferred completions when the receiver is a chained call (product.getPriceModel().|), not just a bare identifier. - Stop memoizing results whose computation hit a cycle guard — they are truncated by what happened to be on the call stack, and a later out-of-cycle query in the same request deserves the full answer. Error handling: - Only guard the plugin's own inference additions; exceptions from the underlying language-service calls propagate to tsserver exactly as they would without the plugin installed (cancellation was already rethrown; now genuine TS errors are no longer swallowed either). Packaging/infra: - Keep the license header in the emitted plugin/usage-inference.js (detach it from the type-only import that tsc elides). - Distinguish method vs property completion kinds; document sortText. - failZero on the infer-usage vscode-test label so a run that discovers zero tests fails instead of passing vacuously. - Trigger extension CI on packages/b2c-script-types/** changes and run CI for PRs targeting feature/** branches. - Add c8 coverage to the package test script; dedupe test:unit; ignore package-level coverage/ output. - Correct the reference-budget comment: it caps result processing and fan-out, not the cost of a single project-wide reference search. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6 --- .github/workflows/ci-vs-extension.yml | 3 + .github/workflows/ci.yml | 1 + .gitignore | 1 + docs/guide/ide-integration.md | 2 +- packages/b2c-script-types/package.json | 5 +- packages/b2c-script-types/plugin/index.js | 38 ++-- .../plugin/usage-inference.js | 119 ++++++++++-- packages/b2c-script-types/src/index.ts | 47 ++--- .../b2c-script-types/src/usage-inference.ts | 131 +++++++++++++- packages/b2c-script-types/test/index.test.js | 81 +++++++++ .../test/usage-inference.real-types.test.js | 25 +++ .../test/usage-inference.test.js | 171 ++++++++++++++++++ packages/b2c-vs-extension/.vscode-test.mjs | 4 + pnpm-lock.yaml | 9 +- 14 files changed, 572 insertions(+), 65 deletions(-) diff --git a/.github/workflows/ci-vs-extension.yml b/.github/workflows/ci-vs-extension.yml index cddb5bf9c..435f33086 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' @@ -15,8 +16,10 @@ on: branches: - main - develop + - 'feature/**' paths: - 'packages/b2c-vs-extension/**' + - 'packages/b2c-script-types/**' - 'packages/b2c-tooling-sdk/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15c489fac..7a330ad5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ on: - main - develop - 'release/**' + - 'feature/**' workflow_dispatch: permissions: 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 49918459c..318317016 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -117,7 +117,7 @@ If your editor's LSP client is launched outside the repo root (for example, open JSDoc-documented functions get full hover/completion support because TypeScript reads the `@param`/`@returns` annotations directly. Plain, undocumented helper functions don't — an unannotated parameter or return value gets widened to `any`, and that `any` propagates to every caller, silencing completion for anything built on top of it. -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) rather than stopping at the first `any`. +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`. Inferred results are heuristic and clearly labeled: diff --git a/packages/b2c-script-types/package.json b/packages/b2c-script-types/package.json index a595ef139..7d0253eea 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -29,14 +29,15 @@ "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", - "test": "pnpm run build && node --test", + "test": "pnpm run build && c8 node --test", "test:agent": "pnpm run build && node --test --test-reporter=dot", - "test:unit": "pnpm run build && node --test", + "test:unit": "pnpm run test", "test:watch": "pnpm run build && node --test --watch" }, "devDependencies": { "@eslint/compat": "catalog:", "@types/node": "catalog:", + "c8": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", "eslint-plugin-header": "catalog:", diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 921339ba0..1254c6b9b 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -593,17 +593,22 @@ function init({ typescript: ts }) { inferenceCache.set(cacheKey, types); return types; }; - // Runs `fn` (either the underlying, un-proxied language service call, or - // our own inference logic on top of it) and degrades to `fallback` if it - // throws, so a plugin override throwing can't take the whole tsserver - // request down with it. `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. + // 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(); @@ -616,7 +621,7 @@ function init({ typescript: ts }) { } }; proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - const original = guarded('hover', () => info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength), undefined); + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; return guarded('hover', () => { @@ -644,7 +649,7 @@ function init({ typescript: ts }) { }, original); }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - const original = guarded('completions', () => info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings), undefined); + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; return guarded('completions', () => { @@ -656,15 +661,18 @@ function init({ typescript: ts }) { if (!node) return original; const propAccess = (0, usage_inference_1.findEnclosingPropertyAccess)(node, ts); - if (!propAccess || !ts.isIdentifier(propAccess.expression)) + if (!propAccess) return original; const checker = program.getTypeChecker(); if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(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 types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService); - return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, baseNode) : []; + return ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; }); if (types.length === 0) return original; diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 87fc2c114..ec5b21e22 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -1,4 +1,9 @@ "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.INFERRED_COMPLETION_SOURCE = void 0; exports.createInferenceContext = createInferenceContext; @@ -8,6 +13,7 @@ exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; exports.inferParameterType = inferParameterType; exports.inferReturnType = inferReturnType; exports.inferTypeForNode = inferTypeForNode; +exports.inferTypeForExpression = inferTypeForExpression; exports.describeTypes = describeTypes; exports.typesToCompletionEntries = typesToCompletionEntries; // Bounds how far we chase an undocumented call chain (helper calls helper calls @@ -23,7 +29,12 @@ const MAX_REFERENCE_HOPS = 2; // 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. +// 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). 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 @@ -56,6 +67,7 @@ function createInferenceContext(ts, languageService) { visiting: new Set(), memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, + cycleHits: 0, }; } /** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ @@ -252,6 +264,40 @@ function hasExplicitParameterType(param, ts) { function hasExplicitReturnType(fn, ts) { return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; } +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +function hasExplicitVariableType(decl, ts) { + return decl.type !== undefined || ts.getJSDocType(decl) !== 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. + */ +function resolveVariableInitializerTypes(ctx, decl, depth, chainHops) { + const { ts } = ctx; + if (!decl.initializer || hasExplicitVariableType(decl, ts)) + return []; + if (ctx.visiting.has(decl)) { + ctx.cycleHits++; + return []; + } + ctx.visiting.add(decl); + try { + return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + } + finally { + ctx.visiting.delete(decl); + } +} /** * 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 @@ -277,7 +323,12 @@ function resolveCalleeDeclaration(ctx, call) { function widenType(checker, type) { return checker.getBaseTypeOfLiteralType(type); } -/** Deduplicates candidate types by their display string. */ +/** + * 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(checker, types) { const seen = new Set(); const out = []; @@ -390,6 +441,14 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { if (inferred.length > 0) return inferred; } + else if (decl && ts.isVariableDeclaration(decl)) { + // ...or a local variable holding an intermediate result — chase its + // initializer the same way, so splitting a chain across `var` + // statements infers exactly like the inline expression would. + const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + if (inferred.length > 0) + return inferred; + } } return []; } @@ -417,9 +476,12 @@ function inferParameterType(ctx, param, depth = 0) { // 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)) + 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)) @@ -438,7 +500,15 @@ function inferParameterType(ctx, param, depth = 0) { types.push(...resolveExpressionTypes(ctx, arg, depth)); } const result = dedupeTypes(checker, types); - ctx.memo.set(param, { atDepth: depth, types: result }); + // 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 { @@ -488,16 +558,22 @@ function inferReturnType(ctx, fn, depth = 0) { return []; if (hasExplicitReturnType(fn, ts)) return []; - if (ctx.visiting.has(fn)) + if (ctx.visiting.has(fn)) { + ctx.cycleHits++; return []; + } ctx.visiting.add(fn); + const cycleHitsBefore = ctx.cycleHits; try { const types = []; for (const expr of collectReturnExpressions(fn, ts)) { types.push(...resolveExpressionTypes(ctx, expr, depth)); } const result = dedupeTypes(checker, types); - ctx.memo.set(fn, { atDepth: depth, types: result }); + // 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 { @@ -520,15 +596,30 @@ function inferTypeForNode(ctx, node) { return []; if (ts.isParameter(decl)) return inferParameterType(ctx, decl); - if (ts.isVariableDeclaration(decl) && decl.initializer && ts.isCallExpression(decl.initializer)) { - const calleeFn = resolveCalleeDeclaration(ctx, decl.initializer); - if (calleeFn) - return inferReturnType(ctx, calleeFn); + 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(checker, 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, checker } = ctx; + if (ts.isIdentifier(expr)) + return inferTypeForNode(ctx, expr); + return dedupeTypes(checker, resolveExpressionTypes(ctx, expr, 0)); +} /** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ function describeTypes(checker, types) { return dedupeTypes(checker, types) @@ -547,8 +638,14 @@ function typesToCompletionEntries(ts, checker, types) { seen.add(name); entries.push({ name, - kind: ts.ScriptElementKind.memberVariableElement, + // 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: exports.INFERRED_COMPLETION_SOURCE, }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 06614b2d7..67f00b1f6 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -12,6 +12,7 @@ import { describeTypes, findEnclosingPropertyAccess, getNodeAtPosition, + inferTypeForExpression, inferTypeForNode, isAnyType, typesToCompletionEntries, @@ -665,17 +666,22 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return types; }; - // Runs `fn` (either the underlying, un-proxied language service call, or - // our own inference logic on top of it) and degrades to `fallback` if it - // throws, so a plugin override throwing can't take the whole tsserver - // request down with it. `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. + // 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(); @@ -687,11 +693,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { }; proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { - const original = guarded( - 'hover', - () => info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength), - undefined, - ); + const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; return guarded( 'hover', @@ -719,11 +721,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { - const original = guarded( - 'completions', - () => info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings), - undefined, - ); + const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; return guarded( 'completions', @@ -734,13 +732,16 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); if (!node) return original; const propAccess = findEnclosingPropertyAccess(node, ts); - if (!propAccess || !ts.isIdentifier(propAccess.expression)) return original; + if (!propAccess) return original; const checker = program.getTypeChecker(); if (!isAnyType(ts, checker.getTypeAtLocation(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 types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { const ctx = createInferenceContext(ts, info.languageService); - return ctx ? inferTypeForNode(ctx, baseNode) : []; + return ctx ? inferTypeForExpression(ctx, baseNode) : []; }); if (types.length === 0) return original; const inferredEntries = typesToCompletionEntries(ts, checker, types); diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 26f702488..1e65a5c09 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -3,6 +3,11 @@ * 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 blank line above keeps the license header detached from the type-only +// import below it: tsc elides a type-only import together with any comment +// attached to it, so without the separation the emitted plugin/ JS would ship +// without its license header. import type tsserver from 'typescript/lib/tsserverlibrary'; // Bounds how far we chase an undocumented call chain (helper calls helper calls @@ -20,7 +25,12 @@ const MAX_REFERENCE_HOPS = 2; // 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. +// 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). const MAX_REFERENCES_PER_REQUEST = 200; // Caps how much of that shared request-wide budget a *single* collectCallSites @@ -76,6 +86,14 @@ export interface InferenceContext { * collectCallSites() every time it processes a reference. */ referenceBudget: number; + /** + * 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; } /** @@ -96,6 +114,7 @@ export function createInferenceContext( visiting: new Set(), memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, + cycleHits: 0, }; } @@ -302,6 +321,45 @@ function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tss return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; } +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: typeof tsserver): boolean { + return decl.type !== undefined || ts.getJSDocType(decl) !== 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. + */ +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 { + return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + } finally { + ctx.visiting.delete(decl); + } +} + /** * 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 @@ -330,7 +388,12 @@ function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver return checker.getBaseTypeOfLiteralType(type); } -/** Deduplicates candidate types by their display string. */ +/** + * 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(checker: tsserver.TypeChecker, types: tsserver.Type[]): tsserver.Type[] { const seen = new Set(); const out: tsserver.Type[] = []; @@ -443,6 +506,12 @@ function resolveExpressionTypes( if (decl && ts.isParameter(decl)) { const inferred = inferParameterType(ctx, decl, depth + 1); if (inferred.length > 0) return inferred; + } else if (decl && ts.isVariableDeclaration(decl)) { + // ...or a local variable holding an intermediate result — chase its + // initializer the same way, so splitting a chain across `var` + // statements infers exactly like the inline expression would. + const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + if (inferred.length > 0) return inferred; } } return []; @@ -473,8 +542,12 @@ export function inferParameterType( // 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)) return []; + 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 []; @@ -491,7 +564,15 @@ export function inferParameterType( } const result = dedupeTypes(checker, types); - ctx.memo.set(param, {atDepth: depth, types: result}); + // 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); @@ -537,15 +618,22 @@ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDec if (cached && cached.atDepth <= depth) return cached.types; if (depth > MAX_INFERENCE_DEPTH) return []; if (hasExplicitReturnType(fn, ts)) return []; - if (ctx.visiting.has(fn)) 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(checker, types); - ctx.memo.set(fn, {atDepth: depth, types: result}); + // 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); @@ -565,14 +653,30 @@ export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): ts const decl = sym?.valueDeclaration; if (!decl) return []; if (ts.isParameter(decl)) return inferParameterType(ctx, decl); - if (ts.isVariableDeclaration(decl) && decl.initializer && ts.isCallExpression(decl.initializer)) { - const calleeFn = resolveCalleeDeclaration(ctx, decl.initializer); - if (calleeFn) return inferReturnType(ctx, calleeFn); + 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(checker, 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, checker} = ctx; + if (ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); + return dedupeTypes(checker, resolveExpressionTypes(ctx, expr, 0)); +} + /** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { return dedupeTypes(checker, types) @@ -595,8 +699,15 @@ export function typesToCompletionEntries( seen.add(name); entries.push({ name, - kind: ts.ScriptElementKind.memberVariableElement, + // 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, }); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index bda5bcdf9..77ce21d48 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -293,6 +293,87 @@ describe('create() proxy — usage inference wiring', () => { 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, ts.createDocumentRegistry()); + 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('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, ts.createDocumentRegistry()); + 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) — 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 index 1e813d132..863d1f865 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -158,6 +158,31 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = 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;'), diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index f4bd72417..69b6c533c 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -494,6 +494,177 @@ describe('usage-inference', () => { }); }); + 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('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 = { diff --git a/packages/b2c-vs-extension/.vscode-test.mjs b/packages/b2c-vs-extension/.vscode-test.mjs index 90762feac..e30f5dc03 100644 --- a/packages/b2c-vs-extension/.vscode-test.mjs +++ b/packages/b2c-vs-extension/.vscode-test.mjs @@ -62,6 +62,10 @@ export default defineConfig([ 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, }, }, ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53edf2aab..25b8cb4bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -449,6 +449,9 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.0 + c8: + specifier: 'catalog:' + version: 11.0.0 eslint: specifier: 'catalog:' version: 9.39.1 @@ -627,15 +630,15 @@ importers: '@salesforce/b2c-tooling-sdk': specifier: workspace:* version: link:../b2c-tooling-sdk - swagger-ui-dist: - specifier: ^5.18.0 - version: 5.32.0 react: specifier: 18.3.1 version: 18.3.1 react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + swagger-ui-dist: + specifier: ^5.18.0 + version: 5.32.0 vscode-html-languageservice: specifier: 'catalog:' version: 5.6.0 From 21b77ccc5ca02fad9dfbcbd56237bf46de05ed39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:31:58 +0000 Subject: [PATCH 09/43] Harden E2E against real SFRA cartridge patterns; fix alias-map export inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studying real SFRA helper modules (app_storefront_base scripts/helpers, scripts/cart) surfaced patterns the E2E suite never exercised: cross-file call sites reached through */ and ~/ cartridge requires, the canonical `module.exports = {name: name}` alias-map export, chain hops parked in intermediate variables, and deep property chains with a nullable middle step (availabilityModel.inventoryRecord: ProductInventoryRecord | null). Engine fix found by this exercise: a reference search on a function exported through an alias map dead-ends at the map's initializer, so the SFRA-canonical export shape inferred nothing cross-file. resolveIndirectReferenceTarget now hops from a property-assignment initializer to the property name and searches on from there. E2E fixture is now a realistic mini-cartridge: an undocumented productHelpers module whose only call sites live in cartService.js via a ~/ require, plus a jsconfig.json so all cartridge files share one configured project (the setup `b2c setup ide vscode-types` recommends) — without it each open file gets its own inferred project and cross-file reference search has nothing to look at. New E2E tests (5): cross-file param inference through the plugin's own module resolution, intermediate-variable hover, chained-receiver completions, nullable deep-property-chain hover and completions. All completion assertions now require at least one member that appears nowhere in any fixture document, so VS Code's word-based suggestions cannot satisfy them vacuously — the pre-existing test asserted only members that literally occur in the fixture text. Matching unit tests: alias-map export cross-file (mechanics) and the nullable deep chain against the real dw.* types. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6 --- .../plugin/usage-inference.js | 11 ++ .../b2c-script-types/src/usage-inference.ts | 12 ++ .../test/usage-inference.real-types.test.js | 43 ++++++ .../test/usage-inference.test.js | 26 ++++ .../cartridge/scripts/cartService.js | 23 ++++ .../scripts/helpers/productHelpers.js | 33 +++++ .../infer-usage-workspace/jsconfig.json | 17 +++ .../script-types-infer-usage.test.ts | 123 +++++++++++++++++- 8 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/cartService.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/productHelpers.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/jsconfig.json diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index ec5b21e22..9ae64f7e5 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -194,6 +194,17 @@ function resolveIndirectReferenceTarget(node, ts) { // 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; } /** diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 1e65a5c09..61634c685 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -251,6 +251,18 @@ function resolveIndirectReferenceTarget( 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; } 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 index 863d1f865..8a2360ff3 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -15,6 +15,7 @@ const { describeTypes, inferParameterType, inferReturnType, + inferTypeForNode, typesToCompletionEntries, } = require('../plugin/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); @@ -291,6 +292,48 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = assert.equal(types.length, 0); }); + 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( diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 69b6c533c..bae78fc0f 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -212,6 +212,32 @@ describe('usage-inference', () => { 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, 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..ef2c7e835 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/cartService.js @@ -0,0 +1,23 @@ +'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'); + +function buildLineItemInfo(productId, quantity) { + var product = ProductMgr.getProduct(productId); + return { + price: productHelpers.getSalePrice(product), + priceValue: productHelpers.getListPriceValue(product), + orderable: productHelpers.isOrderable(product, quantity) + }; +} + +module.exports = { + buildLineItemInfo: buildLineItemInfo +}; 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/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 index 93e5acd83..f06905546 100644 --- 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 @@ -99,9 +99,11 @@ suite('scriptTypesInferUsage — real hover/completion via the VS Code language // (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 member we - // expect instead, the same way the hover test above waits for its - // specific text rather than "any hover content". + // 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', @@ -109,12 +111,125 @@ suite('scriptTypesInferUsage — real hover/completion via the VS Code language dotPosition, ); const items = result?.items.map((i) => (typeof i.label === 'string' ? i.label : i.label.label)) ?? []; - return items.includes('getID') && items.includes('getName') ? items : undefined; + return items.includes('getID') && items.includes('getName') && items.includes('getLongDescription') + ? items + : undefined; }, 25000); // eslint-disable-next-line no-console console.log(`[diagnostic] ${labels.length} completion label(s): ${labels.join(', ')}`); 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(', ')}`); }); }); From 142953a75079c147c66b343bf5c72eddb0ea94b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:49:09 +0000 Subject: [PATCH 10/43] Support module.superModule in usage inference; test cartridge overlays end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SFRA plugin cartridges extend base modules through module.superModule — the runtime handle to the same-path module in the next cartridge down the cartridge path. TypeScript knows nothing about it, so `var base = module.superModule;` and everything derived from base was opaque `any`. Engine: resolveExpressionTypes gets a module.superModule leaf. The plugin supplies a resolver mapping a cartridge file to the same-subpath file in the next cartridge down (probing existence through the language-service host, not ts.sys); the super module's export type is read off its `module.exports = X` assignment via resolveExpressionTypes, so a pass-through overlay (`module.exports = base`) recurses another cartridge down naturally, with a visiting-guard against overlay cycles. When a member found this way is an undocumented function (declared return type `any`), inference recurses into its actual declaration instead of surfacing `any` — and `any`-typed members are never offered as candidates anywhere. Fixture host fix surfaced by these tests: directoryExists forwarded to the real filesystem, so TS's directoryProbablyExists pre-check silently failed nested relative requires between in-memory fixture files — directories implied by the file map now exist. Tests: five engine-level unit tests (export-type resolution, recursion into undocumented base helpers, no-lower-cartridge, no-resolver, and a three-cartridge pass-through chain), a real-dw-types composition test (superModule + alias map + var chains -> Money), a plugin-level test of the cartridge-path resolver wiring, and three VS Code E2E tests against a new app_custom_cartridge overlay fixture (ordered above test_cartridge via dw.json's cartridges field): hover through the full composition, base-module member completions, and Money member completions — E2E completion assertions filter out word-based (Text) suggestions so only typed entries can satisfy them. Docs: ide-integration guide documents superModule support and the one-project requirement (jsconfig) for cross-file inference. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6 --- docs/guide/ide-integration.md | 4 + packages/b2c-script-types/plugin/index.js | 29 +++- .../plugin/usage-inference.js | 111 +++++++++++++- packages/b2c-script-types/src/index.ts | 27 +++- .../b2c-script-types/src/usage-inference.ts | 114 ++++++++++++++- .../test/helpers/fixture-language-service.js | 16 ++- packages/b2c-script-types/test/index.test.js | 68 +++++++++ .../test/usage-inference.real-types.test.js | 51 +++++++ .../test/usage-inference.test.js | 136 ++++++++++++++++++ .../cartridges/app_custom_cartridge/.project | 15 ++ .../scripts/helpers/productHelpers.js | 15 ++ .../fixtures/infer-usage-workspace/dw.json | 3 +- .../script-types-infer-usage.test.ts | 95 +++++++++++- 13 files changed, 671 insertions(+), 13 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/.project create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/app_custom_cartridge/cartridge/scripts/helpers/productHelpers.js diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 318317016..e24b4e3de 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -119,6 +119,10 @@ JSDoc-documented functions get full hover/completion support because TypeScript 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`. +`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. + +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. diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 1254c6b9b..257e6ea42 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -410,6 +410,31 @@ 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; + } + }; + const resolveSuperModulePath = (containingFile) => { + const owner = ownerCartridge(containingFile); + if (!owner) + return undefined; + const subpath = normalize(containingFile).slice(owner.root.length); + for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { + const candidate = cartridges[i].root + subpath; + if (hostFileExists(candidate)) + 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, @@ -636,7 +661,7 @@ function init({ typescript: ts }) { if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node))) return original; const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService); + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; }); if (types.length === 0) @@ -671,7 +696,7 @@ function init({ typescript: ts }) { // hover-driven return inference already resolves it. const baseNode = propAccess.expression; const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService); + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); return ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; }); if (types.length === 0) diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 9ae64f7e5..3e946df87 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -55,7 +55,7 @@ exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usag * 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) { +function createInferenceContext(ts, languageService, resolveSuperModulePath) { const program = languageService.getProgram(); if (!program) return undefined; @@ -68,6 +68,7 @@ function createInferenceContext(ts, languageService) { memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, cycleHits: 0, + resolveSuperModulePath, }; } /** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ @@ -279,6 +280,78 @@ function hasExplicitReturnType(fn, ts) { function hasExplicitVariableType(decl, ts) { return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; } +/** + * 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'; +} +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path (located by the + * host-supplied ctx.resolveSuperModulePath). The export type is read off the + * right-hand side of the super module's top-level `module.exports = X` + * assignment(s) via resolveExpressionTypes — so a plain alias-map export + * yields its concrete object type directly, and a pass-through overlay + * (`module.exports = base` where base is itself `module.superModule`) recurses + * naturally another cartridge down. Members an intermediate overlay adds via + * `module.exports.name = fn` afterwards aren't representable as extra + * candidate types through this hop and are not surfaced. + * + * Only works when the super module's 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 resolveSuperModuleTypes(ctx, expr, depth, chainHops) { + const { ts, checker, program } = ctx; + if (!ctx.resolveSuperModulePath) + return []; + const superPath = ctx.resolveSuperModulePath(expr.getSourceFile().fileName); + if (!superPath) + return []; + // The resolver returns host-normalized (possibly case-folded) paths; + // program keys may differ in case on case-insensitive filesystems. + let superFile = program.getSourceFile(superPath); + if (!superFile) { + const target = superPath.toLowerCase(); + superFile = program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); + } + 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 stmt of superFile.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; + const isModuleExports = ts.isPropertyAccessExpression(left) && + ts.isIdentifier(left.expression) && + left.expression.text === 'module' && + left.name.text === 'exports'; + if (!isModuleExports) + continue; + types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + } + return dedupeTypes(checker, types); + } + finally { + ctx.visiting.delete(superFile); + } +} /** * Chases a local variable's initializer expression — the missing link for the * idiomatic SFCC style of splitting a chain across intermediate variables @@ -419,7 +492,21 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { continue; const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); for (const sig of methodType.getCallSignatures()) { - returnTypes.push(widenType(checker, checker.getReturnTypeOfSignature(sig))); + 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)); + } } } if (returnTypes.length > 0) @@ -427,6 +514,16 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { } } else if (ts.isPropertyAccessExpression(expr)) { + if (isSuperModuleAccess(expr, ts)) { + // `module.superModule` — resolve to the overridden module's export + // type(s) along the cartridge path, rather than treating it as an + // ordinary property access (TS knows nothing about it, so the generic + // handling below could never resolve it). + const inferred = resolveSuperModuleTypes(ctx, expr, depth, chainHops); + if (inferred.length > 0) + return inferred; + return []; + } // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented // (an untyped parameter, say) — infer the base's type first, then look // up this specific property on it, rather than giving up on the whole @@ -435,8 +532,14 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const propTypes = []; for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { const propSymbol = getMemberOfType(checker, baseType, propName); - if (propSymbol) - propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); + 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) return dedupeTypes(checker, propTypes); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 67f00b1f6..382b505db 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -459,6 +459,29 @@ 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; + } + }; + const resolveSuperModulePath = (containingFile: string): string | undefined => { + const owner = ownerCartridge(containingFile); + if (!owner) return undefined; + const subpath = normalize(containingFile).slice(owner.root.length); + for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { + const candidate = cartridges[i].root + subpath; + if (hostFileExists(candidate)) 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, @@ -706,7 +729,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const checker = program.getTypeChecker(); if (!isAnyType(ts, checker.getTypeAtLocation(node))) return original; const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { - const ctx = createInferenceContext(ts, info.languageService); + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); return ctx ? inferTypeForNode(ctx, node) : []; }); if (types.length === 0) return original; @@ -740,7 +763,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // hover-driven return inference already resolves it. const baseNode = propAccess.expression; const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { - const ctx = createInferenceContext(ts, info.languageService); + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); return ctx ? inferTypeForExpression(ctx, baseNode) : []; }); if (types.length === 0) return original; diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 61634c685..2dd146f71 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -94,6 +94,13 @@ export interface InferenceContext { * 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; } /** @@ -103,6 +110,7 @@ export interface InferenceContext { export function createInferenceContext( ts: typeof tsserver, languageService: tsserver.LanguageService, + resolveSuperModulePath?: (containingFile: string) => string | undefined, ): InferenceContext | undefined { const program = languageService.getProgram(); if (!program) return undefined; @@ -115,6 +123,7 @@ export function createInferenceContext( memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, cycleHits: 0, + resolveSuperModulePath, }; } @@ -338,6 +347,79 @@ function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: typeof return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; } +/** + * 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'; +} + +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path (located by the + * host-supplied ctx.resolveSuperModulePath). The export type is read off the + * right-hand side of the super module's top-level `module.exports = X` + * assignment(s) via resolveExpressionTypes — so a plain alias-map export + * yields its concrete object type directly, and a pass-through overlay + * (`module.exports = base` where base is itself `module.superModule`) recurses + * naturally another cartridge down. Members an intermediate overlay adds via + * `module.exports.name = fn` afterwards aren't representable as extra + * candidate types through this hop and are not surfaced. + * + * Only works when the super module's 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 resolveSuperModuleTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker, program} = ctx; + if (!ctx.resolveSuperModulePath) return []; + const superPath = ctx.resolveSuperModulePath(expr.getSourceFile().fileName); + if (!superPath) return []; + // The resolver returns host-normalized (possibly case-folded) paths; + // program keys may differ in case on case-insensitive filesystems. + let superFile = program.getSourceFile(superPath); + if (!superFile) { + const target = superPath.toLowerCase(); + superFile = program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); + } + 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 stmt of superFile.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; + const isModuleExports = + ts.isPropertyAccessExpression(left) && + ts.isIdentifier(left.expression) && + left.expression.text === 'module' && + left.name.text === 'exports'; + if (!isModuleExports) continue; + types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + } + return dedupeTypes(checker, types); + } finally { + ctx.visiting.delete(superFile); + } +} + /** * Chases a local variable's initializer expression — the missing link for the * idiomatic SFCC style of splitting a chain across intermediate variables @@ -492,12 +574,35 @@ function resolveExpressionTypes( if (!methodSymbol) continue; const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); for (const sig of methodType.getCallSignatures()) { - returnTypes.push(widenType(checker, checker.getReturnTypeOfSignature(sig))); + 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)); + } } } if (returnTypes.length > 0) return dedupeTypes(checker, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { + if (isSuperModuleAccess(expr, ts)) { + // `module.superModule` — resolve to the overridden module's export + // type(s) along the cartridge path, rather than treating it as an + // ordinary property access (TS knows nothing about it, so the generic + // handling below could never resolve it). + const inferred = resolveSuperModuleTypes(ctx, expr, depth, chainHops); + if (inferred.length > 0) return inferred; + return []; + } // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented // (an untyped parameter, say) — infer the base's type first, then look // up this specific property on it, rather than giving up on the whole @@ -506,7 +611,12 @@ function resolveExpressionTypes( const propTypes: tsserver.Type[] = []; for (const baseType of resolveExpressionTypes(ctx, expr.expression, depth, chainHops + 1)) { const propSymbol = getMemberOfType(checker, baseType, propName); - if (propSymbol) propTypes.push(widenType(checker, checker.getTypeOfSymbolAtLocation(propSymbol, expr))); + 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) return dedupeTypes(checker, propTypes); } else if (ts.isIdentifier(expr)) { diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js index 128bfcbb6..ea94a3e52 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -26,6 +26,20 @@ function createFixtureHost(files, 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); + } + } + return { getScriptFileNames: fileNames, getScriptVersion: () => '0', @@ -38,7 +52,7 @@ function createFixtureHost(files, options) { getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), fileExists: (fileName) => fileName in files || ts.sys.fileExists(fileName), readFile: (fileName) => files[fileName] ?? ts.sys.readFile(fileName), - directoryExists: (dir) => ts.sys.directoryExists(dir), + directoryExists: (dir) => impliedDirs.has(dir) || ts.sys.directoryExists(dir), getDirectories: (dir) => ts.sys.getDirectories(dir), }; } diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 77ce21d48..64591a4a1 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -338,6 +338,74 @@ describe('create() proxy — usage inference wiring', () => { assert.equal(entryByName.get('maxPrice').kind, ts.ScriptElementKind.memberVariableElement); }); + 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, ts.createDocumentRegistry()); + 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('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 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 index 8a2360ff3..0d69b78ee 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -228,6 +228,57 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); }); + 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` — diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index bae78fc0f..692e95f74 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -641,6 +641,142 @@ describe('usage-inference', () => { }); }); + 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('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 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/dw.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/dw.json index 1d38b945b..c8ac646e3 100644 --- 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 @@ -2,5 +2,6 @@ "hostname": "test-fixture.invalid", "username": "fixture-user", "password": "not-a-real-password", - "code-version": "version1" + "code-version": "version1", + "cartridges": "app_custom_cartridge:test_cartridge" } 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 index f06905546..b44ac1122 100644 --- 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 @@ -116,7 +116,6 @@ suite('scriptTypesInferUsage — real hover/completion via the VS Code language : undefined; }, 25000); - // eslint-disable-next-line no-console console.log(`[diagnostic] ${labels.length} completion label(s): ${labels.join(', ')}`); assert.ok(labels.includes('getID'), `expected getID among completions, got: ${labels.join(', ')}`); assert.ok(labels.includes('getName'), `expected getName among completions, got: ${labels.join(', ')}`); @@ -233,3 +232,97 @@ suite('scriptTypesInferUsage — SFRA-style cross-file and chain patterns', () = 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(', ')}`, + ); + }); +}); From fac042802eca74cff472a388579bc27a98a81875 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 16:11:35 +0000 Subject: [PATCH 11/43] Infer callback params, iterator elements, multi-cartridge overlays; test server.append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more real-SFRA capabilities for usage-based inference: Callback parameters: a function expression in argument position has no name to run a reference search on, so its first parameter is now typed from the element type of a collection-like sibling argument (anything with iterator()/next() — dw.util.Collection and friends), covering the `collections.forEach(coll, function (item) {...})` idiom. The collection argument is resolved through the engine, so it works even when the collection itself only exists via inference of the enclosing helper's parameter. reduce-style callees are skipped (accumulator first), and only the first parameter is mapped. Manual iterator loops needed no engine change — iter/next() chains already resolve through the chain machinery — but are now pinned down by real-dw-types unit tests and an E2E test. Multi-cartridge superModule stacks: the checker's merged module.exports type is used when concrete (it carries `module.exports.name = fn` augmentations), pass-through re-exports additionally recurse another cartridge down, and members that exist ONLY as augmentations on a pass-through level — invisible to any candidate type — are resolved by a dedicated member walk down the cartridge chain (and listed in completions). superModule-derived expressions also bypass the direct checker-type short-circuit and the hover/completion any-gates: TS's own type for them is garbage either way (any or an opaque circular typeof). server.append middleware params turn out to need no inference: with a modules cartridge present the plugin injects its SFRA ambient declarations and TypeScript types (req, res, next) contextually from the typed append() signature. Tests pin down both halves: the params ARE typed, and inference does NOT decorate them. Tests: 12 new unit tests (callback heuristics and guards, three-level overlay stack with mid-level augmentation, plugin-level server.append and overlay-stack wiring, real-dw-types callback/iterator coverage) and 7 new E2E tests against extended fixtures (modules + plugin_promo cartridges, an SFRA-style collections util, variantHelpers, and a controller), all completion assertions filtered to typed entries so word-based suggestions can't satisfy them. Docs updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6 --- docs/guide/ide-integration.md | 7 +- packages/b2c-script-types/plugin/index.js | 37 +- .../plugin/usage-inference.js | 377 ++++++++++++++--- packages/b2c-script-types/src/index.ts | 45 +- .../b2c-script-types/src/usage-inference.ts | 392 +++++++++++++++--- packages/b2c-script-types/test/index.test.js | 131 ++++++ .../test/usage-inference.real-types.test.js | 89 ++++ .../test/usage-inference.test.js | 228 ++++++++++ .../cartridges/modules/.project | 15 + .../cartridges/plugin_promo/.project | 15 + .../scripts/helpers/productHelpers.js | 17 + .../cartridge/controllers/Product.js | 18 + .../cartridge/scripts/cartService.js | 5 +- .../scripts/helpers/variantHelpers.js | 28 ++ .../cartridge/scripts/util/collections.js | 16 + .../fixtures/infer-usage-workspace/dw.json | 2 +- .../script-types-infer-usage.test.ts | 190 +++++++++ 17 files changed, 1479 insertions(+), 133 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/modules/.project create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/.project create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/plugin_promo/cartridge/scripts/helpers/productHelpers.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/controllers/Product.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/variantHelpers.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index e24b4e3de..34042d913 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -119,7 +119,12 @@ JSDoc-documented functions get full hover/completion support because TypeScript 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`. -`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. +`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(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(); }`) resolve through the same chain machinery. +- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature. 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. diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 257e6ea42..34a9f373b 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -658,8 +658,12 @@ function init({ typescript: ts }) { if (!node || !ts.isIdentifier(node)) return original; const checker = program.getTypeChecker(); - if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node))) + // superModule-derived expressions get past the not-any gate: the + // checker's type for them is garbage either way (any or an opaque + // circular typeof), never something worth leaving untouched. + if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node)) && !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, node)) { return original; + } const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; @@ -689,8 +693,11 @@ function init({ typescript: ts }) { if (!propAccess) return original; const checker = program.getTypeChecker(); - if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(propAccess.expression))) + // See the hover gate above for the superModule exception. + if (!(0, usage_inference_1.isAnyType)(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. @@ -699,13 +706,29 @@ function init({ typescript: ts }) { const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); return ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; }); - if (types.length === 0) - return original; - const inferredEntries = (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 = [...(0, usage_inference_1.typesToCompletionEntries)(ts, checker, types), ...augmentedEntries]; if (inferredEntries.length === 0) return original; - const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); - const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + // 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 diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 3e946df87..2debe61a0 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -10,6 +10,8 @@ exports.createInferenceContext = createInferenceContext; exports.isAnyType = isAnyType; exports.getNodeAtPosition = getNodeAtPosition; exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; +exports.traceSuperModuleAccess = traceSuperModuleAccess; +exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; exports.inferParameterType = inferParameterType; exports.inferReturnType = inferReturnType; exports.inferTypeForNode = inferTypeForNode; @@ -50,6 +52,10 @@ const MAX_REFERENCES_PER_CALL = 50; // bounded only by how long an expression a cartridge author (or a generated // file) happens to write, not by a predictable cost. const MAX_CHAIN_HOPS = 10; +// Bounds 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. +const MAX_SUPERMODULE_HOPS = 8; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; /** * Builds a fresh inference context for one top-level hover/completion @@ -290,36 +296,94 @@ function isSuperModuleAccess(expr, ts) { return ts.isIdentifier(expr.expression) && expr.expression.text === 'module' && expr.name.text === 'superModule'; } /** - * Resolves what `module.superModule` evaluates to: the export type(s) of the - * same-subpath module in the next cartridge down the path (located by the - * host-supplied ctx.resolveSuperModulePath). The export type is read off the - * right-hand side of the super module's top-level `module.exports = X` - * assignment(s) via resolveExpressionTypes — so a plain alias-map export - * yields its concrete object type directly, and a pass-through overlay - * (`module.exports = base` where base is itself `module.superModule`) recurses - * naturally another cartridge down. Members an intermediate overlay adds via - * `module.exports.name = fn` afterwards aren't representable as extra - * candidate types through this hop and are not surfaced. - * - * Only works when the super module's 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. + * 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 resolveSuperModuleTypes(ctx, expr, depth, chainHops) { - const { ts, checker, program } = ctx; +function findSuperModuleFile(ctx, fromFileName) { + const { program } = ctx; if (!ctx.resolveSuperModulePath) - return []; - const superPath = ctx.resolveSuperModulePath(expr.getSourceFile().fileName); + return undefined; + const superPath = ctx.resolveSuperModulePath(fromFileName); if (!superPath) - return []; + return undefined; // The resolver returns host-normalized (possibly case-folded) paths; // program keys may differ in case on case-insensitive filesystems. - let superFile = program.getSourceFile(superPath); - if (!superFile) { - const target = superPath.toLowerCase(); - superFile = program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); + 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 (isAnyType(ts, exportsType)) + return false; + return checker.getPropertiesOfType(checker.getApparentType(exportsType)).length > 0; +} +/** + * 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 = findSuperModuleFile(ctx, expr.getSourceFile().fileName); if (!superFile) return []; // Guard against overlay cycles (two cartridges whose modules somehow point @@ -331,20 +395,19 @@ function resolveSuperModuleTypes(ctx, expr, depth, chainHops) { ctx.visiting.add(superFile); try { const types = []; - for (const stmt of superFile.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; - const isModuleExports = ts.isPropertyAccessExpression(left) && - ts.isIdentifier(left.expression) && - left.expression.text === 'module' && - left.name.text === 'exports'; - if (!isModuleExports) - continue; - types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + 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(checker, types); } @@ -352,6 +415,106 @@ function resolveSuperModuleTypes(ctx, expr, depth, chainHops) { ctx.visiting.delete(superFile); } } +/** + * 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; +} +/** + * 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 < 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 = []; + for (const m of matches) { + types.push(...resolveExpressionTypes(ctx, m.expr, depth, chainHops + 1).filter((t) => !isAnyType(ts, t))); + } + return dedupeTypes(checker, 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 []; +} +/** + * 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 < 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; +} /** * Chases a local variable's initializer expression — the missing link for the * idiomatic SFCC style of splitting a chain across intermediate variables @@ -463,6 +626,15 @@ function getMemberOfType(checker, type, name) { */ 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 = traceSuperModuleAccess(ts, checker, expr); + if (superAccessAtRoot) { + return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); + } const direct = checker.getTypeAtLocation(expr); if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; @@ -486,11 +658,7 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const methodAccess = expr.expression; const methodName = methodAccess.name.text; const returnTypes = []; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { - const methodSymbol = getMemberOfType(checker, receiverType, methodName); - if (!methodSymbol) - continue; - const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); + const pushSignatureReturns = (methodType) => { for (const sig of methodType.getCallSignatures()) { const returnType = checker.getReturnTypeOfSignature(sig); if (!isAnyType(ts, returnType)) { @@ -508,22 +676,30 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { 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); + } + } } if (returnTypes.length > 0) return dedupeTypes(checker, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { - if (isSuperModuleAccess(expr, ts)) { - // `module.superModule` — resolve to the overridden module's export - // type(s) along the cartridge path, rather than treating it as an - // ordinary property access (TS knows nothing about it, so the generic - // handling below could never resolve it). - const inferred = resolveSuperModuleTypes(ctx, expr, depth, chainHops); - if (inferred.length > 0) - return inferred; - return []; - } // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented // (an untyped parameter, say) — infer the base's type first, then look // up this specific property on it, rather than giving up on the whole @@ -541,6 +717,14 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { if (!isAnyType(ts, propType)) propTypes.push(widenType(checker, propType)); } + if (propTypes.length === 0) { + // Mirror of the method-chain fallback above: 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))); + } + } if (propTypes.length > 0) return dedupeTypes(checker, propTypes); } @@ -566,6 +750,71 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { } return []; } +/** + * 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; +} +/** + * Infers the type of a callback's first parameter from sibling arguments of + * the call the callback is passed to: `collections.forEach(coll, function + * (item) {...})` — 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), and `reduce`-style callees are + * skipped since their callbacks lead with an accumulator instead. + */ +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 === 'reduce') + return []; + const types = []; + 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; +} /** * Infers a parameter's candidate type(s) from the arguments it's actually * called with across the project, since plain un-annotated JS parameters @@ -600,18 +849,24 @@ function inferParameterType(ctx, param, depth = 0) { const fn = param.parent; if (!ts.isFunctionLike(fn)) return []; - const nameNode = getReferenceNameNode(fn, ts); - if (!nameNode) - return []; const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; const types = []; - for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; - if (!arg) - continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); + const nameNode = getReferenceNameNode(fn, ts); + if (nameNode) { + for (const call of collectCallSites(ctx, nameNode)) { + const arg = call.arguments[paramIndex]; + if (!arg) + continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + } + else { + // 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. + types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } const result = dedupeTypes(checker, types); // Don't memoize a result whose computation hit a cycle guard: it was diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 382b505db..edf8e5ccb 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -8,10 +8,13 @@ import path from 'node:path'; import type tsserver from 'typescript/lib/tsserverlibrary'; import { + collectSuperModuleAugmentedMembers, + traceSuperModuleAccess, createInferenceContext, describeTypes, findEnclosingPropertyAccess, getNodeAtPosition, + INFERRED_COMPLETION_SOURCE, inferTypeForExpression, inferTypeForNode, isAnyType, @@ -727,7 +730,12 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const node = getNodeAtPosition(sourceFile, ts, position); if (!node || !ts.isIdentifier(node)) return original; const checker = program.getTypeChecker(); - if (!isAnyType(ts, checker.getTypeAtLocation(node))) return original; + // superModule-derived expressions get past the not-any gate: the + // checker's type for them is garbage either way (any or an opaque + // circular typeof), never something worth leaving untouched. + if (!isAnyType(ts, checker.getTypeAtLocation(node)) && !traceSuperModuleAccess(ts, checker, node)) { + return original; + } const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); return ctx ? inferTypeForNode(ctx, node) : []; @@ -757,7 +765,13 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const propAccess = findEnclosingPropertyAccess(node, ts); if (!propAccess) return original; const checker = program.getTypeChecker(); - if (!isAnyType(ts, checker.getTypeAtLocation(propAccess.expression))) return original; + // See the hover gate above for the superModule exception. + if ( + !isAnyType(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. @@ -766,11 +780,30 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); return ctx ? inferTypeForExpression(ctx, baseNode) : []; }); - if (types.length === 0) return original; - const inferredEntries = 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 = [...typesToCompletionEntries(ts, checker, types), ...augmentedEntries]; if (inferredEntries.length === 0) return original; - const existingNames = new Set((original?.entries ?? []).map((e) => e.name)); - const merged = [...(original?.entries ?? []), ...inferredEntries.filter((e) => !existingNames.has(e.name))]; + // 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 diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 2dd146f71..f00fa99bd 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -49,6 +49,11 @@ const MAX_REFERENCES_PER_CALL = 50; // file) happens to write, not by a predictable cost. const MAX_CHAIN_HOPS = 10; +// Bounds 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. +const MAX_SUPERMODULE_HOPS = 8; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; interface MemoEntry { @@ -357,22 +362,90 @@ function isSuperModuleAccess(expr: tsserver.PropertyAccessExpression, ts: typeof 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: 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;`). + */ +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. + */ +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; +} + /** * Resolves what `module.superModule` evaluates to: the export type(s) of the - * same-subpath module in the next cartridge down the path (located by the - * host-supplied ctx.resolveSuperModulePath). The export type is read off the - * right-hand side of the super module's top-level `module.exports = X` - * assignment(s) via resolveExpressionTypes — so a plain alias-map export - * yields its concrete object type directly, and a pass-through overlay - * (`module.exports = base` where base is itself `module.superModule`) recurses - * naturally another cartridge down. Members an intermediate overlay adds via - * `module.exports.name = fn` afterwards aren't representable as extra - * candidate types through this hop and are not surfaced. - * - * Only works when the super module's 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. + * 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, @@ -380,17 +453,8 @@ function resolveSuperModuleTypes( depth: number, chainHops: number, ): tsserver.Type[] { - const {ts, checker, program} = ctx; - if (!ctx.resolveSuperModulePath) return []; - const superPath = ctx.resolveSuperModulePath(expr.getSourceFile().fileName); - if (!superPath) return []; - // The resolver returns host-normalized (possibly case-folded) paths; - // program keys may differ in case on case-insensitive filesystems. - let superFile = program.getSourceFile(superPath); - if (!superFile) { - const target = superPath.toLowerCase(); - superFile = program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); - } + 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). @@ -401,18 +465,19 @@ function resolveSuperModuleTypes( ctx.visiting.add(superFile); try { const types: tsserver.Type[] = []; - for (const stmt of superFile.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; - const isModuleExports = - ts.isPropertyAccessExpression(left) && - ts.isIdentifier(left.expression) && - left.expression.text === 'module' && - left.name.text === 'exports'; - if (!isModuleExports) continue; - types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); + 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(checker, types); } finally { @@ -420,6 +485,121 @@ function resolveSuperModuleTypes( } } +/** + * 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; +} + +/** + * 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(checker, 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 []; +} + +/** + * 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; +} + /** * Chases a local variable's initializer expression — the missing link for the * idiomatic SFCC style of splitting a chain across intermediate variables @@ -549,6 +729,15 @@ function resolveExpressionTypes( 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); if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; if (chainHops >= MAX_CHAIN_HOPS) return []; @@ -569,10 +758,7 @@ function resolveExpressionTypes( const methodAccess = expr.expression; const methodName = methodAccess.name.text; const returnTypes: tsserver.Type[] = []; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { - const methodSymbol = getMemberOfType(checker, receiverType, methodName); - if (!methodSymbol) continue; - const methodType = checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name); + const pushSignatureReturns = (methodType: tsserver.Type) => { for (const sig of methodType.getCallSignatures()) { const returnType = checker.getReturnTypeOfSignature(sig); if (!isAnyType(ts, returnType)) { @@ -590,19 +776,27 @@ function resolveExpressionTypes( 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); + } + } } if (returnTypes.length > 0) return dedupeTypes(checker, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { - if (isSuperModuleAccess(expr, ts)) { - // `module.superModule` — resolve to the overridden module's export - // type(s) along the cartridge path, rather than treating it as an - // ordinary property access (TS knows nothing about it, so the generic - // handling below could never resolve it). - const inferred = resolveSuperModuleTypes(ctx, expr, depth, chainHops); - if (inferred.length > 0) return inferred; - return []; - } // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented // (an untyped parameter, say) — infer the base's type first, then look // up this specific property on it, rather than giving up on the whole @@ -618,6 +812,18 @@ function resolveExpressionTypes( // 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 above: 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), + ), + ); + } + } if (propTypes.length > 0) return dedupeTypes(checker, propTypes); } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper @@ -639,6 +845,74 @@ function resolveExpressionTypes( return []; } +/** + * Extracts the element type from a collection-like `type`: something with an + * `iterator()` method whose result has a typed `next()` (dw.util.Collection + * and friends), or something that is itself such an iterator. Returns + * `undefined` when `type` doesn't look like a collection or its element type + * is unknown — never `any`. + * + * @param location - any node in the file where the type is being used; + * required by getTypeOfSymbolAtLocation to resolve member types. + */ +function collectionElementType( + ctx: InferenceContext, + type: tsserver.Type, + location: tsserver.Node, +): tsserver.Type | undefined { + const {ts, checker} = ctx; + const firstCallReturn = (t: tsserver.Type, memberName: string): tsserver.Type | undefined => { + const sym = 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; +} + +/** + * Infers the type of a callback's first parameter from sibling arguments of + * the call the callback is passed to: `collections.forEach(coll, function + * (item) {...})` — 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), and `reduce`-style callees are + * skipped since their callbacks lead with an accumulator instead. + */ +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 === 'reduce') 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; +} + /** * Infers a parameter's candidate type(s) from the arguments it's actually * called with across the project, since plain un-annotated JS parameters @@ -673,16 +947,22 @@ export function inferParameterType( try { const fn = param.parent; if (!ts.isFunctionLike(fn)) return []; - const nameNode = getReferenceNameNode(fn, ts); - if (!nameNode) return []; const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; 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)); + const nameNode = getReferenceNameNode(fn, ts); + if (nameNode) { + for (const call of collectCallSites(ctx, nameNode)) { + const arg = call.arguments[paramIndex]; + if (!arg) continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + } else { + // 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. + types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } const result = dedupeTypes(checker, types); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 64591a4a1..4c3582b9d 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -406,6 +406,137 @@ describe('create() proxy — usage inference wiring', () => { 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, ts.createDocumentRegistry()); + 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, ts.createDocumentRegistry()); + 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 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 index 0d69b78ee..25d4cd853 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -228,6 +228,95 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); }); + 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 diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 692e95f74..3eda8d49e 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -777,6 +777,234 @@ describe('usage-inference', () => { }); }); + 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('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('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 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/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 index ef2c7e835..453b09de0 100644 --- 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 @@ -8,13 +8,16 @@ 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) + orderable: productHelpers.isOrderable(product, quantity), + variantIds: variantHelpers.collectVariantIds(product), + firstVariant: variantHelpers.firstVariantName(product) }; } 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..fbc14eb38 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js @@ -0,0 +1,16 @@ +'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()); + } +} + +module.exports = { + forEach: forEach +}; 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 index c8ac646e3..122a2cb96 100644 --- 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 @@ -3,5 +3,5 @@ "username": "fixture-user", "password": "not-a-real-password", "code-version": "version1", - "cartridges": "app_custom_cartridge:test_cartridge" + "cartridges": "plugin_promo:app_custom_cartridge:test_cartridge:modules" } 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 index b44ac1122..333750400 100644 --- 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 @@ -326,3 +326,193 @@ suite('scriptTypesInferUsage — module.superModule cartridge overlays', () => { ); }); }); + +// 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(', ')}`); + }); +}); From afd4b25d337e73a1a5ee7181b899f9b255506262 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 16:22:02 +0000 Subject: [PATCH 12/43] Add deterministic performance baselines for usage inference The engine runs synchronously inside tsserver on every hover/completion keystroke, and its dominant cost is getReferencesAtPosition (a project-wide scan per call). These tests pin down worst-case cost with deterministic search *counters* rather than wall-clock timings, so they hold on slow CI runners and point at exactly which cap regressed: - widely-referenced helper (300 call sites): <= 4 searches, per-call budget engages, correctness survives the cap - overlong method chain (60 hops): chain cap fires before any search - no-parameter helper chain: delegated to TS's native inference, 0 searches - 12-level parameter-forwarding chain: depth cap keeps cost flat in both directions (0 searches for returns, <= 6 from the deep end) - 20-branch fan-out through a shared sub-helper: request memo collapses it to <= 4 searches - repeated identical hover at the same project version: 0 new searches (served from the position cache) - mutual recursion with heavy call-site fan-in: terminates, budget never goes negative A generous wall-clock ceiling (5s) rides along on each scenario purely as a tripwire for catastrophic regressions (lost cap, infinite loop); the counters are the real baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6 --- .../test/usage-inference.perf.test.js | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 packages/b2c-script-types/test/usage-inference.perf.test.js 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..37b46e48b --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -0,0 +1,324 @@ +/* + * 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, +} = require('./helpers/fixture-language-service'); + +// --------------------------------------------------------------------------- +// 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, +}; + +/** + * 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)}; +} + +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. + const spent = 200 - ctx.referenceBudget; + assert.ok(spent <= 50, `expected the per-call cap (50) to bound processed references, spent ${spent}`); + 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, ts.createDocumentRegistry()); + 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('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`); + }); +}); From 05451216851491f52858dd326e685d34fbe87352 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 16:51:22 +0000 Subject: [PATCH 13/43] Cap full-project scans per request; dedupe sibling-parameter searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance audit of the usage-inference engine at SFRA scale (~1,900 cartridge JS files, 300-call-site hot helper, 3-level overlay stack) found two engine-level gaps, both measured with the real plugin driving a real LanguageService: 1. The number of getReferencesAtPosition SEARCHES per request was only indirectly bounded. MAX_REFERENCES_PER_REQUEST counts search *results*, but each search is a full project scan even when it returns almost nothing: a helper whose call sites feed it results of many DISTINCT exported sub-helpers (each searched once, each contributing 2-3 results) drained the result budget at ~2-3 per scan — 76 scans and 114ms p50 (141ms p95) for a single hover, measured before the fix. A new MAX_SEARCHES_PER_REQUEST budget (12; the costliest legitimate baseline scenario needs 6) bounds the scans directly: same fixture now runs 12 searches in ~42ms p50, still inferring the right type from the in-budget call sites. 2. Two implicit-any parameters of the same function each re-ran the identical reference searches within one request (the request memo keys parameter/function nodes, not searches): 4 scans instead of 2, measured. collectCallSites results are now memoized per name node for the request; reuse is sound because budgets only decrease within a request, so a memoized result is never less complete than a re-run would be. Also: getNodeAtPosition now stops scanning a sibling list once past the target position (siblings are ordered and non-overlapping). Every reference hit walks the AST from its file's root, so in a generated file whose array literal has thousands of elements the old full-list walk cost ~25ms per request on top of the search itself. Three new counter-based baseline scenarios pin these bounds: distinctSubHelperTree (<= 12 searches, and asserts the budget actually engages), multiParamHelper (<= 2), hugeGeneratedFile (<= 2 searches, <= 50 hits processed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb --- .../plugin/usage-inference.js | 47 +++++-- .../b2c-script-types/src/usage-inference.ts | 64 +++++++-- .../test/usage-inference.perf.test.js | 127 ++++++++++++++++++ 3 files changed, 221 insertions(+), 17 deletions(-) diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 2debe61a0..59f9bfcaf 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -56,6 +56,17 @@ const MAX_CHAIN_HOPS = 10; // (top overlay -> mid overlay -> ... -> base). Real cartridge paths rarely // stack more than three or four overlays of the same module. 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. +const MAX_SEARCHES_PER_REQUEST = 12; exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; /** * Builds a fresh inference context for one top-level hover/completion @@ -73,6 +84,8 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath) { visiting: new Set(), memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, + searchBudget: MAX_SEARCHES_PER_REQUEST, + callSiteMemo: new Map(), cycleHits: 0, resolveSuperModulePath, }; @@ -85,14 +98,25 @@ function isAnyType(ts, type) { * 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) && pos < node.getEnd()) { - result = node; - ts.forEachChild(node, visit); - } + 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; @@ -218,12 +242,17 @@ function resolveIndirectReferenceTarget(node, ts) { * 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 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. + * 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 { ts, languageService, program } = ctx; + const memoized = ctx.callSiteMemo.get(nameNode); + if (memoized) + return memoized; const calls = []; const seenNameKeys = new Set(); let frontier = [nameNode]; @@ -231,13 +260,14 @@ function collectCallSites(ctx, nameNode) { for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { const nextFrontier = []; for (const name of frontier) { - if (localBudget <= 0) + 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); + ctx.searchBudget--; const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { if (localBudget <= 0) @@ -266,6 +296,7 @@ function collectCallSites(ctx, nameNode) { } frontier = nextFrontier; } + ctx.callSiteMemo.set(nameNode, calls); return calls; } /** diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index f00fa99bd..4cdb1275a 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -54,6 +54,18 @@ const MAX_CHAIN_HOPS = 10; // stack more than three or four overlays of the same module. 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. +const MAX_SEARCHES_PER_REQUEST = 12; + export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; interface MemoEntry { @@ -91,6 +103,23 @@ export interface InferenceContext { * 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; /** * Mutable, shared across the whole request — incremented every time a * cycle guard fires (a `visiting` hit). A result computed while this moved @@ -127,6 +156,8 @@ export function createInferenceContext( visiting: new Set(), memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, + searchBudget: MAX_SEARCHES_PER_REQUEST, + callSiteMemo: new Map(), cycleHits: 0, resolveSuperModulePath, }; @@ -141,6 +172,14 @@ export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { * 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, @@ -148,11 +187,12 @@ export function getNodeAtPosition( pos: number, ): tsserver.Node | undefined { let result: tsserver.Node | undefined; - const visit = (node: tsserver.Node) => { - if (pos >= node.getStart(sourceFile) && pos < node.getEnd()) { - result = node; - ts.forEachChild(node, visit); - } + 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; @@ -284,12 +324,16 @@ function resolveIndirectReferenceTarget( * 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 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. + * 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: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { const {ts, languageService, program} = ctx; + const memoized = ctx.callSiteMemo.get(nameNode); + if (memoized) return memoized; const calls: tsserver.CallExpression[] = []; const seenNameKeys = new Set(); let frontier: tsserver.Identifier[] = [nameNode]; @@ -298,12 +342,13 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): 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) break; + 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); + ctx.searchBudget--; const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; for (const ref of refs) { if (localBudget <= 0) break; @@ -328,6 +373,7 @@ function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): frontier = nextFrontier; } + ctx.callSiteMemo.set(nameNode, calls); return calls; } diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 37b46e48b..8600463b2 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -66,6 +66,20 @@ const BASELINE = { // 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, }; /** @@ -300,6 +314,119 @@ describe('usage-inference — performance baselines', () => { 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('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'); From ccb568a09a36dec44c2f6a308e8bd1c04388fad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 16:51:52 +0000 Subject: [PATCH 14/43] Cache inference display products, invalidate on Program identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory and churn findings from the same performance audit, both in the plugin-side cache wiring: 1. The per-position inference cache stored checker Type[] objects. A Type pins its checker and, through it, the whole Program it came from — so after an edit, the cache kept the entire previous program graph alive until the next inference-eligible request cleared it (indefinitely, if the user stopped hovering). The cache now stores finished display products instead — the hover note string and the synthesized CompletionEntry array — which retain nothing. "Inference found nothing" is now a cached answer too (it costs the same searches as a hit), and a 512-entry cap bounds a long no-edit session. 2. Invalidation now compares Program instances instead of the project version string. TS constructs a new Program for any semantic change and reuses the instance otherwise, while tsserver's project version also bumps on events that produce no new program — each such bump discarded a perfectly valid cache and re-ran full inference (measured ~13ms per hover on an SFRA-sized project, ~40ms including the request's incremental program update). New baseline scenario versionBumpSameProgram pins the second fix: a version bump with an unchanged program must answer from the cache with 0 new searches. Measured after both commits (SFRA-scale workspace, real edits between requests): hover p50 41ms / p95 80ms, completion p50 32ms / p95 49ms, superModule hover p50 20ms — all inside the 100ms hover / 200ms completion targets; warm-cache repeats ~1ms. Cancellation interrupts mid-request within ~0.3-3ms of the token firing (rethrown, checked inside every reference search). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb --- packages/b2c-script-types/plugin/index.js | 67 ++++++++++------ packages/b2c-script-types/src/index.ts | 78 +++++++++++++------ .../test/usage-inference.perf.test.js | 51 ++++++++++++ 3 files changed, 150 insertions(+), 46 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 34a9f373b..3ffd747c4 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -599,24 +599,42 @@ function init({ typescript: ts }) { // 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); the whole cache is thrown away on a - // project version change rather than tracking per-entry validity, so it - // can't grow without bound across a long editing session — every entry - // in it is guaranteed fresh for the current program. - let inferenceCacheProjectVersion; + // + // 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. + // + // 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(); - const getCachedInference = (cacheKey, compute) => { - const projectVersion = info.project.getProjectVersion(); - if (projectVersion !== inferenceCacheProjectVersion) { + // 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(); - inferenceCacheProjectVersion = projectVersion; + inferenceCacheProgram = program; } - const cached = inferenceCache.get(cacheKey); - if (cached) - return cached; - const types = compute(); - inferenceCache.set(cacheKey, types); - return types; + 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 @@ -664,14 +682,18 @@ function init({ typescript: ts }) { if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node)) && !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, node)) { return original; } - const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { + // `undefined` (inference found nothing) is a cached answer too — + // re-deriving "nothing" costs the same reference searches as + // re-deriving something. + const description = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); - return ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; + const types = ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; + return types.length > 0 ? (0, usage_inference_1.describeTypes)(checker, types) : undefined; }); - if (types.length === 0) + if (!description) return original; const note = { - text: `\n\nInferred from usage: ${(0, usage_inference_1.describeTypes)(checker, types)}`, + text: `\n\nInferred from usage: ${description}`, kind: 'text', }; return { ...original, documentation: [...(original.documentation ?? []), note] }; @@ -702,9 +724,10 @@ function init({ typescript: ts }) { // `product.getPriceModel().|` needs the chain resolved the same way // hover-driven return inference already resolves it. const baseNode = propAccess.expression; - const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { + const typeEntries = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, program, () => { const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); - return ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; + 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 @@ -718,7 +741,7 @@ function init({ typescript: ts }) { sortText: '11', source: usage_inference_1.INFERRED_COMPLETION_SOURCE, })); - const inferredEntries = [...(0, usage_inference_1.typesToCompletionEntries)(ts, checker, types), ...augmentedEntries]; + const inferredEntries = [...typeEntries, ...augmentedEntries]; if (inferredEntries.length === 0) return original; // Dedupe against the original entries AND within the inferred set diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index edf8e5ccb..b5b0ab579 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -673,23 +673,44 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // 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); the whole cache is thrown away on a - // project version change rather than tracking per-entry validity, so it - // can't grow without bound across a long editing session — every entry - // in it is guaranteed fresh for the current program. - let inferenceCacheProjectVersion: string | undefined; - const inferenceCache = new Map(); - const getCachedInference = (cacheKey: string, compute: () => tsserver.Type[]): tsserver.Type[] => { - const projectVersion = info.project.getProjectVersion(); - if (projectVersion !== inferenceCacheProjectVersion) { + // + // 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. + // + // 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(); - inferenceCacheProjectVersion = projectVersion; + inferenceCacheProgram = program; } - const cached = inferenceCache.get(cacheKey); - if (cached) return cached; - const types = compute(); - inferenceCache.set(cacheKey, types); - return types; + 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 @@ -736,13 +757,17 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (!isAnyType(ts, checker.getTypeAtLocation(node)) && !traceSuperModuleAccess(ts, checker, node)) { return original; } - const types = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, () => { + // `undefined` (inference found nothing) is a cached answer too — + // re-deriving "nothing" costs the same reference searches as + // re-deriving something. + const description = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); - return ctx ? inferTypeForNode(ctx, node) : []; + const types = ctx ? inferTypeForNode(ctx, node) : []; + return types.length > 0 ? describeTypes(checker, types) : undefined; }); - if (types.length === 0) return original; + if (!description) return original; const note: tsserver.SymbolDisplayPart = { - text: `\n\nInferred from usage: ${describeTypes(checker, types)}`, + text: `\n\nInferred from usage: ${description}`, kind: 'text', }; return {...original, documentation: [...(original.documentation ?? []), note]}; @@ -776,10 +801,15 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // `product.getPriceModel().|` needs the chain resolved the same way // hover-driven return inference already resolves it. const baseNode = propAccess.expression; - const types = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); - return ctx ? inferTypeForExpression(ctx, baseNode) : []; - }); + const typeEntries = getCachedInference( + `completions:${fileName}:${baseNode.getStart(sourceFile)}`, + program, + () => { + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); + 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 @@ -794,7 +824,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { sortText: '11', source: INFERRED_COMPLETION_SOURCE, })); - const inferredEntries = [...typesToCompletionEntries(ts, checker, types), ...augmentedEntries]; + 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 diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 8600463b2..54508ab42 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -80,6 +80,10 @@ const BASELINE = { // 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, }; /** @@ -427,6 +431,53 @@ describe('usage-inference — performance baselines', () => { 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, ts.createDocumentRegistry()); + 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('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'); From 73c270216bbfab2131418ba4ea04a992712d5891 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:09:56 +0000 Subject: [PATCH 15/43] Memoize typeToString per request; render hover text in one pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up fix for the one audit finding previously left as inspection-only, now measured and confirmed real: candidate types propagate up through every recursion level (parameter -> return -> forwarding helper), and dedupeTypes re-stringified the same Type objects at each level — 192 typeToString calls 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. dedupeTypes now goes through a request-scoped typeToString memo on the inference context (sound: rendering a type is pure for a given checker, and the context never outlives its checker). describeTypes separately stringified every candidate twice — once inside dedupeTypes, once to render — and now dedupes by display string in the same pass that renders it. Same fixture after the fix: 48 stringifications (exactly one per unique type), request wall time 34ms -> 19.5ms. New baseline scenario nestedForwardingStringifications pins the bound with a deterministic counter on the public checker.typeToString: 30 unique candidates through a three-level chain must stay <= 32 calls (unfixed behavior: one call per candidate per level, 120). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb --- .../plugin/usage-inference.js | 51 ++++++++++----- .../b2c-script-types/src/usage-inference.ts | 63 ++++++++++++++----- .../test/usage-inference.perf.test.js | 49 +++++++++++++++ 3 files changed, 129 insertions(+), 34 deletions(-) diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 59f9bfcaf..c8d6df9d3 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -86,6 +86,7 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath) { referenceBudget: MAX_REFERENCES_PER_REQUEST, searchBudget: MAX_SEARCHES_PER_REQUEST, callSiteMemo: new Map(), + typeDisplayStrings: new Map(), cycleHits: 0, resolveSuperModulePath, }; @@ -440,7 +441,7 @@ function resolveSuperModuleTypes(ctx, expr, depth, chainHops) { types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); } } - return dedupeTypes(checker, types); + return dedupeTypes(ctx, types); } finally { ctx.visiting.delete(superFile); @@ -496,7 +497,7 @@ function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chai for (const m of matches) { types.push(...resolveExpressionTypes(ctx, m.expr, depth, chainHops + 1).filter((t) => !isAnyType(ts, t))); } - return dedupeTypes(checker, types); + return dedupeTypes(ctx, types); } // No augmentation at this level: continue downward only through a // pass-through (`module.exports = `); a concrete export either @@ -601,17 +602,26 @@ function resolveCalleeDeclaration(ctx, call) { function widenType(checker, type) { return checker.getBaseTypeOfLiteralType(type); } +/** checker.typeToString memoized per request — see InferenceContext.typeDisplayStrings. */ +function typeDisplayString(ctx, type) { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) + return cached; + const str = ctx.checker.typeToString(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(checker, types) { +function dedupeTypes(ctx, types) { const seen = new Set(); const out = []; for (const t of types) { - const key = checker.typeToString(t); + const key = typeDisplayString(ctx, t); if (seen.has(key)) continue; seen.add(key); @@ -727,7 +737,7 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { } } if (returnTypes.length > 0) - return dedupeTypes(checker, returnTypes); + return dedupeTypes(ctx, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { @@ -757,7 +767,7 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { } } if (propTypes.length > 0) - return dedupeTypes(checker, propTypes); + return dedupeTypes(ctx, propTypes); } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper @@ -855,7 +865,7 @@ function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { * led here; defaults to 0 for a top-level request. */ function inferParameterType(ctx, param, depth = 0) { - const { ts, checker } = ctx; + 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 @@ -899,7 +909,7 @@ function inferParameterType(ctx, param, depth = 0) { // recoverable from the collection argument travelling alongside it. types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } - const result = dedupeTypes(checker, types); + const result = dedupeTypes(ctx, types); // 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 @@ -949,7 +959,7 @@ function collectReturnExpressions(fn, ts) { * led here; defaults to 0 for a top-level request. */ function inferReturnType(ctx, fn, depth = 0) { - const { ts, checker } = ctx; + 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) @@ -969,7 +979,7 @@ function inferReturnType(ctx, fn, depth = 0) { for (const expr of collectReturnExpressions(fn, ts)) { types.push(...resolveExpressionTypes(ctx, expr, depth)); } - const result = dedupeTypes(checker, types); + 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 }); @@ -1002,7 +1012,7 @@ function inferTypeForNode(ctx, node) { // 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(checker, resolveVariableInitializerTypes(ctx, decl, 0, 0)); + return dedupeTypes(ctx, resolveVariableInitializerTypes(ctx, decl, 0, 0)); } if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); @@ -1015,16 +1025,23 @@ function inferTypeForNode(ctx, node) { * no declaration to look up; the expression itself is what gets resolved. */ function inferTypeForExpression(ctx, expr) { - const { ts, checker } = ctx; + const { ts } = ctx; if (ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); - return dedupeTypes(checker, resolveExpressionTypes(ctx, expr, 0)); + return dedupeTypes(ctx, resolveExpressionTypes(ctx, expr, 0)); } -/** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ +/** + * 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) { - return dedupeTypes(checker, types) - .map((t) => checker.typeToString(t)) - .join(' | '); + const seen = new Set(); + for (const t of types) { + seen.add(checker.typeToString(t)); + } + return [...seen].join(' | '); } /** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ function typesToCompletionEntries(ts, checker, types) { diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 4cdb1275a..bfc5e68ea 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -120,6 +120,18 @@ export interface InferenceContext { * 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 @@ -158,6 +170,7 @@ export function createInferenceContext( referenceBudget: MAX_REFERENCES_PER_REQUEST, searchBudget: MAX_SEARCHES_PER_REQUEST, callSiteMemo: new Map(), + typeDisplayStrings: new Map(), cycleHits: 0, resolveSuperModulePath, }; @@ -525,7 +538,7 @@ function resolveSuperModuleTypes( types.push(...resolveExpressionTypes(ctx, bin.right, depth, chainHops + 1)); } } - return dedupeTypes(checker, types); + return dedupeTypes(ctx, types); } finally { ctx.visiting.delete(superFile); } @@ -592,7 +605,7 @@ function resolveSuperModuleMemberTypes( for (const m of matches) { types.push(...resolveExpressionTypes(ctx, m.expr, depth, chainHops + 1).filter((t) => !isAnyType(ts, t))); } - return dedupeTypes(checker, types); + return dedupeTypes(ctx, types); } // No augmentation at this level: continue downward only through a // pass-through (`module.exports = `); a concrete export either @@ -708,17 +721,26 @@ function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver return checker.getBaseTypeOfLiteralType(type); } +/** checker.typeToString memoized per request — see InferenceContext.typeDisplayStrings. */ +function typeDisplayString(ctx: InferenceContext, type: tsserver.Type): string { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) return cached; + const str = ctx.checker.typeToString(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(checker: tsserver.TypeChecker, types: tsserver.Type[]): tsserver.Type[] { +function dedupeTypes(ctx: InferenceContext, types: tsserver.Type[]): tsserver.Type[] { const seen = new Set(); const out: tsserver.Type[] = []; for (const t of types) { - const key = checker.typeToString(t); + const key = typeDisplayString(ctx, t); if (seen.has(key)) continue; seen.add(key); out.push(t); @@ -840,7 +862,7 @@ function resolveExpressionTypes( } } } - if (returnTypes.length > 0) return dedupeTypes(checker, returnTypes); + if (returnTypes.length > 0) return dedupeTypes(ctx, returnTypes); } } else if (ts.isPropertyAccessExpression(expr)) { // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented @@ -870,7 +892,7 @@ function resolveExpressionTypes( ); } } - if (propTypes.length > 0) return dedupeTypes(checker, propTypes); + if (propTypes.length > 0) return dedupeTypes(ctx, propTypes); } else if (ts.isIdentifier(expr)) { // `expr` is itself an undocumented parameter reference (e.g. a helper // that just returns/forwards one of its own params) — chase that @@ -972,7 +994,7 @@ export function inferParameterType( param: tsserver.ParameterDeclaration, depth = 0, ): tsserver.Type[] { - const {ts, checker} = ctx; + 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 @@ -1011,7 +1033,7 @@ export function inferParameterType( types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } - const result = dedupeTypes(checker, types); + const result = dedupeTypes(ctx, types); // 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 @@ -1060,7 +1082,7 @@ function collectReturnExpressions(fn: tsserver.SignatureDeclaration, ts: typeof * led here; defaults to 0 for a top-level request. */ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDeclaration, depth = 0): tsserver.Type[] { - const {ts, checker} = ctx; + 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; @@ -1077,7 +1099,7 @@ export function inferReturnType(ctx: InferenceContext, fn: tsserver.SignatureDec for (const expr of collectReturnExpressions(fn, ts)) { types.push(...resolveExpressionTypes(ctx, expr, depth)); } - const result = dedupeTypes(checker, types); + 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}); @@ -1107,7 +1129,7 @@ export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): ts // 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(checker, resolveVariableInitializerTypes(ctx, decl, 0, 0)); + return dedupeTypes(ctx, resolveVariableInitializerTypes(ctx, decl, 0, 0)); } if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); return []; @@ -1120,16 +1142,23 @@ export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): ts * no declaration to look up; the expression itself is what gets resolved. */ export function inferTypeForExpression(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type[] { - const {ts, checker} = ctx; + const {ts} = ctx; if (ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); - return dedupeTypes(checker, resolveExpressionTypes(ctx, expr, 0)); + return dedupeTypes(ctx, resolveExpressionTypes(ctx, expr, 0)); } -/** Renders candidate types as human-readable hover text, e.g. `"Product | Category"`. */ +/** + * 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 { - return dedupeTypes(checker, types) - .map((t) => checker.typeToString(t)) - .join(' | '); + const seen = new Set(); + for (const t of types) { + seen.add(checker.typeToString(t)); + } + return [...seen].join(' | '); } /** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 54508ab42..b4ce2819f 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -84,6 +84,12 @@ const BASELINE = { // 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, }; /** @@ -478,6 +484,49 @@ describe('usage-inference — performance baselines', () => { 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'); From 894524ba188851c182db5d6dcd4c658105f2bb4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:29:57 +0000 Subject: [PATCH 16/43] Contain module resolvers against path traversal from cloned repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tsserver plugin's require() resolvers join attacker-controlled import specifiers (and a cartridge package.json `main`) onto a root directory without asserting the result stays inside that root. A malicious repository could therefore make a cartridge file resolve `require()` to a file OUTSIDE the workspace and pull it into the TS program: - resolveDwModule: `dw/../../../` escapes the bundled types dir - resolveCartridgeModule: `~/..`, `*/..`, `/..` escape the cartridge - resolveModulesCartridge: `seg/..` and a package.json `main` of `../../..` escape the modules cartridge - an in-cartridge symlink pointing outside the root escapes too Each resolved out-of-root file is returned as a resolvedModule, so TS reads and parses it — an information-disclosure primitive (go-to-definition, completion on the imported binding) driven purely by opening a cloned repo. Add a shared canonicalize-and-contain guard (realpath to defeat symlinks, then `..`/`.` collapse and case/slash fold) applied at every resolver's trust boundary, plus resolveSuperModulePath for defense in depth. A legitimately symlinked cartridge root still resolves because both sides are realpath'd consistently. Adds test/index.security.test.js: one regression test per traversal shape (dw, ~/, */, /, modules bare + package.json main, symlink), each failing before this change, alongside guards that legitimate requires still resolve. Tests drive the real create() proxy resolver against on-disk fixtures, not mocks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- packages/b2c-script-types/plugin/index.js | 55 ++++- packages/b2c-script-types/src/index.ts | 54 ++++- .../test/index.security.test.js | 199 ++++++++++++++++++ 3 files changed, 296 insertions(+), 12 deletions(-) create mode 100644 packages/b2c-script-types/test/index.security.test.js diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 3ffd747c4..12c1a7db8 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -79,6 +79,34 @@ function init({ typescript: ts }) { 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)); + }; + // True when `candidate` resolves to a location at or beneath `rootDir`. + // This is the trust boundary for every resolver below: 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. + const isWithinRoot = (candidate, rootDir) => { + const root = canonicalPath(rootDir); + const resolved = canonicalPath(candidate); + return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); + }; const setCartridges = (list) => { cartridges = list.map(({ name, src }) => { const n = normalize(src); @@ -224,7 +252,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; }; @@ -284,7 +317,10 @@ function init({ typescript: ts }) { const baseAbs = c.root + subpath; for (const ext of CANDIDATE_EXTENSIONS) { const candidate = baseAbs + ext; - if (fileExists(candidate)) { + // `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 (fileExists(candidate) && isWithinRoot(candidate, c.root)) { return { resolved: candidate, source: c.name }; } } @@ -316,20 +352,24 @@ function init({ typescript: ts }) { const baseAbs = modulesCart.root + moduleName; for (const ext of CANDIDATE_EXTENSIONS) { const candidate = baseAbs + ext; - if (fileExists(candidate)) { + // `moduleName` may carry `..` after its first segment (it only can't + // *start* with `.`/`/`); contain it against the modules root. + if (fileExists(candidate) && 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 (fileExists(pkgPath)) { + if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { 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)) { + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + if (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { return { resolved, source: modulesCart.name }; } } @@ -430,7 +470,10 @@ function init({ typescript: ts }) { const subpath = normalize(containingFile).slice(owner.root.length); for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { const candidate = cartridges[i].root + subpath; - if (hostFileExists(candidate)) + // `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; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index b5b0ab579..30bbbeb6b 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -128,6 +128,34 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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)); + }; + + // True when `candidate` resolves to a location at or beneath `rootDir`. + // This is the trust boundary for every resolver below: 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. + const isWithinRoot = (candidate: string, rootDir: string): boolean => { + const root = canonicalPath(rootDir); + const resolved = canonicalPath(candidate); + return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); + }; + const setCartridges = (list: ConfiguredCartridge[]) => { cartridges = list.map(({name, src}) => { const n = normalize(src); @@ -273,7 +301,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; }; @@ -334,7 +366,10 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const baseAbs = c.root + subpath; for (const ext of CANDIDATE_EXTENSIONS) { const candidate = baseAbs + ext; - if (fileExists(candidate)) { + // `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 (fileExists(candidate) && isWithinRoot(candidate, c.root)) { return {resolved: candidate, source: c.name}; } } @@ -363,21 +398,25 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const baseAbs = modulesCart.root + moduleName; for (const ext of CANDIDATE_EXTENSIONS) { const candidate = baseAbs + ext; - if (fileExists(candidate)) { + // `moduleName` may carry `..` after its first segment (it only can't + // *start* with `.`/`/`); contain it against the modules root. + if (fileExists(candidate) && 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 (fileExists(pkgPath)) { + if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { 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)) { + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + if (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { return {resolved, source: modulesCart.name}; } } @@ -480,7 +519,10 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const subpath = normalize(containingFile).slice(owner.root.length); for (let i = cartridges.indexOf(owner) + 1; i < cartridges.length; i++) { const candidate = cartridges[i].root + subpath; - if (hostFileExists(candidate)) return candidate; + // `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; }; 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..c88e95bb7 --- /dev/null +++ b/packages/b2c-script-types/test/index.security.test.js @@ -0,0 +1,199 @@ +/* + * 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 = {};'); + + // 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}`); + }); + + // --- 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}`); + }); +}); From fe8cb61c0bc71f333e27399da62e4cf42858c6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:33:53 +0000 Subject: [PATCH 17/43] Bound untrusted JSON parsing and harden cartridge-rank lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dw.json and a cartridge's package.json are attacker-controlled in a cloned repo and were parsed synchronously on tsserver's thread with no size bound — a multi-hundred-megabyte file is a denial-of-service vector (memory + parse time). Route both reads through a single size-capped reader (1 MiB ceiling; real files are a few KB) that never throws and yields `undefined` for a missing, oversized, or malformed file, which callers already treat as absent. Also guard the BASE_CARTRIDGE_RANK lookup in orderCartridges with a hasOwnProperty check so a cartridge directory named `__proto__`/`constructor` can't read an inherited prototype value and corrupt the sort comparator. Adds a regression test: a valid package.json with a valid in-root `main` but padded past the ceiling must be refused, not parsed (fails before the cap). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- packages/b2c-script-types/plugin/index.js | 69 ++++++++++--------- packages/b2c-script-types/src/index.ts | 64 +++++++++-------- .../test/index.security.test.js | 17 +++++ 3 files changed, 88 insertions(+), 62 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 12c1a7db8..f68819809 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -107,6 +107,28 @@ function init({ typescript: ts }) { const resolved = canonicalPath(candidate); return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); }; + // Parses a workspace JSON file (dw.json, a cartridge's package.json) with a + // hard size ceiling. Both are attacker-controlled in a cloned repo and are + // parsed synchronously on tsserver's thread, so a multi-hundred-megabyte + // file would be a denial-of-service vector (memory + parse time) — a real + // dw.json/package.json is a few KB, so anything past 1 MiB is refused + // outright rather than best-effort parsed. Never throws: a missing, + // oversized, or malformed file yields `undefined`, and callers treat that + // as "absent" rather than failing the whole request. + const MAX_JSON_BYTES = 1024 * 1024; + const readJsonFile = (p) => { + try { + if (ts.sys.getFileSize && ts.sys.getFileSize(p) > MAX_JSON_BYTES) + return undefined; + const content = ts.sys.readFile(p); + if (content === undefined || content.length > MAX_JSON_BYTES) + return undefined; + return JSON.parse(content); + } + catch { + return undefined; + } + }; const setCartridges = (list) => { cartridges = list.map(({ name, src }) => { const n = normalize(src); @@ -178,22 +200,7 @@ function init({ typescript: ts }) { 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 parsed = readJsonFile(dwJsonPath); const value = parsed?.cartridges; if (typeof value === 'string') { return value @@ -228,9 +235,13 @@ function init({ typescript: ts }) { 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(BASE_CARTRIDGE_RANK, name) ? BASE_CARTRIDGE_RANK[name] : 0; indexed.sort((a, b) => { - const ar = BASE_CARTRIDGE_RANK[a.c.name] ?? 0; - const br = BASE_CARTRIDGE_RANK[b.c.name] ?? 0; + const ar = rankOf(a.c.name); + const br = rankOf(b.c.name); if (ar !== br) return ar - br; return a.i - b.i; @@ -361,23 +372,15 @@ function init({ typescript: ts }) { // package.json `main` fallback for directories without an index.js. const pkgPath = baseAbs + '/package.json'; if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - 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, '/'); - // `main` is attacker-controlled JSON content flowing into a path - // join — a `../../..` or absolute value must not escape the root. - if (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { - return { resolved, source: modulesCart.name }; - } - } + const main = readJsonFile(pkgPath)?.main; + if (typeof main === 'string' && main.length > 0) { + const resolved = (modulesCart.root + 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 (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { + return { resolved, source: modulesCart.name }; } } - catch { - // best-effort - } } return undefined; }; diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 30bbbeb6b..ae7cffc03 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -156,6 +156,26 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); }; + // Parses a workspace JSON file (dw.json, a cartridge's package.json) with a + // hard size ceiling. Both are attacker-controlled in a cloned repo and are + // parsed synchronously on tsserver's thread, so a multi-hundred-megabyte + // file would be a denial-of-service vector (memory + parse time) — a real + // dw.json/package.json is a few KB, so anything past 1 MiB is refused + // outright rather than best-effort parsed. Never throws: a missing, + // oversized, or malformed file yields `undefined`, and callers treat that + // as "absent" rather than failing the whole request. + const MAX_JSON_BYTES = 1024 * 1024; + const readJsonFile = (p: string): unknown => { + try { + if (ts.sys.getFileSize && ts.sys.getFileSize(p) > MAX_JSON_BYTES) return undefined; + const content = ts.sys.readFile(p); + if (content === undefined || content.length > MAX_JSON_BYTES) return undefined; + return JSON.parse(content); + } catch { + return undefined; + } + }; + const setCartridges = (list: ConfiguredCartridge[]) => { cartridges = list.map(({name, src}) => { const n = normalize(src); @@ -228,19 +248,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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 parsed = readJsonFile(dwJsonPath); const value = (parsed as {cartridges?: unknown})?.cartridges; if (typeof value === 'string') { return value @@ -278,9 +286,14 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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 = BASE_CARTRIDGE_RANK[a.c.name] ?? 0; - const br = BASE_CARTRIDGE_RANK[b.c.name] ?? 0; + const ar = rankOf(a.c.name); + const br = rankOf(b.c.name); if (ar !== br) return ar - br; return a.i - b.i; }); @@ -408,21 +421,14 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // package.json `main` fallback for directories without an index.js. const pkgPath = baseAbs + '/package.json'; if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - 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, '/'); - // `main` is attacker-controlled JSON content flowing into a path - // join — a `../../..` or absolute value must not escape the root. - if (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { - return {resolved, source: modulesCart.name}; - } - } + const main = (readJsonFile(pkgPath) as {main?: string} | undefined)?.main; + if (typeof main === 'string' && main.length > 0) { + const resolved = (modulesCart.root + 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 (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { + return {resolved, source: modulesCart.name}; } - } catch { - // best-effort } } return undefined; diff --git a/packages/b2c-script-types/test/index.security.test.js b/packages/b2c-script-types/test/index.security.test.js index c88e95bb7..ac27ce2f9 100644 --- a/packages/b2c-script-types/test/index.security.test.js +++ b/packages/b2c-script-types/test/index.security.test.js @@ -49,6 +49,16 @@ function buildWorkspace() { 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;'); @@ -183,6 +193,13 @@ describe('module resolver path-traversal containment', () => { 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', () => { From a0a676f2c9f039b04826ba2226e59ee08e3a5fed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 17:42:30 +0000 Subject: [PATCH 18/43] Gate Script API IntelliSense behind Workspace Trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature forwards workspace-derived cartridge names and filesystem paths to the tsserver plugin, which resolves require() across the whole project and (with inferUsage) reads sibling files to synthesize hover/completion — work that should never act on an unvetted, freshly-cloned repository. It was only implicitly trust-gated (an extension with a main entry and no declared Workspace Trust support is disabled in Restricted Mode), which is fragile. Make the posture explicit and robust: - declare capabilities.untrustedWorkspaces.supported = false so VS Code formally withholds the extension until the workspace is trusted; - additionally gate the script-types push() on vscode.workspace.isTrusted as defense in depth (forwards a disabled config while untrusted), and re-push on onDidGrantWorkspaceTrust so the feature turns on without a reload once the user vouches for the workspace. This also neutralizes a workspace .vscode/settings.json shipped by a repo force-enabling the preview inferUsage flag: such settings only take effect in a trusted workspace, which is now a prerequisite for the feature at all. Adds a changeset covering the full security-hardening pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- .changeset/script-types-security-hardening.md | 6 +++++ packages/b2c-vs-extension/package.json | 5 +++++ .../src/script-types/index.ts | 22 ++++++++++++++++--- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/script-types-security-hardening.md diff --git a/.changeset/script-types-security-hardening.md b/.changeset/script-types-security-hardening.md new file mode 100644 index 000000000..d4c821436 --- /dev/null +++ b/.changeset/script-types-security-hardening.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Security hardening for Script API IntelliSense 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/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 209924a68..51de6b135 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": [ { diff --git a/packages/b2c-vs-extension/src/script-types/index.ts b/packages/b2c-vs-extension/src/script-types/index.ts index ff8f1abf3..ed378c87e 100644 --- a/packages/b2c-vs-extension/src/script-types/index.ts +++ b/packages/b2c-vs-extension/src/script-types/index.ts @@ -62,12 +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, inferUsage}); log.appendLine( - `[ScriptTypes] Pushed ${cartridges.length} cartridge(s); enabled=${enabled}; inferUsage=${inferUsage}; 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(', ')}].`, ); }; @@ -84,6 +96,10 @@ export function registerScriptTypes( } }); + // 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(); @@ -92,5 +108,5 @@ export function registerScriptTypes( ); }); - context.subscriptions.push(cartridgesSub, configChange, refreshCmd); + context.subscriptions.push(cartridgesSub, configChange, trustChange, refreshCmd); } From 1509d3ee46631c0ce79640ab642540158136d9e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:13:13 +0000 Subject: [PATCH 19/43] Refactor usage-inference into focused modules under 800 lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inference engine had grown to a single ~1,200-line src/usage-inference.ts (and a ~1,070-line compiled twin), which is hard to navigate. Split it by responsibility into small, single-purpose modules under src/inference/ behind a re-exporting barrel, keeping the public API (and the `../plugin/usage-inference` import path the tests use) unchanged: constants - the tunable limits that bound a request context - the per-request scratchpad (program, budgets, memo, guards) ast-helpers - pure AST navigation (find node, enclosing access, returns) call-sites - find where a function is called across the project type-helpers - Type utilities + hover text / completion entry synthesis super-module - module.superModule detection and export scanning (leaf ops) core - the recursive engine that ties the leaf modules together Dependencies flow one way (core -> leaves; leaves never import core), so there are no import cycles. The recursive functions that must call each other stay together in core.ts; only genuinely independent helpers were extracted. Pure code move — no behavior change. Largest module is now 503 source / 476 compiled lines. All 104 unit tests pass unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- .../plugin/inference/ast-helpers.js | 93 ++ .../plugin/inference/call-sites.js | 179 +++ .../plugin/inference/constants.js | 65 + .../plugin/inference/context.js | 32 + .../b2c-script-types/plugin/inference/core.js | 476 +++++++ .../plugin/inference/super-module.js | 157 +++ .../plugin/inference/type-helpers.js | 145 ++ .../plugin/usage-inference.js | 1097 +-------------- .../src/inference/ast-helpers.ts | 105 ++ .../src/inference/call-sites.ts | 187 +++ .../src/inference/constants.ts | 71 + .../b2c-script-types/src/inference/context.ts | 123 ++ .../b2c-script-types/src/inference/core.ts | 501 +++++++ .../src/inference/super-module.ts | 170 +++ .../src/inference/type-helpers.ts | 161 +++ .../b2c-script-types/src/usage-inference.ts | 1209 +---------------- 16 files changed, 2518 insertions(+), 2253 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/ast-helpers.js create mode 100644 packages/b2c-script-types/plugin/inference/call-sites.js create mode 100644 packages/b2c-script-types/plugin/inference/constants.js create mode 100644 packages/b2c-script-types/plugin/inference/context.js create mode 100644 packages/b2c-script-types/plugin/inference/core.js create mode 100644 packages/b2c-script-types/plugin/inference/super-module.js create mode 100644 packages/b2c-script-types/plugin/inference/type-helpers.js create mode 100644 packages/b2c-script-types/src/inference/ast-helpers.ts create mode 100644 packages/b2c-script-types/src/inference/call-sites.ts create mode 100644 packages/b2c-script-types/src/inference/constants.ts create mode 100644 packages/b2c-script-types/src/inference/context.ts create mode 100644 packages/b2c-script-types/src/inference/core.ts create mode 100644 packages/b2c-script-types/src/inference/super-module.ts create mode 100644 packages/b2c-script-types/src/inference/type-helpers.ts 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..031a990b1 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -0,0 +1,93 @@ +"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; +} +/** + * True when the developer already gave this parameter an explicit type — TS + * syntax or JSDoc — even if that type is literally `any`. In that case the + * checker's `any` reflects a deliberate choice, not an inference failure, so + * usage inference must never second-guess it. Only genuinely implicit `any` + * (no annotation at all) is fair game. + */ +function hasExplicitParameterType(param, ts) { + return param.type !== undefined || ts.getJSDocType(param) !== undefined; +} +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ +function hasExplicitReturnType(fn, ts) { + return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; +} +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +function hasExplicitVariableType(decl, ts) { + return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; +} +/** + * 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..ce9be2867 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -0,0 +1,179 @@ +"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 either `helper(x)` or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if + * the identifier sits in callee position — one parent up for a direct call, + * 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) && parent.expression === node) + return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if (grandparent && ts.isCallExpression(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 { ts, languageService, program } = ctx; + 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); + 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); + } + } + frontier = nextFrontier; + } + ctx.callSiteMemo.set(nameNode, calls); + return calls; +} 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..25b8031eb --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -0,0 +1,65 @@ +"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.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'; 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..8fe39c413 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -0,0 +1,32 @@ +"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) { + 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, + }; +} 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..8a9d7761e --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -0,0 +1,476 @@ +"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"); +/** + * 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. + */ +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 { + return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + } + 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(coll, function + * (item) {...})` — 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), and `reduce`-style callees are + * skipped since their callbacks lead with an accumulator instead. + */ +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 === 'reduce') + 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); + if (!(0, type_helpers_1.isAnyType)(ts, direct)) + return [(0, type_helpers_1.widenType)(checker, direct)]; + if (chainHops >= constants_1.MAX_CHAIN_HOPS) + return []; + if (ts.isCallExpression(expr)) { + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) + return inferred; + } + if (ts.isPropertyAccessExpression(expr.expression)) { + // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the + // receiver's own base is undocumented — resolveCalleeDeclaration can't + // find a real declaration since the checker never got far enough to + // resolve the method itself. Infer the receiver's type first (recursing + // through as many chained calls/property accesses as it takes to reach + // an untyped parameter or undocumented helper), then look up this + // method by name on that resolved type's real, documented signature(s). + 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); + } + } + } + if (returnTypes.length > 0) + return (0, type_helpers_1.dedupeTypes)(ctx, returnTypes); + } + } + else if (ts.isPropertyAccessExpression(expr)) { + // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented + // (an untyped parameter, say) — infer the base's type first, then look + // up this specific property on it, rather than giving up on the whole + // access just because the access itself resolved to `any`. + 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 above: 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))); + } + } + if (propTypes.length > 0) + return (0, type_helpers_1.dedupeTypes)(ctx, propTypes); + } + else if (ts.isIdentifier(expr)) { + // `expr` is itself an undocumented parameter reference (e.g. a helper + // that just returns/forwards one of its own params) — chase that + // parameter's inferred type too, rather than stopping at `any`. + const sym = checker.getSymbolAtLocation(expr); + const decl = sym?.valueDeclaration; + if (decl && ts.isParameter(decl)) { + const inferred = inferParameterType(ctx, decl, depth + 1); + if (inferred.length > 0) + return inferred; + } + else if (decl && ts.isVariableDeclaration(decl)) { + // ...or a local variable holding an intermediate result — chase its + // initializer the same way, so splitting a chain across `var` + // statements infers exactly like the inline expression would. + const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + if (inferred.length > 0) + return inferred; + } + } + return []; +} +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project, since plain un-annotated JS parameters + * default to `any` with no back-inference from call sites. + * + * @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 types = []; + const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); + if (nameNode) { + 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)); + } + } + else { + // 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. + types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); + } + const result = (0, type_helpers_1.dedupeTypes)(ctx, types); + // 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..8e2ac4a8e --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -0,0 +1,145 @@ +"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.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; +} +/** + * 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 memoized per request — see InferenceContext.typeDisplayStrings. */ +function typeDisplayString(ctx, type) { + const cached = ctx.typeDisplayStrings.get(type); + if (cached !== undefined) + return cached; + const str = ctx.checker.typeToString(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(checker.typeToString(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/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index c8d6df9d3..1ef912798 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -5,1068 +5,35 @@ * 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.INFERRED_COMPLETION_SOURCE = void 0; -exports.createInferenceContext = createInferenceContext; -exports.isAnyType = isAnyType; -exports.getNodeAtPosition = getNodeAtPosition; -exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; -exports.traceSuperModuleAccess = traceSuperModuleAccess; -exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; -exports.inferParameterType = inferParameterType; -exports.inferReturnType = inferReturnType; -exports.inferTypeForNode = inferTypeForNode; -exports.inferTypeForExpression = inferTypeForExpression; -exports.describeTypes = describeTypes; -exports.typesToCompletionEntries = typesToCompletionEntries; -// Bounds 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. -const MAX_INFERENCE_DEPTH = 3; -// Bounds 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. -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). -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. -const MAX_REFERENCES_PER_CALL = 50; -// Bounds 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. -const MAX_CHAIN_HOPS = 10; -// Bounds 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. -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. -const MAX_SEARCHES_PER_REQUEST = 12; -exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; -/** - * 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) { - 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, - }; -} -/** 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; -} -/** - * 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; -} -/** - * 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 either `helper(x)` or - * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if - * the identifier sits in callee position — one parent up for a direct call, - * 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) && parent.expression === node) - return parent; - if (ts.isPropertyAccessExpression(parent) && parent.name === node) { - const grandparent = parent.parent; - if (grandparent && ts.isCallExpression(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 { ts, languageService, program } = ctx; - const memoized = ctx.callSiteMemo.get(nameNode); - if (memoized) - return memoized; - const calls = []; - const seenNameKeys = new Set(); - let frontier = [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 = []; - 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); - 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); - } - } - frontier = nextFrontier; - } - ctx.callSiteMemo.set(nameNode, calls); - return calls; -} -/** - * True when the developer already gave this parameter an explicit type — TS - * syntax or JSDoc — even if that type is literally `any`. In that case the - * checker's `any` reflects a deliberate choice, not an inference failure, so - * usage inference must never second-guess it. Only genuinely implicit `any` - * (no annotation at all) is fair game. - */ -function hasExplicitParameterType(param, ts) { - return param.type !== undefined || ts.getJSDocType(param) !== undefined; -} -/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ -function hasExplicitReturnType(fn, ts) { - return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; -} -/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ -function hasExplicitVariableType(decl, ts) { - return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; -} -/** - * 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 (isAnyType(ts, exportsType)) - return false; - return checker.getPropertiesOfType(checker.getApparentType(exportsType)).length > 0; -} -/** - * 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 = 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 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); - } -} -/** - * 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; -} -/** - * 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 < 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 = []; - 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 []; -} -/** - * 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 < 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; -} -/** - * 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. - */ -function resolveVariableInitializerTypes(ctx, decl, depth, chainHops) { - const { ts } = ctx; - if (!decl.initializer || hasExplicitVariableType(decl, ts)) - return []; - if (ctx.visiting.has(decl)) { - ctx.cycleHits++; - return []; - } - ctx.visiting.add(decl); - try { - return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); - } - finally { - ctx.visiting.delete(decl); - } -} -/** - * 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; -} -/** - * 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 memoized per request — see InferenceContext.typeDisplayStrings. */ -function typeDisplayString(ctx, type) { - const cached = ctx.typeDisplayStrings.get(type); - if (cached !== undefined) - return cached; - const str = ctx.checker.typeToString(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); -} -/** - * 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 = traceSuperModuleAccess(ts, checker, expr); - if (superAccessAtRoot) { - return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); - } - const direct = checker.getTypeAtLocation(expr); - if (!isAnyType(ts, direct)) - return [widenType(checker, direct)]; - if (chainHops >= MAX_CHAIN_HOPS) - return []; - if (ts.isCallExpression(expr)) { - const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) - return inferred; - } - if (ts.isPropertyAccessExpression(expr.expression)) { - // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the - // receiver's own base is undocumented — resolveCalleeDeclaration can't - // find a real declaration since the checker never got far enough to - // resolve the method itself. Infer the receiver's type first (recursing - // through as many chained calls/property accesses as it takes to reach - // an untyped parameter or undocumented helper), then look up this - // method by name on that resolved type's real, documented signature(s). - 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 (!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); - } - } - } - if (returnTypes.length > 0) - return dedupeTypes(ctx, returnTypes); - } - } - else if (ts.isPropertyAccessExpression(expr)) { - // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented - // (an untyped parameter, say) — infer the base's type first, then look - // up this specific property on it, rather than giving up on the whole - // access just because the access itself resolved to `any`. - const propName = expr.name.text; - const propTypes = []; - 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 above: 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))); - } - } - if (propTypes.length > 0) - return dedupeTypes(ctx, propTypes); - } - else if (ts.isIdentifier(expr)) { - // `expr` is itself an undocumented parameter reference (e.g. a helper - // that just returns/forwards one of its own params) — chase that - // parameter's inferred type too, rather than stopping at `any`. - const sym = checker.getSymbolAtLocation(expr); - const decl = sym?.valueDeclaration; - if (decl && ts.isParameter(decl)) { - const inferred = inferParameterType(ctx, decl, depth + 1); - if (inferred.length > 0) - return inferred; - } - else if (decl && ts.isVariableDeclaration(decl)) { - // ...or a local variable holding an intermediate result — chase its - // initializer the same way, so splitting a chain across `var` - // statements infers exactly like the inline expression would. - const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); - if (inferred.length > 0) - return inferred; - } - } - return []; -} -/** - * 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; -} -/** - * Infers the type of a callback's first parameter from sibling arguments of - * the call the callback is passed to: `collections.forEach(coll, function - * (item) {...})` — 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), and `reduce`-style callees are - * skipped since their callbacks lead with an accumulator instead. - */ -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 === 'reduce') - return []; - const types = []; - 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; -} -/** - * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project, since plain un-annotated JS parameters - * default to `any` with no back-inference from call sites. - * - * @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 > 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 types = []; - const nameNode = getReferenceNameNode(fn, ts); - if (nameNode) { - for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; - if (!arg) - continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - } - else { - // 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. - types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); - } - const result = dedupeTypes(ctx, types); - // 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); - } -} -/** - * 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; -} -/** - * 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 > 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 = []; - 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. - */ -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 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 dedupeTypes(ctx, resolveExpressionTypes(ctx, expr, 0)); -} -/** - * 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(checker.typeToString(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: exports.INFERRED_COMPLETION_SOURCE, - }); - } - } - return entries; -} +exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isAnyType = 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 +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, "isAnyType", { enumerable: true, get: function () { return type_helpers_1.isAnyType; } }); +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; } }); 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..dda48b346 --- /dev/null +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// 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; +} + +/** + * True when the developer already gave this parameter an explicit type — TS + * syntax or JSDoc — even if that type is literally `any`. In that case the + * checker's `any` reflects a deliberate choice, not an inference failure, so + * usage inference must never second-guess it. Only genuinely implicit `any` + * (no annotation at all) is fair game. + */ +export function hasExplicitParameterType(param: tsserver.ParameterDeclaration, ts: typeof tsserver): boolean { + return param.type !== undefined || ts.getJSDocType(param) !== undefined; +} + +/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ +export function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): boolean { + return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; +} + +/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ +export function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: typeof tsserver): boolean { + return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; +} + +/** + * 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..00e21236a --- /dev/null +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -0,0 +1,187 @@ +/* + * 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. + +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'; + +/** + * 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 either `helper(x)` or + * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if + * the identifier sits in callee position — one parent up for a direct call, + * two parents up when the identifier is the `.name` of a property access. + */ +function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { + const parent = node.parent; + if (!parent) return undefined; + if (ts.isCallExpression(parent) && parent.expression === node) return parent; + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + const grandparent = parent.parent; + if (grandparent && ts.isCallExpression(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: tsserver.CallExpression} | {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): tsserver.CallExpression[] { + const {ts, languageService, program} = ctx; + const memoized = ctx.callSiteMemo.get(nameNode); + if (memoized) return memoized; + const calls: tsserver.CallExpression[] = []; + 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); + + 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); + } + } + frontier = nextFrontier; + } + + ctx.callSiteMemo.set(nameNode, calls); + return calls; +} 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..3d9241f0f --- /dev/null +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -0,0 +1,71 @@ +/* + * 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'; 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..092259b48 --- /dev/null +++ b/packages/b2c-script-types/src/inference/context.ts @@ -0,0 +1,123 @@ +/* + * 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 {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; +} + +/** + * 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, +): 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, + }; +} 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..e035d90d9 --- /dev/null +++ b/packages/b2c-script-types/src/inference/core.ts @@ -0,0 +1,501 @@ +/* + * 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 {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, widenType} from './type-helpers'; + +/** + * Resolves the function-like declaration a call expression's callee refers + * to, via its symbol or — as a fallback for shapes the symbol lookup misses + * — 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. + */ +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 { + return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + } 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(coll, function + * (item) {...})` — 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), and `reduce`-style callees are + * skipped since their callbacks lead with an accumulator instead. + */ +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 === 'reduce') 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); + if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; + if (chainHops >= MAX_CHAIN_HOPS) return []; + if (ts.isCallExpression(expr)) { + const calleeFn = resolveCalleeDeclaration(ctx, expr); + if (calleeFn) { + const inferred = inferReturnType(ctx, calleeFn, depth + 1); + if (inferred.length > 0) return inferred; + } + if (ts.isPropertyAccessExpression(expr.expression)) { + // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the + // receiver's own base is undocumented — resolveCalleeDeclaration can't + // find a real declaration since the checker never got far enough to + // resolve the method itself. Infer the receiver's type first (recursing + // through as many chained calls/property accesses as it takes to reach + // an untyped parameter or undocumented helper), then look up this + // method by name on that resolved type's real, documented signature(s). + 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); + } + } + } + if (returnTypes.length > 0) return dedupeTypes(ctx, returnTypes); + } + } else if (ts.isPropertyAccessExpression(expr)) { + // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented + // (an untyped parameter, say) — infer the base's type first, then look + // up this specific property on it, rather than giving up on the whole + // access just because the access itself resolved to `any`. + 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 above: 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), + ), + ); + } + } + if (propTypes.length > 0) return dedupeTypes(ctx, propTypes); + } else if (ts.isIdentifier(expr)) { + // `expr` is itself an undocumented parameter reference (e.g. a helper + // that just returns/forwards one of its own params) — chase that + // parameter's inferred type too, rather than stopping at `any`. + const sym = checker.getSymbolAtLocation(expr); + const decl = sym?.valueDeclaration; + if (decl && ts.isParameter(decl)) { + const inferred = inferParameterType(ctx, decl, depth + 1); + if (inferred.length > 0) return inferred; + } else if (decl && ts.isVariableDeclaration(decl)) { + // ...or a local variable holding an intermediate result — chase its + // initializer the same way, so splitting a chain across `var` + // statements infers exactly like the inline expression would. + const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); + if (inferred.length > 0) return inferred; + } + } + return []; +} + +/** + * Infers a parameter's candidate type(s) from the arguments it's actually + * called with across the project, since plain un-annotated JS parameters + * default to `any` with no back-inference from call sites. + * + * @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 types: tsserver.Type[] = []; + const nameNode = getReferenceNameNode(fn, ts); + if (nameNode) { + for (const call of collectCallSites(ctx, nameNode)) { + const arg = call.arguments[paramIndex]; + if (!arg) continue; + types.push(...resolveExpressionTypes(ctx, arg, depth)); + } + } else { + // 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. + types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); + } + + const result = dedupeTypes(ctx, types); + // 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..d466783d3 --- /dev/null +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// 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; +} + +/** + * 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 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 = ctx.checker.typeToString(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(checker.typeToString(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/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index bfc5e68ea..d7631decc 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -4,1191 +4,24 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -// The blank line above keeps the license header detached from the type-only -// import below it: tsc elides a type-only import together with any comment -// attached to it, so without the separation the emitted plugin/ JS would ship -// without its license header. -import type tsserver from 'typescript/lib/tsserverlibrary'; - -// Bounds 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. -const MAX_INFERENCE_DEPTH = 3; - -// Bounds 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. -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). -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. -const MAX_REFERENCES_PER_CALL = 50; - -// Bounds 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. -const MAX_CHAIN_HOPS = 10; - -// Bounds 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. -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. -const MAX_SEARCHES_PER_REQUEST = 12; - -export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; - -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; -} - -/** - * 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, -): 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, - }; -} - -/** 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; -} - -/** - * 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; -} - -/** - * 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: 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 either `helper(x)` or - * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if - * the identifier sits in callee position — one parent up for a direct call, - * two parents up when the identifier is the `.name` of a property access. - */ -function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { - const parent = node.parent; - if (!parent) return undefined; - if (ts.isCallExpression(parent) && parent.expression === node) return parent; - if (ts.isPropertyAccessExpression(parent) && parent.name === node) { - const grandparent = parent.parent; - if (grandparent && ts.isCallExpression(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: tsserver.CallExpression} | {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. - */ -function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { - const {ts, languageService, program} = ctx; - const memoized = ctx.callSiteMemo.get(nameNode); - if (memoized) return memoized; - const calls: tsserver.CallExpression[] = []; - 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); - - 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); - } - } - frontier = nextFrontier; - } - - ctx.callSiteMemo.set(nameNode, calls); - return calls; -} - -/** - * True when the developer already gave this parameter an explicit type — TS - * syntax or JSDoc — even if that type is literally `any`. In that case the - * checker's `any` reflects a deliberate choice, not an inference failure, so - * usage inference must never second-guess it. Only genuinely implicit `any` - * (no annotation at all) is fair game. - */ -function hasExplicitParameterType(param: tsserver.ParameterDeclaration, ts: typeof tsserver): boolean { - return param.type !== undefined || ts.getJSDocType(param) !== undefined; -} - -/** Same idea as {@link hasExplicitParameterType}, but for a function's return type. */ -function hasExplicitReturnType(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): boolean { - return fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; -} - -/** Same idea as {@link hasExplicitParameterType}, but for a variable declaration (`var x = ...`). */ -function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: typeof tsserver): boolean { - return decl.type !== undefined || ts.getJSDocType(decl) !== undefined; -} - -/** - * 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). - */ -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;`). - */ -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. - */ -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; -} - -/** - * 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); - } -} - -/** - * 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; -} - -/** - * 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 []; -} - -/** - * 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; -} - -/** - * 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. - */ -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 { - return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); - } finally { - ctx.visiting.delete(decl); - } -} - -/** - * 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; -} - -/** - * 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: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { - return checker.getBaseTypeOfLiteralType(type); -} - -/** checker.typeToString memoized per request — see InferenceContext.typeDisplayStrings. */ -function typeDisplayString(ctx: InferenceContext, type: tsserver.Type): string { - const cached = ctx.typeDisplayStrings.get(type); - if (cached !== undefined) return cached; - const str = ctx.checker.typeToString(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: 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. - */ -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}. */ -function getMemberOfType( - checker: tsserver.TypeChecker, - type: tsserver.Type, - name: string, -): tsserver.Symbol | undefined { - return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); -} - -/** - * 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); - if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; - if (chainHops >= MAX_CHAIN_HOPS) return []; - if (ts.isCallExpression(expr)) { - const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) return inferred; - } - if (ts.isPropertyAccessExpression(expr.expression)) { - // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the - // receiver's own base is undocumented — resolveCalleeDeclaration can't - // find a real declaration since the checker never got far enough to - // resolve the method itself. Infer the receiver's type first (recursing - // through as many chained calls/property accesses as it takes to reach - // an untyped parameter or undocumented helper), then look up this - // method by name on that resolved type's real, documented signature(s). - 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); - } - } - } - if (returnTypes.length > 0) return dedupeTypes(ctx, returnTypes); - } - } else if (ts.isPropertyAccessExpression(expr)) { - // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented - // (an untyped parameter, say) — infer the base's type first, then look - // up this specific property on it, rather than giving up on the whole - // access just because the access itself resolved to `any`. - 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 above: 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), - ), - ); - } - } - if (propTypes.length > 0) return dedupeTypes(ctx, propTypes); - } else if (ts.isIdentifier(expr)) { - // `expr` is itself an undocumented parameter reference (e.g. a helper - // that just returns/forwards one of its own params) — chase that - // parameter's inferred type too, rather than stopping at `any`. - const sym = checker.getSymbolAtLocation(expr); - const decl = sym?.valueDeclaration; - if (decl && ts.isParameter(decl)) { - const inferred = inferParameterType(ctx, decl, depth + 1); - if (inferred.length > 0) return inferred; - } else if (decl && ts.isVariableDeclaration(decl)) { - // ...or a local variable holding an intermediate result — chase its - // initializer the same way, so splitting a chain across `var` - // statements infers exactly like the inline expression would. - const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); - if (inferred.length > 0) return inferred; - } - } - return []; -} - -/** - * Extracts the element type from a collection-like `type`: something with an - * `iterator()` method whose result has a typed `next()` (dw.util.Collection - * and friends), or something that is itself such an iterator. Returns - * `undefined` when `type` doesn't look like a collection or its element type - * is unknown — never `any`. - * - * @param location - any node in the file where the type is being used; - * required by getTypeOfSymbolAtLocation to resolve member types. - */ -function collectionElementType( - ctx: InferenceContext, - type: tsserver.Type, - location: tsserver.Node, -): tsserver.Type | undefined { - const {ts, checker} = ctx; - const firstCallReturn = (t: tsserver.Type, memberName: string): tsserver.Type | undefined => { - const sym = 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; -} - -/** - * Infers the type of a callback's first parameter from sibling arguments of - * the call the callback is passed to: `collections.forEach(coll, function - * (item) {...})` — 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), and `reduce`-style callees are - * skipped since their callbacks lead with an accumulator instead. - */ -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 === 'reduce') 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; -} - -/** - * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project, since plain un-annotated JS parameters - * default to `any` with no back-inference from call sites. - * - * @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 types: tsserver.Type[] = []; - const nameNode = getReferenceNameNode(fn, ts); - if (nameNode) { - for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; - if (!arg) continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - } else { - // 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. - types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); - } - - const result = dedupeTypes(ctx, types); - // 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); - } -} - -/** - * 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: 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; -} - -/** - * 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)); -} - -/** - * 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(checker.typeToString(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; -} +// 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 + +export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; +export {createInferenceContext} from './inference/context'; +export {getNodeAtPosition, findEnclosingPropertyAccess} from './inference/ast-helpers'; +export {describeTypes, isAnyType, typesToCompletionEntries} from './inference/type-helpers'; +export {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './inference/super-module'; +export {inferParameterType, inferReturnType, inferTypeForExpression, inferTypeForNode} from './inference/core'; + +export type {InferenceContext} from './inference/context'; From a3766ae3c6c1f6a06600af9fdfc1cf9d09725608 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:19:31 +0000 Subject: [PATCH 20/43] Extract cartridge discovery and constants out of index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.ts had grown to ~910 lines. Move the pieces that don't touch the plugin's mutable per-project state into two focused modules under src/resolver/, leaving index.ts as just the plugin lifecycle (config, module-resolution wiring, hover/completion overrides): resolver/constants - shared cartridge types + lookup tables (ambient module names, candidate extensions, base-cartridge ranks, discovery ignore list, JSON size ceiling) resolver/cartridge-discovery - the size-capped JSON reader, .project discovery walk, dw.json cartridge-order parsing, ordering, and declare-module range scanning — all pure/standalone functions taking `ts` (+ a fileExists probe) as plain arguments The bundled-types path constants (TYPES_DIR/GLOBAL_DTS/SFRA_SERVER_DTS) stay in index.ts because they resolve against `__dirname`, which must point at the plugin's own output location. Pure code move — no behavior change. index.ts is now 724 source / 659 compiled lines. All 104 unit tests and the 17 VS Code E2E tests pass unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- packages/b2c-script-types/plugin/index.js | 186 +-------------- .../plugin/resolver/cartridge-discovery.js | 163 ++++++++++++++ .../plugin/resolver/constants.js | 54 +++++ packages/b2c-script-types/src/index.ts | 212 +----------------- .../src/resolver/cartridge-discovery.ts | 165 ++++++++++++++ .../src/resolver/constants.ts | 89 ++++++++ 6 files changed, 494 insertions(+), 375 deletions(-) create mode 100644 packages/b2c-script-types/plugin/resolver/cartridge-discovery.js create mode 100644 packages/b2c-script-types/plugin/resolver/constants.js create mode 100644 packages/b2c-script-types/src/resolver/cartridge-discovery.ts create mode 100644 packages/b2c-script-types/src/resolver/constants.ts diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index f68819809..15c983128 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -9,7 +9,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { */ const node_path_1 = __importDefault(require("node:path")); const usage_inference_1 = require("./usage-inference"); -const PLUGIN_NAME = '@salesforce/b2c-script-types'; +const constants_1 = require("./resolver/constants"); +const cartridge_discovery_1 = require("./resolver/cartridge-discovery"); 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 @@ -20,40 +21,6 @@ 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 }) { // tsserver calls this factory function fresh for every project that loads // the plugin (once per tsconfig/jsconfig root), so these variables are a @@ -107,28 +74,6 @@ function init({ typescript: ts }) { const resolved = canonicalPath(candidate); return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); }; - // Parses a workspace JSON file (dw.json, a cartridge's package.json) with a - // hard size ceiling. Both are attacker-controlled in a cloned repo and are - // parsed synchronously on tsserver's thread, so a multi-hundred-megabyte - // file would be a denial-of-service vector (memory + parse time) — a real - // dw.json/package.json is a few KB, so anything past 1 MiB is refused - // outright rather than best-effort parsed. Never throws: a missing, - // oversized, or malformed file yields `undefined`, and callers treat that - // as "absent" rather than failing the whole request. - const MAX_JSON_BYTES = 1024 * 1024; - const readJsonFile = (p) => { - try { - if (ts.sys.getFileSize && ts.sys.getFileSize(p) > MAX_JSON_BYTES) - return undefined; - const content = ts.sys.readFile(p); - if (content === undefined || content.length > MAX_JSON_BYTES) - return undefined; - return JSON.parse(content); - } - catch { - return undefined; - } - }; const setCartridges = (list) => { cartridges = list.map(({ name, src }) => { const n = normalize(src); @@ -160,94 +105,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; - const parsed = readJsonFile(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; - }; - // 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 })); - // 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(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); - }; const isCartridgeFile = (filePath) => { if (!enabled || cartridges.length === 0) return false; @@ -326,7 +183,7 @@ function init({ typescript: ts }) { return undefined; for (const c of order) { const baseAbs = c.root + subpath; - for (const ext of CANDIDATE_EXTENSIONS) { + 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 @@ -355,13 +212,13 @@ function init({ typescript: ts }) { // 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)) + 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.root + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { + 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. @@ -372,7 +229,7 @@ function init({ typescript: ts }) { // package.json `main` fallback for directories without an index.js. const pkgPath = baseAbs + '/package.json'; if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - const main = readJsonFile(pkgPath)?.main; + const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; if (typeof main === 'string' && main.length > 0) { const resolved = (modulesCart.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); // `main` is attacker-controlled JSON content flowing into a path @@ -395,7 +252,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) @@ -403,27 +260,6 @@ 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) @@ -431,7 +267,7 @@ function init({ typescript: ts }) { 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 @@ -440,9 +276,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)` : '')); 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..b0dd13f1f --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js @@ -0,0 +1,163 @@ +"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_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. + */ +function readJsonFile(ts, filePath) { + try { + if (ts.sys.getFileSize && ts.sys.getFileSize(filePath) > 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/src/index.ts b/packages/b2c-script-types/src/index.ts index ae7cffc03..f1cd5be8b 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -20,38 +20,16 @@ import { isAnyType, typesToCompletionEntries, } from './usage-inference'; +import {CANDIDATE_EXTENSIONS, PLUGIN_NAME, SFRA_AMBIENT_MODULES} from './resolver/constants'; +import type {ConfiguredCartridge, NormalizedCartridge, PluginConfig} from './resolver/constants'; +import { + discoverCartridgesOnDisk, + orderCartridges, + parseDeclareModuleRanges, + readDwJsonCartridges, + readJsonFile, +} from './resolver/cartridge-discovery'; -interface ConfiguredCartridge { - name: string; - src: string; -} - -interface PluginConfig { - /** @deprecated use cartridges; kept for backward compatibility */ - cartridgeRoots?: string[]; - cartridges?: ConfiguredCartridge[]; - enabled?: boolean; - /** - * Disable filesystem auto-discovery when no cartridges are pushed in. Defaults - * to false — i.e. auto-discovery runs unless the host explicitly opts out. - */ - autoDiscover?: boolean; - /** - * 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; -} - -interface NormalizedCartridge { - name: string; - /** Forward-slash path with trailing '/'. Lowercased on case-insensitive filesystems. */ - root: string; -} - -const PLUGIN_NAME = '@salesforce/b2c-script-types'; const TYPES_DIR = path.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, // `customer`, `empty(...)`, the `dw.*` namespace alias, etc.). The plugin @@ -62,44 +40,6 @@ 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}) { // tsserver calls this factory function fresh for every project that loads @@ -156,26 +96,6 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); }; - // Parses a workspace JSON file (dw.json, a cartridge's package.json) with a - // hard size ceiling. Both are attacker-controlled in a cloned repo and are - // parsed synchronously on tsserver's thread, so a multi-hundred-megabyte - // file would be a denial-of-service vector (memory + parse time) — a real - // dw.json/package.json is a few KB, so anything past 1 MiB is refused - // outright rather than best-effort parsed. Never throws: a missing, - // oversized, or malformed file yields `undefined`, and callers treat that - // as "absent" rather than failing the whole request. - const MAX_JSON_BYTES = 1024 * 1024; - const readJsonFile = (p: string): unknown => { - try { - if (ts.sys.getFileSize && ts.sys.getFileSize(p) > MAX_JSON_BYTES) return undefined; - const content = ts.sys.readFile(p); - if (content === undefined || content.length > MAX_JSON_BYTES) return undefined; - return JSON.parse(content); - } catch { - return undefined; - } - }; - const setCartridges = (list: ConfiguredCartridge[]) => { cartridges = list.map(({name, src}) => { const n = normalize(src); @@ -211,95 +131,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; - const parsed = readJsonFile(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; - }; - - // 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})); - // 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); - }; - const isCartridgeFile = (filePath: string): boolean => { if (!enabled || cartridges.length === 0) return false; const f = normalize(filePath); @@ -421,7 +252,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // package.json `main` fallback for directories without an index.js. const pkgPath = baseAbs + '/package.json'; if (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - const main = (readJsonFile(pkgPath) as {main?: string} | undefined)?.main; + const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; if (typeof main === 'string' && main.length > 0) { const resolved = (modulesCart.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); // `main` is attacker-controlled JSON content flowing into a path @@ -453,25 +284,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); @@ -491,8 +303,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( 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..49abcb16c --- /dev/null +++ b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts @@ -0,0 +1,165 @@ +/* + * 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 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. + */ +export function readJsonFile(ts: typeof tsserver, filePath: string): unknown { + try { + if (ts.sys.getFileSize && ts.sys.getFileSize(filePath) > 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..f211ba666 --- /dev/null +++ b/packages/b2c-script-types/src/resolver/constants.ts @@ -0,0 +1,89 @@ +/* + * 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. */ + root: 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; From 16f46bb40425156ceb419a3782546e3c7c22fb67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:37:22 +0000 Subject: [PATCH 21/43] Reduce complexity of resolveExpressionTypes and collectCallSites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fallow/ESLint flagged these two as the codebase's complexity hotspots. Decompose them without changing behavior: - resolveExpressionTypes is now a small dispatcher that routes an `any` expression to one of three focused resolvers by kind — resolveCallResultTypes (call / method-chain), resolvePropertyTypes (property access), and resolveIdentifierTypes (parameter / local variable). Each returns [] where the original fell through, so the dispatch is equivalent. Cyclomatic 29 -> 7, cognitive 61 -> 6. - collectCallSites now delegates the inner per-reference processing loop to collectCallsFromName, which classifies each reference hit into a call site or a next-hop name and threads the remaining local budget back via its return value. Cyclomatic 19 -> 9, cognitive 37 -> 12. The worst function in the package drops from cognitive 61 to 14. Pure mechanical extraction — all 104 unit tests and the 17 VS Code E2E tests pass unchanged, and core.ts stays under 800 lines. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- .../plugin/inference/call-sites.js | 67 +++--- .../b2c-script-types/plugin/inference/core.js | 207 +++++++++-------- .../src/inference/call-sites.ts | 65 ++++-- .../b2c-script-types/src/inference/core.ts | 216 ++++++++++-------- 4 files changed, 312 insertions(+), 243 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index ce9be2867..dd34a24d6 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -127,7 +127,6 @@ function resolveIndirectReferenceTarget(node, ts) { * Results are memoized per name node for the duration of the request. */ function collectCallSites(ctx, nameNode) { - const { ts, languageService, program } = ctx; const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; @@ -145,35 +144,49 @@ function collectCallSites(ctx, nameNode) { if (seenNameKeys.has(key)) continue; seenNameKeys.add(key); - 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); - } + 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/core.js b/packages/b2c-script-types/plugin/inference/core.js index 8a9d7761e..6cdb89ea4 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -215,114 +215,123 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { return [(0, type_helpers_1.widenType)(checker, direct)]; if (chainHops >= constants_1.MAX_CHAIN_HOPS) return []; - if (ts.isCallExpression(expr)) { - const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) - return inferred; - } - if (ts.isPropertyAccessExpression(expr.expression)) { - // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the - // receiver's own base is undocumented — resolveCalleeDeclaration can't - // find a real declaration since the checker never got far enough to - // resolve the method itself. Infer the receiver's type first (recursing - // through as many chained calls/property accesses as it takes to reach - // an untyped parameter or undocumented helper), then look up this - // method by name on that resolved type's real, documented signature(s). - 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)); + // The checker gave up (`any`). Dispatch on the kind of expression to a + // focused resolver. Each returns [] when it can't do better than `any`, 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); + 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; } - 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); - } - } + // 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)); } - if (returnTypes.length > 0) - return (0, type_helpers_1.dedupeTypes)(ctx, returnTypes); } + }; + 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)); } - else if (ts.isPropertyAccessExpression(expr)) { - // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented - // (an untyped parameter, say) — infer the base's type first, then look - // up this specific property on it, rather than giving up on the whole - // access just because the access itself resolved to `any`. - 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 above: 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))); + 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); } } - if (propTypes.length > 0) - return (0, type_helpers_1.dedupeTypes)(ctx, propTypes); } - else if (ts.isIdentifier(expr)) { - // `expr` is itself an undocumented parameter reference (e.g. a helper - // that just returns/forwards one of its own params) — chase that - // parameter's inferred type too, rather than stopping at `any`. - const sym = checker.getSymbolAtLocation(expr); - const decl = sym?.valueDeclaration; - if (decl && ts.isParameter(decl)) { - const inferred = inferParameterType(ctx, decl, depth + 1); - if (inferred.length > 0) - return inferred; - } - else if (decl && ts.isVariableDeclaration(decl)) { - // ...or a local variable holding an intermediate result — chase its - // initializer the same way, so splitting a chain across `var` - // statements infers exactly like the inline expression would. - const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); - if (inferred.length > 0) - return inferred; + 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 []; } /** diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 00e21236a..4fb817964 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -140,7 +140,6 @@ function resolveIndirectReferenceTarget( * Results are memoized per name node for the duration of the request. */ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): tsserver.CallExpression[] { - const {ts, languageService, program} = ctx; const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; const calls: tsserver.CallExpression[] = []; @@ -156,28 +155,7 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; if (seenNameKeys.has(key)) continue; seenNameKeys.add(key); - - 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); - } + localBudget = collectCallsFromName(ctx, name, calls, nextFrontier, localBudget); } frontier = nextFrontier; } @@ -185,3 +163,44 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident 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: tsserver.CallExpression[], + 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/core.ts b/packages/b2c-script-types/src/inference/core.ts index e035d90d9..477ba9d71 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -254,107 +254,135 @@ function resolveExpressionTypes( const direct = checker.getTypeAtLocation(expr); if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; if (chainHops >= MAX_CHAIN_HOPS) return []; - if (ts.isCallExpression(expr)) { - const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) return inferred; - } - if (ts.isPropertyAccessExpression(expr.expression)) { - // `expr` (e.g. `x.getPriceModel().getPrice()`) is `any` because the - // receiver's own base is undocumented — resolveCalleeDeclaration can't - // find a real declaration since the checker never got far enough to - // resolve the method itself. Infer the receiver's type first (recursing - // through as many chained calls/property accesses as it takes to reach - // an untyped parameter or undocumented helper), then look up this - // method by name on that resolved type's real, documented signature(s). - 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)); + // The checker gave up (`any`). Dispatch on the kind of expression to a + // focused resolver. Each returns [] when it can't do better than `any`, 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); + 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; } - 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); - } - } + // 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)); } - if (returnTypes.length > 0) return dedupeTypes(ctx, returnTypes); } - } else if (ts.isPropertyAccessExpression(expr)) { - // `expr` (e.g. `x.ID`) is `any` because its base is itself undocumented - // (an untyped parameter, say) — infer the base's type first, then look - // up this specific property on it, rather than giving up on the whole - // access just because the access itself resolved to `any`. - 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 above: 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), - ), - ); + }; + 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); } } - if (propTypes.length > 0) return dedupeTypes(ctx, propTypes); - } else if (ts.isIdentifier(expr)) { - // `expr` is itself an undocumented parameter reference (e.g. a helper - // that just returns/forwards one of its own params) — chase that - // parameter's inferred type too, rather than stopping at `any`. - const sym = checker.getSymbolAtLocation(expr); - const decl = sym?.valueDeclaration; - if (decl && ts.isParameter(decl)) { - const inferred = inferParameterType(ctx, decl, depth + 1); - if (inferred.length > 0) return inferred; - } else if (decl && ts.isVariableDeclaration(decl)) { - // ...or a local variable holding an intermediate result — chase its - // initializer the same way, so splitting a chain across `var` - // statements infers exactly like the inline expression would. - const inferred = resolveVariableInitializerTypes(ctx, decl, depth, chainHops + 1); - if (inferred.length > 0) return inferred; + } + 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 []; } From 41ea62df8b97106b8cdca5d89072b2af29140891 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:56:18 +0000 Subject: [PATCH 22/43] Drop c8 from b2c-script-types; restore lockfile to match main The b2c-script-types package pulled c8 into its own importer purely to run a coverage-wrapped `test` script, which was the only net-new entry in pnpm-lock.yaml on this branch and tripped the third-party-dependency review gate. The package's tests run fine under plain `node --test` (test:agent, used by CI, never invoked c8), so drop the c8 devDependency and the `c8` wrapper from the `test` script. With c8 gone from the only package that added it, the lockfile is byte-for-byte identical to main again (the remaining diff was a cosmetic importer reorder), so `pnpm install --frozen-lockfile` passes and there are no net-new third-party dependencies to review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr --- packages/b2c-script-types/package.json | 3 +-- pnpm-lock.yaml | 9 +++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/b2c-script-types/package.json b/packages/b2c-script-types/package.json index 7d0253eea..51125d858 100644 --- a/packages/b2c-script-types/package.json +++ b/packages/b2c-script-types/package.json @@ -29,7 +29,7 @@ "typecheck:agent": "tsc -p . --noEmit --pretty false", "format": "prettier --write src", "format:check": "prettier --check src", - "test": "pnpm run build && c8 node --test", + "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" @@ -37,7 +37,6 @@ "devDependencies": { "@eslint/compat": "catalog:", "@types/node": "catalog:", - "c8": "catalog:", "eslint": "catalog:", "eslint-config-prettier": "catalog:", "eslint-plugin-header": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25b8cb4bc..53edf2aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -449,9 +449,6 @@ importers: '@types/node': specifier: 'catalog:' version: 22.19.0 - c8: - specifier: 'catalog:' - version: 11.0.0 eslint: specifier: 'catalog:' version: 9.39.1 @@ -630,15 +627,15 @@ importers: '@salesforce/b2c-tooling-sdk': specifier: workspace:* version: link:../b2c-tooling-sdk + swagger-ui-dist: + specifier: ^5.18.0 + version: 5.32.0 react: specifier: 18.3.1 version: 18.3.1 react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) - swagger-ui-dist: - specifier: ^5.18.0 - version: 5.32.0 vscode-html-languageservice: specifier: 'catalog:' version: 5.6.0 From 62781967bf6fcc27841bfb598d4a438e9f777af6 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 16:57:16 +0200 Subject: [PATCH 23/43] Add usage-based fallback inference for undocumented Script API values When call-site inference finds nothing (a helper only reachable indirectly, or genuinely dead code), match a parameter or local variable's own member/method usage against the vendored dw.* ambient classes to recover its type. Also covers the manual-indexing loop idiom (var item = items[i]) where the element type can only come from the loop variable's own downstream usage. Fixes found while dogfooding this against a real cartridge: - Hover on a chained access's member name (not just the receiver) resolved nothing even when the receiver inferred fine. - The ambient class index was cached per-Program instead of per LanguageService, so it rebuilt on nearly every keystroke while editing a cartridge file, making completions unreliably slow on large real projects. - Hover now borrows the real declaration's display header, doc comment, and JSDoc tags instead of a bare "Inferred from usage: X" note. - A class's nested custom-attributes interface (ICustomAttributes.X) rendered with the same display name as the unrelated top-level class X. - A dangling, mid-edit member access (`shipment.` immediately followed by more code on later lines) could parse together with the next statement, poisoning usage-based matching with a phantom member and silently producing no completions for the position being typed. Adds unit coverage in b2c-script-types and end-to-end VS Code integration coverage (hover + completions) in b2c-vs-extension for all of the above. Co-Authored-By: Claude Sonnet 5 --- ...pt-types-infer-usage-from-member-access.md | 13 ++ packages/b2c-script-types/plugin/index.js | 79 +++++++- .../plugin/inference/constants.js | 15 +- .../plugin/inference/context.js | 3 +- .../b2c-script-types/plugin/inference/core.js | 27 ++- .../plugin/inference/type-helpers.js | 36 +++- .../plugin/inference/usage-match.js | 169 ++++++++++++++++ .../plugin/usage-inference.js | 8 +- packages/b2c-script-types/src/index.ts | 92 ++++++++- .../src/inference/constants.ts | 15 ++ .../b2c-script-types/src/inference/context.ts | 16 ++ .../b2c-script-types/src/inference/core.ts | 26 ++- .../src/inference/type-helpers.ts | 36 +++- .../src/inference/usage-match.ts | 179 +++++++++++++++++ .../b2c-script-types/src/usage-inference.ts | 8 +- .../test/helpers/real-dw-types.js | 2 + packages/b2c-script-types/test/index.test.js | 182 ++++++++++++++++- .../b2c-script-types/test/usage-match.test.js | 189 ++++++++++++++++++ .../scripts/helpers/shippingHelpers.js | 58 ++++++ .../script-types-infer-usage.test.ts | 132 ++++++++++++ 20 files changed, 1250 insertions(+), 35 deletions(-) create mode 100644 .changeset/script-types-infer-usage-from-member-access.md create mode 100644 packages/b2c-script-types/plugin/inference/usage-match.js create mode 100644 packages/b2c-script-types/src/inference/usage-match.ts create mode 100644 packages/b2c-script-types/test/usage-match.test.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js diff --git a/.changeset/script-types-infer-usage-from-member-access.md b/.changeset/script-types-infer-usage-from-member-access.md new file mode 100644 index 000000000..3e2d42f26 --- /dev/null +++ b/.changeset/script-types-infer-usage-from-member-access.md @@ -0,0 +1,13 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API usage inference now also matches an undocumented parameter or local variable's own member/method accesses (e.g. `shipment.custom`, `shipment.productLineItems`) against the Script API's ambient classes when no call site or usable initializer can resolve its type at all. This recovers 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`. + +Also fixes several bugs uncovered while dogfooding this: +- 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. diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 15c983128..ef6e67a0d 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -11,6 +11,23 @@ const node_path_1 = __importDefault(require("node:path")); const usage_inference_1 = require("./usage-inference"); const constants_1 = require("./resolver/constants"); const cartridge_discovery_1 = require("./resolver/cartridge-discovery"); +/** + * 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 @@ -490,6 +507,12 @@ function init({ typescript: ts }) { // 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 @@ -567,18 +590,58 @@ function init({ typescript: ts }) { // `undefined` (inference found nothing) is a cached answer too — // re-deriving "nothing" costs the same reference searches as // re-deriving something. - const description = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); - const types = ctx ? (0, usage_inference_1.inferTypeForNode)(ctx, node) : []; - return types.length > 0 ? (0, usage_inference_1.describeTypes)(checker, types) : undefined; + 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 (!description) + if (!inferred) return original; const note = { - text: `\n\nInferred from usage: ${description}`, + text: `\n\nInferred from usage: ${inferred.description}`, kind: 'text', }; - return { ...original, documentation: [...(original.documentation ?? []), note] }; + 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) => { @@ -607,7 +670,7 @@ function init({ typescript: ts }) { // 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); + 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); }); diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 25b8031eb..f979cb281 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -5,7 +5,7 @@ * 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.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; +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. @@ -63,3 +63,16 @@ exports.MAX_SEARCHES_PER_REQUEST = 12; // 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. +exports.MIN_USAGE_SIGNATURE_MEMBERS = 2; +// If the member-name signature still ties across more candidates than this +// after ranking by specificity (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; diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js index 8fe39c413..9dcb87d83 100644 --- a/packages/b2c-script-types/plugin/inference/context.js +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -11,7 +11,7 @@ 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) { +function createInferenceContext(ts, languageService, resolveSuperModulePath, triggerPosition) { const program = languageService.getProgram(); if (!program) return undefined; @@ -28,5 +28,6 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath) { 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 index 6cdb89ea4..634c3ebb8 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -14,6 +14,7 @@ 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 @@ -44,6 +45,12 @@ function resolveCalleeDeclaration(ctx, call) { * 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; @@ -55,7 +62,10 @@ function resolveVariableInitializerTypes(ctx, decl, depth, chainHops) { } ctx.visiting.add(decl); try { - return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + 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)); } finally { ctx.visiting.delete(decl); @@ -337,7 +347,10 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { /** * Infers a parameter's candidate type(s) from the arguments it's actually * called with across the project, since plain un-annotated JS parameters - * default to `any` with no back-inference from call sites. + * 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. @@ -387,7 +400,15 @@ function inferParameterType(ctx, param, depth = 0) { // recoverable from the collection argument travelling alongside it. types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } - const result = (0, type_helpers_1.dedupeTypes)(ctx, types); + let result = (0, type_helpers_1.dedupeTypes)(ctx, types); + // No call site could be found or resolved at all (a helper only ever + // reached indirectly — a Controller route dispatching through a name the + // reference search can't follow, or genuinely dead/unused code). Rather + // than give up, try to match how the parameter's own body uses it against + // the program's ambient classes. + if (result.length === 0) { + result = (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param)); + } // 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 diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index 8e2ac4a8e..f611fb3ef 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -27,12 +27,42 @@ function isAnyType(ts, type) { function widenType(checker, type) { return checker.getBaseTypeOfLiteralType(type); } -/** checker.typeToString memoized per request — see InferenceContext.typeDisplayStrings. */ +/** + * `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) { + const simple = checker.typeToString(type); + 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 = ctx.checker.typeToString(type); + const str = computeTypeDisplayString(ctx.checker, type); ctx.typeDisplayStrings.set(type, str); return str; } @@ -112,7 +142,7 @@ function collectionElementType(ctx, type, location) { function describeTypes(checker, types) { const seen = new Set(); for (const t of types) { - seen.add(checker.typeToString(t)); + seen.add(computeTypeDisplayString(checker, t)); } return [...seen].join(' | '); } 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..c81c06158 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/usage-match.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 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.collectParameterMemberUsage = collectParameterMemberUsage; +exports.collectVariableMemberUsage = collectVariableMemberUsage; +exports.matchAmbientTypesByUsage = matchAmbientTypesByUsage; +const constants_1 = require("./constants"); +// 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 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. + * + * Generic classes (e.g. `Product`) are skipped: their declared type here + * is the unsubstituted generic (`Product`, not `Product`), which + * would render misleadingly in hover text with no real instantiation context + * to substitute from. + */ +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 isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) + continue; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (!symbol) + continue; + const type = checker.getDeclaredTypeOfSymbol(symbol); + const memberNames = new Set(); + for (const prop of checker.getPropertiesOfType(type)) { + memberNames.add(prop.getName()); + } + if (memberNames.size === 0) + continue; + candidates.push({ type, memberNames }); + } + } + classIndexCache.set(ctx.languageService, candidates); + return candidates; +} +/** + * 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. + * + * 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); + } + 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); +} +/** + * 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. "Most specific" means fewest total + * members — the tightest-fitting shape, not just any 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). + */ +function matchAmbientTypesByUsage(ctx, memberNames) { + if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS) + 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 []; + const minSize = Math.min(...matches.map((m) => m.memberNames.size)); + const tightest = matches.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/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 1ef912798..a87f3262d 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -5,7 +5,7 @@ * 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.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isAnyType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; +exports.matchAmbientTypesByUsage = exports.collectVariableMemberUsage = exports.collectParameterMemberUsage = exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = 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 @@ -18,6 +18,7 @@ exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnT // 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"); @@ -27,6 +28,7 @@ Object.defineProperty(exports, "getNodeAtPosition", { enumerable: true, get: fun 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, "typesToCompletionEntries", { enumerable: true, get: function () { return type_helpers_1.typesToCompletionEntries; } }); var super_module_1 = require("./inference/super-module"); @@ -37,3 +39,7 @@ Object.defineProperty(exports, "inferParameterType", { enumerable: true, get: fu 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 f1cd5be8b..2e823ee17 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -13,6 +13,7 @@ import { createInferenceContext, describeTypes, findEnclosingPropertyAccess, + getMemberOfType, getNodeAtPosition, INFERRED_COMPLETION_SOURCE, inferTypeForExpression, @@ -30,6 +31,35 @@ import { readJsonFile, } from './resolver/cartridge-discovery'; +// 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[]; +} + +/** + * 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 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 @@ -542,6 +572,12 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // 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 @@ -551,13 +587,13 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // 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(); + 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 = ( + const getCachedInference = ( cacheKey: string, program: tsserver.Program, compute: () => T, @@ -620,17 +656,53 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // `undefined` (inference found nothing) is a cached answer too — // re-deriving "nothing" costs the same reference searches as // re-deriving something. - const description = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); - const types = ctx ? inferTypeForNode(ctx, node) : []; - return types.length > 0 ? describeTypes(checker, types) : undefined; + 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 (!description) return original; + if (!inferred) return original; const note: tsserver.SymbolDisplayPart = { - text: `\n\nInferred from usage: ${description}`, + text: `\n\nInferred from usage: ${inferred.description}`, kind: 'text', }; - return {...original, documentation: [...(original.documentation ?? []), note]}; + 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, ); @@ -665,7 +737,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { `completions:${fileName}:${baseNode.getStart(sourceFile)}`, program, () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath, position); const types = ctx ? inferTypeForExpression(ctx, baseNode) : []; return typesToCompletionEntries(ts, checker, types); }, diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 3d9241f0f..139b100e8 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -69,3 +69,18 @@ export const MAX_SEARCHES_PER_REQUEST = 12; // 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. +export const MIN_USAGE_SIGNATURE_MEMBERS = 2; + +// If the member-name signature still ties across more candidates than this +// after ranking by specificity (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; diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 092259b48..e55c6e9f2 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -93,6 +93,20 @@ export interface InferenceContext { * 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; } /** @@ -103,6 +117,7 @@ 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; @@ -119,5 +134,6 @@ export function createInferenceContext( 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 index 477ba9d71..f78111dbe 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -31,6 +31,7 @@ import { traceSuperModuleAccess, } from './super-module'; import {collectionElementType, dedupeTypes, getMemberOfType, isAnyType, widenType} from './type-helpers'; +import {collectParameterMemberUsage, collectVariableMemberUsage, matchAmbientTypesByUsage} from './usage-match'; /** * Resolves the function-like declaration a call expression's callee refers @@ -64,6 +65,12 @@ function resolveCalleeDeclaration( * 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, @@ -79,7 +86,9 @@ function resolveVariableInitializerTypes( } ctx.visiting.add(decl); try { - return resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); + if (resolved.length > 0) return resolved; + return matchAmbientTypesByUsage(ctx, collectVariableMemberUsage(ctx, decl)); } finally { ctx.visiting.delete(decl); } @@ -389,7 +398,10 @@ function resolveIdentifierTypes( /** * Infers a parameter's candidate type(s) from the arguments it's actually * called with across the project, since plain un-annotated JS parameters - * default to `any` with no back-inference from call sites. + * 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. @@ -438,7 +450,15 @@ export function inferParameterType( types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); } - const result = dedupeTypes(ctx, types); + let result = dedupeTypes(ctx, types); + // No call site could be found or resolved at all (a helper only ever + // reached indirectly — a Controller route dispatching through a name the + // reference search can't follow, or genuinely dead/unused code). Rather + // than give up, try to match how the parameter's own body uses it against + // the program's ambient classes. + if (result.length === 0) { + result = matchAmbientTypesByUsage(ctx, collectParameterMemberUsage(ctx, param)); + } // 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 diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index d466783d3..8d92b1a60 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -30,11 +30,41 @@ export function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): t return checker.getBaseTypeOfLiteralType(type); } -/** checker.typeToString memoized per request — see InferenceContext.typeDisplayStrings. */ +/** + * `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 { + const simple = checker.typeToString(type); + 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 = ctx.checker.typeToString(type); + const str = computeTypeDisplayString(ctx.checker, type); ctx.typeDisplayStrings.set(type, str); return str; } @@ -123,7 +153,7 @@ export function collectionElementType( export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { const seen = new Set(); for (const t of types) { - seen.add(checker.typeToString(t)); + seen.add(computeTypeDisplayString(checker, t)); } return [...seen].join(' | '); } 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..4eba2033a --- /dev/null +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -0,0 +1,179 @@ +/* + * 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 {MAX_USAGE_MATCH_CANDIDATES, MIN_USAGE_SIGNATURE_MEMBERS} from './constants'; +import type {InferenceContext} from './context'; + +interface AmbientClassCandidate { + readonly type: tsserver.Type; + readonly memberNames: ReadonlySet; +} + +// 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 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. + * + * Generic classes (e.g. `Product`) are skipped: their declared type here + * is the unsubstituted generic (`Product`, not `Product`), which + * would render misleadingly in hover text with no real instantiation context + * to substitute from. + */ +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 isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) continue; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (!symbol) continue; + const type = checker.getDeclaredTypeOfSymbol(symbol); + const memberNames = new Set(); + for (const prop of checker.getPropertiesOfType(type)) { + memberNames.add(prop.getName()); + } + if (memberNames.size === 0) continue; + candidates.push({type, memberNames}); + } + } + classIndexCache.set(ctx.languageService, candidates); + return candidates; +} + +/** + * 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. + * + * 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); + } + 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); +} + +/** + * 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. "Most specific" means fewest total + * members — the tightest-fitting shape, not just any 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). + */ +export function matchAmbientTypesByUsage(ctx: InferenceContext, memberNames: ReadonlySet): tsserver.Type[] { + if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS) 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 []; + const minSize = Math.min(...matches.map((m) => m.memberNames.size)); + const tightest = matches.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/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index d7631decc..e2c238629 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -16,12 +16,18 @@ // 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, isAnyType, typesToCompletionEntries} from './inference/type-helpers'; +export {describeTypes, getMemberOfType, isAnyType, 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/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index bd02be177..22f33599e 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -28,6 +28,8 @@ const REAL_DW_TYPES = { 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'), }; /** diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 4c3582b9d..f13bf8637 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -11,8 +11,9 @@ 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} = require('./helpers/fixture-language-service'); -const {REAL_DW_TYPES} = require('./helpers/real-dw-types'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); const AMBIENT_TYPES = ` declare function getProduct(): {ID: string; name: string}; @@ -338,6 +339,185 @@ describe('create() proxy — usage inference wiring', () => { 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, ts.createDocumentRegistry()); + 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, ts.createDocumentRegistry()); + 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('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, ts.createDocumentRegistry()); + 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 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..25f29a086 --- /dev/null +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -0,0 +1,189 @@ +/* + * 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', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Shipment'], ''), + '/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('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 neuhaus-core'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 neuhaus-core shape, but only one member (`preorderable`) is ever + // accessed on the loop variable — below MIN_USAGE_SIGNATURE_MEMBERS, so + // the engine correctly declines to guess rather than latch onto whichever + // ambient class happens to expose that one name. + 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); + + assert.deepEqual(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'); + }); +}); 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/integration/script-types-infer-usage.test.ts b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts index 333750400..6cd4c8f9b 100644 --- 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 @@ -516,3 +516,135 @@ suite('scriptTypesInferUsage — multi-cartridge superModule stack (plugin_promo 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(', ')}`, + ); + }); +}); From 5d93175c301ac1e80599a60af0f510311caab647 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 17:03:33 +0200 Subject: [PATCH 24/43] Remove leftover diagnostic console.log from infer-usage integration test Debugging output left over from diagnosing the earlier flaky assertions; the failure-path assert messages already report the full completion label list. Co-Authored-By: Claude Sonnet 5 --- .../src/test/integration/script-types-infer-usage.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 6cd4c8f9b..99f6b7198 100644 --- 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 @@ -116,7 +116,6 @@ suite('scriptTypesInferUsage — real hover/completion via the VS Code language : undefined; }, 25000); - console.log(`[diagnostic] ${labels.length} completion label(s): ${labels.join(', ')}`); 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( From ffe497beac0b6d5ce90b4f0565fd6c3ae3d9fb73 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 17:26:09 +0200 Subject: [PATCH 25/43] Address PR audit findings in Script API IntelliSense plugin Extracts security-critical cartridge module resolution and path-containment logic out of index.ts's closures into a testable resolver/module-resolution.ts module, deduplicates the legacy/modern module-resolution host hooks, hardens the JSON size-cap fallback, fixes a workspace-trust label bug in the VS Code extension, and cleans up docs/changeset/CI trigger inconsistencies found during review. Co-Authored-By: Claude Sonnet 5 --- ...pt-types-infer-usage-from-member-access.md | 4 +- .github/workflows/ci-vs-extension.yml | 1 - .github/workflows/ci.yml | 1 - packages/b2c-script-types/README.md | 3 +- packages/b2c-script-types/plugin/index.js | 256 ++++------------- .../plugin/resolver/cartridge-discovery.js | 8 +- .../plugin/resolver/module-resolution.js | 190 +++++++++++++ packages/b2c-script-types/src/index.ts | 264 ++++-------------- .../src/resolver/cartridge-discovery.ts | 8 +- .../src/resolver/module-resolution.ts | 214 ++++++++++++++ .../src/script-types/index.ts | 3 +- 11 files changed, 532 insertions(+), 420 deletions(-) create mode 100644 packages/b2c-script-types/plugin/resolver/module-resolution.js create mode 100644 packages/b2c-script-types/src/resolver/module-resolution.ts diff --git a/.changeset/script-types-infer-usage-from-member-access.md b/.changeset/script-types-infer-usage-from-member-access.md index 3e2d42f26..0b5d5c76b 100644 --- a/.changeset/script-types-infer-usage-from-member-access.md +++ b/.changeset/script-types-infer-usage-from-member-access.md @@ -1,6 +1,6 @@ --- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch +'@salesforce/b2c-cli': minor +'b2c-vs-extension': minor --- Script API usage inference now also matches an undocumented parameter or local variable's own member/method accesses (e.g. `shipment.custom`, `shipment.productLineItems`) against the Script API's ambient classes when no call site or usable initializer can resolve its type at all. This recovers 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`. diff --git a/.github/workflows/ci-vs-extension.yml b/.github/workflows/ci-vs-extension.yml index 435f33086..c3e32089a 100644 --- a/.github/workflows/ci-vs-extension.yml +++ b/.github/workflows/ci-vs-extension.yml @@ -16,7 +16,6 @@ on: branches: - main - develop - - 'feature/**' paths: - 'packages/b2c-vs-extension/**' - 'packages/b2c-script-types/**' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a330ad5a..15c489fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ on: - main - develop - 'release/**' - - 'feature/**' workflow_dispatch: permissions: diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index c717c0729..6f3f6f658 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -65,7 +65,8 @@ 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) — and surface it as an +[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 diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index ef6e67a0d..c38fdbb21 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -11,6 +11,7 @@ const node_path_1 = __importDefault(require("node:path")); 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) @@ -58,39 +59,11 @@ 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(); - }; - // 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)); - }; - // True when `candidate` resolves to a location at or beneath `rootDir`. - // This is the trust boundary for every resolver below: 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. - const isWithinRoot = (candidate, rootDir) => { - const root = canonicalPath(rootDir); - const resolved = canonicalPath(candidate); - return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); - }; + // + // 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); @@ -154,114 +127,12 @@ 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 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 (fileExists(candidate) && isWithinRoot(candidate, c.root)) { - 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 (constants_1.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 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 (fileExists(candidate) && 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 (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.root + 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 (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { - return { resolved, source: modulesCart.name }; - } - } - } - return undefined; - }; - const ownerCartridge = (containingFile) => { - const f = normalize(containingFile); - return cartridges.find((c) => f.startsWith(c.root)); - }; + // See resolver/module-resolution.ts for the resolution rules and security + // rationale. These wrappers just bind the closure's live cartridge list and + // path-safety primitives. + const resolveCartridgeModule = (moduleName, containingFile) => (0, module_resolution_1.resolveCartridgeModule)(cartridges, moduleName, containingFile, { normalize, isWithinRoot, fileExists }); + const resolveModulesCartridge = (moduleName) => (0, module_resolution_1.resolveModulesCartridge)(ts, cartridges, moduleName, { isWithinRoot, fileExists }); + 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. @@ -277,12 +148,6 @@ function init({ typescript: ts }) { } return undefined; }; - 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(`[${constants_1.PLUGIN_NAME}] ${msg}`); applyConfig(info.config); @@ -360,6 +225,34 @@ 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); + 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 undefined; + }; const origResolveModuleNameLiterals = host.resolveModuleNameLiterals?.bind(host); if (origResolveModuleNameLiterals) { host.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, options, containingSourceFile, reusedNames) => { @@ -369,41 +262,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 }, + }; }); }; } @@ -417,32 +281,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; }); }; } diff --git a/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js index b0dd13f1f..cbde0d0a1 100644 --- a/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js +++ b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js @@ -18,6 +18,7 @@ exports.parseDeclareModuleRanges = parseDeclareModuleRanges; // `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"); /** @@ -25,10 +26,15 @@ const constants_1 = require("./constants"); * 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 (ts.sys.getFileSize && ts.sys.getFileSize(filePath) > constants_1.MAX_JSON_BYTES) + 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) 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..b01479263 --- /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.root + 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.root + 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.root + 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/index.ts b/packages/b2c-script-types/src/index.ts index 2e823ee17..d75d9c2c1 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -21,15 +21,20 @@ import { isAnyType, typesToCompletionEntries, } from './usage-inference'; -import {CANDIDATE_EXTENSIONS, PLUGIN_NAME, SFRA_AMBIENT_MODULES} from './resolver/constants'; +import {PLUGIN_NAME} from './resolver/constants'; import type {ConfiguredCartridge, NormalizedCartridge, PluginConfig} from './resolver/constants'; import { discoverCartridgesOnDisk, orderCartridges, parseDeclareModuleRanges, readDwJsonCartridges, - readJsonFile, } 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 @@ -92,39 +97,11 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // backslashes on Windows — we have to normalize to match. We also fold case on // case-insensitive filesystems (Windows + default macOS HFS+/APFS) so a path // like "C:/Proj" matches a cartridge root of "c:/proj". - const caseSensitive = ts.sys.useCaseSensitiveFileNames; - const normalize = (p: string): string => { - const slashed = p.replace(/\\/g, '/'); - return caseSensitive ? slashed : slashed.toLowerCase(); - }; - - // 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)); - }; - - // True when `candidate` resolves to a location at or beneath `rootDir`. - // This is the trust boundary for every resolver below: 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. - const isWithinRoot = (candidate: string, rootDir: string): boolean => { - const root = canonicalPath(rootDir); - const resolved = canonicalPath(candidate); - return (resolved + '/').startsWith(root.endsWith('/') ? root : root + '/'); - }; + // + // 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}) => { @@ -192,113 +169,20 @@ 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" + // See resolver/module-resolution.ts for the resolution rules and security + // rationale. These wrappers just bind the closure's live cartridge list and + // path-safety primitives. 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]; - } + ): {resolved: string; source: string} | undefined => + resolveCartridgeModuleImpl(cartridges, moduleName, containingFile, {normalize, isWithinRoot, fileExists}); - if (!subpath) return undefined; - - for (const c of order) { - const baseAbs = c.root + 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 (fileExists(candidate) && isWithinRoot(candidate, c.root)) { - 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; - // `moduleName` may carry `..` after its first segment (it only can't - // *start* with `.`/`/`); contain it against the modules root. - if (fileExists(candidate) && 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 (fileExists(pkgPath) && isWithinRoot(pkgPath, modulesCart.root)) { - const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.root + 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 (fileExists(resolved) && isWithinRoot(resolved, modulesCart.root)) { - return {resolved, source: modulesCart.name}; - } - } - } - return undefined; - }; + const resolveModulesCartridge = (moduleName: string): {resolved: string; source: string} | undefined => + resolveModulesCartridgeImpl(ts, cartridges, moduleName, {isWithinRoot, fileExists}); - 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 @@ -315,12 +199,6 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return undefined; }; - 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}`); @@ -400,6 +278,38 @@ 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); + 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 undefined; + }; + const origResolveModuleNameLiterals = host.resolveModuleNameLiterals?.bind(host); if (origResolveModuleNameLiterals) { host.resolveModuleNameLiterals = ( @@ -421,41 +331,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; }); }; } @@ -482,32 +362,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; }); }; } diff --git a/packages/b2c-script-types/src/resolver/cartridge-discovery.ts b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts index 49abcb16c..e0c0e91f7 100644 --- a/packages/b2c-script-types/src/resolver/cartridge-discovery.ts +++ b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts @@ -10,6 +10,7 @@ // 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'; @@ -22,10 +23,15 @@ import type {ConfiguredCartridge} from './constants'; * 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 (ts.sys.getFileSize && ts.sys.getFileSize(filePath) > MAX_JSON_BYTES) return undefined; + 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); 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..1e7159dea --- /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.root + 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.root + 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.root + 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-vs-extension/src/script-types/index.ts b/packages/b2c-vs-extension/src/script-types/index.ts index ed378c87e..fabea7792 100644 --- a/packages/b2c-vs-extension/src/script-types/index.ts +++ b/packages/b2c-vs-extension/src/script-types/index.ts @@ -103,8 +103,9 @@ export function registerScriptTypes( 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'}).`, ); }); From 63d67dea4dd967ad533cbb42a5bcf64a59d77d11 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 18:04:48 +0200 Subject: [PATCH 26/43] Recover usage-inferred types from a single, globally-unique member access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from real-world dogfooding (neuhaus-core's addressHelpers.js): removing JSDoc from getAddressBookAddressByForm(addressBook, form) lost hover/completion for addressBook entirely, since its only direct member access is addressBook.addresses — a single-member usage signature, below the existing two-member ambiguity threshold. That threshold exists to reject common member names (e.g. .custom) shared by many ambient classes, but .addresses is declared by exactly one: dw.customer.AddressBook. matchAmbientTypesByUsage now accepts a single-member signature when it's globally unambiguous, while still rejecting one that ties across multiple classes. Co-Authored-By: Claude Sonnet 5 --- ...-types-infer-usage-unique-single-member.md | 6 +++ .../plugin/inference/constants.js | 6 ++- .../plugin/inference/usage-match.js | 11 +++++- .../src/inference/constants.ts | 6 ++- .../src/inference/usage-match.ts | 10 ++++- .../test/helpers/real-dw-types.js | 2 + .../b2c-script-types/test/usage-match.test.js | 38 +++++++++++++++++++ 7 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .changeset/script-types-infer-usage-unique-single-member.md diff --git a/.changeset/script-types-infer-usage-unique-single-member.md b/.changeset/script-types-infer-usage-unique-single-member.md new file mode 100644 index 000000000..127866c96 --- /dev/null +++ b/.changeset/script-types-infer-usage-unique-single-member.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API usage inference now also recovers a type from a single accessed member when that member 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 that's shared by several classes (e.g. the common `.custom` attribute pattern) is still correctly left unresolved rather than guessed at. diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index f979cb281..0cb1e5f7f 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -69,7 +69,11 @@ exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usag // 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. +// 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 (fewest total members), the match is too diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index c81c06158..d5d89abcf 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -147,9 +147,16 @@ function collectVariableMemberUsage(ctx, decl) { * 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. */ function matchAmbientTypesByUsage(ctx, memberNames) { - if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS) + if (memberNames.size === 0) return []; const candidates = buildAmbientClassIndex(ctx); const matches = candidates.filter((candidate) => { @@ -161,6 +168,8 @@ function matchAmbientTypesByUsage(ctx, memberNames) { }); if (matches.length === 0) return []; + if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) + return []; const minSize = Math.min(...matches.map((m) => m.memberNames.size)); const tightest = matches.filter((m) => m.memberNames.size === minSize); if (tightest.length > constants_1.MAX_USAGE_MATCH_CANDIDATES) diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 139b100e8..c332e0546 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -76,7 +76,11 @@ export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred // 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. +// 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 diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index 4eba2033a..05cac7d6a 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -161,9 +161,16 @@ export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver * 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. */ export function matchAmbientTypesByUsage(ctx: InferenceContext, memberNames: ReadonlySet): tsserver.Type[] { - if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS) return []; + if (memberNames.size === 0) return []; const candidates = buildAmbientClassIndex(ctx); const matches = candidates.filter((candidate) => { for (const name of memberNames) { @@ -172,6 +179,7 @@ export function matchAmbientTypesByUsage(ctx: InferenceContext, memberNames: Rea return true; }); if (matches.length === 0) return []; + if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; const minSize = Math.min(...matches.map((m) => m.memberNames.size)); const tightest = matches.filter((m) => m.memberNames.size === minSize); if (tightest.length > MAX_USAGE_MATCH_CANDIDATES) return []; diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index 22f33599e..43cdb70c1 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -30,6 +30,8 @@ const REAL_DW_TYPES = { Profile: dtsPath('dw', 'customer', 'Profile'), Shipment: dtsPath('dw', 'order', 'Shipment'), ProductLineItem: dtsPath('dw', 'order', 'ProductLineItem'), + AddressBook: dtsPath('dw', 'customer', 'AddressBook'), + CustomerAddress: dtsPath('dw', 'customer', 'CustomerAddress'), }; /** diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 25f29a086..69322cbbf 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -90,6 +90,44 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage assert.deepEqual(types, []); }); + it('infers a single accessed member when it uniquely identifies one ambient class (addressBook.addresses)', () => { + // Real-world shape from neuhaus-core'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'); From be920c96535b9b39a61108e2c45aee50f396a2f9 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 18:14:41 +0200 Subject: [PATCH 27/43] Add perf baseline for the addressBook.addresses usage-match scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the real-world case fixed in 63d67dea: a no-call-site parameter resolved via the single-unique-member ambient-class match. Asserts the ambient-class index (every dw.* class's member-name set) is built once per LanguageService and a repeated hover at the same position adds zero further getPropertiesOfType calls, per the existing deterministic-counter convention in this file (wall-clock is only a catastrophic-regression tripwire). Measured directly: cold hover ~34ms (1470 getPropertiesOfType calls to build the index against this fixture's real dw.* program), warm hover 0.005ms (0 additional calls) — confirms the fallback stays cheap on repeated keystrokes. Co-Authored-By: Claude Sonnet 5 --- .../test/usage-inference.perf.test.js | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index b4ce2819f..7ed84eb05 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -22,6 +22,7 @@ const { createFixtureLanguageService, findFunctionDeclaration, } = require('./helpers/fixture-language-service'); +const {realTypesPrelude} = require('./helpers/real-dw-types'); // --------------------------------------------------------------------------- // Performance baselines. @@ -90,6 +91,11 @@ const BASELINE = { // 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, }; /** @@ -113,6 +119,23 @@ function withReferenceCounter(languageService) { 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(); @@ -548,4 +571,45 @@ describe('usage-inference — performance baselines', () => { 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 neuhaus-core'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`); + }); }); From 53078d0fc9e57b36152ce4af3757d32e30c3516e Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 18:44:47 +0200 Subject: [PATCH 28/43] Recognize 'member' in obj as usage evidence, from omoda-core survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveyed a large real-world SFRA storefront (omoda-core) for patterns our usage-inference engine might miss. The `'member' in obj` existence-check guard — used 261 times across 107 files there to test an optional custom attribute before reading it — was invisible to collectMemberUsageInScope, which only recognized direct `obj.member` property access. A parameter whose only usage evidence was such a guard (no direct read nearby) produced no inference at all. Also expands regression coverage with real-world shapes found in the same survey: mutually-exclusive boolean-flag branches (dw.catalog.Category), and an object literal built from a parameter's properties and passed to a call argument rather than returned — both already handled correctly by the existing control-flow-agnostic AST walk, now locked in as characterization tests. Other surveyed patterns (ES6 classes, arrow-function exports, destructured function parameters) don't appear in this codebase's ES5-style scripts, so were not pursued. Co-Authored-By: Claude Sonnet 5 --- .../script-types-infer-usage-in-operator.md | 6 + .../plugin/inference/usage-match.js | 14 ++ .../src/inference/usage-match.ts | 15 ++ .../b2c-script-types/test/usage-match.test.js | 161 ++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 .changeset/script-types-infer-usage-in-operator.md diff --git a/.changeset/script-types-infer-usage-in-operator.md b/.changeset/script-types-infer-usage-in-operator.md new file mode 100644 index 000000000..798e1f636 --- /dev/null +++ b/.changeset/script-types-infer-usage-in-operator.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API usage inference now also recognizes a `'member' in obj` existence check (e.g. `'Subsoort' in apiProduct.custom`) as evidence of that member, not just a direct `obj.member` read. This is a very common SFCC idiom for guarding an optional custom attribute before reading it, and previously produced no usage evidence at all when it was the only access on an undocumented parameter. diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index d5d89abcf..5f92a903c 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -72,6 +72,13 @@ function buildAmbientClassIndex(ctx) { * count; a chained `x.custom.fromStoreId` only contributes `custom` — the * deeper hop describes `custom`'s shape, not `x`'s. * + * 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 @@ -91,6 +98,13 @@ function collectMemberUsageInScope(ctx, symbol, scope) { triggerPosition <= node.name.getStart())) { members.add(node.name.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); diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index 05cac7d6a..f458c0913 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -84,6 +84,13 @@ function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] * count; a chained `x.custom.fromStoreId` only contributes `custom` — the * deeper hop describes `custom`'s shape, not `x`'s. * + * 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 @@ -106,6 +113,14 @@ function collectMemberUsageInScope(ctx: InferenceContext, symbol: tsserver.Symbo ) ) { members.add(node.name.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); }; diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 69322cbbf..7308c5fd1 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -224,4 +224,165 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage assert.equal(describeTypes(ctx.checker, types), 'Shipment'); }); + + describe("the `'member' in x` existence-check idiom as usage evidence", () => { + // Real-world shape from omoda-core: 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. omoda-core'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 omoda-core'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 omoda-core'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 omoda-core's productHelpers.js's getProductType + // (there, checking product.master/variant/variationGroup/productSet/ + // bundle/optionProduct — dw.catalog.Product itself is generic and + // deliberately excluded from ambient-class matching, see + // buildAmbientClassIndex, so this repro substitutes dw.catalog.Category's + // own four real boolean flags): 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 omoda-core'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'); + }); }); From 95342896121da6be3a71446787856d8a2976ee31 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 18:59:37 +0200 Subject: [PATCH 29/43] Recognize `new Helper(x)` as a call site, from mul-core survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surveyed a second real-world SFRA storefront (mul-core) for usage-inference gaps. Confirmed the same constructor-function "class" model idiom found in omoda-core (StoreModel, ProductLineItem, CartModel, AccountModel, Contact, ...) is invoked almost exclusively via `new Helper(x)`, never a plain call — a shape collectCallSites/findCallInCalleePosition couldn't see at all, since `new` expressions are a distinct AST node kind from CallExpression. call-sites.ts now treats a CallExpression and a NewExpression uniformly as a `CallSite` wherever a call site is collected or matched (findCallInCalleePosition, resolveIndirectReferenceTarget, collectCallSites/collectCallsFromName, InferenceContext.callSiteMemo). inferParameterType guards against a bare `new Helper` (no parens) whose `arguments` is `undefined`, unlike a plain call's always-present array. Also surveyed: this codebase, like omoda-core, is pure ES5-style SFRA (no ES6 classes/arrow-exports/destructured parameters); the two genuine destructured-return-value cases found (`var {a,b} = undocumentedFn()`) and the `Foo.prototype = Base.prototype` constructor-inheritance idiom are real but substantially more complex to support — deferred given effort/value tradeoff. Co-Authored-By: Claude Sonnet 5 --- ...script-types-infer-usage-new-expression.md | 6 ++ .../plugin/inference/call-sites.js | 16 ++-- .../b2c-script-types/plugin/inference/core.js | 16 ++-- .../src/inference/call-sites.ts | 43 ++++++++--- .../b2c-script-types/src/inference/context.ts | 3 +- .../b2c-script-types/src/inference/core.ts | 16 ++-- .../test/usage-inference.real-types.test.js | 28 +++++++ .../test/usage-inference.test.js | 76 +++++++++++++++++++ 8 files changed, 173 insertions(+), 31 deletions(-) create mode 100644 .changeset/script-types-infer-usage-new-expression.md diff --git a/.changeset/script-types-infer-usage-new-expression.md b/.changeset/script-types-infer-usage-new-expression.md new file mode 100644 index 000000000..f17af9d2a --- /dev/null +++ b/.changeset/script-types-infer-usage-new-expression.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API usage inference now also recognizes `new Helper(x)` constructor calls as a call site when inferring a parameter's type, not just plain `helper(x)` calls. This is SFRA's other very common way to invoke an undocumented "class" model (e.g. `new ProductLineItem(...)`, `new StoreModel(...)`), which previously produced no inference at all since a `new` expression is a different AST shape than a plain call. diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index dd34a24d6..f64c3784f 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -42,21 +42,25 @@ function getReferenceNameNode(fn, ts) { return undefined; } /** - * Given a reference identifier (`helper` in either `helper(x)` or - * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if - * the identifier sits in callee position — one parent up for a direct call, - * two parents up when the identifier is the `.name` of a property access. + * 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) && parent.expression === node) + 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) && grandparent.expression === parent) + if (grandparent && + (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && + grandparent.expression === parent) { return grandparent; + } } return undefined; } diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 634c3ebb8..ed85f004f 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -346,11 +346,13 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { } /** * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project, 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}. + * 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. @@ -388,7 +390,9 @@ function inferParameterType(ctx, param, depth = 0) { const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); if (nameNode) { for (const call of (0, call_sites_1.collectCallSites)(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; + // A bare `new Helper` (no parens) has `arguments === undefined`, + // unlike a plain call, which always has an (possibly empty) array. + const arg = call.arguments?.[paramIndex]; if (!arg) continue; types.push(...resolveExpressionTypes(ctx, arg, depth)); diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 4fb817964..39e8dec31 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -9,7 +9,8 @@ // 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. +// 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'; @@ -17,6 +18,17 @@ 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: @@ -47,18 +59,25 @@ export function getReferenceNameNode( } /** - * Given a reference identifier (`helper` in either `helper(x)` or - * `exports.helper(x)`/`obj.helper(x)`), finds the enclosing CallExpression if - * the identifier sits in callee position — one parent up for a direct call, - * two parents up when the identifier is the `.name` of a property access. + * 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): tsserver.CallExpression | undefined { +function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): CallSite | undefined { const parent = node.parent; if (!parent) return undefined; - if (ts.isCallExpression(parent) && parent.expression === node) return parent; + 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) && grandparent.expression === parent) return grandparent; + if ( + grandparent && + (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && + grandparent.expression === parent + ) { + return grandparent; + } } return undefined; } @@ -92,7 +111,7 @@ function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node function resolveIndirectReferenceTarget( node: tsserver.Node, ts: typeof tsserver, -): {kind: 'call'; call: tsserver.CallExpression} | {kind: 'name'; name: tsserver.Identifier} | undefined { +): {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier} | undefined { const parent = node.parent; if (!parent) return undefined; @@ -139,10 +158,10 @@ function resolveIndirectReferenceTarget( * 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): tsserver.CallExpression[] { +export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): CallSite[] { const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; - const calls: tsserver.CallExpression[] = []; + const calls: CallSite[] = []; const seenNameKeys = new Set(); let frontier: tsserver.Identifier[] = [nameNode]; let localBudget = Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget); @@ -175,7 +194,7 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident function collectCallsFromName( ctx: InferenceContext, name: tsserver.Identifier, - calls: tsserver.CallExpression[], + calls: CallSite[], nextFrontier: tsserver.Identifier[], localBudget: number, ): number { diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index e55c6e9f2..4dd976ca8 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -12,6 +12,7 @@ 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 { @@ -65,7 +66,7 @@ export interface InferenceContext { * 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; + readonly callSiteMemo: Map; /** * Request-scoped memo of checker.typeToString() results, used by * dedupeTypes(). Candidate types propagate up through every recursion diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index f78111dbe..55d514d64 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -397,11 +397,13 @@ function resolveIdentifierTypes( /** * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project, 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}. + * 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. @@ -439,7 +441,9 @@ export function inferParameterType( const nameNode = getReferenceNameNode(fn, ts); if (nameNode) { for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments[paramIndex]; + // A bare `new Helper` (no parens) has `arguments === undefined`, + // unlike a plain call, which always has an (possibly empty) array. + const arg = call.arguments?.[paramIndex]; if (!arg) continue; types.push(...resolveExpressionTypes(ctx, arg, depth)); } 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 index 25d4cd853..108e8d200 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -80,6 +80,34 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = 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 omoda-core and mul-core: 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;'), diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 3eda8d49e..73550be8b 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -51,6 +51,82 @@ describe('usage-inference', () => { 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 omoda-core/mul-core: `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('unions candidate types across a mix of plain-call and `new` constructor call sites', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function Wrapper(input) { + this.value = input; + } + Wrapper(getProduct()); + new Wrapper(getInventory()); + module.exports = Wrapper; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const fn = findFunctionDeclaration(sourceFile, 'Wrapper'); + const param = fn.parameters[0]; + + const types = inferParameterType(ctx, param); + + assert.equal(types.length, 2); + const rendered = types.map((t) => ctx.checker.typeToString(t)).sort(); + assert.deepEqual(rendered, ['{ ID: string; name: string; }', '{ quantity: number; }']); + }); + + 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('unions candidate types across multiple call sites', () => { const files = { '/types.d.ts': AMBIENT_TYPES, From 50643df225256fcc8bf7f04db1278121a72efcb9 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 19:00:39 +0200 Subject: [PATCH 30/43] Consolidate PR changesets into one All six changesets touching @salesforce/b2c-cli and b2c-vs-extension in this PR describe facets of the same shipped feature (usage inference) and its accompanying security hardening. Merged into a single minor changeset so the changelog reads as one coherent entry instead of six fragments. Co-Authored-By: Claude Sonnet 5 --- .../script-types-infer-usage-from-member-access.md | 13 ------------- .changeset/script-types-infer-usage-in-operator.md | 6 ------ .../script-types-infer-usage-new-expression.md | 6 ------ ...script-types-infer-usage-unique-single-member.md | 6 ------ .changeset/script-types-security-hardening.md | 6 ------ 5 files changed, 37 deletions(-) delete mode 100644 .changeset/script-types-infer-usage-from-member-access.md delete mode 100644 .changeset/script-types-infer-usage-in-operator.md delete mode 100644 .changeset/script-types-infer-usage-new-expression.md delete mode 100644 .changeset/script-types-infer-usage-unique-single-member.md delete mode 100644 .changeset/script-types-security-hardening.md diff --git a/.changeset/script-types-infer-usage-from-member-access.md b/.changeset/script-types-infer-usage-from-member-access.md deleted file mode 100644 index 0b5d5c76b..000000000 --- a/.changeset/script-types-infer-usage-from-member-access.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@salesforce/b2c-cli': minor -'b2c-vs-extension': minor ---- - -Script API usage inference now also matches an undocumented parameter or local variable's own member/method accesses (e.g. `shipment.custom`, `shipment.productLineItems`) against the Script API's ambient classes when no call site or usable initializer can resolve its type at all. This recovers 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`. - -Also fixes several bugs uncovered while dogfooding this: -- 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. diff --git a/.changeset/script-types-infer-usage-in-operator.md b/.changeset/script-types-infer-usage-in-operator.md deleted file mode 100644 index 798e1f636..000000000 --- a/.changeset/script-types-infer-usage-in-operator.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Script API usage inference now also recognizes a `'member' in obj` existence check (e.g. `'Subsoort' in apiProduct.custom`) as evidence of that member, not just a direct `obj.member` read. This is a very common SFCC idiom for guarding an optional custom attribute before reading it, and previously produced no usage evidence at all when it was the only access on an undocumented parameter. diff --git a/.changeset/script-types-infer-usage-new-expression.md b/.changeset/script-types-infer-usage-new-expression.md deleted file mode 100644 index f17af9d2a..000000000 --- a/.changeset/script-types-infer-usage-new-expression.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Script API usage inference now also recognizes `new Helper(x)` constructor calls as a call site when inferring a parameter's type, not just plain `helper(x)` calls. This is SFRA's other very common way to invoke an undocumented "class" model (e.g. `new ProductLineItem(...)`, `new StoreModel(...)`), which previously produced no inference at all since a `new` expression is a different AST shape than a plain call. diff --git a/.changeset/script-types-infer-usage-unique-single-member.md b/.changeset/script-types-infer-usage-unique-single-member.md deleted file mode 100644 index 127866c96..000000000 --- a/.changeset/script-types-infer-usage-unique-single-member.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Script API usage inference now also recovers a type from a single accessed member when that member 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 that's shared by several classes (e.g. the common `.custom` attribute pattern) is still correctly left unresolved rather than guessed at. diff --git a/.changeset/script-types-security-hardening.md b/.changeset/script-types-security-hardening.md deleted file mode 100644 index d4c821436..000000000 --- a/.changeset/script-types-security-hardening.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Security hardening for Script API IntelliSense 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. From 34606355808c3b323431e67a217e2859c4b19455 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 19:01:06 +0200 Subject: [PATCH 31/43] Fold consolidated changeset content into script-types-infer-usage.md The previous commit removed the five now-redundant changeset files but missed staging the actual merged content in script-types-infer-usage.md. Co-Authored-By: Claude Sonnet 5 --- .changeset/script-types-infer-usage.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 4cd5426e3..120867a27 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -4,3 +4,18 @@ --- 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(...)`). + +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. + +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. From 6357d6cf2d8a8d97a7523b0940b13632ba6b81ef Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 19:33:41 +0200 Subject: [PATCH 32/43] Prefer identifier-name match over size when ambient classes tie; share test DocumentRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world bug reported from mul-core's plugin_marketing_cloud/accountHelpers.js: `var profile = resettingCustomer.profile;` inferred as dw.customer. ProductListRegistrant instead of dw.customer.Profile. Root cause: both classes expose the same email/firstName/lastName/custom field subset touched in that function, and matchAmbientTypesByUsage's "fewest total members" tiebreak picked ProductListRegistrant (21 members) over Profile (92 members) every time, purely because it has less surface area — never the large, contextually correct class. A parameter/variable conventionally named after the SFCC class it holds is now checked first: when exactly one matching candidate's own class name matches the identifier (case- insensitively), that candidate wins outright, before size-based tiebreaking ever runs. Ambient class names are unique, so at most one candidate can ever match this way. Also fixes the test suite's own real-world performance problem, found while investigating why this suite intermittently timed out: every fixture LanguageService created its own brand-new ts.DocumentRegistry, discarding TypeScript's built-in mechanism for reusing an already-parsed-and-bound SourceFile across LanguageServices — forcing the entire vendored dw/* declaration tree to be re-parsed and re-bound from scratch on every single test. Fixture LanguageServices now share one module-level DocumentRegistry; in-memory fixture files use their own text as their "version" (so two tests reusing the same path with different content are never confused for each other), while real on-disk files (the expensive part) keep a constant version and get parsed once total. Measured: a cold fixture build costs ~300ms; a warm one sharing the same real dw.* files now costs ~17ms. Co-Authored-By: Claude Sonnet 5 --- .changeset/script-types-infer-usage.md | 2 + .../b2c-script-types/plugin/inference/core.js | 4 +- .../plugin/inference/usage-match.js | 21 +++- .../b2c-script-types/src/inference/core.ts | 12 +- .../src/inference/usage-match.ts | 26 ++++- .../test/helpers/fixture-language-service.js | 28 ++++- .../test/helpers/real-dw-types.js | 1 + .../b2c-script-types/test/usage-match.test.js | 104 ++++++++++++++++++ 8 files changed, 188 insertions(+), 10 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 120867a27..8999c0449 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -11,6 +11,8 @@ Beyond call-site and return-expression inference, the engine also recognizes: - 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. diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index ed85f004f..4dd5ffc8f 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -65,7 +65,7 @@ function resolveVariableInitializerTypes(ctx, decl, depth, chainHops) { 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)); + 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); @@ -411,7 +411,7 @@ function inferParameterType(ctx, param, depth = 0) { // than give up, try to match how the parameter's own body uses it against // the program's ambient classes. if (result.length === 0) { - result = (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param)); + result = (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param), ts.isIdentifier(param.name) ? param.name.text : undefined); } // 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 diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 5f92a903c..6b45f9901 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -58,7 +58,7 @@ function buildAmbientClassIndex(ctx) { } if (memberNames.size === 0) continue; - candidates.push({ type, memberNames }); + candidates.push({ type, memberNames, name: stmt.name.text }); } } classIndexCache.set(ctx.languageService, candidates); @@ -168,8 +168,20 @@ function collectVariableMemberUsage(ctx, decl) { * "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-based tiebreaking even runs. */ -function matchAmbientTypesByUsage(ctx, memberNames) { +function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { if (memberNames.size === 0) return []; const candidates = buildAmbientClassIndex(ctx); @@ -182,6 +194,11 @@ function matchAmbientTypesByUsage(ctx, memberNames) { }); if (matches.length === 0) return []; + if (identifierName) { + const byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); + if (byName.length === 1) + return [byName[0].type]; + } if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; const minSize = Math.min(...matches.map((m) => m.memberNames.size)); diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 55d514d64..c0f8f3272 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -88,7 +88,11 @@ function resolveVariableInitializerTypes( try { const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); if (resolved.length > 0) return resolved; - return matchAmbientTypesByUsage(ctx, collectVariableMemberUsage(ctx, decl)); + return matchAmbientTypesByUsage( + ctx, + collectVariableMemberUsage(ctx, decl), + ts.isIdentifier(decl.name) ? decl.name.text : undefined, + ); } finally { ctx.visiting.delete(decl); } @@ -461,7 +465,11 @@ export function inferParameterType( // than give up, try to match how the parameter's own body uses it against // the program's ambient classes. if (result.length === 0) { - result = matchAmbientTypesByUsage(ctx, collectParameterMemberUsage(ctx, param)); + result = matchAmbientTypesByUsage( + ctx, + collectParameterMemberUsage(ctx, param), + ts.isIdentifier(param.name) ? param.name.text : undefined, + ); } // 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 diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index f458c0913..bb3945300 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -22,6 +22,8 @@ import type {InferenceContext} from './context'; 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 @@ -69,7 +71,7 @@ function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] memberNames.add(prop.getName()); } if (memberNames.size === 0) continue; - candidates.push({type, memberNames}); + candidates.push({type, memberNames, name: stmt.name.text}); } } classIndexCache.set(ctx.languageService, candidates); @@ -183,8 +185,24 @@ export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver * "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-based tiebreaking even runs. */ -export function matchAmbientTypesByUsage(ctx: InferenceContext, memberNames: ReadonlySet): tsserver.Type[] { +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) => { @@ -194,6 +212,10 @@ export function matchAmbientTypesByUsage(ctx: InferenceContext, memberNames: Rea return true; }); if (matches.length === 0) return []; + if (identifierName) { + const byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); + if (byName.length === 1) return [byName[0].type]; + } if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; const minSize = Math.min(...matches.map((m) => m.memberNames.size)); const tightest = matches.filter((m) => m.memberNames.size === minSize); diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js index ea94a3e52..c91b1418a 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -42,7 +42,20 @@ function createFixtureHost(files, options) { return { getScriptFileNames: fileNames, - getScriptVersion: () => '0', + // 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 = files[fileName] ?? ts.sys.readFile(fileName); return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); @@ -57,12 +70,23 @@ function createFixtureHost(files, options) { }; } +// Shared across every fixture LanguageService created in this process (see +// createFixtureLanguageService): 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, ts.createDocumentRegistry()); + return ts.createLanguageService(host, sharedDocumentRegistry); } // Finds a top-level `function name(...) {...}` declaration in a fixture diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index 43cdb70c1..b6d3522fb 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -32,6 +32,7 @@ const REAL_DW_TYPES = { ProductLineItem: dtsPath('dw', 'order', 'ProductLineItem'), AddressBook: dtsPath('dw', 'customer', 'AddressBook'), CustomerAddress: dtsPath('dw', 'customer', 'CustomerAddress'), + ProductListRegistrant: dtsPath('dw', 'customer', 'ProductListRegistrant'), }; /** diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 7308c5fd1..4db76ceda 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -385,4 +385,108 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage assert.equal(describeTypes(ctx.checker, types), 'ProductLineItem'); }); + + describe('identifier-name tiebreak (prefers the class matching the variable/parameter name)', () => { + // Real-world bug from mul-core's plugin_marketing_cloud/accountHelpers.js + // (sentAccountActivationEmail): `var profile = resettingCustomer.profile;` + // is only ever read via email/firstName/lastName/custom — a field subset + // shared by both the real dw.customer.Profile (420 lines, dozens of + // members) and the much smaller dw.customer.ProductListRegistrant (70 + // lines). "Fewest total members" alone picked ProductListRegistrant + // every time, purely because it has less surface area — never the + // large, contextually correct Profile. `resettingCustomer` itself is + // deliberately left uninferred (an explicit, if made-up, JSDoc + // `@param {obj}` type) — the fallback only ever reaches `profile`'s own + // usage signature, matching the exact real-world path. + it('infers Profile (not the smaller, equally-matching ProductListRegistrant) for a variable literally named `profile`', () => { + const files = { + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant'], ''), + '/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'); + + // resettingCustomer's own explicit (if nonsensical) JSDoc type must be + // left alone, exactly as it is in the real file. + const resettingCustomerTypes = inferParameterType(ctx, fn.parameters[0]); + assert.deepEqual(resettingCustomerTypes, []); + + 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. + // Keeps the same `@param {obj}` JSDoc block as the test above: without + // it, `resettingCustomer`'s own single-member usage (`.profile`) + // uniquely matches `dw.customer.Customer` on its own, resolving + // `.profile` through Customer's real declared property and never + // reaching the ambient-fallback path this test means to exercise. + const files = { + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant'], ''), + '/accountHelpers.js': ` + /** + * @param {obj} resettingCustomer - object that contains user's email address and name information. + */ + 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, []); + }); + }); }); From 9cc724b7a0c6370708422f054796472d632a6802 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 19:51:53 +0200 Subject: [PATCH 33/43] Share the test DocumentRegistry in index.test.js too index.test.js and index.security.test.js build their LanguageService directly (not via createFixtureLanguageService), so the earlier DocumentRegistry-sharing fix didn't reach them. index.test.js alone created 13 fresh DocumentRegistry instances across its tests, each forcing a full re-parse/re-bind of the vendored dw/* tree. Switching those call sites to the same shared, content-versioned registry cuts this file's run time from ~4.1s to ~2.0s, with identical pass/fail results before and after. Co-Authored-By: Claude Sonnet 5 --- .../test/helpers/fixture-language-service.js | 27 +++++++++++------- packages/b2c-script-types/test/index.test.js | 28 +++++++++---------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/b2c-script-types/test/helpers/fixture-language-service.js b/packages/b2c-script-types/test/helpers/fixture-language-service.js index c91b1418a..0b9b26a42 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -70,15 +70,17 @@ function createFixtureHost(files, options) { }; } -// Shared across every fixture LanguageService created in this process (see -// createFixtureLanguageService): 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. +// 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 @@ -106,4 +108,9 @@ function findFunctionDeclaration(sourceFile, name) { return found; } -module.exports = {createFixtureHost, createFixtureLanguageService, findFunctionDeclaration}; +module.exports = { + createFixtureHost, + createFixtureLanguageService, + findFunctionDeclaration, + sharedDocumentRegistry, +}; diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index f13bf8637..44f7335ba 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -12,7 +12,7 @@ const ts = require('typescript'); const init = require('../plugin/index'); const {INFERRED_COMPLETION_SOURCE} = require('../plugin/usage-inference'); -const {createFixtureHost} = require('./helpers/fixture-language-service'); +const {createFixtureHost, sharedDocumentRegistry} = require('./helpers/fixture-language-service'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); const AMBIENT_TYPES = ` @@ -36,7 +36,7 @@ const FIXTURE_FILES = { function createPluginProxy(config) { const {create} = init({typescript: ts}); const host = createFixtureHost(FIXTURE_FILES); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const info = { languageService, languageServiceHost: host, @@ -175,7 +175,7 @@ describe('create() proxy — usage inference wiring', () => { '/typed.ts': `function helper(x: {aVeryLongPropertyNameHere: string; anotherVeryLongPropertyName: number; yetAnotherLongOne: boolean}) { return x; }`, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -214,7 +214,7 @@ describe('create() proxy — usage inference wiring', () => { // 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, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -268,7 +268,7 @@ describe('create() proxy — usage inference wiring', () => { const proxy = (() => { const {create} = init({typescript: ts}); const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); return create({ languageService, languageServiceHost: host, @@ -313,7 +313,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -357,7 +357,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -415,7 +415,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -483,7 +483,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -545,7 +545,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -603,7 +603,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -674,7 +674,7 @@ describe('create() proxy — usage inference wiring', () => { `, }; const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const languageService = ts.createLanguageService(host, sharedDocumentRegistry); const {create} = init({typescript: ts}); const proxy = create({ languageService, @@ -723,7 +723,7 @@ describe('create() proxy — usage inference wiring', () => { // 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, ts.createDocumentRegistry()); + const realLanguageService = ts.createLanguageService(host, sharedDocumentRegistry); const languageService = new Proxy(realLanguageService, { get(target, prop) { if (prop === 'getQuickInfoAtPosition' || prop === 'getCompletionsAtPosition') { @@ -767,7 +767,7 @@ describe('create() proxy — usage inference wiring', () => { module.exports = {helper}; `, }); - const realLanguageService = ts.createLanguageService(host, ts.createDocumentRegistry()); + const realLanguageService = ts.createLanguageService(host, sharedDocumentRegistry); const languageService = new Proxy(realLanguageService, { get(target, prop) { if (prop === 'getQuickInfoAtPosition' || prop === 'getCompletionsAtPosition') { From 945d02687e7b75ffc14d37a38e3c5a259d436c8f Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 19:55:21 +0200 Subject: [PATCH 34/43] Share the test DocumentRegistry in the remaining perf-test fixtures Two LanguageService instances in usage-inference.perf.test.js still created their own fresh DocumentRegistry instead of reusing the shared one, missed by the earlier fixture-language-service.js and index.test.js fixes. Closes out that cleanup for consistency. Co-Authored-By: Claude Sonnet 5 --- packages/b2c-script-types/test/usage-inference.perf.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 7ed84eb05..124786c4f 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -21,6 +21,7 @@ const { createFixtureHost, createFixtureLanguageService, findFunctionDeclaration, + sharedDocumentRegistry, } = require('./helpers/fixture-language-service'); const {realTypesPrelude} = require('./helpers/real-dw-types'); @@ -317,7 +318,7 @@ describe('usage-inference — performance baselines', () => { `, }; const host = createFixtureHost(files); - const baseLs = ts.createLanguageService(host, ts.createDocumentRegistry()); + const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); const counter = withReferenceCounter(baseLs); const {create} = init({typescript: ts}); const proxy = create({ @@ -475,7 +476,7 @@ describe('usage-inference — performance baselines', () => { `, }; const host = createFixtureHost(files); - const baseLs = ts.createLanguageService(host, ts.createDocumentRegistry()); + const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); const counter = withReferenceCounter(baseLs); const {create} = init({typescript: ts}); let projectVersion = 1; From 4c3a874fa87123fa51e308845e5d71657b682ea9 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 20:00:57 +0200 Subject: [PATCH 35/43] Fix cartridge-relative resolution returning case-folded paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NormalizedCartridge.root is lowercased on case-insensitive filesystems so ownerCartridge/isCartridgeFile can do prefix comparisons safely, but resolveCartridgeModule, resolveModulesCartridge, and resolveSuperModulePath were also using it to build the actual resolved path handed back to TypeScript — silently lowercasing every resolved cartridge file path on macOS/Windows. This mostly went unnoticed because case-insensitive filesystems open the file anyway, but it broke exact-match containment checks (as seen in the index.security.test.js path-traversal suite) and, more significantly, silently broke the superModule overlay-chain resolution whenever a fixture or real project used mixed-case paths, since that comparison is an exact string lookup rather than a filesystem call. Adds a `rawRoot` (original case) alongside `root` (folded, comparison- only) on NormalizedCartridge, and switches every path-construction site to use it while leaving comparison sites on the folded form. Co-Authored-By: Claude Sonnet 5 --- packages/b2c-script-types/plugin/index.js | 18 ++++++++++++++---- .../plugin/resolver/module-resolution.js | 6 +++--- packages/b2c-script-types/src/index.ts | 18 ++++++++++++++---- .../b2c-script-types/src/resolver/constants.ts | 13 ++++++++++++- .../src/resolver/module-resolution.ts | 6 +++--- 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index c38fdbb21..e524cbaa6 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -67,7 +67,12 @@ function init({ typescript: ts }) { 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) => { @@ -188,9 +193,14 @@ function init({ typescript: ts }) { const owner = ownerCartridge(containingFile); if (!owner) return undefined; - const subpath = normalize(containingFile).slice(owner.root.length); + // 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].root + subpath; + 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. @@ -308,7 +318,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 } }; diff --git a/packages/b2c-script-types/plugin/resolver/module-resolution.js b/packages/b2c-script-types/plugin/resolver/module-resolution.js index b01479263..5a0c52cd8 100644 --- a/packages/b2c-script-types/plugin/resolver/module-resolution.js +++ b/packages/b2c-script-types/plugin/resolver/module-resolution.js @@ -127,7 +127,7 @@ function resolveCartridgeModule(cartridges, moduleName, containingFile, deps) { if (!subpath) return undefined; for (const c of order) { - const baseAbs = c.root + subpath; + 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 `..` @@ -164,7 +164,7 @@ function resolveModulesCartridge(ts, cartridges, moduleName, deps) { const modulesCart = cartridges.find((c) => c.name === 'modules'); if (!modulesCart) return undefined; - const baseAbs = modulesCart.root + moduleName; + 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 @@ -178,7 +178,7 @@ function resolveModulesCartridge(ts, cartridges, moduleName, deps) { 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.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + 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)) { diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index d75d9c2c1..f2b377c99 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -106,7 +106,12 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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 + '/', + }; }); }; @@ -242,9 +247,14 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const resolveSuperModulePath = (containingFile: string): string | undefined => { const owner = ownerCartridge(containingFile); if (!owner) return undefined; - const subpath = normalize(containingFile).slice(owner.root.length); + // 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].root + subpath; + 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. @@ -388,7 +398,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}}; diff --git a/packages/b2c-script-types/src/resolver/constants.ts b/packages/b2c-script-types/src/resolver/constants.ts index f211ba666..c14484970 100644 --- a/packages/b2c-script-types/src/resolver/constants.ts +++ b/packages/b2c-script-types/src/resolver/constants.ts @@ -38,8 +38,19 @@ export interface PluginConfig { export interface NormalizedCartridge { name: string; - /** Forward-slash path with trailing '/'. Lowercased on case-insensitive filesystems. */ + /** + * 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. diff --git a/packages/b2c-script-types/src/resolver/module-resolution.ts b/packages/b2c-script-types/src/resolver/module-resolution.ts index 1e7159dea..0efa3b420 100644 --- a/packages/b2c-script-types/src/resolver/module-resolution.ts +++ b/packages/b2c-script-types/src/resolver/module-resolution.ts @@ -148,7 +148,7 @@ export function resolveCartridgeModule( if (!subpath) return undefined; for (const c of order) { - const baseAbs = c.root + subpath; + const baseAbs = c.rawRoot + subpath; for (const ext of CANDIDATE_EXTENSIONS) { const candidate = baseAbs + ext; // `subpath` comes straight from the import specifier, so a `..` @@ -187,7 +187,7 @@ export function resolveModulesCartridge( const modulesCart = cartridges.find((c) => c.name === 'modules'); if (!modulesCart) return undefined; - const baseAbs = modulesCart.root + moduleName; + 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 @@ -202,7 +202,7 @@ export function resolveModulesCartridge( 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.root + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + 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)) { From 385eda3ef04b97cd98a536c75bc05630a58649ad Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 20:40:23 +0200 Subject: [PATCH 36/43] Harden usage inference: silence weak/conflicting guesses, expand SFRA coverage Prefer silence over noisy unions when call sites disagree or evidence is only ubiquitous members; rank ambient matches by distinctiveness; cover map/filter/ find callbacks; resolve cartridge requires through the LS host; add a golden corpus and hardening tests drawn from real storefront patterns. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 + docs/guide/ide-integration.md | 11 +- packages/b2c-script-types/plugin/index.js | 25 +- .../plugin/inference/constants.js | 32 ++- .../b2c-script-types/plugin/inference/core.js | 93 +++++--- .../plugin/inference/usage-match.js | 124 +++++++--- packages/b2c-script-types/src/index.ts | 37 +-- .../src/inference/constants.ts | 33 ++- .../b2c-script-types/src/inference/core.ts | 116 ++++++--- .../src/inference/usage-match.ts | 125 ++++++++-- .../b2c-script-types/test/corpus/cases.json | 81 +++++++ .../test/corpus/corpus.test.js | 87 +++++++ .../test/helpers/assert-inference.js | 115 +++++++++ .../test/helpers/cartridge-fixture.js | 113 +++++++++ .../test/helpers/fixture-language-service.js | 24 +- packages/b2c-script-types/test/index.test.js | 5 +- .../test/usage-inference.hardening.test.js | 222 ++++++++++++++++++ .../test/usage-inference.perf.test.js | 9 +- .../test/usage-inference.real-types.test.js | 4 +- .../test/usage-inference.test.js | 96 +++++++- .../b2c-script-types/test/usage-match.test.js | 20 +- 21 files changed, 1205 insertions(+), 169 deletions(-) create mode 100644 packages/b2c-script-types/test/corpus/cases.json create mode 100644 packages/b2c-script-types/test/corpus/corpus.test.js create mode 100644 packages/b2c-script-types/test/helpers/assert-inference.js create mode 100644 packages/b2c-script-types/test/helpers/cartridge-fixture.js create mode 100644 packages/b2c-script-types/test/usage-inference.hardening.test.js diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 8999c0449..a5480151b 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,4 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. +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` (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. + 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/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 34042d913..eb7920602 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -123,7 +123,7 @@ Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or Two more SFRA idioms are covered: -- **Iteration callbacks** — `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(); }`) resolve through the same chain machinery. +- **Iteration callbacks** — `collections.forEach` / `map` / `filter` / `every` / `some` / `find` (element-first callback; `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(); }`) resolve through the same chain machinery. - **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature. 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. @@ -132,9 +132,18 @@ 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/plugin/index.js b/packages/b2c-script-types/plugin/index.js index e524cbaa6..a4518bbb8 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -132,11 +132,6 @@ function init({ typescript: ts }) { return false; } }; - // See resolver/module-resolution.ts for the resolution rules and security - // rationale. These wrappers just bind the closure's live cartridge list and - // path-safety primitives. - const resolveCartridgeModule = (moduleName, containingFile) => (0, module_resolution_1.resolveCartridgeModule)(cartridges, moduleName, containingFile, { normalize, isWithinRoot, fileExists }); - const resolveModulesCartridge = (moduleName) => (0, module_resolution_1.resolveModulesCartridge)(ts, cartridges, moduleName, { isWithinRoot, fileExists }); 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 @@ -189,6 +184,20 @@ function init({ typescript: ts }) { 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) @@ -242,10 +251,12 @@ function init({ typescript: ts }) { // 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 = resolveCartridgeModule(text, containingFile); + const cart = resolveCartridgeModuleOnHost(text, containingFile); if (cart) { return { resolvedFileName: cart.resolved, @@ -253,7 +264,7 @@ function init({ typescript: ts }) { isExternalLibraryImport: false, }; } - const mod = resolveModulesCartridge(text); + const mod = resolveModulesCartridgeOnHost(text); if (mod) { return { resolvedFileName: mod.resolved, diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 0cb1e5f7f..9c1abb34b 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -5,7 +5,7 @@ * 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_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; +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. @@ -76,7 +76,31 @@ exports.INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usag // 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 (fewest total members), the match is too -// ambiguous to be a useful hint — silence beats a wall of unrelated -// candidates in the hover text. +// 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 (neuhaus-core). + 'find', +]); diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 4dd5ffc8f..8bf0ae7e8 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -159,12 +159,14 @@ function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chai } /** * Infers the type of a callback's first parameter from sibling arguments of - * the call the callback is passed to: `collections.forEach(coll, function - * (item) {...})` — 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), and `reduce`-style callees are - * skipped since their callbacks lead with an accumulator instead. + * 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; @@ -178,7 +180,7 @@ function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { : ts.isIdentifier(call.expression) ? call.expression.text : undefined; - if (calleeName === 'reduce') + if (!calleeName || !constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) return []; const types = []; for (const arg of call.arguments) { @@ -344,6 +346,50 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { 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; +} +/** + * Turns raw call-site/callback candidates into the final answer for a + * parameter: 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 result = (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 && result.length > constants_1.MAX_CALL_SITE_CANDIDATES) + return []; + if (result.length > 0) + return result; + // No call site could be found or resolved at all (a helper only ever + // reached indirectly — a Controller route dispatching through a name the + // reference search can't follow, or genuinely dead/unused code). Rather + // than give up, try to match how the parameter's own body uses it against + // the program's ambient classes. + return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param), 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 @@ -386,33 +432,14 @@ function inferParameterType(ctx, param, depth = 0) { const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; - const types = []; const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); - if (nameNode) { - for (const call of (0, call_sites_1.collectCallSites)(ctx, nameNode)) { - // A bare `new Helper` (no parens) has `arguments === undefined`, - // unlike a plain call, which always has an (possibly empty) array. - const arg = call.arguments?.[paramIndex]; - if (!arg) - continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - } - else { - // 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. - types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); - } - let result = (0, type_helpers_1.dedupeTypes)(ctx, types); - // No call site could be found or resolved at all (a helper only ever - // reached indirectly — a Controller route dispatching through a name the - // reference search can't follow, or genuinely dead/unused code). Rather - // than give up, try to match how the parameter's own body uses it against - // the program's ambient classes. - if (result.length === 0) { - result = (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param), ts.isIdentifier(param.name) ? param.name.text : undefined); - } + 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); + 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 diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 6b45f9901..8da42c7a7 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -24,16 +24,36 @@ const constants_1 = require("./constants"); // 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 generic / nameless / has no members. + * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. + */ +function candidateFromDeclaration(checker, ts, stmt) { + const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + // Generic classes (e.g. `Product`) are skipped: their declared type here + // is the unsubstituted generic (`Product`, not `Product`), which + // would render misleadingly in hover text with no real instantiation context + // to substitute from. + if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) + 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. - * - * Generic classes (e.g. `Product`) are skipped: their declared type here - * is the unsubstituted generic (`Product`, not `Product`), which - * would render misleadingly in hover text with no real instantiation context - * to substitute from. */ function buildAmbientClassIndex(ctx) { const cached = classIndexCache.get(ctx.languageService); @@ -45,32 +65,50 @@ function buildAmbientClassIndex(ctx) { if (!sourceFile.isDeclarationFile) continue; for (const stmt of sourceFile.statements) { - const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); - if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) - continue; - const symbol = checker.getSymbolAtLocation(stmt.name); - if (!symbol) - continue; - const type = checker.getDeclaredTypeOfSymbol(symbol); - const memberNames = new Set(); - for (const prop of checker.getPropertiesOfType(type)) { - memberNames.add(prop.getName()); - } - if (memberNames.size === 0) - continue; - candidates.push({ type, memberNames, name: stmt.name.text }); + 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. + * 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 @@ -156,9 +194,15 @@ function collectVariableMemberUsage(ctx, decl) { /** * 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. "Most specific" means fewest total - * members — the tightest-fitting shape, not just any superset. Returns `[]` - * when the signature is too weak to be worth guessing from (see + * 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). * @@ -179,7 +223,10 @@ function collectVariableMemberUsage(ctx, decl) { * 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-based tiebreaking even runs. + * before size/distinctiveness ranking even runs — but only after the + * weak-signature silence guards below. A parameter literally named + * `shipment` whose only evidence is `.custom` must still stay silent: + * the name alone must not override "too weak / ambiguous" evidence. */ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { if (memberNames.size === 0) @@ -194,15 +241,36 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { }); if (matches.length === 0) return []; + // Silence guards run BEFORE the identifier-name short-circuit: they judge + // the raw usage signature against the full match set. A name match among + // an otherwise-ambiguous weak signature (e.g. `shipment` + only `.custom`) + // must not rescue a guess we would otherwise refuse. + if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) + 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. + const strongCount = [...memberNames].filter((n) => !constants_1.WEAK_USAGE_MEMBERS.has(n)).length; + if (strongCount === 0 && matches.length > 1) + return []; if (identifierName) { const byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); if (byName.length === 1) return [byName[0].type]; } - if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) - return []; - const minSize = Math.min(...matches.map((m) => m.memberNames.size)); - const tightest = matches.filter((m) => m.memberNames.size === minSize); + 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/src/index.ts b/packages/b2c-script-types/src/index.ts index f2b377c99..bbc7b9b98 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -174,18 +174,6 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { } }; - // See resolver/module-resolution.ts for the resolution rules and security - // rationale. These wrappers just bind the closure's live cartridge list and - // path-safety primitives. - const resolveCartridgeModule = ( - moduleName: string, - containingFile: string, - ): {resolved: string; source: string} | undefined => - resolveCartridgeModuleImpl(cartridges, moduleName, containingFile, {normalize, isWithinRoot, fileExists}); - - const resolveModulesCartridge = (moduleName: string): {resolved: string; source: string} | undefined => - resolveModulesCartridgeImpl(ts, cartridges, moduleName, {isWithinRoot, fileExists}); - const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => ownerCartridgeImpl(cartridges, normalize, containingFile); @@ -244,6 +232,25 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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; @@ -298,10 +305,12 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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 = resolveCartridgeModule(text, containingFile); + const cart = resolveCartridgeModuleOnHost(text, containingFile); if (cart) { return { resolvedFileName: cart.resolved, @@ -309,7 +318,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { isExternalLibraryImport: false, }; } - const mod = resolveModulesCartridge(text); + const mod = resolveModulesCartridgeOnHost(text); if (mod) { return { resolvedFileName: mod.resolved, diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index c332e0546..050b201f0 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -84,7 +84,34 @@ export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred export const MIN_USAGE_SIGNATURE_MEMBERS = 2; // If the member-name signature still ties across more candidates than this -// after ranking by specificity (fewest total members), the match is too -// ambiguous to be a useful hint — silence beats a wall of unrelated -// candidates in the hover text. +// 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 (neuhaus-core). + 'find', +]); diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index c0f8f3272..a87fe656c 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -15,7 +15,13 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {MAX_CHAIN_HOPS, MAX_INFERENCE_DEPTH, MAX_SUPERMODULE_HOPS} from './constants'; +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, @@ -197,12 +203,14 @@ function resolveSuperModuleMemberTypes( /** * Infers the type of a callback's first parameter from sibling arguments of - * the call the callback is passed to: `collections.forEach(coll, function - * (item) {...})` — 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), and `reduce`-style callees are - * skipped since their callbacks lead with an accumulator instead. + * 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, @@ -219,7 +227,7 @@ function inferCallbackParameterTypes( : ts.isIdentifier(call.expression) ? call.expression.text : undefined; - if (calleeName === 'reduce') return []; + if (!calleeName || !ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) return []; const types: tsserver.Type[] = []; for (const arg of call.arguments) { if (arg === fn) continue; @@ -399,6 +407,63 @@ function resolveIdentifierTypes( 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; +} + +/** + * Turns raw call-site/callback candidates into the final answer for a + * parameter: 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 result = 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 && result.length > MAX_CALL_SITE_CANDIDATES) return []; + if (result.length > 0) return result; + // No call site could be found or resolved at all (a helper only ever + // reached indirectly — a Controller route dispatching through a name the + // reference search can't follow, or genuinely dead/unused code). Rather + // than give up, try to match how the parameter's own body uses it against + // the program's ambient classes. + return matchAmbientTypesByUsage( + ctx, + collectParameterMemberUsage(ctx, param), + 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 @@ -441,36 +506,15 @@ export function inferParameterType( const paramIndex = fn.parameters.indexOf(param); if (paramIndex < 0) return []; - const types: tsserver.Type[] = []; const nameNode = getReferenceNameNode(fn, ts); - if (nameNode) { - for (const call of collectCallSites(ctx, nameNode)) { - // A bare `new Helper` (no parens) has `arguments === undefined`, - // unlike a plain call, which always has an (possibly empty) array. - const arg = call.arguments?.[paramIndex]; - if (!arg) continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - } else { - // 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. - types.push(...inferCallbackParameterTypes(ctx, fn, paramIndex, depth)); - } + 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); - let result = dedupeTypes(ctx, types); - // No call site could be found or resolved at all (a helper only ever - // reached indirectly — a Controller route dispatching through a name the - // reference search can't follow, or genuinely dead/unused code). Rather - // than give up, try to match how the parameter's own body uses it against - // the program's ambient classes. - if (result.length === 0) { - result = matchAmbientTypesByUsage( - ctx, - collectParameterMemberUsage(ctx, param), - ts.isIdentifier(param.name) ? param.name.text : undefined, - ); - } + 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 diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index bb3945300..65c6e90d5 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -16,7 +16,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {MAX_USAGE_MATCH_CANDIDATES, MIN_USAGE_SIGNATURE_MEMBERS} from './constants'; +import {MAX_USAGE_MATCH_CANDIDATES, MIN_USAGE_SIGNATURE_MEMBERS, WEAK_USAGE_MEMBERS} from './constants'; import type {InferenceContext} from './context'; interface AmbientClassCandidate { @@ -42,16 +42,38 @@ interface AmbientClassCandidate { // 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 generic / nameless / has no members. + * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. + */ +function candidateFromDeclaration( + checker: tsserver.TypeChecker, + ts: typeof tsserver, + stmt: tsserver.Statement, +): AmbientClassCandidate | undefined { + const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); + // Generic classes (e.g. `Product`) are skipped: their declared type here + // is the unsubstituted generic (`Product`, not `Product`), which + // would render misleadingly in hover text with no real instantiation context + // to substitute from. + if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) 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. - * - * Generic classes (e.g. `Product`) are skipped: their declared type here - * is the unsubstituted generic (`Product`, not `Product`), which - * would render misleadingly in hover text with no real instantiation context - * to substitute from. */ function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] { const cached = classIndexCache.get(ctx.languageService); @@ -61,30 +83,52 @@ function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] for (const sourceFile of ctx.program.getSourceFiles()) { if (!sourceFile.isDeclarationFile) continue; for (const stmt of sourceFile.statements) { - const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); - if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) continue; - const symbol = checker.getSymbolAtLocation(stmt.name); - if (!symbol) continue; - const type = checker.getDeclaredTypeOfSymbol(symbol); - const memberNames = new Set(); - for (const prop of checker.getPropertiesOfType(type)) { - memberNames.add(prop.getName()); - } - if (memberNames.size === 0) continue; - candidates.push({type, memberNames, name: stmt.name.text}); + 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. + * 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 @@ -173,9 +217,15 @@ export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver /** * 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. "Most specific" means fewest total - * members — the tightest-fitting shape, not just any superset. Returns `[]` - * when the signature is too weak to be worth guessing from (see + * 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). * @@ -196,7 +246,10 @@ export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver * 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-based tiebreaking even runs. + * before size/distinctiveness ranking even runs — but only after the + * weak-signature silence guards below. A parameter literally named + * `shipment` whose only evidence is `.custom` must still stay silent: + * the name alone must not override "too weak / ambiguous" evidence. */ export function matchAmbientTypesByUsage( ctx: InferenceContext, @@ -212,13 +265,35 @@ export function matchAmbientTypesByUsage( return true; }); if (matches.length === 0) return []; + // Silence guards run BEFORE the identifier-name short-circuit: they judge + // the raw usage signature against the full match set. A name match among + // an otherwise-ambiguous weak signature (e.g. `shipment` + only `.custom`) + // must not rescue a guess we would otherwise refuse. + if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) 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. + const strongCount = [...memberNames].filter((n) => !WEAK_USAGE_MEMBERS.has(n)).length; + if (strongCount === 0 && matches.length > 1) return []; + if (identifierName) { const byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); if (byName.length === 1) return [byName[0].type]; } - if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; - const minSize = Math.min(...matches.map((m) => m.memberNames.size)); - const tightest = matches.filter((m) => m.memberNames.size === minSize); + + 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/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json new file mode 100644 index 000000000..f540ee212 --- /dev/null +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -0,0 +1,81 @@ +[ + { + "id": "neuhaus-addressBook-addresses", + "source": "neuhaus-core 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": "neuhaus-collections-map-option-item", + "source": "neuhaus-core productHelpers.getLineItemOptionNames (collections.map)", + "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": "omoda-custom-in-check-contributes-custom", + "source": "omoda-core seoHelpers ('bannerImage' in seoSource.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 'fromStoreId' 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 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": "mul-new-helper-constructor", + "source": "mul-core constructor-function model pattern", + "description": "new Helper(x) is a call site for parameter inference", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function ProductLineItem(product) {\n this.product = product;\n}\nnew ProductLineItem(getSomeProduct());\n" + }, + "globals": " function getSomeProduct(): Product;", + "target": {"file": "/helpers.js", "function": "ProductLineItem", "param": 0}, + "expect": "Product" + }, + { + "id": "shipment-custom-plus-line-items", + "source": "synthetic — distinctive co-member with .custom", + "description": ".custom + .productLineItems still resolves Shipment via distinctiveness", + "dwTypes": ["Shipment", "ProductLineItem", "Profile"], + "files": { + "/helpers.js": "function describeShipment(shipment) {\n var c = shipment.custom;\n return shipment.productLineItems;\n}\n" + }, + "target": {"file": "/helpers.js", "function": "describeShipment", "param": 0}, + "expect": "Shipment" + } +] 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..078eee8b8 --- /dev/null +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -0,0 +1,87 @@ +/* + * 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, +} = 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 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'] = corpusCase.globals; + } + return files; +} + +function resolveParam(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 findCallbackParam(sourceFile, corpusCase.target.param ?? 0); + } + const fn = findFunctionDeclaration(sourceFile, corpusCase.target.function); + return 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 param = resolveParam(ctx, corpusCase); + + if (corpusCase.expectMembers) { + const members = [...collectParameterMemberUsage(ctx, param)].sort(); + assert.deepEqual(members, [...corpusCase.expectMembers].sort()); + return; + } + + const types = inferParameterType(ctx, param); + if (corpusCase.expect === null) { + assert.deepEqual(types, [], `expected silence for ${corpusCase.id}`); + 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 index 0b9b26a42..1508faa6d 100644 --- a/packages/b2c-script-types/test/helpers/fixture-language-service.js +++ b/packages/b2c-script-types/test/helpers/fixture-language-service.js @@ -40,7 +40,10 @@ function createFixtureHost(files, options) { } } - return { + 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 @@ -57,17 +60,30 @@ function createFixtureHost(files, options) { // this cache exists to short-circuit. getScriptVersion: (fileName) => files[fileName] ?? 'on-disk', getScriptSnapshot: (fileName) => { - const text = files[fileName] ?? ts.sys.readFile(fileName); + const text = readFile(fileName); return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); }, getCurrentDirectory: () => '/', getCompilationSettings: () => compilerOptions, getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), - fileExists: (fileName) => fileName in files || ts.sys.fileExists(fileName), - readFile: (fileName) => files[fileName] ?? ts.sys.readFile(fileName), + 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 diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 44f7335ba..827138999 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -236,13 +236,16 @@ describe('create() proxy — usage inference wiring', () => { // 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('quantity')); + 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', () => { 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..a08d65255 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -0,0 +1,222 @@ +/* + * 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, []); + }); + }); + + describe('multi-cartridge require call sites (cartridge-fixture factory)', () => { + it('infers a helper parameter from a call site reached through require("~/...")', () => { + // Mirrors mul-core/neuhaus-core: 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 index 124786c4f..f0dafa105 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -171,9 +171,14 @@ describe('usage-inference — performance baselines', () => { counter.referenceSearches() <= BASELINE.widelyReferencedHelper, `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.referenceSearches()}`, ); - // The per-call reference budget must actually engage. + // 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.ok(spent <= 50, `expected the per-call cap (50) to bound processed references, spent ${spent}`); + 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`); }); 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 index 108e8d200..676237039 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -423,7 +423,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = assert.ok(names.includes('getName')); }); - it('unions candidate types across call sites passing different real dw.* classes (Product and Category)', () => { + it('stays silent when call sites pass different real dw.* classes (Product vs Category)', () => { const files = { '/types.d.ts': realTypesPrelude( ['Product', 'ProductMgr', 'Category'], @@ -441,7 +441,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Product | Category'); + assert.deepEqual(types, []); }); it('does not infer a false-positive type for a Product-shaped helper that is never called', () => { diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 73550be8b..be251fa11 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -78,7 +78,7 @@ describe('usage-inference', () => { assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); }); - it('unions candidate types across a mix of plain-call and `new` constructor call sites', () => { + 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': ` @@ -98,9 +98,30 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(types.length, 2); - const rendered = types.map((t) => ctx.checker.typeToString(t)).sort(); - assert.deepEqual(rendered, ['{ ID: string; name: string; }', '{ quantity: number; }']); + 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', () => { @@ -127,7 +148,7 @@ describe('usage-inference', () => { assert.deepEqual(types, []); }); - it('unions candidate types across multiple call sites', () => { + it('stays silent when call-site argument types conflict (no noisy union)', () => { const files = { '/types.d.ts': AMBIENT_TYPES, '/helper.js': ` @@ -147,9 +168,30 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(types.length, 2); - const rendered = types.map((t) => ctx.checker.typeToString(t)).sort(); - assert.deepEqual(rendered, ['{ ID: string; name: string; }', '{ quantity: number; }']); + 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', () => { @@ -950,6 +992,44 @@ describe('usage-inference', () => { 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, diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 4db76ceda..2491bfa0e 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -75,8 +75,12 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); 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'], ''), + '/types.d.ts': realTypesPrelude(['Shipment', 'ProductLineItem', 'Profile', 'Customer'], ''), '/shippingHelpers.js': ` function touchCustom(shipment) { shipment.custom.fromStoreId = null; @@ -90,6 +94,20 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 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('infers a single accessed member when it uniquely identifies one ambient class (addressBook.addresses)', () => { // Real-world shape from neuhaus-core's addressHelpers.js: // getAddressBookAddressByForm(addressBook, form) only ever touches From bb53c036bce3f775c0d71d9df60189c39817fb34 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 21:04:18 +0200 Subject: [PATCH 37/43] Fix dogfood false positives for Store/address and Customer inference Drop call-site types that can't satisfy a parameter's body members (mul-core duck-typed Store models), and allow a named parameter with one strong member (customer + .profile) to resolve even when that member is shared. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 +- .../b2c-script-types/plugin/inference/core.js | 43 ++++++++--- .../plugin/inference/usage-match.js | 31 +++++--- .../b2c-script-types/src/inference/core.ts | 51 +++++++++---- .../src/inference/usage-match.ts | 30 +++++--- .../test/helpers/real-dw-types.js | 2 + .../test/usage-inference.hardening.test.js | 73 +++++++++++++++++++ .../b2c-script-types/test/usage-match.test.js | 35 +++++++++ 8 files changed, 224 insertions(+), 43 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index a5480151b..78ea99ad5 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,6 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. -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` (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. +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` (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), and a conventionally named parameter with a single strong member (`customer` + `.profile`) is trusted even when that member is shared by another ambient class. 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/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 8bf0ae7e8..1356a652d 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -361,14 +361,33 @@ function collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, 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: dedupe, silence conflicting top-level unions, otherwise fall - * back to ambient usage matching when nothing resolved. + * 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 result = (0, type_helpers_1.dedupeTypes)(ctx, types); + 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, @@ -379,16 +398,20 @@ function finalizeParameterCandidates(ctx, param, types, depth) { // 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 && result.length > constants_1.MAX_CALL_SITE_CANDIDATES) + 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 call site could be found or resolved at all (a helper only ever - // reached indirectly — a Controller route dispatching through a name the - // reference search can't follow, or genuinely dead/unused code). Rather - // than give up, try to match how the parameter's own body uses it against - // the program's ambient classes. - return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectParameterMemberUsage)(ctx, param), ts.isIdentifier(param.name) ? param.name.text : undefined); + // 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 diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 8da42c7a7..7fb98365c 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -224,9 +224,11 @@ function collectVariableMemberUsage(ctx, decl) { * 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-signature silence guards below. A parameter literally named - * `shipment` whose only evidence is `.custom` must still stay silent: - * the name alone must not override "too weak / ambiguous" evidence. + * 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) @@ -241,25 +243,34 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { }); if (matches.length === 0) return []; - // Silence guards run BEFORE the identifier-name short-circuit: they judge - // the raw usage signature against the full match set. A name match among - // an otherwise-ambiguous weak signature (e.g. `shipment` + only `.custom`) - // must not rescue a guess we would otherwise refuse. - if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) - 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. + // 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`) 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 byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); if (byName.length === 1) return [byName[0].type]; } + // 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, diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index a87fe656c..f518ba954 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -427,10 +427,31 @@ function collectArgumentTypesFromCallSites( 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: dedupe, silence conflicting top-level unions, otherwise fall - * back to ambient usage matching when nothing resolved. + * 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, @@ -439,7 +460,8 @@ function finalizeParameterCandidates( depth: number, ): tsserver.Type[] { const {ts} = ctx; - const result = dedupeTypes(ctx, types); + 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, @@ -450,18 +472,21 @@ function finalizeParameterCandidates( // 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 && result.length > MAX_CALL_SITE_CANDIDATES) return []; - if (result.length > 0) return result; - // No call site could be found or resolved at all (a helper only ever - // reached indirectly — a Controller route dispatching through a name the - // reference search can't follow, or genuinely dead/unused code). Rather - // than give up, try to match how the parameter's own body uses it against - // the program's ambient classes. - return matchAmbientTypesByUsage( + 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, - collectParameterMemberUsage(ctx, param), - ts.isIdentifier(param.name) ? param.name.text : undefined, + 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); } /** diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index 65c6e90d5..65c52fcea 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -247,9 +247,11 @@ export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver * 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-signature silence guards below. A parameter literally named - * `shipment` whose only evidence is `.custom` must still stay silent: - * the name alone must not override "too weak / ambiguous" evidence. + * 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, @@ -265,24 +267,34 @@ export function matchAmbientTypesByUsage( return true; }); if (matches.length === 0) return []; - // Silence guards run BEFORE the identifier-name short-circuit: they judge - // the raw usage signature against the full match set. A name match among - // an otherwise-ambiguous weak signature (e.g. `shipment` + only `.custom`) - // must not rescue a guess we would otherwise refuse. - if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) 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. + // 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`) 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 byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); if (byName.length === 1) return [byName[0].type]; } + // 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, diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index b6d3522fb..c2f3bc9a1 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -33,6 +33,8 @@ const REAL_DW_TYPES = { 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'), }; /** diff --git a/packages/b2c-script-types/test/usage-inference.hardening.test.js b/packages/b2c-script-types/test/usage-inference.hardening.test.js index a08d65255..9a9460d07 100644 --- a/packages/b2c-script-types/test/usage-inference.hardening.test.js +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -127,6 +127,79 @@ describe('usage-inference hardening', () => { 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 (mul-core 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)', () => { diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 2491bfa0e..0ed04bd7e 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -108,6 +108,41 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 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 neuhaus-core'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 neuhaus-core's addressHelpers.js: // getAddressBookAddressByForm(addressBook, form) only ever touches From 83044b64c90ad3a7d64a2f8a3498b9c054863338 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 21:10:32 +0200 Subject: [PATCH 38/43] Expand storefront inference corpus and index generic Product ambient matches Dogfood-derived golden cases (anonymized) cover Customer/Basket/Order/Profile silence traps, and ambient matching now includes Product as Product so JSDoc-less product helpers match IntelliJ-style Script API IntelliSense. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 +- .../plugin/inference/type-helpers.js | 12 +- .../plugin/inference/usage-match.js | 15 +- .../src/inference/type-helpers.ts | 12 +- .../src/inference/usage-match.ts | 15 +- .../b2c-script-types/test/corpus/cases.json | 236 ++++++++++++++++-- .../test/corpus/corpus.test.js | 34 ++- .../test/helpers/real-dw-types.js | 5 + .../test/usage-inference.real-types.test.js | 9 +- .../b2c-script-types/test/usage-match.test.js | 7 +- 10 files changed, 297 insertions(+), 50 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 78ea99ad5..a16b7c390 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,6 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. -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` (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), and a conventionally named parameter with a single strong member (`customer` + `.profile`) is trusted even when that member is shared by another ambient class. +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` (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; and generic Script API classes like `Product` are included in ambient matching (shown as `Product`), so JSDoc-less `product` helpers recover the same class IntelliJ gets from typings + naming conventions. 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/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index f611fb3ef..a411acddb 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -50,7 +50,17 @@ function widenType(checker, type) { * generic-instantiation display is left untouched. */ function computeTypeDisplayString(checker, type) { - const simple = checker.typeToString(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; diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 7fb98365c..9ed409796 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -26,16 +26,19 @@ const constants_1 = require("./constants"); const classIndexCache = new WeakMap(); /** * Indexes one top-level class/interface declaration into an ambient-class - * candidate, or `undefined` when it's generic / nameless / has no members. + * 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); - // Generic classes (e.g. `Product`) are skipped: their declared type here - // is the unsubstituted generic (`Product`, not `Product`), which - // would render misleadingly in hover text with no real instantiation context - // to substitute from. - if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) + if (!isClassOrInterface || !stmt.name) return undefined; const symbol = checker.getSymbolAtLocation(stmt.name); if (!symbol) diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 8d92b1a60..c574d15f1 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -53,7 +53,17 @@ export function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): t * generic-instantiation display is left untouched. */ function computeTypeDisplayString(checker: tsserver.TypeChecker, type: tsserver.Type): string { - const simple = checker.typeToString(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: 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\./, ''); diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index 65c52fcea..fcd1831d3 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -44,8 +44,15 @@ const classIndexCache = new WeakMap`) 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, @@ -53,11 +60,7 @@ function candidateFromDeclaration( stmt: tsserver.Statement, ): AmbientClassCandidate | undefined { const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); - // Generic classes (e.g. `Product`) are skipped: their declared type here - // is the unsubstituted generic (`Product`, not `Product`), which - // would render misleadingly in hover text with no real instantiation context - // to substitute from. - if (!isClassOrInterface || !stmt.name || (stmt.typeParameters?.length ?? 0) > 0) return undefined; + if (!isClassOrInterface || !stmt.name) return undefined; const symbol = checker.getSymbolAtLocation(stmt.name); if (!symbol) return undefined; const type = checker.getDeclaredTypeOfSymbol(symbol); diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index f540ee212..a1fbe39f7 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -1,7 +1,7 @@ [ { - "id": "neuhaus-addressBook-addresses", - "source": "neuhaus-core addressHelpers.getAddressBookAddressByForm", + "id": "addressbook-unique-addresses", + "source": "storefront addressHelpers.getAddressBookAddressByForm", "description": "Single unique member .addresses recovers AddressBook with no call sites", "dwTypes": ["AddressBook", "CustomerAddress"], "files": { @@ -11,8 +11,153 @@ "expect": "AddressBook" }, { - "id": "neuhaus-collections-map-option-item", - "source": "neuhaus-core productHelpers.getLineItemOptionNames (collections.map)", + "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": "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", @@ -22,12 +167,24 @@ "expect": "optionID" }, { - "id": "omoda-custom-in-check-contributes-custom", - "source": "omoda-core seoHelpers ('bannerImage' in seoSource.custom)", + "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 'fromStoreId' in shipment.custom;\n}\n" + "/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"] @@ -35,7 +192,7 @@ { "id": "silence-conflicting-call-sites", "source": "synthetic — conflicting Product vs Order call sites", - "description": "Must stay silent when call-site types do not converge", + "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" @@ -56,26 +213,63 @@ "expect": null }, { - "id": "mul-new-helper-constructor", - "source": "mul-core constructor-function model pattern", - "description": "new Helper(x) is a call site for parameter inference", + "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 ProductLineItem(product) {\n this.product = product;\n}\nnew ProductLineItem(getSomeProduct());\n" + "/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": "ProductLineItem", "param": 0}, - "expect": "Product" + "target": {"file": "/helpers.js", "function": "resolveProductKey", "param": 0}, + "expect": null }, { - "id": "shipment-custom-plus-line-items", - "source": "synthetic — distinctive co-member with .custom", - "description": ".custom + .productLineItems still resolves Shipment via distinctiveness", - "dwTypes": ["Shipment", "ProductLineItem", "Profile"], + "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 describeShipment(shipment) {\n var c = shipment.custom;\n return shipment.productLineItems;\n}\n" + "/helpers.js": "function readProfile(obj) {\n return obj.profile;\n}\n" }, - "target": {"file": "/helpers.js", "function": "describeShipment", "param": 0}, - "expect": "Shipment" + "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 } ] diff --git a/packages/b2c-script-types/test/corpus/corpus.test.js b/packages/b2c-script-types/test/corpus/corpus.test.js index 078eee8b8..553e98902 100644 --- a/packages/b2c-script-types/test/corpus/corpus.test.js +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -17,6 +17,7 @@ const { createInferenceContext, describeTypes, inferParameterType, + inferTypeForNode, } = require('../../plugin/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('../helpers/fixture-language-service'); const {realTypesPrelude} = require('../helpers/real-dw-types'); @@ -37,24 +38,41 @@ function findCallbackParam(sourceFile, paramIndex = 0) { 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'] = corpusCase.globals; + files['/types.d.ts'] = `declare global {\n${corpusCase.globals}\n}\n`; } return files; } -function resolveParam(ctx, corpusCase) { +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 findCallbackParam(sourceFile, corpusCase.target.param ?? 0); + 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 fn.parameters[corpusCase.target.param ?? 0]; + return {kind: 'param', node: fn.parameters[corpusCase.target.param ?? 0]}; } describe('usage-inference golden corpus (real-storefront shapes)', () => { @@ -63,15 +81,17 @@ describe('usage-inference golden corpus (real-storefront shapes)', () => { const languageService = createFixtureLanguageService(buildFiles(corpusCase)); const ctx = createInferenceContext(ts, languageService); assert.ok(ctx, 'expected an inference context'); - const param = resolveParam(ctx, corpusCase); + const target = resolveTarget(ctx, corpusCase); if (corpusCase.expectMembers) { - const members = [...collectParameterMemberUsage(ctx, param)].sort(); + 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 = inferParameterType(ctx, param); + const types = + target.kind === 'variable' ? inferTypeForNode(ctx, target.node.name) : inferParameterType(ctx, target.node); if (corpusCase.expect === null) { assert.deepEqual(types, [], `expected silence for ${corpusCase.id}`); return; diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index c2f3bc9a1..7658d48e4 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -23,6 +23,7 @@ const REAL_DW_TYPES = { 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'), @@ -35,6 +36,10 @@ const REAL_DW_TYPES = { 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'), }; /** 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 index 676237039..e25050ebf 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -444,9 +444,12 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = assert.deepEqual(types, []); }); - it('does not infer a false-positive type for a Product-shaped helper that is never called', () => { + 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'], ''), + '/types.d.ts': realTypesPrelude(['Product', 'Variant', 'Category'], ''), '/productHelpers.js': ` function getDisplayName(product) { return product.getName(); @@ -457,7 +460,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(types.length, 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)', () => { diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 0ed04bd7e..30611370a 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -377,10 +377,9 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage it('infers dw.catalog.Category from mutually-exclusive boolean-flag branches (getProductType shape)', () => { // Real-world shape from omoda-core's productHelpers.js's getProductType // (there, checking product.master/variant/variationGroup/productSet/ - // bundle/optionProduct — dw.catalog.Product itself is generic and - // deliberately excluded from ambient-class matching, see - // buildAmbientClassIndex, so this repro substitutes dw.catalog.Category's - // own four real boolean flags): a chain of if/else-if branches, each + // 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 From 54e2ce99fd210f5aaa710f355a978ef1c5f75d7b Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 21:15:21 +0200 Subject: [PATCH 39/43] Ignore placeholder SFRA JSDoc so usage inference can recover dw.* types Treat @param {Object}/{obj}/{*}/{} as non-explicit while still respecting deliberate any and real dw.* annotations, matching IntelliJ's JSDoc-first model without silencing the common undocumented storefront helpers. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 4 +- packages/b2c-script-types/plugin/index.js | 13 +++-- .../plugin/inference/ast-helpers.js | 58 ++++++++++++++++--- .../b2c-script-types/plugin/inference/core.js | 12 ++-- .../plugin/inference/type-helpers.js | 21 +++++++ .../plugin/usage-inference.js | 3 +- packages/b2c-script-types/src/index.ts | 17 ++++-- .../src/inference/ast-helpers.ts | 56 +++++++++++++++--- .../b2c-script-types/src/inference/core.ts | 21 +++++-- .../src/inference/type-helpers.ts | 18 ++++++ .../b2c-script-types/src/usage-inference.ts | 8 ++- .../b2c-script-types/test/corpus/cases.json | 11 ++++ .../test/usage-inference.test.js | 49 ++++++++++++++++ .../b2c-script-types/test/usage-match.test.js | 15 ++--- 15 files changed, 260 insertions(+), 48 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index a16b7c390..9a177c511 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,6 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. -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` (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; and generic Script API classes like `Product` are included in ambient matching (shown as `Product`), so JSDoc-less `product` helpers recover the same class IntelliJ gets from typings + naming conventions. +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` (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; generic Script API classes like `Product` are included in ambient matching (shown as `Product`); 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/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index eb7920602..d6bbe7567 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -115,9 +115,9 @@ If your editor's LSP client is launched outside the repo root (for example, open ### Inferring types for undocumented helpers (experimental) -JSDoc-documented functions get full hover/completion support because TypeScript reads the `@param`/`@returns` annotations directly. Plain, undocumented helper functions don't — an unannotated parameter or return value gets widened to `any`, and that `any` propagates to every caller, silencing completion for anything built on top of it. +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`. +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;`). diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index a4518bbb8..c3c39871e 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -442,10 +442,13 @@ function init({ typescript: ts }) { if (!node || !ts.isIdentifier(node)) return original; const checker = program.getTypeChecker(); - // superModule-derived expressions get past the not-any gate: the + // 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. - if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(node)) && !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, node)) { + // 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 — @@ -521,8 +524,8 @@ function init({ typescript: ts }) { if (!propAccess) return original; const checker = program.getTypeChecker(); - // See the hover gate above for the superModule exception. - if (!(0, usage_inference_1.isAnyType)(ts, checker.getTypeAtLocation(propAccess.expression)) && + // 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; } diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index 031a990b1..08bcb2647 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -49,22 +49,64 @@ function findEnclosingPropertyAccess(node, ts) { return undefined; } /** - * True when the developer already gave this parameter an explicit type — TS - * syntax or JSDoc — even if that type is literally `any`. In that case the - * checker's `any` reflects a deliberate choice, not an inference failure, so - * usage inference must never second-guess it. Only genuinely implicit `any` - * (no annotation at all) is fair game. + * 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 param.type !== undefined || ts.getJSDocType(param) !== undefined; + 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 fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; + 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 decl.type !== undefined || ts.getJSDocType(decl) !== undefined; + return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); } /** * Recursively walks a function body collecting `return` expressions, without diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 1356a652d..14b02a9ed 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -223,13 +223,17 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); } const direct = checker.getTypeAtLocation(expr); - if (!(0, type_helpers_1.isAnyType)(ts, direct)) + // 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 (`any`). Dispatch on the kind of expression to a - // focused resolver. Each returns [] when it can't do better than `any`, so - // an unhandled kind (or an exhausted branch) falls through to []. + // 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)) diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index a411acddb..070019838 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.isAnyType = isAnyType; +exports.isOpenForUsageInference = isOpenForUsageInference; exports.widenType = widenType; exports.typeDisplayString = typeDisplayString; exports.dedupeTypes = dedupeTypes; @@ -19,6 +20,26 @@ const constants_1 = require("./constants"); 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. + */ +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; + 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 diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index a87f3262d..04dd7ec7e 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -5,7 +5,7 @@ * 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.isAnyType = exports.getMemberOfType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; +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 @@ -30,6 +30,7 @@ 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; } }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index bbc7b9b98..32bf141ee 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -18,7 +18,7 @@ import { INFERRED_COMPLETION_SOURCE, inferTypeForExpression, inferTypeForNode, - isAnyType, + isOpenForUsageInference, typesToCompletionEntries, } from './usage-inference'; import {PLUGIN_NAME} from './resolver/constants'; @@ -522,10 +522,15 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const node = getNodeAtPosition(sourceFile, ts, position); if (!node || !ts.isIdentifier(node)) return original; const checker = program.getTypeChecker(); - // superModule-derived expressions get past the not-any gate: the + // 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. - if (!isAnyType(ts, checker.getTypeAtLocation(node)) && !traceSuperModuleAccess(ts, checker, node)) { + // 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 — @@ -597,9 +602,9 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const propAccess = findEnclosingPropertyAccess(node, ts); if (!propAccess) return original; const checker = program.getTypeChecker(); - // See the hover gate above for the superModule exception. + // See the hover gate above for the superModule / weak-type exception. if ( - !isAnyType(ts, checker.getTypeAtLocation(propAccess.expression)) && + !isOpenForUsageInference(ts, checker.getTypeAtLocation(propAccess.expression)) && !traceSuperModuleAccess(ts, checker, propAccess.expression) ) { return original; diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index dda48b346..70908f2c9 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -57,24 +57,64 @@ export function findEnclosingPropertyAccess( } /** - * True when the developer already gave this parameter an explicit type — TS - * syntax or JSDoc — even if that type is literally `any`. In that case the - * checker's `any` reflects a deliberate choice, not an inference failure, so - * usage inference must never second-guess it. Only genuinely implicit `any` - * (no annotation at all) is fair game. + * 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 param.type !== undefined || ts.getJSDocType(param) !== undefined; + 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 fn.type !== undefined || ts.getJSDocReturnType(fn) !== undefined; + 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 decl.type !== undefined || ts.getJSDocType(decl) !== undefined; + return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); } /** diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index f518ba954..907af384c 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -36,7 +36,14 @@ import { isConcreteExportAssignment, traceSuperModuleAccess, } from './super-module'; -import {collectionElementType, dedupeTypes, getMemberOfType, isAnyType, widenType} from './type-helpers'; +import { + collectionElementType, + dedupeTypes, + getMemberOfType, + isAnyType, + isOpenForUsageInference, + widenType, +} from './type-helpers'; import {collectParameterMemberUsage, collectVariableMemberUsage, matchAmbientTypesByUsage} from './usage-match'; /** @@ -273,11 +280,15 @@ function resolveExpressionTypes( return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); } const direct = checker.getTypeAtLocation(expr); - if (!isAnyType(ts, direct)) return [widenType(checker, direct)]; + // 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 (`any`). Dispatch on the kind of expression to a - // focused resolver. Each returns [] when it can't do better than `any`, so - // an unhandled kind (or an exhausted branch) falls through to []. + // 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); diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index c574d15f1..123cf5ef2 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -21,6 +21,24 @@ 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. + */ +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; + 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 diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index e2c238629..77f672807 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -21,7 +21,13 @@ export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; export {createInferenceContext} from './inference/context'; export {getNodeAtPosition, findEnclosingPropertyAccess} from './inference/ast-helpers'; -export {describeTypes, getMemberOfType, isAnyType, typesToCompletionEntries} from './inference/type-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 { diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index a1fbe39f7..086a8ee5b 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -21,6 +21,17 @@ "target": {"file": "/helpers.js", "function": "getPasswordResetToken", "param": 0}, "expect": "Customer" }, + { + "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", diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index be251fa11..27a47b20d 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -21,6 +21,7 @@ const { 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}; @@ -417,6 +418,54 @@ describe('usage-inference', () => { }); }); + 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 = { diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 30611370a..9364f3a76 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -446,13 +446,14 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // members) and the much smaller dw.customer.ProductListRegistrant (70 // lines). "Fewest total members" alone picked ProductListRegistrant // every time, purely because it has less surface area — never the - // large, contextually correct Profile. `resettingCustomer` itself is - // deliberately left uninferred (an explicit, if made-up, JSDoc - // `@param {obj}` type) — the fallback only ever reaches `profile`'s own - // usage signature, matching the exact real-world path. + // large, contextually correct Profile. `resettingCustomer` itself stays + // uninferred (placeholder `@param {obj}` is ignored, but `.profile` alone + // is ambiguous across Customer/ServiceConfig and the name doesn't match) + // so the fallback only ever reaches `profile`'s own usage signature — + // matching the exact real-world path. it('infers Profile (not the smaller, equally-matching ProductListRegistrant) for a variable literally named `profile`', () => { const files = { - '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant'], ''), + '/types.d.ts': realTypesPrelude(['Profile', 'ProductListRegistrant', 'Customer', 'ServiceConfig'], ''), '/accountHelpers.js': ` /** * @param {obj} resettingCustomer - object that contains user's email address and name information. @@ -470,8 +471,8 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sentAccountActivationEmail'); - // resettingCustomer's own explicit (if nonsensical) JSDoc type must be - // left alone, exactly as it is in the real file. + // Placeholder `{obj}` no longer blocks inference, but the body only + // contributes `.profile` under a non-matching name — still silent. const resettingCustomerTypes = inferParameterType(ctx, fn.parameters[0]); assert.deepEqual(resettingCustomerTypes, []); From bec37e5b03c97f06d9ec07b7658aa0fe5daf1ebc Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 21:48:21 +0200 Subject: [PATCH 40/43] Align usage inference closer to IntelliJ SFCC storefront patterns. Recover lineItem/pli/priceModel aliases, collections.first ternary returns, and instanceof class checks that JetBrains surfaces from typings and naming conventions. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 4 +- .../plugin/inference/constants.js | 35 +++++- .../b2c-script-types/plugin/inference/core.js | 33 ++++- .../plugin/inference/usage-match.js | 104 +++++++++++++++- .../src/inference/constants.ts | 34 ++++++ .../b2c-script-types/src/inference/core.ts | 40 ++++-- .../src/inference/usage-match.ts | 111 ++++++++++++++++- .../b2c-script-types/test/corpus/cases.json | 69 +++++++++++ .../test/helpers/real-dw-types.js | 4 + .../test/usage-inference.test.js | 56 +++++++++ .../b2c-script-types/test/usage-match.test.js | 115 ++++++++++++++++-- 12 files changed, 572 insertions(+), 35 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 9a177c511..45c580385 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,6 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. -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` (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; generic Script API classes like `Product` are included in ambient matching (shown as `Product`); 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. +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`, …) 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/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index d6bbe7567..80281a74f 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -123,7 +123,9 @@ Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or Two more SFRA idioms are covered: -- **Iteration callbacks** — `collections.forEach` / `map` / `filter` / `every` / `some` / `find` (element-first callback; `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(); }`) resolve through the same chain machinery. +- **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. +- **`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. diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 9c1abb34b..e26db1134 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -5,7 +5,7 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.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; +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. @@ -103,4 +103,37 @@ exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ // SFRA `collections.find(coll, function (item) {...})` — same element-first // shape; used heavily for address-book / line-item lookups (neuhaus-core). 'find', + // Stock SFRA `collections.first` takes only the collection, but several + // storefronts (and stickyio / 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). + */ +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'], + ['customeraddress', 'CustomerAddress'], + ['orderaddress', 'OrderAddress'], ]); diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 14b02a9ed..6ab3c886c 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -240,6 +240,19 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { 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 []; } /** @@ -460,12 +473,20 @@ function inferParameterType(ctx, param, depth = 0) { if (paramIndex < 0) return []; const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); - 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); + // `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 diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 9ed409796..613bfa59f 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -6,9 +6,26 @@ */ 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`) plus the curated SFRA aliases (`lineItem` / `pli` → + * `ProductLineItem`). 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; +} // 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. @@ -139,6 +156,15 @@ function collectMemberUsageInScope(ctx, symbol, scope) { 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) && @@ -175,6 +201,61 @@ function collectParameterMemberUsage(ctx, param) { 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 (Adyen, + * Avalara, stickyio calculate.js) 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 @@ -257,18 +338,33 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { 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`) that - // uniquely matches one of the ambient candidates short-circuits here — - // even when the usage signature is a single strong member. Real SFRA shape: + // 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 byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); + 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) { + const lower = identifierName.toLowerCase(); + const namedIntentionally = constants_1.CONVENTIONAL_IDENTIFIER_ALIASES.has(lower) || + candidates.some((c) => c.name.toLowerCase() === lower); + 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`. diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 050b201f0..60c524c93 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -114,4 +114,38 @@ export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ // SFRA `collections.find(coll, function (item) {...})` — same element-first // shape; used heavily for address-book / line-item lookups (neuhaus-core). 'find', + // Stock SFRA `collections.first` takes only the collection, but several + // storefronts (and stickyio / 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). + */ +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'], + ['customeraddress', 'CustomerAddress'], + ['orderaddress', 'OrderAddress'], ]); diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 907af384c..6c86f38ae 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -44,7 +44,12 @@ import { isOpenForUsageInference, widenType, } from './type-helpers'; -import {collectParameterMemberUsage, collectVariableMemberUsage, matchAmbientTypesByUsage} from './usage-match'; +import { + collectParameterInstanceOfTypes, + collectParameterMemberUsage, + collectVariableMemberUsage, + matchAmbientTypesByUsage, +} from './usage-match'; /** * Resolves the function-like declaration a call expression's callee refers @@ -292,6 +297,19 @@ function resolveExpressionTypes( 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 []; } @@ -543,12 +561,20 @@ export function inferParameterType( if (paramIndex < 0) return []; const nameNode = getReferenceNameNode(fn, ts); - 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); + // `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 diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index fcd1831d3..aeb37eed8 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -16,8 +16,29 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {MAX_USAGE_MATCH_CANDIDATES, MIN_USAGE_SIGNATURE_MEMBERS, WEAK_USAGE_MEMBERS} from './constants'; +import { + CONVENTIONAL_IDENTIFIER_ALIASES, + 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`) plus the curated SFRA aliases (`lineItem` / `pli` → + * `ProductLineItem`). 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; +} interface AmbientClassCandidate { readonly type: tsserver.Type; @@ -162,6 +183,16 @@ function collectMemberUsageInScope(ctx: InferenceContext, symbol: tsserver.Symbo ) ) { 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 && @@ -199,6 +230,61 @@ export function collectParameterMemberUsage(ctx: InferenceContext, param: tsserv 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 (Adyen, + * Avalara, stickyio calculate.js) 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 @@ -281,17 +367,32 @@ export function matchAmbientTypesByUsage( 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`) that - // uniquely matches one of the ambient candidates short-circuits here — - // even when the usage signature is a single strong member. Real SFRA shape: + // 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 byName = matches.filter((m) => m.name.toLowerCase() === identifierName.toLowerCase()); + 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) { + const lower = identifierName.toLowerCase(); + const namedIntentionally = + CONVENTIONAL_IDENTIFIER_ALIASES.has(lower) || + candidates.some((c) => c.name.toLowerCase() === lower); + if (namedIntentionally) return []; + } } // Below-minimum signatures that are still ambiguous (no unique name match) diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 086a8ee5b..0e5a674c1 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -282,5 +282,74 @@ }, "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 Adyen/Avalara 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" } ] diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index 7658d48e4..ec79fbc91 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -40,6 +40,10 @@ const REAL_DW_TYPES = { 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'), }; /** diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 27a47b20d..9d3c265c3 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -546,6 +546,42 @@ describe('usage-inference', () => { 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': ` @@ -996,6 +1032,26 @@ describe('usage-inference', () => { 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 diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 9364f3a76..7db59aad0 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -227,9 +227,11 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage it('stays quiet for a real-world single-member loop variable (hasPreorderableLineItem shape)', () => { // Same neuhaus-core shape, but only one member (`preorderable`) is ever - // accessed on the loop variable — below MIN_USAGE_SIGNATURE_MEMBERS, so - // the engine correctly declines to guess rather than latch onto whichever - // ambient class happens to expose that one name. + // 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': ` @@ -254,7 +256,9 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferTypeForNode(ctx, lineItemDecl.name); - assert.deepEqual(types, []); + // 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', () => { @@ -492,16 +496,16 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // 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. - // Keeps the same `@param {obj}` JSDoc block as the test above: without - // it, `resettingCustomer`'s own single-member usage (`.profile`) - // uniquely matches `dw.customer.Customer` on its own, resolving - // `.profile` through Customer's real declared property and never - // reaching the ambient-fallback path this test means to exercise. + // 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 {obj} resettingCustomer - object that contains user's email address and name information. + * @param {any} resettingCustomer - deliberately any so contactInfo must use ambient fallback. */ function sentAccountActivationEmail(resettingCustomer) { var contactInfo = resettingCustomer.profile; @@ -541,5 +545,96 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 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('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); + }); }); }); From d066c616c6b32754fc38895284c08e50dcc9f8b6 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 22:01:54 +0200 Subject: [PATCH 41/43] Add PascalCase naming aliases and VS Code regressions for inference. Recognize resettingCustomer/apiProduct/currentBasket-style identifiers, expand the storefront corpus, and cover naming/instanceof/collections.first through the VS Code hover suite. Co-authored-by: Cursor --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 2 +- .../plugin/inference/constants.js | 57 +++++++- .../plugin/inference/usage-match.js | 34 +++-- .../src/inference/constants.ts | 56 +++++++- .../src/inference/usage-match.ts | 35 +++-- .../b2c-script-types/test/corpus/cases.json | 57 +++++++- .../test/usage-inference.hardening.test.js | 4 +- .../test/usage-inference.perf.test.js | 2 +- .../test/usage-inference.real-types.test.js | 2 +- .../test/usage-inference.test.js | 2 +- .../b2c-script-types/test/usage-match.test.js | 129 ++++++++++++++---- .../scripts/helpers/namingHelpers.js | 46 +++++++ .../cartridge/scripts/util/collections.js | 10 +- .../script-types-infer-usage.test.ts | 72 ++++++++++ 15 files changed, 444 insertions(+), 66 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 45c580385..119c5cde4 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -20,6 +20,6 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. -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`, …) 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. +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/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 80281a74f..ba2586630 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -124,7 +124,7 @@ Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or 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. +- **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. diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index e26db1134..7cc4a97a9 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -5,7 +5,7 @@ * 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_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; +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. @@ -101,10 +101,10 @@ exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ 'every', 'some', // SFRA `collections.find(coll, function (item) {...})` — same element-first - // shape; used heavily for address-book / line-item lookups (neuhaus-core). + // 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 stickyio / calculate.js ports) call it with a predicate + // 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', @@ -118,7 +118,9 @@ exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ * * Keep this list conservative: only aliases that are unambiguous in real * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs - * OrderAddress vs Store address models). + * 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'], @@ -136,4 +138,51 @@ exports.CONVENTIONAL_IDENTIFIER_ALIASES = new Map([ ['couponlineitem', 'CouponLineItem'], ['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). + */ +exports.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES = [ + ['GiftCertificateLineItem', 'GiftCertificateLineItem'], + ['CouponLineItem', 'CouponLineItem'], + ['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/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 613bfa59f..450c7d8ec 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -14,16 +14,25 @@ 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`) plus the curated SFRA aliases (`lineItem` / `pli` → - * `ProductLineItem`). Returns lowercased names for comparison against - * candidate.class names. + * `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) + 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 @@ -224,11 +233,11 @@ function instanceTypeFromInstanceOfRhs(ctx, rhs) { } /** * Collects concrete types asserted via `param instanceof SomeType` in the - * parameter's enclosing function body. Real payment/cart helpers (Adyen, - * Avalara, stickyio calculate.js) 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. + * 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; @@ -359,9 +368,10 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { // 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) { - const lower = identifierName.toLowerCase(); - const namedIntentionally = constants_1.CONVENTIONAL_IDENTIFIER_ALIASES.has(lower) || - candidates.some((c) => c.name.toLowerCase() === lower); + // `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 []; } diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 60c524c93..2a4225ec6 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -112,10 +112,10 @@ export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ 'every', 'some', // SFRA `collections.find(coll, function (item) {...})` — same element-first - // shape; used heavily for address-book / line-item lookups (neuhaus-core). + // 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 stickyio / calculate.js ports) call it with a predicate + // 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', @@ -130,7 +130,9 @@ export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ * * Keep this list conservative: only aliases that are unambiguous in real * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs - * OrderAddress vs Store address models). + * 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'], @@ -148,4 +150,52 @@ export const CONVENTIONAL_IDENTIFIER_ALIASES: ReadonlyMap = new ['couponlineitem', 'CouponLineItem'], ['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). + */ +export const CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES: ReadonlyArray = [ + ['GiftCertificateLineItem', 'GiftCertificateLineItem'], + ['CouponLineItem', 'CouponLineItem'], + ['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/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts index aeb37eed8..5b275944d 100644 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ b/packages/b2c-script-types/src/inference/usage-match.ts @@ -18,6 +18,7 @@ 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, @@ -28,15 +29,25 @@ 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`) plus the curated SFRA aliases (`lineItem` / `pli` → - * `ProductLineItem`). Returns lowercased names for comparison against - * candidate.class names. + * `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()); + 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; } @@ -251,11 +262,11 @@ function instanceTypeFromInstanceOfRhs(ctx: InferenceContext, rhs: tsserver.Expr /** * Collects concrete types asserted via `param instanceof SomeType` in the - * parameter's enclosing function body. Real payment/cart helpers (Adyen, - * Avalara, stickyio calculate.js) 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. + * 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, @@ -387,10 +398,10 @@ export function matchAmbientTypesByUsage( // 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) { - const lower = identifierName.toLowerCase(); + // `conventional.size > 1` means an alias or PascalCase suffix fired + // (`resettingCustomer` → Customer, `lineItem` → ProductLineItem). const namedIntentionally = - CONVENTIONAL_IDENTIFIER_ALIASES.has(lower) || - candidates.some((c) => c.name.toLowerCase() === lower); + conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); if (namedIntentionally) return []; } } diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 0e5a674c1..913a57910 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -21,6 +21,61 @@ "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", @@ -318,7 +373,7 @@ }, { "id": "instanceof-product-line-item", - "source": "storefront Adyen/Avalara lineItemHelper", + "source": "storefront payment/tax lineItemHelper", "description": "Single instanceof ProductLineItem recovers that class with no call sites", "dwTypes": ["ProductLineItem", "ShippingLineItem", "PriceAdjustment"], "files": { diff --git a/packages/b2c-script-types/test/usage-inference.hardening.test.js b/packages/b2c-script-types/test/usage-inference.hardening.test.js index 9a9460d07..eb4766047 100644 --- a/packages/b2c-script-types/test/usage-inference.hardening.test.js +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -128,7 +128,7 @@ describe('usage-inference hardening', () => { assert.deepEqual(types, []); }); - it('stays silent when a duck-typed Store model call site resolves but the body also uses address-only fields (mul-core copyCustomerAddressToShipment)', () => { + 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 @@ -204,7 +204,7 @@ describe('usage-inference hardening', () => { describe('multi-cartridge require call sites (cartridge-fixture factory)', () => { it('infers a helper parameter from a call site reached through require("~/...")', () => { - // Mirrors mul-core/neuhaus-core: helpers consumed via cartridge-relative + // 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. diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index f0dafa105..d5fe5c921 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -579,7 +579,7 @@ describe('usage-inference — performance baselines', () => { }); it('caches the ambient-class index across repeated hovers on a real dw.* no-call-site parameter (addressBook.addresses)', () => { - // Real-world shape from neuhaus-core's addressHelpers.js: an uncalled + // 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 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 index e25050ebf..d58d8fe09 100644 --- a/packages/b2c-script-types/test/usage-inference.real-types.test.js +++ b/packages/b2c-script-types/test/usage-inference.real-types.test.js @@ -81,7 +81,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); it('infers dw.catalog.Product for a constructor-function model parameter, invoked via `new` (StoreModel/ProductLineItem shape)', () => { - // Real-world shape from omoda-core and mul-core: SFRA "class" models + // 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 diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 9d3c265c3..63373ba50 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -53,7 +53,7 @@ describe('usage-inference', () => { }); it('infers a parameter type from a `new Helper(x)` constructor call site (SFRA constructor-function model pattern)', () => { - // Real-world shape from omoda-core/mul-core: `function StoreModel(storeObject, location) {...}` + // 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 diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 7db59aad0..284623a82 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -109,7 +109,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('lets an identifier-name match rescue a single strong member shared by multiple classes (customer + .profile)', () => { - // Real-world shape from neuhaus-core's accountHelpers.js: + // 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 — @@ -144,7 +144,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('infers a single accessed member when it uniquely identifies one ambient class (addressBook.addresses)', () => { - // Real-world shape from neuhaus-core's addressHelpers.js: + // 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 @@ -190,7 +190,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it("infers a manual-indexing loop variable's type from its own usage (var item = items[i])", () => { - // Real-world shape from neuhaus-core's checkoutHelpers.js: an + // 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 @@ -226,7 +226,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('stays quiet for a real-world single-member loop variable (hasPreorderableLineItem shape)', () => { - // Same neuhaus-core shape, but only one member (`preorderable`) is ever + // 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), @@ -283,10 +283,10 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); describe("the `'member' in x` existence-check idiom as usage evidence", () => { - // Real-world shape from omoda-core: 261 occurrences across 107 files + // 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. omoda-core's productBase.js checking + // 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. @@ -310,7 +310,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it("infers a real-world class purely from `in` checks (getProductSetOrder shape: ('x' in productCustom) ? ... : null)", () => { - // Mirrors omoda-core's productHelpers.js: no direct property-access + // 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 @@ -333,7 +333,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('combines an `in` check with a direct property-access read on the same member without double-counting (category.parent tree-walk shape)', () => { - // Mirrors omoda-core's dynamicAddressHelpers.js/productSearch.js: + // 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'], ''), @@ -379,7 +379,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('infers dw.catalog.Category from mutually-exclusive boolean-flag branches (getProductType shape)', () => { - // Real-world shape from omoda-core's productHelpers.js's getProductType + // 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 @@ -417,7 +417,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); it('infers a parameter from a member-built object literal passed to a call argument, not returned (pushReview shape)', () => { - // Real-world shape from omoda-core's Reviews.js job step: the + // 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 @@ -443,18 +443,15 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }); describe('identifier-name tiebreak (prefers the class matching the variable/parameter name)', () => { - // Real-world bug from mul-core's plugin_marketing_cloud/accountHelpers.js - // (sentAccountActivationEmail): `var profile = resettingCustomer.profile;` - // is only ever read via email/firstName/lastName/custom — a field subset - // shared by both the real dw.customer.Profile (420 lines, dozens of - // members) and the much smaller dw.customer.ProductListRegistrant (70 - // lines). "Fewest total members" alone picked ProductListRegistrant - // every time, purely because it has less surface area — never the - // large, contextually correct Profile. `resettingCustomer` itself stays - // uninferred (placeholder `@param {obj}` is ignored, but `.profile` alone - // is ambiguous across Customer/ServiceConfig and the name doesn't match) - // so the fallback only ever reaches `profile`'s own usage signature — - // matching the exact real-world path. + // 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'], ''), @@ -475,10 +472,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sentAccountActivationEmail'); - // Placeholder `{obj}` no longer blocks inference, but the body only - // contributes `.profile` under a non-matching name — still silent. - const resettingCustomerTypes = inferParameterType(ctx, fn.parameters[0]); - assert.deepEqual(resettingCustomerTypes, []); + assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'Customer'); let profileDecl; const visit = (n) => { @@ -577,6 +571,89 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 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'], ''), 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/util/collections.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js index fbc14eb38..67f6599b7 100644 --- 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 @@ -11,6 +11,14 @@ function forEach(collection, callback) { } } +// 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 + forEach: forEach, + first: first }; 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 index 99f6b7198..df07f0241 100644 --- 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 @@ -647,3 +647,75 @@ suite('scriptTypesInferUsage — matching ambient classes from usage with no cal ); }); }); + +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}`); + }); +}); From d89a17da6b59b5337bfe6f2275eecffa210905b8 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 23:07:37 +0200 Subject: [PATCH 42/43] Fix Prettier wrapping in infer-usage VS Code tests. Co-authored-by: Cursor --- .../integration/script-types-infer-usage.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index df07f0241..e9a9f1afb 100644 --- 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 @@ -675,7 +675,11 @@ suite('scriptTypesInferUsage — naming aliases, instanceof, and collections.fir 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), + offsetPosition( + namingDoc, + 'sendPasswordResetEmail(email, resettingCustomer', + 'sendPasswordResetEmail(email, '.length, + ), /Customer/, true, ); @@ -715,7 +719,12 @@ suite('scriptTypesInferUsage — naming aliases, instanceof, and collections.fir 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); + const text = await hoverTextMatching( + namingDoc, + offsetPosition(namingDoc, 'variant ? variant.getID'), + /Variant/, + true, + ); assert.ok(/Variant/.test(text), `expected Variant from collections.first, got: ${text}`); }); }); From cab34f9414ae7f11a7378d81ec7c219927c6368d Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Tue, 21 Jul 2026 23:46:27 +0200 Subject: [PATCH 43/43] Fix @param {Object} gate and *LineItem naming false friends in usage inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-facing fixes to the (still-Preview) Script API usage-inference engine, plus regression coverage that closes the gap the failing VS Code integration tests exposed. 1. Hover/completion entry gate rejected `@param {Object}`. checkJs resolves the ubiquitous SFRA `@param {Object}` placeholder to the global `Object` interface — not `any`, not the lowercase `object` non-primitive — so isOpenForUsageInference() treated it as a real type and skipped inference in the live editor, breaking the feature's headline "undocumented SFRA helper" case. The unit/corpus suites call inferParameterType() directly and never exercised the gate, so this only surfaced in the VS Code host (the 3 CI failures: resettingCustomer/lineItem naming tests, all @param {Object}). Weak `{Object}` now opens the gate like the other placeholders. Added a proxy-wiring unit test that goes through getQuickInfoAtPosition so the fast layer catches this next time. 2. Specific dw.order line-item subclasses mis-resolved to ProductLineItem. A parameter named bonusDiscountLineItem / productShippingLineItem was forced to ProductLineItem by the generic bare-`LineItem` naming suffix. Added the two uncovered subclasses to the alias/suffix maps (ordered before the bare fallback) so they resolve to their own class when body usage fits, and stay silent otherwise, rather than guessing the wrong sibling. Also hardens the corpus silence assertion to check types.length (never deepEqual against TS Type objects, which hangs on circular structure), adds three corpus cases and a VS Code integration suite covering the subclass disambiguation and real-`dw.*`-JSDoc deference. Co-Authored-By: Claude Opus 4.8 --- .changeset/script-types-infer-usage.md | 2 + .../plugin/inference/constants.js | 20 ++++++ .../plugin/inference/type-helpers.js | 14 ++++ .../plugin/inference/usage-match.js | 3 +- .../src/inference/constants.ts | 20 ++++++ .../src/inference/type-helpers.ts | 13 ++++ .../b2c-script-types/test/corpus/cases.json | 33 +++++++++ .../test/corpus/corpus.test.js | 9 ++- .../test/helpers/real-dw-types.js | 2 + packages/b2c-script-types/test/index.test.js | 55 +++++++++++++++ .../scripts/helpers/lineItemHelpers.js | 32 +++++++++ .../script-types-infer-usage.test.ts | 67 +++++++++++++++++++ 12 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 119c5cde4..0af1fa4f5 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -19,6 +19,8 @@ Also fixes several bugs uncovered while dogfooding this against real projects: - 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. diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 7cc4a97a9..68178f140 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -136,6 +136,13 @@ exports.CONVENTIONAL_IDENTIFIER_ALIASES = new Map([ ['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 @@ -156,10 +163,23 @@ exports.CONVENTIONAL_IDENTIFIER_ALIASES = new Map([ * 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'], diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index 070019838..73c42592e 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -29,6 +29,18 @@ function isAnyType(ts, type) { * 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)) @@ -38,6 +50,8 @@ function isOpenForUsageInference(ts, type) { 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; } /** diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js index 450c7d8ec..d80ebd9b8 100644 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ b/packages/b2c-script-types/plugin/inference/usage-match.js @@ -370,8 +370,7 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { 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()); + const namedIntentionally = conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); if (namedIntentionally) return []; } diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 2a4225ec6..a70e60c4f 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -148,6 +148,13 @@ export const CONVENTIONAL_IDENTIFIER_ALIASES: ReadonlyMap = new ['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 @@ -169,10 +176,23 @@ export const CONVENTIONAL_IDENTIFIER_ALIASES: ReadonlyMap = new * 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'], diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 123cf5ef2..c7534940f 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -30,12 +30,25 @@ export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { * 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; } diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 913a57910..f42053f33 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -406,5 +406,38 @@ }, "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 index 553e98902..1cacdcbe8 100644 --- a/packages/b2c-script-types/test/corpus/corpus.test.js +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -93,7 +93,14 @@ describe('usage-inference golden corpus (real-storefront shapes)', () => { const types = target.kind === 'variable' ? inferTypeForNode(ctx, target.node.name) : inferParameterType(ctx, target.node); if (corpusCase.expect === null) { - assert.deepEqual(types, [], `expected silence for ${corpusCase.id}`); + // 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; } diff --git a/packages/b2c-script-types/test/helpers/real-dw-types.js b/packages/b2c-script-types/test/helpers/real-dw-types.js index ec79fbc91..e47d9291a 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -31,6 +31,8 @@ const REAL_DW_TYPES = { 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'), diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 827138999..e359aca39 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -458,6 +458,61 @@ describe('create() proxy — usage inference wiring', () => { ); }); + 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 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/integration/script-types-infer-usage.test.ts b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts index e9a9f1afb..52354a56b 100644 --- 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 @@ -728,3 +728,70 @@ suite('scriptTypesInferUsage — naming aliases, instanceof, and collections.fir 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}`, + ); + }); +});