From 73006c46bf7579dd1171c5baff697bbc62a854f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 10:35:46 +0000 Subject: [PATCH 01/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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}`, + ); + }); +}); From 3868dfde705925076dc2f9e17e36fc627c56d289 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:07:44 +0000 Subject: [PATCH 44/76] test(script-types): add ground-truth evaluation harness for usage inference Strips documented dw.* JSDoc types one file at a time in memory, runs inference through the real plugin hover path, and scores the answer against the documented type (exact / union / related / wrong / silent), plus fire rate on undocumented parameters and request latency. Skipped unless B2C_INFERENCE_CORPUS points at real cartridges. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../test/eval/ground-truth.test.js | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 packages/b2c-script-types/test/eval/ground-truth.test.js diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js new file mode 100644 index 000000000..911d55be1 --- /dev/null +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +// Ground-truth evaluation of usage inference against real cartridges. +// +// Skipped unless B2C_INFERENCE_CORPUS names one or more directories +// (path.delimiter-separated) that contain cartridges (`.project` markers): +// +// B2C_INFERENCE_CORPUS=/path/to/sfra/cartridges:/path/to/plugin_x/cartridges \ +// pnpm --filter @salesforce/b2c-script-types exec mocha test/eval/ground-truth.test.js +// +// Real cartridges document many helpers with a `dw.*` JSDoc type. Those are +// the answer key: for one file at a time, every `{dw.*}` @param/@returns type +// is blanked out in memory (same length, so offsets stay valid), and the +// plugin is asked — through the real hover path for parameters, the engine +// entry point for returns — what it would infer for the now-undocumented +// value. Each answer is scored against the documented type: +// +// exact same class(es) as documented +// union a union that contains the documented class +// related an ancestor or descendant of the documented class +// (`LineItemCtnr` documented, `Basket` inferred, or vice versa) +// wrong an unrelated class +// silent no inference +// +// Precision = (exact + union + related) / fired, recall = the same / total. +// Parameters that carry no type at all in the original source are hovered +// too, to measure how often inference fires on genuinely undocumented code. +// +// Optional: B2C_INFERENCE_CARTRIDGE_PATH (colon-separated cartridge names) +// sets the cartridge order, B2C_INFERENCE_REPORT writes the full JSON report +// (mismatches, timings, undocumented samples) to that file. + +const fs = require('node:fs'); +const path = require('node:path'); + +const ts = require('typescript'); + +const init = require('../../src/index'); +const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); +const {createInferenceContext, describeTypes, inferReturnType} = require('../../src/usage-inference'); + +const CORPUS = process.env.B2C_INFERENCE_CORPUS; +const INFERRED_NOTE = 'Inferred from usage: '; +const DW_TYPE_EXPRESSION = /^\{[^{}]*\bdw\.[^{}]*\}$/; +const SERVER_SIDE_EXCLUDES = ['**/client/**', '**/static/**', '**/node_modules/**']; + +function discoverCorpusCartridges(roots) { + const discovered = roots.flatMap((root) => discoverCartridgesOnDisk(ts, root, ts.sys.fileExists)); + const configured = process.env.B2C_INFERENCE_CARTRIDGE_PATH?.split(':').filter(Boolean); + return orderCartridges(discovered, configured); +} + +function serverScriptFiles(cartridges) { + return cartridges.flatMap((c) => + ts.sys.readDirectory(c.src, ['.js'], SERVER_SIDE_EXCLUDES).map((f) => f.replace(/\\/g, '/')), + ); +} + +/** A disk-backed LanguageServiceHost whose file contents can be overridden in memory. */ +function createCorpusHost(fileNames, currentDirectory) { + const overrides = new Map(); + const versions = new Map(); + const compilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + allowJs: true, + checkJs: false, + noEmit: true, + }; + const readFile = (fileName) => overrides.get(fileName) ?? ts.sys.readFile(fileName); + const host = { + getScriptFileNames: () => [...fileNames, ts.getDefaultLibFilePath(compilerOptions)], + getScriptVersion: (fileName) => String(versions.get(fileName) ?? 0), + getScriptSnapshot: (fileName) => { + const text = readFile(fileName); + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); + }, + getCurrentDirectory: () => currentDirectory, + getCompilationSettings: () => compilerOptions, + getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts), + fileExists: (fileName) => overrides.has(fileName) || ts.sys.fileExists(fileName), + readFile, + directoryExists: (dir) => ts.sys.directoryExists(dir), + getDirectories: (dir) => ts.sys.getDirectories(dir), + resolveModuleNameLiterals: (literals, containingFile, _redirected, options) => + literals.map((literal) => ts.resolveModuleName(literal.text, containingFile, options, host)), + }; + const setOverride = (fileName, text) => { + if (text === undefined) overrides.delete(fileName); + else overrides.set(fileName, text); + versions.set(fileName, (versions.get(fileName) ?? 0) + 1); + }; + return {host, setOverride}; +} + +function createCorpusPlugin(cartridges, fileNames) { + const {host, setOverride} = createCorpusHost(fileNames, cartridges[0]?.src ?? '/'); + const languageService = ts.createLanguageService(host); + const proxy = init({typescript: ts}).create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: () => {}}}, + getCurrentDirectory: () => cartridges[0]?.src ?? '/', + getProjectVersion: () => '1', + }, + config: {enabled: true, autoDiscover: false, inferUsage: true, cartridges}, + }); + return {languageService, proxy, setOverride}; +} + +// --------------------------------------------------------------------------- +// Answer key: documented `dw.*` parameters and returns, located in the +// original program. +// --------------------------------------------------------------------------- + +/** The `{...}` span of a JSDoc tag's type when it names a `dw.*` type, else undefined. */ +function dwTypeSpan(tag) { + const expression = tag?.typeExpression; + if (!expression || !DW_TYPE_EXPRESSION.test(expression.getText())) return undefined; + return {start: expression.getStart(), end: expression.getEnd()}; +} + +/** Simple class names of a documented type's non-nullable constituents (`dw.order.Basket|null` -> ['Basket']). */ +function documentedNames(checker, type) { + const parts = type.isUnion() ? type.types : [type]; + return parts + .filter((t) => !(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))) + .map((t) => (t.aliasSymbol ?? t.getSymbol())?.getName()) + .filter(Boolean); +} + +function functionLabel(fn, sourceFile) { + const name = fn.name ?? (fn.parent && 'name' in fn.parent ? fn.parent.name : undefined); + const line = sourceFile.getLineAndCharacterOfPosition(fn.getStart(sourceFile)).line + 1; + return `${path.basename(sourceFile.fileName)}:${line} ${name ? name.getText(sourceFile) : ''}`; +} + +function collectFunctionTargets(checker, fn, sourceFile, out) { + for (const param of fn.parameters) { + if (!ts.isIdentifier(param.name)) continue; + const tag = ts.getJSDocParameterTags(param)[0]; + const span = dwTypeSpan(tag); + const label = `${functionLabel(fn, sourceFile)}(${param.name.text})`; + if (span) { + const names = documentedNames(checker, checker.getTypeAtLocation(param.name)); + out.params.push({label, position: param.name.getStart(sourceFile), names}); + out.blanks.push(span); + } else if (!tag?.typeExpression && !param.type) { + out.undocumented.push({label, position: param.name.getStart(sourceFile)}); + } + } + const returnTag = ts.getJSDocTags(fn).find((t) => ts.isJSDocReturnTag(t)); + const returnSpan = dwTypeSpan(returnTag); + if (returnSpan) { + const signature = checker.getSignatureFromDeclaration(fn); + const names = signature ? documentedNames(checker, checker.getReturnTypeOfSignature(signature)) : []; + out.returns.push({label: `${functionLabel(fn, sourceFile)}()`, position: fn.getStart(sourceFile), names}); + out.blanks.push(returnSpan); + } +} + +function collectFileTargets(program, fileName) { + const sourceFile = program.getSourceFile(fileName); + const checker = program.getTypeChecker(); + const out = {fileName, params: [], returns: [], undocumented: [], blanks: []}; + if (!sourceFile) return out; + const visit = (node) => { + if (ts.isFunctionLike(node) && 'parameters' in node) collectFunctionTargets(checker, node, sourceFile, out); + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return out; +} + +/** The file's text with every documented `{dw.*}` type expression blanked to spaces (offsets unchanged). */ +function blankSpans(text, spans) { + let out = text; + for (const {start, end} of spans) out = out.slice(0, start) + ' '.repeat(end - start) + out.slice(end); + return out; +} + +// --------------------------------------------------------------------------- +// Scoring +// --------------------------------------------------------------------------- + +/** Maps every top-level class/interface name in the dw.* declarations to the names of all its ancestors. */ +function buildAncestry(program) { + const checker = program.getTypeChecker(); + const ancestry = new Map(); + const ancestorsOf = (type, seen = new Set()) => { + for (const base of type.isClassOrInterface() ? checker.getBaseTypes(type) : []) { + const name = base.getSymbol()?.getName(); + if (!name || seen.has(name)) continue; + seen.add(name); + // A generic base (`List`) is a type reference; its declaration is the target. + ancestorsOf(base.target ?? base, seen); + } + return seen; + }; + for (const sf of program.getSourceFiles()) { + if (!sf.isDeclarationFile || !sf.fileName.includes('/types/dw/')) continue; + for (const stmt of sf.statements) { + if (!(ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) || !stmt.name) continue; + const symbol = checker.getSymbolAtLocation(stmt.name); + if (symbol) ancestry.set(stmt.name.text, ancestorsOf(checker.getDeclaredTypeOfSymbol(symbol))); + } + } + return ancestry; +} + +/** `Product | ProductLineItem` -> ['Product', 'ProductLineItem']. */ +function parseInferredNames(description) { + return description.split(' | ').map((part) => part.replace(/<.*>$/, '').trim()); +} + +function isRelated(ancestry, a, b) { + return a === b || ancestry.get(a)?.has(b) === true || ancestry.get(b)?.has(a) === true; +} + +function classify(ancestry, documented, inferred) { + if (!inferred) return 'silent'; + const names = parseInferredNames(inferred); + if (names.every((n) => documented.includes(n))) return 'exact'; + if (names.length > 1 && names.some((n) => documented.includes(n))) return 'union'; + if (names.every((n) => documented.some((d) => isRelated(ancestry, n, d)))) return 'related'; + return 'wrong'; +} + +function inferredFromHover(proxy, fileName, position) { + const info = proxy.getQuickInfoAtPosition(fileName, position); + const text = (info?.documentation ?? []).map((p) => p.text).join(''); + const index = text.lastIndexOf(INFERRED_NOTE); + return index === -1 ? undefined : text.slice(index + INFERRED_NOTE.length).trim(); +} + +function inferredReturn(languageService, fileName, position) { + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx?.program.getSourceFile(fileName); + if (!ctx || !sourceFile) return undefined; + let fn; + const visit = (node) => { + if (fn) return; + if (ts.isFunctionLike(node) && node.getStart(sourceFile) === position) fn = node; + else ts.forEachChild(node, visit); + }; + visit(sourceFile); + const types = fn ? inferReturnType(ctx, fn) : []; + return types.length > 0 ? describeTypes(ctx.checker, types) : undefined; +} + +function timed(timings, compute) { + const start = process.hrtime.bigint(); + const result = compute(); + timings.push(Number(process.hrtime.bigint() - start) / 1e6); + return result; +} + +function scoreTarget(report, ancestry, kind, target, inferred) { + const verdict = classify(ancestry, target.names, inferred); + report[kind][verdict]++; + if (verdict !== 'exact' && verdict !== 'union') { + report.mismatches.push({kind, verdict, target: target.label, documented: target.names.join(' | '), inferred}); + } +} + +function emptyScore() { + return {exact: 0, union: 0, related: 0, wrong: 0, silent: 0}; +} + +function evaluateFile(plugin, ancestry, targets, report) { + const {proxy, languageService, setOverride} = plugin; + setOverride(targets.fileName, blankSpans(ts.sys.readFile(targets.fileName), targets.blanks)); + try { + for (const target of targets.params) { + const inferred = timed(report.timings, () => inferredFromHover(proxy, targets.fileName, target.position)); + scoreTarget(report, ancestry, 'params', target, inferred); + } + for (const target of targets.returns) { + const inferred = timed(report.timings, () => inferredReturn(languageService, targets.fileName, target.position)); + scoreTarget(report, ancestry, 'returns', target, inferred); + } + } finally { + setOverride(targets.fileName, undefined); + } +} + +function evaluateUndocumented(proxy, allTargets, report) { + for (const targets of allTargets) { + for (const target of targets.undocumented) { + const inferred = timed(report.timings, () => inferredFromHover(proxy, targets.fileName, target.position)); + report.undocumented.total++; + if (!inferred) continue; + report.undocumented.fired++; + report.undocumented.samples.push({target: target.label, inferred}); + } + } +} + +function percentile(sorted, p) { + return sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; +} + +function summarize(report) { + const line = (kind) => { + const s = report[kind]; + const total = s.exact + s.union + s.related + s.wrong + s.silent; + const good = s.exact + s.union + s.related; + const fired = total - s.silent; + const pct = (n, d) => (d === 0 ? 'n/a' : `${((100 * n) / d).toFixed(1)}%`); + return ( + `${kind.padEnd(8)} total=${total} exact=${s.exact} union=${s.union} related=${s.related} ` + + `wrong=${s.wrong} silent=${s.silent} | precision=${pct(good, fired)} ` + + `strict=${pct(s.exact, fired)} recall=${pct(good, total)}` + ); + }; + const sorted = [...report.timings].sort((a, b) => a - b); + const u = report.undocumented; + return [ + line('params'), + line('returns'), + `undocumented params fired on ${u.fired}/${u.total}`, + `latency ms p50=${percentile(sorted, 50).toFixed(1)} p95=${percentile(sorted, 95).toFixed(1)} ` + + `max=${(sorted.at(-1) ?? 0).toFixed(1)} over ${sorted.length} requests`, + ].join('\n'); +} + +(CORPUS ? describe : describe.skip)('usage inference ground truth (B2C_INFERENCE_CORPUS)', function () { + this.timeout(30 * 60 * 1000); + + it('scores inference against documented dw.* types', () => { + const cartridges = discoverCorpusCartridges(CORPUS.split(path.delimiter).filter(Boolean)); + const fileNames = serverScriptFiles(cartridges); + const plugin = createCorpusPlugin(cartridges, fileNames); + const program = plugin.languageService.getProgram(); + const ancestry = buildAncestry(program); + const allTargets = fileNames.map((f) => collectFileTargets(program, f)); + const report = { + params: emptyScore(), + returns: emptyScore(), + undocumented: {total: 0, fired: 0, samples: []}, + mismatches: [], + timings: [], + }; + + evaluateUndocumented(plugin.proxy, allTargets, report); + for (const targets of allTargets) { + if (targets.params.length > 0 || targets.returns.length > 0) evaluateFile(plugin, ancestry, targets, report); + } + + const summary = summarize(report); + process.stdout.write(`\n${cartridges.length} cartridges, ${fileNames.length} files\n${summary}\n`); + if (process.env.B2C_INFERENCE_REPORT) { + fs.writeFileSync(process.env.B2C_INFERENCE_REPORT, JSON.stringify({summary, ...report}, null, 2)); + } + }); +}); From 4cbb8870e29da77f6044cb8277f060d1d1f9e83f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:13:06 +0000 Subject: [PATCH 45/76] test(script-types): discover corpus cartridges that lack a .project file Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../b2c-script-types/test/eval/ground-truth.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 911d55be1..4a4ee46f3 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -49,8 +49,21 @@ const INFERRED_NOTE = 'Inferred from usage: '; const DW_TYPE_EXPRESSION = /^\{[^{}]*\bdw\.[^{}]*\}$/; const SERVER_SIDE_EXCLUDES = ['**/client/**', '**/static/**', '**/node_modules/**']; +// `.project` markers are what the plugin itself discovers by, but some +// published cartridges ship without one — any direct child with a +// `cartridge/` folder counts too. +function cartridgesWithoutProjectFile(root, known) { + return fs + .readdirSync(root, {withFileTypes: true}) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, 'cartridge'))) + .map((entry) => ({name: entry.name, src: path.join(root, entry.name)})) + .filter((c) => !known.has(c.name)); +} + function discoverCorpusCartridges(roots) { const discovered = roots.flatMap((root) => discoverCartridgesOnDisk(ts, root, ts.sys.fileExists)); + const known = new Set(discovered.map((c) => c.name)); + discovered.push(...roots.flatMap((root) => cartridgesWithoutProjectFile(root, known))); const configured = process.env.B2C_INFERENCE_CARTRIDGE_PATH?.split(':').filter(Boolean); return orderCartridges(discovered, configured); } From 6eb25dc22074b981c0bae40ae6575361308bc162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:08:40 +0000 Subject: [PATCH 46/76] test(script-types): report reference searches and slowest requests in ground truth Wall time varies by several milliseconds between runs on the same machine, which hides real cost regressions. The harness now also counts reference searches per request (deterministic on every machine and run) and lists the slowest requests by target, so a latency change can be traced to the helper that causes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../src/inference/usage-match.ts | 426 ------------------ .../test/eval/ground-truth.test.js | 46 +- 2 files changed, 38 insertions(+), 434 deletions(-) delete mode 100644 packages/b2c-script-types/src/inference/usage-match.ts diff --git a/packages/b2c-script-types/src/inference/usage-match.ts b/packages/b2c-script-types/src/inference/usage-match.ts deleted file mode 100644 index 5b275944d..000000000 --- a/packages/b2c-script-types/src/inference/usage-match.ts +++ /dev/null @@ -1,426 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -// Last-resort fallback for when the rest of the engine (call-site and -// return-expression driven, see ./core) can't find anything: a parameter that -// is never passed a documented value anywhere in the project — e.g. a -// helper only ever called from a Controller via `require(...)`, which the -// reference search can't see through — still gets used *somewhere* in its own -// function body. Scanning which members it's accessed by (`shipment.custom`, -// `shipment.productLineItems`) and matching that shape against every ambient -// class the program knows about (the vendored dw.* Script API, chiefly) can -// recover a plausible type purely from usage, with no call site at all. - -import type tsserver from 'typescript/lib/tsserverlibrary'; - -import { - CONVENTIONAL_IDENTIFIER_ALIASES, - CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES, - MAX_USAGE_MATCH_CANDIDATES, - MIN_USAGE_SIGNATURE_MEMBERS, - WEAK_USAGE_MEMBERS, -} from './constants'; -import type {InferenceContext} from './context'; -import {isOpenForUsageInference} from './type-helpers'; - -/** - * Resolves a parameter/variable identifier to the ambient class simple name(s) - * it conventionally denotes: exact case-insensitive match (`customer` → - * `Customer`), curated short aliases (`pli` → `ProductLineItem`), and - * PascalCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → - * `Product`). Returns lowercased names for comparison against candidate - * class names. - */ -function conventionalAmbientNames(identifierName: string): ReadonlySet { - const lower = identifierName.toLowerCase(); - const names = new Set([lower]); - const alias = CONVENTIONAL_IDENTIFIER_ALIASES.get(lower); - if (alias) { - names.add(alias.toLowerCase()); - return names; - } - for (const [pascalSuffix, className] of CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES) { - if (identifierName.length > pascalSuffix.length && identifierName.endsWith(pascalSuffix)) { - names.add(className.toLowerCase()); - break; - } - } - return names; -} - -interface AmbientClassCandidate { - readonly type: tsserver.Type; - readonly memberNames: ReadonlySet; - /** The class/interface's own declared name, e.g. `"Profile"` — see the identifier-name tiebreak in {@link matchAmbientTypesByUsage}. */ - readonly name: string; -} - -// Keyed by LanguageService, NOT by Program: tsserver hands the plugin a -// brand-new Program object on every edit to a file the project contains — -// including every keystroke in the very file someone is actively typing in. -// The ambient class shape (every dw.* class's member set) never changes for -// the life of a project, so a Program-keyed cache would rebuild this index -// (iterate every source file, call getPropertiesOfType on every dw.* class) -// on nearly every completion request while a cartridge file is being edited. -// On a large real project that rebuild is slow enough to blow past a -// completion request's cancellation budget, so completions would silently -// come back empty far more often than hover (a discrete, non-keystroke-driven -// request) — while the *next* Program, once the edit settles, would pay the -// same cost again. `languageService` is stable for as long as the tsserver -// project itself is open, and — just as importantly for tests — distinct -// per fixture, since each test builds its own LanguageService. -const classIndexCache = new WeakMap(); - -/** - * Indexes one top-level class/interface declaration into an ambient-class - * candidate, or `undefined` when it's nameless / has no members. - * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. - * - * Generic classes (chiefly `dw.catalog.Product`) are included: skipping - * them left the most common storefront parameter (`product`) matching only - * non-generic subclasses like `Variant` / `VariationGroup`, which is worse - * than showing the unsubstituted generic. Hover display rewrites `Product` - * → `Product` in {@link computeTypeDisplayString} so the editor never - * surfaces a bare type-parameter name with no instantiation context. - */ -function candidateFromDeclaration( - checker: tsserver.TypeChecker, - ts: typeof tsserver, - stmt: tsserver.Statement, -): AmbientClassCandidate | undefined { - const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); - if (!isClassOrInterface || !stmt.name) return undefined; - const symbol = checker.getSymbolAtLocation(stmt.name); - if (!symbol) return undefined; - const type = checker.getDeclaredTypeOfSymbol(symbol); - const memberNames = new Set(); - for (const prop of checker.getPropertiesOfType(type)) { - memberNames.add(prop.getName()); - } - if (memberNames.size === 0) return undefined; - return {type, memberNames, name: stmt.name.text}; -} - -/** - * Indexes every top-level class/interface declared in a `.d.ts` file visible - * to the program (the vendored dw.* Script API, plus whatever else a - * project's ambient types pull in) by its full member-name set, so - * {@link matchAmbientTypesByUsage} can look candidates up by shape. - */ -function buildAmbientClassIndex(ctx: InferenceContext): AmbientClassCandidate[] { - const cached = classIndexCache.get(ctx.languageService); - if (cached) return cached; - const {ts, checker} = ctx; - const candidates: AmbientClassCandidate[] = []; - for (const sourceFile of ctx.program.getSourceFiles()) { - if (!sourceFile.isDeclarationFile) continue; - for (const stmt of sourceFile.statements) { - const candidate = candidateFromDeclaration(checker, ts, stmt); - if (candidate) candidates.push(candidate); - } - } - classIndexCache.set(ctx.languageService, candidates); - return candidates; -} - -/** - * How many ambient classes declare `memberName`. Built once per - * matchAmbientTypesByUsage call so distinctiveness scoring stays O(matches × - * signature) rather than re-scanning the whole index per member per match. - */ -function buildMemberFrequency(candidates: readonly AmbientClassCandidate[]): Map { - const freq = new Map(); - for (const candidate of candidates) { - for (const name of candidate.memberNames) { - freq.set(name, (freq.get(name) ?? 0) + 1); - } - } - return freq; -} - -/** - * Higher = the usage signature's members are rarer across the ambient index - * (and not just ubiquitous `.custom` / `.UUID` noise). Weak members still - * contribute, but at a steep discount so a distinctive co-member dominates. - */ -function distinctivenessScore(memberNames: ReadonlySet, frequency: ReadonlyMap): number { - let score = 0; - for (const name of memberNames) { - const weight = WEAK_USAGE_MEMBERS.has(name) ? 0.15 : 1; - score += weight / Math.max(frequency.get(name) ?? 1, 1); - } - return score; -} - -/** - * Collects the names of every member accessed directly on whatever `symbol` - * identifies, anywhere in `scope` (including inside nested closures — a - * `Transaction.wrap(function () {...})` callback still reads/writes an outer - * parameter or variable it closes over). Only direct `x.member` accesses - * count; a chained `x.custom.fromStoreId` only contributes `custom` — the - * deeper hop describes `custom`'s shape, not `x`'s. That one-hop rule is also - * what makes the SFRA `product.custom.foo` / `'foo' in product.custom` idiom - * usable as ExtensibleObject-family evidence without guessing attribute names. - * - * Also counts a `'member' in x` existence check as evidence of `member` — - * a very common real-world SFCC idiom for guarding an optional custom - * attribute or a conditionally-present property (`'appliedPromotions' in - * this`, `'Subsoort' in apiProduct.custom`) before reading it, sometimes - * with no direct property-access read anywhere nearby to otherwise carry the - * signal. - * - * Skips the one property access the current request's own cursor sits - * inside of (see {@link InferenceContext.triggerPosition}) — a dangling - * `shipment.` mid-edit, immediately followed by more code, parses as a - * (nonsensical but syntactically valid) access to whatever identifier comes - * next, and that phantom member name must not count as real usage evidence - * for resolving the very completion being asked for. - */ -function collectMemberUsageInScope(ctx: InferenceContext, symbol: tsserver.Symbol, scope: tsserver.Node): Set { - const {ts, checker, triggerPosition} = ctx; - const members = new Set(); - const visit = (node: tsserver.Node) => { - if ( - ts.isPropertyAccessExpression(node) && - ts.isIdentifier(node.expression) && - checker.getSymbolAtLocation(node.expression) === symbol && - !( - triggerPosition !== undefined && - node.expression.getEnd() <= triggerPosition && - triggerPosition <= node.name.getStart() - ) - ) { - members.add(node.name.text); - } else if ( - ts.isElementAccessExpression(node) && - ts.isIdentifier(node.expression) && - checker.getSymbolAtLocation(node.expression) === symbol && - node.argumentExpression && - ts.isStringLiteralLike(node.argumentExpression) - ) { - // `lineItem['productID']` / `profile['email']` — same evidence as a dot - // read; common when the member name is computed from a form key. - members.add(node.argumentExpression.text); - } else if ( - ts.isBinaryExpression(node) && - node.operatorToken.kind === ts.SyntaxKind.InKeyword && - ts.isStringLiteralLike(node.left) && - ts.isIdentifier(node.right) && - checker.getSymbolAtLocation(node.right) === symbol - ) { - members.add(node.left.text); - } - ts.forEachChild(node, visit); - }; - visit(scope); - return members; -} - -/** Walks up from `node` to the body of the nearest enclosing function-like declaration, if any. */ -function findEnclosingFunctionBody(node: tsserver.Node, ts: typeof tsserver): tsserver.Node | undefined { - let current: tsserver.Node | undefined = node.parent; - while (current) { - if (ts.isFunctionLike(current)) return (current as tsserver.FunctionLikeDeclaration).body; - current = current.parent; - } - return undefined; -} - -/** Collects `param`'s own member-usage signature — see {@link collectMemberUsageInScope}. */ -export function collectParameterMemberUsage(ctx: InferenceContext, param: tsserver.ParameterDeclaration): Set { - const {ts, checker} = ctx; - const fn = param.parent; - if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) return new Set(); - const body = (fn as tsserver.FunctionLikeDeclaration).body; - if (!body) return new Set(); - const symbol = checker.getSymbolAtLocation(param.name); - if (!symbol) return new Set(); - return collectMemberUsageInScope(ctx, symbol, body); -} - -/** - * Resolves the *instance* type tested by an `instanceof` right-hand side - * (`dw.order.ProductLineItem`, a local class binding, …). Prefer a construct - * signature's return type; otherwise the declared type of the RHS symbol. - */ -function instanceTypeFromInstanceOfRhs(ctx: InferenceContext, rhs: tsserver.Expression): tsserver.Type | undefined { - const {checker, ts} = ctx; - const rhsType = checker.getTypeAtLocation(rhs); - for (const sig of rhsType.getConstructSignatures()) { - const instance = checker.getReturnTypeOfSignature(sig); - if (!isOpenForUsageInference(ts, instance)) return instance; - } - const symbol = rhsType.getSymbol() ?? checker.getSymbolAtLocation(rhs); - if (!symbol) return undefined; - const declared = checker.getDeclaredTypeOfSymbol(symbol); - if (isOpenForUsageInference(ts, declared)) return undefined; - return declared; -} - -/** - * Collects concrete types asserted via `param instanceof SomeType` in the - * parameter's enclosing function body. Real payment/cart helpers (and common - * calculate.js ports) branch on `lineItem instanceof dw.order.ProductLineItem` - * — JetBrains' JS evaluator narrows from that; without collecting it here, a - * polymorphic `lineItem` parameter stays ambient-ambiguous even when the body - * names the class explicitly. - */ -export function collectParameterInstanceOfTypes( - ctx: InferenceContext, - param: tsserver.ParameterDeclaration, -): tsserver.Type[] { - const {ts, checker} = ctx; - const fn = param.parent; - if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) return []; - const body = (fn as tsserver.FunctionLikeDeclaration).body; - if (!body) return []; - const symbol = checker.getSymbolAtLocation(param.name); - if (!symbol) return []; - const types: tsserver.Type[] = []; - const visit = (node: tsserver.Node) => { - if ( - ts.isBinaryExpression(node) && - node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && - ts.isIdentifier(node.left) && - checker.getSymbolAtLocation(node.left) === symbol - ) { - const instance = instanceTypeFromInstanceOfRhs(ctx, node.right); - if (instance) types.push(instance); - } - ts.forEachChild(node, visit); - }; - visit(body); - return types; -} - -/** - * Collects a local variable's own member-usage signature within its - * enclosing function (or the whole file, for a top-level variable) — the - * counterpart to {@link collectParameterMemberUsage} for the common - * manual-indexing loop shape TS can't type at all on its own: - * `for (var i = 0; i < items.length; i++) { var item = items[i]; ...item.foo }`. - * `items[i]` is `any` (items itself is undocumented), so nothing about - * `item`'s initializer helps — but `item`'s own usage further down does. - */ -export function collectVariableMemberUsage(ctx: InferenceContext, decl: tsserver.VariableDeclaration): Set { - const {ts, checker} = ctx; - if (!ts.isIdentifier(decl.name)) return new Set(); - const symbol = checker.getSymbolAtLocation(decl.name); - if (!symbol) return new Set(); - const scope = findEnclosingFunctionBody(decl, ts) ?? decl.getSourceFile(); - return collectMemberUsageInScope(ctx, symbol, scope); -} - -/** - * Matches a member-name usage signature against every ambient class the - * program knows about, returning the type(s) of whichever candidate(s) expose - * all of them, most-specific first. - * - * Ranking (after an optional identifier-name short-circuit): - * 1. Highest distinctiveness score — rarer used members win over ubiquitous - * ones like `.custom` / `.UUID` (see {@link WEAK_USAGE_MEMBERS}). - * 2. Fewest total members among that top score tier — the tightest-fitting - * shape, not just any rare-member superset. - * - * Returns `[]` when the signature is too weak to be worth guessing from (see - * MIN_USAGE_SIGNATURE_MEMBERS) or when it still ties across too many - * unrelated candidates to be a useful hint (MAX_USAGE_MATCH_CANDIDATES). - * - * Exception: a signature below MIN_USAGE_SIGNATURE_MEMBERS is still trusted - * when it's globally unambiguous — exactly one ambient class in the whole - * program declares all of these members at all (not just tied for - * "tightest"). A member name that's this rare is as strong a signal as a - * multi-member signature; a signature that's both weak AND ambiguous is what - * MIN_USAGE_SIGNATURE_MEMBERS exists to filter out. - * - * @param identifierName - the parameter/variable's own name, when it's a - * plain identifier (`profile`, `shipment`). Real-world bug: a common field - * subset (`email`/`firstName`/`lastName`/`custom`) is shared by both the - * large `dw.customer.Profile` and the much smaller `dw.customer. - * ProductListRegistrant` — "fewest total members" alone picks the small, - * unrelated class every time purely because it has less surface area, never - * the large, contextually correct one. A variable conventionally named after - * the SFCC class it holds is a stronger, more specific signal than raw - * member count, so a name match short-circuits straight to that candidate - * (ambient class names are unique, so at most one can ever match this way) - * before size/distinctiveness ranking even runs — but only after the - * weak-only silence guard below. A parameter literally named `shipment` - * whose only evidence is `.custom` must still stay silent: the name alone - * must not override weak-only evidence. A single *strong* member plus a - * matching name (`customer` + `.profile`) is trusted, since one-hop usage - * collection often yields just the first property of a longer chain. - */ -export function matchAmbientTypesByUsage( - ctx: InferenceContext, - memberNames: ReadonlySet, - identifierName?: string, -): tsserver.Type[] { - if (memberNames.size === 0) return []; - const candidates = buildAmbientClassIndex(ctx); - const matches = candidates.filter((candidate) => { - for (const name of memberNames) { - if (!candidate.memberNames.has(name)) return false; - } - return true; - }); - if (matches.length === 0) return []; - // A signature made only of ubiquitous members (`.custom` / `.UUID` / …) is - // never discriminative enough when more than one ambient class matches — - // distinctiveness scoring alone can't break the tie usefully because every - // match saw the same weak evidence. Silence rather than guessing the - // smallest ExtensibleObject. This guard MUST run before the identifier-name - // short-circuit: a parameter literally named `shipment` whose only evidence - // is `.custom` must stay silent — the name alone must not override - // "too weak" evidence (see usage-match tests). - const strongCount = [...memberNames].filter((n) => !WEAK_USAGE_MEMBERS.has(n)).length; - if (strongCount === 0 && matches.length > 1) return []; - - // A conventionally named parameter (`customer`, `profile`, `shipment`, or - // an SFRA alias like `lineItem` / `pli` → ProductLineItem) that uniquely - // matches one of the ambient candidates short-circuits here — even when - // the usage signature is a single strong member. Real SFRA shape: - // `function getPasswordResetToken(customer) { customer.profile.credentials… }` - // only contributes `.profile` (one-hop member collection), which is shared - // by `dw.customer.Customer` and `dw.svc.ServiceConfig`, but the parameter - // name makes the intended class unambiguous. Weak-only signatures never - // reach this point (guard above). - if (identifierName) { - const conventional = conventionalAmbientNames(identifierName); - const byName = matches.filter((m) => conventional.has(m.name.toLowerCase())); - if (byName.length === 1) return [byName[0].type]; - // The identifier named a real Script API class (or an SFRA alias of one), - // but that class isn't among the usage matches. A thin signature's - // "globally unique member" hit is then almost certainly a *different* - // class that happens to share one property — e.g. `lineItem.preorderable` - // uniquely matching `ProductInventoryRecord` while the author clearly - // meant a line item. Silence rather than override the naming hint. - if (byName.length === 0 && memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS) { - // `conventional.size > 1` means an alias or PascalCase suffix fired - // (`resettingCustomer` → Customer, `lineItem` → ProductLineItem). - const namedIntentionally = - conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); - if (namedIntentionally) return []; - } - } - - // Below-minimum signatures that are still ambiguous (no unique name match) - // stay silent — e.g. an unnamed/`obj` parameter that only touches `.profile`. - if (memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) return []; - - const frequency = buildMemberFrequency(candidates); - const scored = matches.map((m) => ({ - candidate: m, - score: distinctivenessScore(memberNames, frequency), - })); - const bestScore = Math.max(...scored.map((s) => s.score)); - // Floating-point slack: weights are small rationals; equality is fine in - // practice but a tiny epsilon keeps ranking stable if a future weight isn't. - const topTier = scored.filter((s) => bestScore - s.score < 1e-9).map((s) => s.candidate); - const minSize = Math.min(...topTier.map((m) => m.memberNames.size)); - const tightest = topTier.filter((m) => m.memberNames.size === minSize); - if (tightest.length > MAX_USAGE_MATCH_CANDIDATES) return []; - return tightest.map((m) => m.type); -} diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 4a4ee46f3..97faf0f72 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -30,10 +30,13 @@ // Precision = (exact + union + related) / fired, recall = the same / total. // Parameters that carry no type at all in the original source are hovered // too, to measure how often inference fires on genuinely undocumented code. +// Cost is reported as wall time and as reference searches per request; the +// search count is the same on every machine and run, so compare that one. // // Optional: B2C_INFERENCE_CARTRIDGE_PATH (colon-separated cartridge names) // sets the cartridge order, B2C_INFERENCE_REPORT writes the full JSON report -// (mismatches, timings, undocumented samples) to that file. +// (mismatches, timings, searches, slowest requests, undocumented samples) to +// that file. const fs = require('node:fs'); const path = require('node:path'); @@ -114,6 +117,15 @@ function createCorpusHost(fileNames, currentDirectory) { function createCorpusPlugin(cartridges, fileNames) { const {host, setOverride} = createCorpusHost(fileNames, cartridges[0]?.src ?? '/'); const languageService = ts.createLanguageService(host); + // Reference searches are what an inference request spends its time on; + // counting them gives a cost measure that, unlike wall time, is the same + // on every machine and run. + const searches = {count: 0}; + const getReferencesAtPosition = languageService.getReferencesAtPosition.bind(languageService); + languageService.getReferencesAtPosition = (fileName, position) => { + searches.count++; + return getReferencesAtPosition(fileName, position); + }; const proxy = init({typescript: ts}).create({ languageService, languageServiceHost: host, @@ -124,7 +136,7 @@ function createCorpusPlugin(cartridges, fileNames) { }, config: {enabled: true, autoDiscover: false, inferUsage: true, cartridges}, }); - return {languageService, proxy, setOverride}; + return {languageService, proxy, setOverride, searches}; } // --------------------------------------------------------------------------- @@ -264,13 +276,23 @@ function inferredReturn(languageService, fileName, position) { }; visit(sourceFile); const types = fn ? inferReturnType(ctx, fn) : []; - return types.length > 0 ? describeTypes(ctx.checker, types) : undefined; + return types.length > 0 ? describeTypes(ctx, types) : undefined; } -function timed(timings, compute) { +const SLOWEST_REPORTED = 20; +// The live reference-search counter, kept under a symbol so the JSON report leaves it out. +const SEARCH_COUNTER = Symbol('searchCounter'); + +function timed(report, label, compute) { + const searchesBefore = report[SEARCH_COUNTER].count; const start = process.hrtime.bigint(); const result = compute(); - timings.push(Number(process.hrtime.bigint() - start) / 1e6); + const ms = Number(process.hrtime.bigint() - start) / 1e6; + const searches = report[SEARCH_COUNTER].count - searchesBefore; + report.timings.push(ms); + report.searches.push(searches); + report.slowest.push({target: label, ms, searches}); + report.slowest.sort((a, b) => b.ms - a.ms).splice(SLOWEST_REPORTED); return result; } @@ -291,11 +313,13 @@ function evaluateFile(plugin, ancestry, targets, report) { setOverride(targets.fileName, blankSpans(ts.sys.readFile(targets.fileName), targets.blanks)); try { for (const target of targets.params) { - const inferred = timed(report.timings, () => inferredFromHover(proxy, targets.fileName, target.position)); + const inferred = timed(report, target.label, () => inferredFromHover(proxy, targets.fileName, target.position)); scoreTarget(report, ancestry, 'params', target, inferred); } for (const target of targets.returns) { - const inferred = timed(report.timings, () => inferredReturn(languageService, targets.fileName, target.position)); + const inferred = timed(report, target.label, () => + inferredReturn(languageService, targets.fileName, target.position), + ); scoreTarget(report, ancestry, 'returns', target, inferred); } } finally { @@ -306,7 +330,7 @@ function evaluateFile(plugin, ancestry, targets, report) { function evaluateUndocumented(proxy, allTargets, report) { for (const targets of allTargets) { for (const target of targets.undocumented) { - const inferred = timed(report.timings, () => inferredFromHover(proxy, targets.fileName, target.position)); + const inferred = timed(report, target.label, () => inferredFromHover(proxy, targets.fileName, target.position)); report.undocumented.total++; if (!inferred) continue; report.undocumented.fired++; @@ -333,6 +357,7 @@ function summarize(report) { ); }; const sorted = [...report.timings].sort((a, b) => a - b); + const searches = [...report.searches].sort((a, b) => a - b); const u = report.undocumented; return [ line('params'), @@ -340,6 +365,8 @@ function summarize(report) { `undocumented params fired on ${u.fired}/${u.total}`, `latency ms p50=${percentile(sorted, 50).toFixed(1)} p95=${percentile(sorted, 95).toFixed(1)} ` + `max=${(sorted.at(-1) ?? 0).toFixed(1)} over ${sorted.length} requests`, + `reference searches p50=${percentile(searches, 50)} p95=${percentile(searches, 95)} ` + + `max=${searches.at(-1) ?? 0} total=${searches.reduce((sum, n) => sum + n, 0)}`, ].join('\n'); } @@ -359,6 +386,9 @@ function summarize(report) { undocumented: {total: 0, fired: 0, samples: []}, mismatches: [], timings: [], + searches: [], + slowest: [], + [SEARCH_COUNTER]: plugin.searches, }; evaluateUndocumented(plugin.proxy, allTargets, report); From 3ab99242dd5be3b9155db16f6439869adf1fdfdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:08:50 +0000 Subject: [PATCH 47/76] refactor(script-types): split usage inference into evidence and one shared policy Restructures the inference engine so every parameter, return value and variable, at every depth, is decided by the same rules (src/inference/policy.ts), and moves toward IntelliJ-style behaviour: - Conflicting call sites now show a union (at most three types, each fitting every member the body uses) instead of going silent; wider evidence collapses to the closest shared superclass that still fits. - One walk per scope builds a usage profile (members, instanceof/typeof guards, typed uses, assignments). Members the code tests for first ('m' in x, hasOwnProperty) or uses only in a branch such a test picks are not required of every value. - Call-site candidates must also fit the typed helpers the body passes the value to (a string at one buggy call site no longer beats Product), while sibling classes of the same family stay (Order passed to a Basket-documented LineItemCtnr helper). Narrowed references and ambiguous overloads are not treated as uses. - The ambient-class index skips TypeScript's default library, dedupes merged declarations, holds no Type objects and is cached per declaration file, so editing a cartridge .js file never re-indexes the Script API. - Identifier names only break ties among classes the usage already fits, via suffix matching against the index plus a short abbreviation list. - Call sites are followed through export maps ({images: require(...)}) and local aliases (var run = helper); `x instanceof Object` is no evidence. - Reference search results are cached per Program, so hovering one parameter after another runs each search once; cached searches still spend the request budget, so answers do not depend on hover order. On the attached real cartridges (SFRA plus three plugins, JSDoc stripped per file) parameter recall rises from 60.6% to 82.3% at 96.8% precision, return precision from 55.6% to 90.5%, and total reference searches fall from 1134 to 748. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- packages/b2c-script-types/plugin/index.js | 12 +- .../plugin/inference/ambient-index.js | 66 ++ .../plugin/inference/call-sites.js | 143 ++-- .../plugin/inference/constants.js | 138 +--- .../plugin/inference/context.js | 47 ++ .../b2c-script-types/plugin/inference/core.js | 655 +++++----------- .../plugin/inference/framework-contracts.js | 33 + .../plugin/inference/naming.js | 80 ++ .../plugin/inference/policy.js | 186 +++++ .../plugin/inference/super-module.js | 87 ++- .../plugin/inference/type-helpers.js | 254 +++--- .../plugin/inference/usage-match.js | 396 ---------- .../plugin/inference/usage-profile.js | 286 +++++++ .../plugin/usage-inference.js | 36 +- packages/b2c-script-types/src/index.ts | 14 +- .../src/inference/ambient-index.ts | 89 +++ .../src/inference/call-sites.ts | 173 +++-- .../src/inference/constants.ts | 138 +--- .../b2c-script-types/src/inference/context.ts | 71 +- .../b2c-script-types/src/inference/core.ts | 732 ++++++------------ .../src/inference/framework-contracts.ts | 56 ++ .../b2c-script-types/src/inference/naming.ts | 83 ++ .../b2c-script-types/src/inference/policy.ts | 255 ++++++ .../src/inference/super-module.ts | 103 ++- .../src/inference/type-helpers.ts | 281 ++++--- .../src/inference/usage-profile.ts | 359 +++++++++ .../b2c-script-types/src/usage-inference.ts | 33 +- .../b2c-script-types/test/corpus/cases.json | 20 +- .../test/corpus/corpus.test.js | 18 +- .../test/index.security.test.js | 2 +- packages/b2c-script-types/test/index.test.js | 20 +- .../test/usage-inference.hardening.test.js | 13 +- .../test/usage-inference.perf.test.js | 130 ++-- .../test/usage-inference.real-types.test.js | 87 ++- .../test/usage-inference.test.js | 252 +++++- .../b2c-script-types/test/usage-match.test.js | 132 +++- 36 files changed, 3271 insertions(+), 2209 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/ambient-index.js create mode 100644 packages/b2c-script-types/plugin/inference/framework-contracts.js create mode 100644 packages/b2c-script-types/plugin/inference/naming.js create mode 100644 packages/b2c-script-types/plugin/inference/policy.js delete mode 100644 packages/b2c-script-types/plugin/inference/usage-match.js create mode 100644 packages/b2c-script-types/plugin/inference/usage-profile.js create mode 100644 packages/b2c-script-types/src/inference/ambient-index.ts create mode 100644 packages/b2c-script-types/src/inference/framework-contracts.ts create mode 100644 packages/b2c-script-types/src/inference/naming.ts create mode 100644 packages/b2c-script-types/src/inference/policy.ts create mode 100644 packages/b2c-script-types/src/inference/usage-profile.ts diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 4f434b944..6d341b9ba 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -466,7 +466,7 @@ function init({ typescript: ts }) { // re-deriving "nothing" costs the same reference searches as // re-deriving something. const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath, position); + const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); if (!ctx) return undefined; // Hovering the member name of a property access @@ -481,7 +481,7 @@ function init({ typescript: ts }) { 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); + const description = (0, usage_inference_1.describeTypes)(ctx, 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 @@ -554,13 +554,7 @@ function init({ typescript: ts }) { // 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 augmentedEntries = (augmentedCtx ? (0, usage_inference_1.collectSuperModuleAugmentedMembers)(augmentedCtx, baseNode) : []).map((m) => (0, usage_inference_1.inferredCompletionEntry)(ts, m.name, m.isMethod)); const inferredEntries = [...typeEntries, ...augmentedEntries]; if (inferredEntries.length === 0) return original; diff --git a/packages/b2c-script-types/plugin/inference/ambient-index.js b/packages/b2c-script-types/plugin/inference/ambient-index.js new file mode 100644 index 000000000..97961c741 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/ambient-index.js @@ -0,0 +1,66 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getAmbientClasses = getAmbientClasses; +exports.ambientClassType = ambientClassType; +exports.findAmbientClassType = findAmbientClassType; +const classesByFile = new WeakMap(); +const classesByProgram = new WeakMap(); +function indexDeclaration(ctx, statement) { + const { ts, checker } = ctx; + if (!(ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) || !statement.name) + return undefined; + const symbol = checker.getSymbolAtLocation(statement.name); + if (!symbol) + return undefined; + const memberNames = new Set(checker.getPropertiesOfType(checker.getDeclaredTypeOfSymbol(symbol)).map((m) => m.name)); + if (memberNames.size === 0) + return undefined; + return { name: statement.name.text, memberNames, declaration: statement }; +} +function indexFile(ctx, sourceFile) { + const cached = classesByFile.get(sourceFile); + if (cached) + return cached; + const classes = sourceFile.statements.flatMap((statement) => indexDeclaration(ctx, statement) ?? []); + classesByFile.set(sourceFile, classes); + return classes; +} +/** + * Every top-level class and interface in the program's declaration files, + * except TypeScript's own default library (DOM and ES classes such as + * `HTMLDataElement` would otherwise match ordinary SFCC usage). + */ +function getAmbientClasses(ctx) { + const { program } = ctx; + const cached = classesByProgram.get(program); + if (cached) + return cached; + const classes = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.isDeclarationFile && !program.isSourceFileDefaultLibrary(sourceFile)) + .flatMap((sourceFile) => indexFile(ctx, sourceFile)); + classesByProgram.set(program, classes); + return classes; +} +/** The class's declared (uninstantiated) type, resolved with the current checker. */ +function ambientClassType(ctx, ambientClass) { + const { checker } = ctx; + const name = ambientClass.declaration.name; + const symbol = name && checker.getSymbolAtLocation(name); + return symbol ? checker.getDeclaredTypeOfSymbol(symbol) : undefined; +} +/** + * The declared type of the ambient class called `name`, preferring the + * vendored Script API's declaration (under a `/dw/` directory) when a + * project declares another class of the same name. + */ +function findAmbientClassType(ctx, name) { + const named = getAmbientClasses(ctx).filter((ambientClass) => ambientClass.name === name); + const preferred = named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; + return preferred && ambientClassType(ctx, preferred); +} diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index f64c3784f..4f2f127fd 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -9,6 +9,28 @@ exports.getReferenceNameNode = getReferenceNameNode; exports.collectCallSites = collectCallSites; const constants_1 = require("./constants"); const ast_helpers_1 = require("./ast-helpers"); +// Reference searches are what an inference request spends its time on, and +// a Program never changes, so their results are kept for as long as the +// Program lives: hovering one parameter after another in an unchanged +// project runs each search once. Entries are plain file names and spans, so +// they pin no checker. A search served from here still spends the request's +// search budget, so a request reaches the same call sites, and the same +// answer, whether or not an earlier request warmed the cache. +const referencesByProgram = new WeakMap(); +function findReferences(ctx, name) { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + const sourceFile = name.getSourceFile(); + references = ctx.languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + cache.set(name, references); + } + return references; +} /** * Identifies the name to run findReferences on for a function-like * declaration that itself has no `name` (the common CommonJS shapes: @@ -41,27 +63,55 @@ function getReferenceNameNode(fn, ts) { } return undefined; } +/** The function enclosing `node`, if any. */ +function enclosingFunction(node, ts) { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) + return current; + } + return undefined; +} /** - * 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. + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call, ts) { + const list = call.arguments[1]; + if (!list) + return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') + return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} +/** + * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, + * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it + * invokes, if it sits in callee position. The name of a property access + * (`obj.helper`) is treated as the whole access. */ function findCallInCalleePosition(node, ts) { - const parent = node.parent; + const callee = node.parent && ts.isPropertyAccessExpression(node.parent) && node.parent.name === node ? node.parent : node; + const parent = callee.parent; if (!parent) return undefined; - if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) - return parent; - if (ts.isPropertyAccessExpression(parent) && parent.name === node) { - const grandparent = parent.parent; - if (grandparent && - (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && - grandparent.expression === parent) { - return grandparent; - } + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === callee) { + return { node: parent, args: parent.arguments ?? [] }; } + if (!ts.isPropertyAccessExpression(parent) || parent.expression !== callee) + return undefined; + const call = parent.parent; + if (!call || !ts.isCallExpression(call) || call.expression !== parent) + return undefined; + if (parent.name.text === 'call') + return { node: call, args: call.arguments.slice(1) }; + if (parent.name.text === 'apply') + return { node: call, args: applyArguments(call, ts) }; return undefined; } /** @@ -76,13 +126,33 @@ function isRequireCallExpression(node, ts) { node.arguments.length > 0 && ts.isStringLiteralLike(node.arguments[0])); } +/** + * The name a value is bound to when `expression` initializes a variable + * (`var helper = …`) or an object property (`{helper: …}`) — the next name + * to search references for when the value's own references dead-end there. + */ +function bindingNameOf(expression, ts) { + const parent = expression.parent; + if (!parent || !(ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))) + return undefined; + return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; +} /** * 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`). + * binding indirection: + * + * - the module specifier of a `require(...)` whose result is bound to a + * variable (`var helper = require('./helper')`) or to a property of an + * export map (SFRA's `decorators/index.js`: + * `{images: require('./images')}`, called as `decorators.images(...)`); + * - a destructuring binding element (`const {helper} = require(...)` or + * `const {helper: local} = someObject`); + * - an alias of the function itself (`var run = helper`, or SFRA's canonical + * `module.exports = {getSalePrice: getSalePrice}` export shape): a search + * on the function name dead-ends at the alias, while the consumers + * (`productHelpers.getSalePrice(x)` in another file) are references of the + * alias name. * * @returns Either the further name to search references for, or — for an * immediately-invoked require (`require('./helper')(x)`) — the call site itself. @@ -91,34 +161,19 @@ 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 }; + const isRequireSpecifier = ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts); + const value = isRequireSpecifier ? parent : node; + const invocation = value.parent; + if (isRequireSpecifier && invocation && ts.isCallExpression(invocation) && invocation.expression === value) { + return { kind: 'call', call: { node: invocation, args: invocation.arguments } }; // require('./helper')(x) } - return undefined; + const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; + return name && { kind: 'name', name }; } /** * Finds actual call sites for `nameNode`, following up to @@ -164,11 +219,9 @@ function collectCallSites(ctx, nameNode) { * fanning out once it's exhausted. */ function collectCallsFromName(ctx, name, calls, nextFrontier, localBudget) { - const { ts, languageService, program } = ctx; - const sourceFile = name.getSourceFile(); + const { ts, program } = ctx; ctx.searchBudget--; - const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; - for (const ref of refs) { + for (const ref of findReferences(ctx, name)) { if (localBudget <= 0) break; localBudget--; diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 68178f140..656dafd99 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_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; +exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.WEAK_USAGE_MEMBERS = exports.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = 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,32 +63,23 @@ 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 — 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. +// Ambient usage matching (see ./ambient-index): a usage signature with fewer +// distinct member names than this is only trusted when exactly one ambient +// class declares it (`.addresses` is AddressBook's alone) or when the +// identifier name picks one of the matches (`customer` + `.profile`). exports.MIN_USAGE_SIGNATURE_MEMBERS = 2; -// If the member-name signature still ties across more candidates than this -// after ranking by specificity (distinctiveness, then fewest total members), -// the match is too ambiguous to be a useful hint — silence beats a wall of -// unrelated candidates in the hover text. -exports.MAX_USAGE_MATCH_CANDIDATES = 5; -// When call-site arguments don't converge on a single distinct type, silence -// rather than union a noisy hover like `Product | Order`. A two-type union is -// already usually wrong for any given call site; ambient usage-matching is -// also skipped in that case — conflicting evidence is not "no call sites". -exports.MAX_CALL_SITE_CANDIDATES = 1; +// Most distinct types a hover or completion list shows as a union, IntelliJ +// style (`Product | Order`). More than this collapses to the candidates' +// closest shared ancestor class when one fits, and otherwise stays silent: a +// longer union is noise rather than a hint. +exports.MAX_UNION_TYPES = 3; +// Root classes nearly every Script API class inherits from. Offering them as +// the "common ancestor" of unrelated candidates tells the reader nothing. +exports.UNINFORMATIVE_ANCESTORS = new Set(['Object', 'ExtensibleObject', 'PersistentObject']); // Member names so common across dw.* that they barely discriminate a class // on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). -// They still count as usage evidence for matching, but contribute far less -// to the distinctiveness score used to rank ambient candidates. +// They still narrow an ambient match, but a signature made only of these +// never picks one class out of several. exports.WEAK_USAGE_MEMBERS = new Set(['custom', 'UUID', 'toString', 'valueOf']); // Callee names whose callbacks lead with the collection element // (`collections.forEach(coll, function (item) {...})`). Only these get the @@ -101,7 +92,7 @@ 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 (a storefront cartridge). + // shape; used heavily for address-book and line-item lookups. 'find', // Stock SFRA `collections.first` takes only the collection, but several // storefronts (and common calculate.js ports) call it with a predicate @@ -109,100 +100,3 @@ exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ // when present so the predicate parameter still gets a type. 'first', ]); -/** - * SFRA/storefront parameter names that conventionally hold a Script API class - * whose declared name does not equal the identifier (case-insensitive). Used - * by ambient usage-matching's identifier short-circuit — `lineItem` must map - * to `ProductLineItem`, not look for a nonexistent ambient class named - * `LineItem`. Keys are lowercase; values are ambient class simple names. - * - * Keep this list conservative: only aliases that are unambiguous in real - * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs - * OrderAddress vs Store address models). Prefer adding PascalCase suffixes to - * {@link CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES} for `resettingCustomer`-style - * names; this map is for short / all-lowercase tokens (`pli`, `pricemodel`). - */ -exports.CONVENTIONAL_IDENTIFIER_ALIASES = new Map([ - ['lineitem', 'ProductLineItem'], - ['pli', 'ProductLineItem'], - ['productlineitem', 'ProductLineItem'], - ['pricemodel', 'ProductPriceModel'], - ['availabilitymodel', 'ProductAvailabilityModel'], - ['shippingaddress', 'OrderAddress'], - ['billingaddress', 'OrderAddress'], - ['paymentinstrument', 'OrderPaymentInstrument'], - ['shippingmethod', 'ShippingMethod'], - ['shippinglineitem', 'ShippingLineItem'], - ['priceadjustment', 'PriceAdjustment'], - ['giftcertificatelineitem', 'GiftCertificateLineItem'], - ['couponlineitem', 'CouponLineItem'], - // Other concrete dw.order line-item subclasses. Without these, the bare - // `LineItem` PascalCase suffix (below) would force an all-lowercase - // `bonusdiscountlineitem` / `productshippinglineitem` to ProductLineItem — - // a wrong guess for a differently-named sibling class (see the matching - // PascalCase suffixes and the *LineItem note there). - ['bonusdiscountlineitem', 'BonusDiscountLineItem'], - ['productshippinglineitem', 'ProductShippingLineItem'], - ['customeraddress', 'CustomerAddress'], - ['orderaddress', 'OrderAddress'], - // High-frequency all-lowercase / compound forms seen across storefronts - // (when authors don't camelCase the class token). - ['currentbasket', 'Basket'], - ['currentcustomer', 'Customer'], - ['currentorder', 'Order'], - ['apiproduct', 'Product'], - ['apiorder', 'Order'], - ['apilineitem', 'ProductLineItem'], -]); -/** - * Trailing PascalCase class tokens → ambient class simple name. Matched with - * `identifierName.endsWith(pascalSuffix)` (case-sensitive on the original - * identifier) so `resettingCustomer` / `apiProduct` / `currentBasket` resolve - * while all-lowercase noise like `border` / `emailaddress` does not. - * - * Ordered longest-first so `productLineItem` hits ProductLineItem rather than - * Product. Generic `Address` is omitted — too many false friends - * (`emailAddress`, `ipAddress`, store address models). - * - * The *LineItem subclasses (`ProductLineItem`, `BonusDiscountLineItem`, - * `CouponLineItem`, `GiftCertificateLineItem`, `ShippingLineItem`, - * `ProductShippingLineItem`) must ALL precede the bare `LineItem` → - * ProductLineItem fallback, and each longer name must precede any shorter one - * it ends with (`ProductShippingLineItem` before `ShippingLineItem`), because - * the matcher stops at the first `endsWith` hit in array order. Without the - * specific entries, a `bonusDiscountLineItem` / `productShippingLineItem` - * parameter would resolve to the wrong sibling class (ProductLineItem / - * ShippingLineItem) whenever its body only touches members shared through the - * common `LineItem` base — the classic silence-vs-wrong-guess trap. - */ -exports.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES = [ - ['GiftCertificateLineItem', 'GiftCertificateLineItem'], - ['BonusDiscountLineItem', 'BonusDiscountLineItem'], - ['CouponLineItem', 'CouponLineItem'], - ['ProductShippingLineItem', 'ProductShippingLineItem'], - ['ProductLineItem', 'ProductLineItem'], - ['ShippingLineItem', 'ShippingLineItem'], - ['OrderPaymentInstrument', 'OrderPaymentInstrument'], - ['PaymentInstrument', 'OrderPaymentInstrument'], - ['ProductAvailabilityModel', 'ProductAvailabilityModel'], - ['AvailabilityModel', 'ProductAvailabilityModel'], - ['ProductPriceModel', 'ProductPriceModel'], - ['PriceModel', 'ProductPriceModel'], - ['ShippingAddress', 'OrderAddress'], - ['BillingAddress', 'OrderAddress'], - ['CustomerAddress', 'CustomerAddress'], - ['OrderAddress', 'OrderAddress'], - ['ShippingMethod', 'ShippingMethod'], - ['PriceAdjustment', 'PriceAdjustment'], - ['LineItem', 'ProductLineItem'], - ['Customer', 'Customer'], - ['Profile', 'Profile'], - ['Product', 'Product'], - ['Basket', 'Basket'], - ['Shipment', 'Shipment'], - ['Category', 'Category'], - ['Order', 'Order'], - ['Store', 'Store'], - ['Variant', 'Variant'], - ['Money', 'Money'], -]; diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js index 9dcb87d83..d876e1701 100644 --- a/packages/b2c-script-types/plugin/inference/context.js +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -6,6 +6,8 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.createInferenceContext = createInferenceContext; +exports.withCycleGuard = withCycleGuard; +exports.withInferenceGuards = withInferenceGuards; const constants_1 = require("./constants"); /** * Builds a fresh inference context for one top-level hover/completion @@ -26,8 +28,53 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath, tri searchBudget: constants_1.MAX_SEARCHES_PER_REQUEST, callSiteMemo: new Map(), typeDisplayStrings: new Map(), + profiles: new Map(), cycleHits: 0, resolveSuperModulePath, triggerPosition, }; } +/** + * Runs `compute` with `node` marked as in progress, so a cycle that leads + * back to the same node (`var a = b; var b = a;`, or two helpers returning + * each other's result) gets `fallback` instead of recursing forever. Each hit + * is counted in ctx.cycleHits; see {@link withInferenceGuards}. + */ +function withCycleGuard(ctx, node, fallback, compute) { + if (ctx.visiting.has(node)) { + ctx.cycleHits++; + return fallback; + } + ctx.visiting.add(node); + try { + return compute(); + } + finally { + ctx.visiting.delete(node); + } +} +/** + * The shared preamble for every memoized inference entry point: serve a memo + * hit, enforce MAX_INFERENCE_DEPTH, break cycles, and memoize the result. + * + * The memo is consulted before the depth cap: a result computed at an equal + * or shallower depth had at least as much budget as this call would, so it is + * reusable however deep the current path is. A result whose computation hit + * a cycle guard is not memoized: it was cut short by what happened to be on + * the current call stack, and the same node reached later from outside the + * cycle could resolve more. + */ +function withInferenceGuards(ctx, node, depth, compute) { + const cached = ctx.memo.get(node); + if (cached && cached.atDepth <= depth) + return cached.types; + if (depth > constants_1.MAX_INFERENCE_DEPTH) + return []; + return withCycleGuard(ctx, node, [], () => { + const cycleHitsBefore = ctx.cycleHits; + const types = compute(); + if (ctx.cycleHits === cycleHitsBefore) + ctx.memo.set(node, { atDepth: depth, types }); + return types; + }); +} diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 6ab3c886c..5fbc4ac60 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -10,11 +10,17 @@ exports.inferReturnType = inferReturnType; exports.inferTypeForNode = inferTypeForNode; exports.inferTypeForExpression = inferTypeForExpression; const constants_1 = require("./constants"); +const context_1 = require("./context"); const ast_helpers_1 = require("./ast-helpers"); const call_sites_1 = require("./call-sites"); +const framework_contracts_1 = require("./framework-contracts"); +const policy_1 = require("./policy"); const super_module_1 = require("./super-module"); const type_helpers_1 = require("./type-helpers"); -const usage_match_1 = require("./usage-match"); +const usage_profile_1 = require("./usage-profile"); +function identifierText(ctx, name) { + return ctx.ts.isIdentifier(name) ? name.text : 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 @@ -26,560 +32,309 @@ function resolveCalleeDeclaration(ctx, call) { 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; + const sigDecl = checker.getResolvedSignature(call)?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } /** - * Chases a local variable's initializer expression — the missing link for the - * idiomatic SFCC style of splitting a chain across intermediate variables - * (`var priceModel = product.getPriceModel(); return priceModel.getPrice();`), - * which would otherwise dead-end at the variable reference even though the - * exact same logic written inline resolves fine. - * - * Guarded three ways: an explicit type/JSDoc annotation on the variable means - * its `any` is deliberate (same rule as parameters/returns); the `visiting` - * set breaks initializer cycles (`var a = b; var b = a;`) and records the hit - * in ctx.cycleHits; and the hop is charged to `chainHops` — following a - * variable never crosses a function boundary, so it's an in-expression hop, - * not a recursion-depth step. - * - * Falls back to matching the variable's own usage against ambient classes - * (see {@link collectVariableMemberUsage}) when the initializer itself - * resolves to nothing — the common shape for a manual-indexing loop variable - * (`var item = items[i]`), where `items[i]` stays `any` no matter what since - * `items` itself is undocumented. + * Everything a local variable can hold: its initializer plus every later + * `x = value` assignment (`var result = null; if (a) result = x; else result + * = y;`), decided as one candidate set. Covers the idiomatic SFCC style of + * splitting a chain across intermediate variables (`var priceModel = + * product.getPriceModel(); return priceModel.getPrice();`), which would + * otherwise dead-end at the variable even though the inline expression + * resolves fine. An explicit annotation means the variable's `any` is + * deliberate (same rule as parameters and returns). Following a variable + * never crosses a function boundary, so it is charged to `chainHops`, not to + * the recursion depth. */ -function 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++; +function resolveVariableTypes(ctx, decl, depth, chainHops) { + if ((0, ast_helpers_1.hasExplicitVariableType)(decl, ctx.ts)) return []; + return (0, context_1.withCycleGuard)(ctx, decl, [], () => { + const profile = (0, usage_profile_1.usageProfileOf)(ctx, decl); + const values = decl.initializer ? [decl.initializer, ...profile.assignedValues] : profile.assignedValues; + const evidence = values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops)); + return (0, policy_1.decideType)(ctx, evidence, profile, identifierText(ctx, decl.name), false); + }); +} +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes(ctx, assignment, depth, chainHops) { + const { ts, checker } = ctx; + const concrete = (0, super_module_1.isConcreteExportAssignment)(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || (0, super_module_1.traceSuperModuleAccess)(ts, checker, assignment.right)) { + types.push(...resolveExpressionTypes(ctx, assignment.right, depth, chainHops + 1)); } - ctx.visiting.add(decl); - try { - const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); - if (resolved.length > 0) - return resolved; - return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, (0, usage_match_1.collectVariableMemberUsage)(ctx, decl), ts.isIdentifier(decl.name) ? decl.name.text : undefined); - } - finally { - ctx.visiting.delete(decl); - } + return types; } /** * 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}. + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. */ function resolveSuperModuleTypes(ctx, expr, 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); - } + // The guard catches overlay cycles from a misconfigured cartridge path. + return (0, context_1.withCycleGuard)(ctx, superFile, [], () => (0, type_helpers_1.dedupeTypes)(ctx, (0, super_module_1.collectExportAssignments)(superFile, ctx.ts).full.flatMap((assignment) => superModuleExportTypes(ctx, assignment, depth, chainHops)))); } /** - * 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). + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. */ function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, 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; + for (const { members } of (0, super_module_1.superModuleLevels)(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) + continue; + const types = matches.flatMap((member) => resolveExpressionTypes(ctx, member.expr, depth, chainHops + 1)); + return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); } return []; } /** * Infers the type of a callback's first parameter from sibling arguments of * the call the callback is passed to: `collections.forEach` / `map` / - * `filter` / `every` / `some` / `find` (see {@link ELEMENT_FIRST_CALLBACK_CALLEES}). - * A function expression in argument position has no name to run a reference - * search on, but the collection travelling alongside it names the element - * type. Only the first parameter is mapped (SFRA's collections util passes - * the element first). Unknown callees and `reduce` (accumulator first) are - * left alone — applying the heuristic to an arbitrary helper would guess wrong - * more often than it helps. + * `filter` / `every` / `some` / `find` / `first` (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. Unknown callees and + * `reduce` (accumulator first) are left alone — applying the heuristic to an + * arbitrary helper would guess wrong more often than it helps. */ function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { - const { ts, checker } = ctx; - if (paramIndex !== 0) - return []; + const { ts } = ctx; const call = fn.parent; - if (!call || !ts.isCallExpression(call) || !call.arguments.some((arg) => arg === fn)) + if (paramIndex !== 0 || !call || !ts.isCallExpression(call) || !call.arguments.includes(fn)) { return []; - const calleeName = ts.isPropertyAccessExpression(call.expression) - ? call.expression.name.text - : ts.isIdentifier(call.expression) - ? call.expression.text - : undefined; - if (!calleeName || !constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) - return []; - const types = []; - for (const arg of call.arguments) { - if (arg === fn) - continue; - for (const argType of resolveExpressionTypes(ctx, arg, depth)) { - const element = (0, type_helpers_1.collectionElementType)(ctx, argType, arg); - if (element) - types.push((0, type_helpers_1.widenType)(checker, element)); - } } - return types; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + if (!ts.isIdentifier(callee) || !constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text)) + return []; + return call.arguments + .filter((arg) => arg !== fn) + .flatMap((arg) => resolveExpressionTypes(ctx, arg, depth).map((type) => (0, type_helpers_1.collectionElementType)(ctx, type, arg))) + .filter((element) => element !== undefined); } /** - * 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`. + * Resolves the candidate type(s) of `expr`: the checker's own type when it is + * informative, otherwise a type recovered through whatever `expr` is built + * from (an undocumented callee's returns, a receiver's real members, a + * variable's values, a parameter's call sites). * * @param chainHops - how many `.method()`/`.prop` hops within the *same* * static expression have already been chased (e.g. the `2` in * `a.b().c().d()` when resolving `d`'s receiver `a.b().c()`). This is * distinct from `depth`, which only advances when crossing into another - * undocumented helper's own 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. + * undocumented helper's own inference — chain-hopping never crosses a + * function boundary, so it needs its own bound (`MAX_CHAIN_HOPS`) to keep the + * cost of a very long inline chain predictable. * @returns An array (rather than a single unioned Type) because the public - * TypeChecker API exposed via tsserverlibrary has no way to synthesize a - * union Type — callers merge candidates for display/completions themselves. + * TypeChecker API has no way to synthesize a union Type. */ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const { ts, checker } = ctx; - // module.superModule (or a `var base = module.superModule` alias) first, - // BEFORE trusting the checker's direct type: TS knows nothing about SFCC - // overlay semantics, and its type for these expressions is never - // meaningful — sometimes `any`, sometimes an opaque circular `typeof - // base` that would wrongly satisfy the not-any short-circuit below. - const superAccessAtRoot = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); - if (superAccessAtRoot) { - return resolveSuperModuleTypes(ctx, superAccessAtRoot, depth, chainHops); - } - const direct = checker.getTypeAtLocation(expr); - // Prefer a concrete checker type, but keep chasing through placeholder - // shapes (`any` / `object` / `{}`) the same way — SFRA JSDoc often types - // helpers as `{Object}` which checkJs widens to `any`, and an empty `{}` - // annotation is equally useless as a call-site candidate. - if (!(0, type_helpers_1.isOpenForUsageInference)(ts, direct)) - return [(0, type_helpers_1.widenType)(checker, direct)]; + // module.superModule (or a `var base = module.superModule` alias) first: + // TS knows nothing about SFCC overlay semantics, and its type for these + // expressions is never meaningful — sometimes `any`, sometimes an opaque + // circular `typeof base` that would pass for informative. + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); + if (superAccess) + return resolveSuperModuleTypes(ctx, superAccess, depth, chainHops); + const direct = (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(expr)); + if (direct.length > 0) + return direct; if (chainHops >= constants_1.MAX_CHAIN_HOPS) return []; - // The checker gave up. Dispatch on the kind of expression to a focused - // resolver. Each returns [] when it can't do better, so an unhandled kind - // (or an exhausted branch) falls through to []. if (ts.isCallExpression(expr)) return resolveCallResultTypes(ctx, expr, depth, chainHops); if (ts.isPropertyAccessExpression(expr)) return resolvePropertyTypes(ctx, expr, depth, chainHops); if (ts.isIdentifier(expr)) return resolveIdentifierTypes(ctx, expr, depth, chainHops); - // SFRA helpers often return through a ternary (`return it.hasNext() ? it.next() - // : null` — the body of `collections.first`) or a parenthesized subexpression. - // Without chasing both branches the whole return collapses to `any` even when - // the collection argument at the call site is fully typed. - if (ts.isConditionalExpression(expr)) { - return (0, type_helpers_1.dedupeTypes)(ctx, [ - ...resolveExpressionTypes(ctx, expr.whenTrue, depth, chainHops + 1), - ...resolveExpressionTypes(ctx, expr.whenFalse, depth, chainHops + 1), - ]); - } - if (ts.isParenthesizedExpression(expr)) { + if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); - } + // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) + // and `a || b` / `a ?? b` can each evaluate to either side. + if (ts.isConditionalExpression(expr)) + return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); + if (ts.isBinaryExpression(expr)) + return resolveLogicalTypes(ctx, expr, depth, chainHops); return []; } +function resolveAlternatives(ctx, alternatives, depth, chainHops) { + return (0, type_helpers_1.dedupeTypes)(ctx, alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1))); +} +/** `a || b` and `a ?? b` evaluate to either side; `a && b` to `b` when it's worth showing at all. */ +function resolveLogicalTypes(ctx, expr, depth, chainHops) { + const { SyntaxKind } = ctx.ts; + switch (expr.operatorToken.kind) { + case SyntaxKind.BarBarToken: + case SyntaxKind.QuestionQuestionToken: + return resolveAlternatives(ctx, [expr.left, expr.right], depth, chainHops); + case SyntaxKind.AmpersandAmpersandToken: + return resolveExpressionTypes(ctx, expr.right, depth, chainHops + 1); + default: + return []; + } +} /** - * 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`. + * The declared types of `access`'s member on each candidate type of its + * receiver (`x.getPriceModel` when `x` is undocumented but inferable). When + * no receiver type carries the member and the receiver is (an alias of) + * `module.superModule`, the member may be an export augmentation added by a + * pass-through overlay level, which no export type can carry. */ -function resolveCallResultTypes(ctx, expr, depth, chainHops) { +function memberTypesOfReceiver(ctx, access, depth, chainHops) { const { ts, checker } = ctx; - const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) - return inferred; - } - // resolveCalleeDeclaration can't find a real declaration for a method whose - // receiver base is undocumented (the checker never resolved the method), so - // infer the receiver's type first, then look this method up by name on it. - if (!ts.isPropertyAccessExpression(expr.expression)) - return []; - const methodAccess = expr.expression; - const methodName = methodAccess.name.text; - const returnTypes = []; - const pushSignatureReturns = (methodType) => { - for (const sig of methodType.getCallSignatures()) { - const returnType = checker.getReturnTypeOfSignature(sig); - if (!(0, type_helpers_1.isAnyType)(ts, returnType)) { - returnTypes.push((0, type_helpers_1.widenType)(checker, returnType)); - continue; - } - // The member resolved but its own return type is `any` — the - // superModule case, where the base module's export type carries an - // undocumented function. `any` is never a useful candidate to surface; - // recurse into the function's actual declaration instead, the same - // fallback resolveCalleeDeclaration provides for direct calls. - const sigDecl = sig.declaration; - if (sigDecl && ts.isFunctionLike(sigDecl)) { - returnTypes.push(...inferReturnType(ctx, sigDecl, depth + 1)); - } - } - }; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { - const methodSymbol = (0, type_helpers_1.getMemberOfType)(checker, receiverType, methodName); - if (!methodSymbol) - continue; - pushSignatureReturns(checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name)); - } - if (returnTypes.length === 0) { - // No candidate type carried this method — but if the receiver is (an - // alias of) module.superModule, the method may be an export - // *augmentation* added by a pass-through overlay level, which no - // candidate type can carry. - const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, methodAccess.expression); - if (superAccess) { - for (const memberType of resolveSuperModuleMemberTypes(ctx, superAccess, methodName, depth, chainHops)) { - pushSignatureReturns(memberType); - } - } - } - return returnTypes.length > 0 ? (0, type_helpers_1.dedupeTypes)(ctx, returnTypes) : []; + const memberName = access.name.text; + const types = resolveExpressionTypes(ctx, access.expression, depth, chainHops + 1).flatMap((receiverType) => { + const member = (0, type_helpers_1.getMemberOfType)(checker, receiverType, memberName); + return member ? [checker.getTypeOfSymbolAtLocation(member, access.name)] : []; + }); + if (types.length > 0) + return types; + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, access.expression); + return superAccess ? resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) : []; } /** - * 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. + * The return types of `methodType`'s call signatures. A signature returning + * `any` (an undocumented function reached through a superModule export type) + * is inferred from its own declaration instead. */ -function resolvePropertyTypes(ctx, expr, depth, chainHops) { +function signatureReturnTypes(ctx, methodType, depth) { 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) : []; + return methodType.getCallSignatures().flatMap((signature) => { + const returnType = checker.getReturnTypeOfSignature(signature); + if (!(0, type_helpers_1.isAnyType)(ts, returnType)) + return [returnType]; + const declaration = signature.declaration; + return declaration && ts.isFunctionLike(declaration) ? inferReturnType(ctx, declaration, depth + 1) : []; + }); } /** - * 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. + * Resolves an `any` call expression: first by inferring the callee's own + * return type, then — for a method call on an undocumented receiver + * (`x.getPriceModel().getPrice()`) — from the method's real signature(s) on + * the receiver's inferred type(s). */ +function resolveCallResultTypes(ctx, expr, depth, chainHops) { + const calleeFn = resolveCalleeDeclaration(ctx, expr); + const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; + if (inferred.length > 0 || !ctx.ts.isPropertyAccessExpression(expr.expression)) + return inferred; + return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, depth, chainHops).flatMap((methodType) => signatureReturnTypes(ctx, methodType, depth))); +} +/** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ +function resolvePropertyTypes(ctx, expr, depth, chainHops) { + return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, expr, depth, chainHops).filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); +} +/** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { const { ts, 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 resolveVariableTypes(ctx, decl, depth, chainHops + 1); return []; } /** - * Collects argument types at `paramIndex` across every call/`new` site of - * `nameNode`. A bare `new Helper` (no parens) has `arguments === undefined` - * and contributes nothing. - */ -function collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) { - const types = []; - for (const call of (0, call_sites_1.collectCallSites)(ctx, nameNode)) { - const arg = call.arguments?.[paramIndex]; - if (!arg) - continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - return types; -} -/** - * True when `type` exposes every member name in `memberNames`. Used to drop - * call-site candidates that can't actually support the parameter's own body - * — the classic SFRA duck-typing trap where a Store *model* is passed into a - * helper that also reads CustomerAddress-only fields (`companyName`, - * `postBox`, …). Without this filter the resolvable model wins the hover - * even though the body is not a Store. - */ -function typeSatisfiesMemberUsage(ctx, type, memberNames) { - if (memberNames.size === 0) - return true; - for (const name of memberNames) { - if (!(0, type_helpers_1.getMemberOfType)(ctx.checker, type, name)) - return false; - } - return true; -} -/** - * Turns raw call-site/callback candidates into the final answer for a - * parameter: drop types the body can't use, dedupe, silence conflicting - * top-level unions, otherwise fall back to ambient usage matching when - * nothing resolved. + * The argument types a parameter receives: at every call site of its + * function across the project (`helper(x)`, `new Helper(x)`, + * `Helper.call(this, x)`), or — for an anonymous callback with no name to + * search for — from the collection it iterates. */ -function finalizeParameterCandidates(ctx, param, types, depth) { - const { ts } = ctx; - const usage = (0, usage_match_1.collectParameterMemberUsage)(ctx, param); - const raw = (0, type_helpers_1.dedupeTypes)(ctx, types); - // Conflicting call-site arguments (e.g. Product at one site, Order at - // another) are not a useful hover — silence rather than a noisy union, - // and do NOT fall through to ambient matching: we already have evidence, - // it just doesn't converge. - // - // Only enforce this at depth 0 (a top-level hover/completion on the - // parameter itself). Recursive callers that chase through a forwarding - // helper still need the full candidate set so return-type inference and - // the typeToString memo baselines keep working; the editor never shows - // those intermediate unions unlabeled. - if (depth === 0 && raw.length > constants_1.MAX_CALL_SITE_CANDIDATES) - return []; - // Keep only call-site types that expose every member the parameter body - // actually touches. A partial resolution (one duck-typed Store model call - // site resolves, an untyped preferredAddress site doesn't) must not surface - // "Store" on a helper whose body also reads address-only fields the model - // never declares. - const result = (0, type_helpers_1.dedupeTypes)(ctx, raw.filter((t) => typeSatisfiesMemberUsage(ctx, t, usage))); - if (result.length > 0) - return result; - // No usable call-site type (none found, none resolved, or none that fit - // the body). Match how the parameter's own body uses it against the - // program's ambient classes instead. - return (0, usage_match_1.matchAmbientTypesByUsage)(ctx, usage, ts.isIdentifier(param.name) ? param.name.text : undefined); +function parameterEvidence(ctx, fn, paramIndex, depth) { + const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ctx.ts); + if (!nameNode) + return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); + return (0, call_sites_1.collectCallSites)(ctx, nameNode).flatMap((site) => { + const arg = site.args[paramIndex]; + return arg ? resolveExpressionTypes(ctx, arg, depth) : []; + }); } /** - * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project — a plain call (`helper(x)`) or a - * constructor invocation (`new Helper(x)`, SFRA's other common "class" model - * shape) — since plain un-annotated JS parameters default to `any` with no - * back-inference from call sites. Falls back to matching the parameter's own - * usage (which members it's accessed by) against the program's ambient - * classes when no call site could be found or resolved at all — see - * {@link matchAmbientTypesByUsage}. + * Infers a parameter's type(s): from a platform contract when the function + * is one the platform calls (see ./framework-contracts), otherwise from the + * arguments it receives and its own usage (see ./policy for how the two are + * weighed). Plain un-annotated JS parameters default to `any` with no + * back-inference from call sites, which is the gap this fills. * * @param depth - Recursion budget already consumed by the call chain that * led here; defaults to 0 for a top-level request. */ function inferParameterType(ctx, param, depth = 0) { const { ts } = ctx; - // 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)) + const fn = param.parent; + if ((0, ast_helpers_1.hasExplicitParameterType)(param, ts) || !ts.isFunctionLike(fn)) 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 []; + return (0, context_1.withInferenceGuards)(ctx, param, depth, () => { const paramIndex = fn.parameters.indexOf(param); - if (paramIndex < 0) - return []; - const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ts); - // `instanceof dw.order.ProductLineItem` (and friends) is concrete class - // evidence from the body itself — merge it with call-site candidates so a - // helper that never sees a typed call site still recovers the class the - // author named. finalizeParameterCandidates still silences multi-type - // unions at depth 0, so a polymorphic Adyen-style branch stays quiet. - const types = [ - ...(nameNode - ? collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) - : // No name to search references for — an anonymous callback passed - // directly in argument position. Its element type may still be - // recoverable from the collection argument travelling alongside it. - inferCallbackParameterTypes(ctx, fn, paramIndex, depth)), - ...(0, usage_match_1.collectParameterInstanceOfTypes)(ctx, param), - ]; - const result = finalizeParameterCandidates(ctx, param, types, depth); - // Don't memoize a result whose computation hit a cycle guard: it was - // truncated by what happened to be on the *current* call stack, and the - // same node queried later in this request from outside the cycle could - // legitimately resolve more. (Depth-cap truncation, by contrast, IS - // safely memoized — the atDepth field encodes exactly how truncated it - // can be, and reuse is restricted accordingly.) - if (ctx.cycleHits === cycleHitsBefore) { - ctx.memo.set(param, { atDepth: depth, types: result }); - } - return result; - } - finally { - ctx.visiting.delete(param); - } + const contract = (0, framework_contracts_1.frameworkParameterTypes)(ctx, fn, paramIndex); + if (contract.length > 0) + return contract; + const evidence = parameterEvidence(ctx, fn, paramIndex, depth); + return (0, policy_1.decideType)(ctx, evidence, (0, usage_profile_1.usageProfileOf)(ctx, param), identifierText(ctx, param.name), true); + }); } /** - * 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 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)) + if ((0, ast_helpers_1.hasExplicitReturnType)(fn, ctx.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); - } + return (0, context_1.withInferenceGuards)(ctx, fn, depth, () => { + const types = (0, ast_helpers_1.collectReturnExpressions)(fn, ctx.ts).flatMap((expr) => resolveExpressionTypes(ctx, expr, depth)); + return (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, types)); + }); } /** - * Entry point for 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 hover and completions on an identifier: infers the type of + * the parameter or variable it names, or the return type of the function it + * names. */ function inferTypeForNode(ctx, node) { const { ts, checker } = ctx; if (!ts.isIdentifier(node)) return []; - const sym = checker.getSymbolAtLocation(node); - const decl = sym?.valueDeclaration; + const decl = checker.getSymbolAtLocation(node)?.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.isVariableDeclaration(decl)) + return resolveVariableTypes(ctx, decl, 0, 0); if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); return []; } /** - * Like {@link inferTypeForNode}, but for an arbitrary expression 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. + * Like {@link inferTypeForNode}, but for an arbitrary expression — the + * completion case `product.getPriceModel().|` and member hovers, where the + * thing to type is a call or chain rather than a declared name. */ function inferTypeForExpression(ctx, expr) { - const { ts } = ctx; - if (ts.isIdentifier(expr)) + if (ctx.ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); - return (0, type_helpers_1.dedupeTypes)(ctx, resolveExpressionTypes(ctx, expr, 0)); + return (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, resolveExpressionTypes(ctx, expr, 0))); } diff --git a/packages/b2c-script-types/plugin/inference/framework-contracts.js b/packages/b2c-script-types/plugin/inference/framework-contracts.js new file mode 100644 index 000000000..60789cff4 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/framework-contracts.js @@ -0,0 +1,33 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.frameworkParameterTypes = frameworkParameterTypes; +const ambient_index_1 = require("./ambient-index"); +const call_sites_1 = require("./call-sites"); +const FRAMEWORK_CONTRACTS = [ + { + directory: '/cartridge/experience/components/', + functionName: 'render', + parameterTypes: ['ComponentScriptContext', 'Map'], + }, + { + directory: '/cartridge/experience/pages/', + functionName: 'render', + parameterTypes: ['PageScriptContext', 'Map'], + }, +]; +/** The contract-defined type of `fn`'s parameter at `paramIndex`, if `fn` is a platform entry point. */ +function frameworkParameterTypes(ctx, fn, paramIndex) { + const functionName = (0, call_sites_1.getReferenceNameNode)(fn, ctx.ts)?.text; + if (!functionName) + return []; + const fileName = fn.getSourceFile().fileName.replace(/\\/g, '/'); + const contract = FRAMEWORK_CONTRACTS.find((candidate) => candidate.functionName === functionName && fileName.includes(candidate.directory)); + const typeName = contract?.parameterTypes[paramIndex]; + const type = typeName ? (0, ambient_index_1.findAmbientClassType)(ctx, typeName) : undefined; + return type ? [type] : []; +} diff --git a/packages/b2c-script-types/plugin/inference/naming.js b/packages/b2c-script-types/plugin/inference/naming.js new file mode 100644 index 000000000..7aea9be78 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/naming.js @@ -0,0 +1,80 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pickByName = pickByName; +// Identifier-name hints. SFCC code conventionally names a variable after the +// Script API class it holds (`customer`, `currentBasket`, `apiProduct`, +// `pli`). Usage alone often fits several classes (`.email` + `.firstName` +// fits both Profile and ProductListRegistrant), so the name is used to pick +// one — but only ever among classes the usage already fits: a name never adds +// a candidate of its own. +// Abbreviations whose class the identifier does not spell out, keyed and +// valued in lowercase. Everything else is derived from the identifier's own +// camelCase words, so this stays short. +const ABBREVIATIONS = new Map([ + ['pli', 'productlineitem'], + ['lineitem', 'productlineitem'], + ['paymentinstrument', 'orderpaymentinstrument'], + ['shippingaddress', 'orderaddress'], + ['billingaddress', 'orderaddress'], +]); +// All-lowercase qualifiers SFRA code glues onto a class name +// (`apiproduct`, `currentbasket`): stripping them leaves the class. +const QUALIFIER_PREFIXES = ['api', 'current', 'default', 'selected', 'new']; +// The expansion goes first: SFRA's `lineItem` parameters hold a +// ProductLineItem, even where the usage would also fit the LineItem base. +function withAbbreviation(key) { + const expanded = ABBREVIATIONS.get(key); + return expanded ? [expanded, key] : [key]; +} +/** + * Lowercased class names `identifier` conventionally denotes, most specific + * first: the whole name, then each shorter camelCase word suffix + * (`resettingCustomer` → `customer`, `productShippingLineItem` → + * `shippinglineitem` → `lineitem` → `item`), each preceded by its + * abbreviation expansion, then the name with a qualifier prefix stripped. + * Splitting on camelCase boundaries only is what keeps an all-lowercase + * `border` from reading as `Order`. + */ +function conventionalClassNames(identifier) { + const words = identifier.split(/(?=[A-Z])/); + const keys = words.flatMap((_, start) => withAbbreviation(words.slice(start).join('').toLowerCase())); + const lower = identifier.toLowerCase(); + for (const prefix of QUALIFIER_PREFIXES) { + if (lower.length > prefix.length && lower.startsWith(prefix)) + keys.push(...withAbbreviation(lower.slice(prefix.length))); + } + return keys; +} +/** + * Picks the one candidate `identifier` names, or `undefined` when the name + * points at none of them (or at several equally). Besides + * {@link conventionalClassNames}, a camelCase identifier that is the tail of + * exactly one candidate's name also counts (`priceModel` → + * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). + */ +function pickByName(identifier, candidates) { + const byName = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = byName.get(key); + if (named) + named.push(candidate); + else + byName.set(key, [candidate]); + } + for (const key of conventionalClassNames(identifier)) { + const named = byName.get(key); + if (named?.length === 1) + return named[0]; + } + if (identifier === identifier.toLowerCase()) + return undefined; + const lower = identifier.toLowerCase(); + const tails = candidates.filter((candidate) => candidate.name.toLowerCase().endsWith(lower)); + return tails.length === 1 ? tails[0] : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js new file mode 100644 index 000000000..0a112f611 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -0,0 +1,186 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizeCandidates = normalizeCandidates; +exports.limitUnion = limitUnion; +exports.matchAmbientTypesByUsage = matchAmbientTypesByUsage; +exports.decideType = decideType; +const ambient_index_1 = require("./ambient-index"); +const constants_1 = require("./constants"); +const naming_1 = require("./naming"); +const type_helpers_1 = require("./type-helpers"); +const NO_MEMBERS = new Set(); +/** The members every value the profile describes must have: those used without a presence test first. */ +function requiredMembers(profile) { + if (profile.optionalMemberNames.size === 0) + return profile.memberNames; + return new Set([...profile.memberNames].filter((name) => !profile.optionalMemberNames.has(name))); +} +/** Step 1: split unions, widen literals, drop uninformative types, dedupe by display. */ +function normalizeCandidates(ctx, types) { + return (0, type_helpers_1.dedupeTypes)(ctx, types.flatMap((type) => (0, type_helpers_1.informativeParts)(ctx, type))); +} +/** The class or interface behind `type` (`Product` for `Product`), if it is one. */ +function classOf(ctx, type) { + const { ts } = ctx; + if (!(type.flags & ts.TypeFlags.Object)) + return undefined; + const objectType = type; + const target = objectType.objectFlags & ts.ObjectFlags.Reference ? type.target : objectType; + return target.objectFlags & ts.ObjectFlags.ClassOrInterface ? target : undefined; +} +/** Every superclass and implemented interface of `type`, nearest first (excluding `type` itself). */ +function ancestorsOf(ctx, type) { + const ancestors = []; + const start = classOf(ctx, type); + for (let queue = start ? [start] : []; queue.length > 0;) { + const current = queue.shift(); + for (const base of ctx.checker.getBaseTypes(current)) { + const baseClass = classOf(ctx, base); + if (baseClass && baseClass !== start && !ancestors.includes(baseClass)) { + ancestors.push(baseClass); + queue.push(baseClass); + } + } + } + return ancestors; +} +function isAncestorOf(ctx, ancestor, type) { + const ancestorClass = classOf(ctx, ancestor); + return ancestorClass !== undefined && ancestorsOf(ctx, type).includes(ancestorClass); +} +/** The class behind `type` and its ancestors, root classes (`PersistentObject`, ...) aside. */ +function lineageOf(ctx, type) { + const own = classOf(ctx, type); + return [...(own ? [own] : []), ...ancestorsOf(ctx, type)].filter((lineageClass) => !constants_1.UNINFORMATIVE_ANCESTORS.has(lineageClass.symbol?.name ?? '')); +} +/** + * What the value is passed or assigned as, each use as the informative + * alternatives it accepts (`Product | null` accepts a `Product`; an `any`, + * `Object` or `any[]` use accepts anything and is left out). + */ +function constrainingUses(ctx, profile) { + return profile.contextualTypes.map((use) => (0, type_helpers_1.informativeParts)(ctx, use)).filter((parts) => parts.length > 0); +} +/** + * True when `type` can stand wherever the body uses the value: assignable to + * (an alternative of) each use, or at least from the same class family (an + * `Order` passed to a helper documented as taking a `Basket` that really + * takes any `LineItemCtnr`, a common over-narrow JSDoc). A different kind of + * value never fits: a `string` argument at one buggy call site of + * `getBonusUnitPrice(apiProduct)`, whose body passes it on as a `Product`. + * TypeScript builds without the public `isTypeAssignableTo` skip the check. + */ +function fitsEveryUse(ctx, type, uses) { + const { checker } = ctx; + if (uses.length === 0 || typeof checker.isTypeAssignableTo !== 'function') + return true; + const lineage = lineageOf(ctx, type); + const fits = (use) => checker.isTypeAssignableTo(type, use) || lineageOf(ctx, use).some((useClass) => lineage.includes(useClass)); + return uses.every((alternatives) => alternatives.some(fits)); +} +/** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ +function mostGeneral(ctx, types) { + const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); + return types.filter((type) => !ancestorsOf(ctx, type).some((ancestor) => candidateClasses.has(ancestor))); +} +/** The nearest superclass all `types` share that still has `memberNames`, ignoring root classes. */ +function closestCommonAncestor(ctx, types, memberNames) { + const [first, ...rest] = types; + const restAncestors = rest.map((type) => ancestorsOf(ctx, type)); + return ancestorsOf(ctx, first).find((ancestor) => !constants_1.UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && + restAncestors.every((ancestors) => ancestors.includes(ancestor)) && + (0, type_helpers_1.hasAllMembers)(ctx.checker, ancestor, memberNames)); +} +/** Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, else the closest shared superclass, else silence. */ +function limitUnion(ctx, types, memberNames = NO_MEMBERS) { + const general = mostGeneral(ctx, types); + if (general.length <= constants_1.MAX_UNION_TYPES) + return general; + const ancestor = closestCommonAncestor(ctx, general, memberNames); + return ancestor ? [ancestor] : []; +} +/** + * The single most specific type the value is used as: one every other such + * type is a superclass of (`basket` passed both where a `Basket` and where a + * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. + */ +function mostSpecificUse(ctx, profile, required) { + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => (0, type_helpers_1.hasAllMembers)(ctx.checker, type, required)); + const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); + return mostSpecific.length === 1 ? mostSpecific : []; +} +/** Resolves matched index entries to types, merging declarations of the same symbol. */ +function resolveMatches(ctx, classes) { + const bySymbol = new Map(); + for (const ambientClass of classes) { + const type = (0, ambient_index_1.ambientClassType)(ctx, ambientClass); + const symbol = type?.getSymbol(); + if (type && symbol && !bySymbol.has(symbol)) + bySymbol.set(symbol, { name: ambientClass.name, type }); + } + return [...bySymbol.values()]; +} +/** + * Step 4b: matches a usage signature against every ambient class (see + * ./ambient-index). Silent when no class has every member; when only + * ubiquitous members (`.custom`, `.UUID`) were used and several classes fit; + * and when a single member fits several classes the identifier name can't + * choose between. A name that denotes a class the usage does *not* fit + * never falls back to a vaguer reading of the same name + * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and + * silences a single-member signature outright: `lineItem.preorderable` + * uniquely matching ProductInventoryRecord is a coincidence, not a hint. + * + * @param identifierName - the parameter's or variable's own name, used only + * to pick among classes the usage already fits (see ./naming). + */ +function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { + if (memberNames.size === 0) + return []; + const classes = (0, ambient_index_1.getAmbientClasses)(ctx); + const fitting = classes.filter((ambientClass) => [...memberNames].every((name) => ambientClass.memberNames.has(name))); + const onlyWeakMembers = [...memberNames].every((name) => constants_1.WEAK_USAGE_MEMBERS.has(name)); + if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) + return []; + const matches = resolveMatches(ctx, fitting); + const denoted = identifierName ? (0, naming_1.pickByName)(identifierName, classes) : undefined; + const named = denoted + ? matches.find((match) => match.name === denoted.name) + : identifierName && (0, naming_1.pickByName)(identifierName, matches); + if (named) + return [named.type]; + const thinSignature = memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS; + if (thinSignature && (denoted || matches.length > 1)) + return []; + return limitUnion(ctx, matches.map((match) => match.type), memberNames); +} +/** + * Decides what a parameter or variable holds from the evidence ./core + * gathered for it (call-site arguments, assigned values, framework + * callbacks) plus its own usage profile. + * + * @param fitUsage - drop candidates lacking a member the profile shows in + * use. On for parameters, whose call sites are independent samples that may + * pass a look-alike; off for a variable's own assigned values. + */ +function decideType(ctx, evidence, profile, identifierName, fitUsage) { + const guards = normalizeCandidates(ctx, profile.guardTypes); + const guardKeys = new Set(guards.map((type) => (0, type_helpers_1.typeDisplayString)(ctx, type))); + const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); + const required = requiredMembers(profile); + const uses = constrainingUses(ctx, profile); + const fits = (type) => guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type)) || + ((0, type_helpers_1.hasAllMembers)(ctx.checker, type, required) && fitsEveryUse(ctx, type, uses)); + const fitting = fitUsage ? candidates.filter(fits) : candidates; + if (fitting.length > 0) + return limitUnion(ctx, fitting, required); + const used = mostSpecificUse(ctx, profile, required); + if (used.length > 0) + return used; + return matchAmbientTypesByUsage(ctx, profile.memberNames, identifierName); +} diff --git a/packages/b2c-script-types/plugin/inference/super-module.js b/packages/b2c-script-types/plugin/inference/super-module.js index 4019f2229..9f49513a1 100644 --- a/packages/b2c-script-types/plugin/inference/super-module.js +++ b/packages/b2c-script-types/plugin/inference/super-module.js @@ -9,6 +9,7 @@ exports.findSuperModuleFile = findSuperModuleFile; exports.collectExportAssignments = collectExportAssignments; exports.isConcreteExportAssignment = isConcreteExportAssignment; exports.traceSuperModuleAccess = traceSuperModuleAccess; +exports.superModuleLevels = superModuleLevels; exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; const constants_1 = require("./constants"); const type_helpers_1 = require("./type-helpers"); @@ -58,27 +59,25 @@ function collectExportAssignments(sf, ts) { if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; const bin = stmt.expression; - if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken) + if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(bin.left)) 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') { + const base = bin.left.expression; + if (isModuleExports(base, ts)) + members.push({ name: bin.left.name.text, expr: bin.right }); + else if (ts.isIdentifier(base) && base.text === 'exports') + members.push({ name: bin.left.name.text, expr: bin.right }); + else if (isModuleExports(bin.left, ts)) 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 }; } +/** `module.exports`, identified structurally. */ +function isModuleExports(expr, ts) { + return (ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports'); +} /** * True when a `module.exports = X` assignment gives the checker a genuinely * usable exports type: not `any`, and actually exposing members. A @@ -118,13 +117,39 @@ function traceSuperModuleAccess(ts, checker, expr) { } return undefined; } +/** True when a level re-exports what is below it (`module.exports = base`) rather than replacing it. */ +function passesThrough(ctx, exports) { + const { ts, checker } = ctx; + return exports.full.some((bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined); +} +/** + * Walks the superModule chain below the file containing `superAccess`, one + * cartridge level at a time, yielding each level's export assignments. The + * walk continues downward only through a pass-through level + * (`module.exports = base`): a level with a concrete `module.exports` + * replaces everything below it at runtime, unless it carries the base along. + */ +function* superModuleLevels(ctx, superAccess) { + const seen = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < constants_1.MAX_SUPERMODULE_HOPS; hop++) { + const superFile = findSuperModuleFile(ctx, fromFileName); + if (!superFile || seen.has(superFile)) + return; + seen.add(superFile); + const exports = collectExportAssignments(superFile, ctx.ts); + yield exports; + if (!passesThrough(ctx, exports)) + return; + fromFileName = superFile.fileName; + } +} /** * 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. + * pass-through levels — the members the superModule's export types cannot + * carry. Used to complete after `base.` in an overlay; the first (highest) + * level defining a name wins, matching runtime override order. */ function collectSuperModuleAugmentedMembers(ctx, expr) { const { ts, checker } = ctx; @@ -133,25 +158,13 @@ function collectSuperModuleAugmentedMembers(ctx, expr) { 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)) + for (const { members } of superModuleLevels(ctx, superAccess)) { + for (const member of members) { + if (seenNames.has(member.name)) continue; - seenNames.add(m.name); - const type = checker.getTypeAtLocation(m.expr); - out.push({ name: m.name, isMethod: type.getCallSignatures().length > 0 }); + seenNames.add(member.name); + out.push({ name: member.name, isMethod: checker.getTypeAtLocation(member.expr).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 index 73c42592e..f2bb2e6e2 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -7,13 +7,14 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.isAnyType = isAnyType; exports.isOpenForUsageInference = isOpenForUsageInference; -exports.widenType = widenType; +exports.informativeParts = informativeParts; exports.typeDisplayString = typeDisplayString; exports.dedupeTypes = dedupeTypes; -exports.getNonNullableApparentType = getNonNullableApparentType; exports.getMemberOfType = getMemberOfType; +exports.hasAllMembers = hasAllMembers; exports.collectionElementType = collectionElementType; exports.describeTypes = describeTypes; +exports.inferredCompletionEntry = inferredCompletionEntry; 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. */ @@ -22,25 +23,20 @@ function isAnyType(ts, type) { } /** * 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. + * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or + * the global `Object` interface. Used as the hover/completion gate. * * Deliberately excludes named classes (even wrong ones like a mis-documented * `Request`) — those are strong enough that overriding them would fight both * TypeScript and IntelliJ's JSDoc-first model. * - * 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. + * `Object` is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O, distinct from the lowercase `object` + * non-primitive and from `{*}`/`{}`/`{obj}`, which widen to `any` or an empty + * type). It carries no Script API information, so a value typed as bare + * `Object` is effectively undocumented — exactly the case usage inference + * exists for. No dw.* class is named plain `Object`, so keying on the name + * can't shadow a real Script API type. */ function isOpenForUsageInference(ts, type) { if (isAnyType(ts, type)) @@ -50,96 +46,138 @@ 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 symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; +} +// Type flags that never make a useful hover or completion candidate on their +// own: `null`/`undefined` (the empty branch of `x ? y : null`), `void`, +// `never`, `unknown`, and an unbound type parameter (`T` from a generic +// declaration nobody instantiated). +function uninformativeFlags(ts) { + return (ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Never | + ts.TypeFlags.Void | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.TypeParameter); +} +/** An object type with no members and no signatures: the `{}` of `options || {}`. */ +function isEmptyObjectType(ts, checker, type) { + return ((type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + type.getCallSignatures().length === 0 && + type.getConstructSignatures().length === 0 && + checker.getIndexInfosOfType(type).length === 0); +} +/** + * True when `type` tells a reader something: not one of the + * {@link uninformativeFlags} kinds, not open for inference (see + * {@link isOpenForUsageInference}), not an empty object, and — for an array — + * an array of something informative (`any[]`, `T[]` and `undefined[]` are as + * empty as `any`). + */ +function isInformativeType(ts, checker, type) { + if (type.flags & uninformativeFlags(ts)) + return false; + if (isOpenForUsageInference(ts, type) || isEmptyObjectType(ts, checker, type)) + return false; + if (!checker.isArrayType(type)) return true; - return false; + const [element] = checker.getTypeArguments(type); + return element !== undefined && isInformativeType(ts, checker, element); } /** - * 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. + * Splits `type` into the parts worth showing: union members are considered + * one by one (so `Product | null` keeps `Product`), literals are widened to + * their primitive (`"a" | "b"` reads as `string`), and every + * non-informative part is dropped. */ -function widenType(checker, type) { - return checker.getBaseTypeOfLiteralType(type); +function informativeParts(ctx, type) { + const { ts, checker } = ctx; + const parts = type.isUnion() ? type.types : [type]; + return parts + .map((part) => checker.getBaseTypeOfLiteralType(part)) + .filter((part) => isInformativeType(ts, checker, part)); } /** - * `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. + * True when a generic instantiation's type arguments add nothing a reader + * needs: each one is still an unbound type parameter (the declared type of + * `Product`, as ambient matching produces it), `any`, or exactly the + * parameter's declared default (`Product`). */ -function computeTypeDisplayString(checker, type) { - let simple = checker.typeToString(type); - // Ambient usage-matching indexes generic Script API classes via their - // unsubstituted declared type (`Product`). With no call-site - // instantiation to substitute from, surface the conventional SFCC form - // `Product` instead of a dangling type-parameter name. Only rewrite - // single-letter param slots (`T`, `T, U`) — never real arguments like - // `Product`. - simple = simple.replace(/<([A-Z](?:\s*,\s*[A-Z])*)>/g, (_match, inner) => { - const params = inner.split(/\s*,\s*/); - return `<${params.map(() => 'any').join(', ')}>`; +function hasOnlyDefaultTypeArguments(ts, checker, type) { + if (!(type.flags & ts.TypeFlags.Object)) + return true; + if (!(type.objectFlags & ts.ObjectFlags.Reference)) + return true; + const reference = type; + const parameters = reference.target.typeParameters ?? []; + return checker.getTypeArguments(reference).every((argument, index) => { + if (argument.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any)) + return true; + const parameter = parameters[index]; + return parameter !== undefined && checker.getDefaultFromTypeParameter(parameter) === argument; }); +} +/** + * The name a reader knows a class or interface by. Usually its own name; a + * declaration nested in a namespace keeps the namespace (the vendored + * `ICustomAttributes.Shipment`, the type of `shipment.custom`, must not read + * as the unrelated `Shipment` class). The `global.` wrapper of + * `declare global` and a quoted ambient-module prefix + * (`"server/server".Response`) are implementation details and are dropped. + */ +function classDisplayName(checker, symbol) { + return checker + .getFullyQualifiedName(symbol) + .replace(/^global\./, '') + .replace(/^"[^"]*"\./, ''); +} +/** + * Renders a candidate type for hover text and dedupe keys. A class or + * interface instantiated only with default type arguments reads as the bare + * class name (`Product`, `LineItemCtnr`); everything else — real generic + * arguments (`Collection`), primitives, object literals, lib types — + * is rendered by the checker as TypeScript itself would. + */ +function computeTypeDisplayString(ts, checker, type) { const symbol = type.getSymbol(); - if (!symbol) - return simple; - const qualified = checker.getFullyQualifiedName(symbol).replace(/^global\./, ''); - return qualified === symbol.getName() ? simple : qualified; + const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; + if (isClassOrInterface && hasOnlyDefaultTypeArguments(ts, checker, type)) + return classDisplayName(checker, symbol); + return checker.typeToString(type); } /** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ function typeDisplayString(ctx, type) { const cached = ctx.typeDisplayStrings.get(type); if (cached !== undefined) return cached; - const str = computeTypeDisplayString(ctx.checker, type); - ctx.typeDisplayStrings.set(type, str); - return str; + const display = computeTypeDisplayString(ctx.ts, ctx.checker, type); + ctx.typeDisplayStrings.set(type, display); + return display; } /** * Deduplicates candidate types by their display string. Two distinct types - * that happen to render identically (e.g. same-named classes from different - * modules) collapse into one — acceptable here because every consumer of the + * that render identically (`Product` and `Product` both read as + * `Product`) collapse into one — acceptable because every consumer of the * result is display-oriented (hover text, completion-member names). */ function dedupeTypes(ctx, types) { const seen = new Set(); - const out = []; - for (const t of types) { - const key = typeDisplayString(ctx, t); + return types.filter((type) => { + const key = typeDisplayString(ctx, type); if (seen.has(key)) - continue; + return false; seen.add(key); - out.push(t); - } - return out; + return true; + }); } /** - * 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. + * Strips any nullable part from `type` and computes its apparent type before + * a member lookup. `getPropertyOfType` on a union only returns members common + * to *every* constituent, so an un-stripped `Product | null` (the shape of + * `ProductMgr.getProduct()`) would never resolve any member; the apparent + * type also exposes a primitive's wrapper-object members (`.length`). */ function getNonNullableApparentType(checker, type) { return checker.getApparentType(checker.getNonNullableType(type)); @@ -148,6 +186,14 @@ function getNonNullableApparentType(checker, type) { function getMemberOfType(checker, type, name) { return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); } +/** True when `type` exposes every member name in `memberNames` (vacuously true for none). */ +function hasAllMembers(checker, type, memberNames) { + for (const name of memberNames) { + if (!getMemberOfType(checker, type, name)) + return false; + } + return true; +} /** * Extracts the element type from a collection-like `type`: something with an * `iterator()` method whose result has a typed `next()` (dw.util.Collection @@ -161,14 +207,11 @@ function getMemberOfType(checker, type, name) { function collectionElementType(ctx, type, location) { const { ts, checker } = ctx; const firstCallReturn = (t, memberName) => { - const sym = checker.getPropertyOfType(getNonNullableApparentType(checker, t), memberName); + const sym = getMemberOfType(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 [signature] = checker.getTypeOfSymbolAtLocation(sym, location).getCallSignatures(); + return signature && checker.getReturnTypeOfSignature(signature); }; const iteratorType = firstCallReturn(type, 'iterator') ?? type; const element = firstCallReturn(iteratorType, 'next'); @@ -178,18 +221,23 @@ function collectionElementType(ctx, type, location) { return undefined; return element; } +/** Renders candidate types as hover text, e.g. `"Product | Category"`. */ +function describeTypes(ctx, types) { + return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); +} /** - * 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. + * One synthesized member completion. `sortText` '11' mirrors TS's own + * SortText.LocationPriority — the rank ordinary resolved members get — so + * inferred members sort alongside real ones rather than above or below them. */ -function describeTypes(checker, types) { - const seen = new Set(); - for (const t of types) { - seen.add(computeTypeDisplayString(checker, t)); - } - return [...seen].join(' | '); +function inferredCompletionEntry(ts, name, isMethod) { + return { + name, + kind: isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: constants_1.INFERRED_COMPLETION_SOURCE, + }; } /** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ function typesToCompletionEntries(ts, checker, types) { @@ -201,19 +249,7 @@ function typesToCompletionEntries(ts, checker, types) { 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, - }); + entries.push(inferredCompletionEntry(ts, name, (sym.flags & ts.SymbolFlags.Method) !== 0)); } } return entries; diff --git a/packages/b2c-script-types/plugin/inference/usage-match.js b/packages/b2c-script-types/plugin/inference/usage-match.js deleted file mode 100644 index d80ebd9b8..000000000 --- a/packages/b2c-script-types/plugin/inference/usage-match.js +++ /dev/null @@ -1,396 +0,0 @@ -"use strict"; -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.collectParameterMemberUsage = collectParameterMemberUsage; -exports.collectParameterInstanceOfTypes = collectParameterInstanceOfTypes; -exports.collectVariableMemberUsage = collectVariableMemberUsage; -exports.matchAmbientTypesByUsage = matchAmbientTypesByUsage; -const constants_1 = require("./constants"); -const type_helpers_1 = require("./type-helpers"); -/** - * Resolves a parameter/variable identifier to the ambient class simple name(s) - * it conventionally denotes: exact case-insensitive match (`customer` → - * `Customer`), curated short aliases (`pli` → `ProductLineItem`), and - * PascalCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → - * `Product`). Returns lowercased names for comparison against candidate - * class names. - */ -function conventionalAmbientNames(identifierName) { - const lower = identifierName.toLowerCase(); - const names = new Set([lower]); - const alias = constants_1.CONVENTIONAL_IDENTIFIER_ALIASES.get(lower); - if (alias) { - names.add(alias.toLowerCase()); - return names; - } - for (const [pascalSuffix, className] of constants_1.CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES) { - if (identifierName.length > pascalSuffix.length && identifierName.endsWith(pascalSuffix)) { - names.add(className.toLowerCase()); - break; - } - } - return names; -} -// Keyed by LanguageService, NOT by Program: tsserver hands the plugin a -// brand-new Program object on every edit to a file the project contains — -// including every keystroke in the very file someone is actively typing in. -// The ambient class shape (every dw.* class's member set) never changes for -// the life of a project, so a Program-keyed cache would rebuild this index -// (iterate every source file, call getPropertiesOfType on every dw.* class) -// on nearly every completion request while a cartridge file is being edited. -// On a large real project that rebuild is slow enough to blow past a -// completion request's cancellation budget, so completions would silently -// come back empty far more often than hover (a discrete, non-keystroke-driven -// request) — while the *next* Program, once the edit settles, would pay the -// same cost again. `languageService` is stable for as long as the tsserver -// project itself is open, and — just as importantly for tests — distinct -// per fixture, since each test builds its own LanguageService. -const classIndexCache = new WeakMap(); -/** - * Indexes one top-level class/interface declaration into an ambient-class - * candidate, or `undefined` when it's nameless / has no members. - * Extracted from {@link buildAmbientClassIndex} so the walk stays flat. - * - * Generic classes (chiefly `dw.catalog.Product`) are included: skipping - * them left the most common storefront parameter (`product`) matching only - * non-generic subclasses like `Variant` / `VariationGroup`, which is worse - * than showing the unsubstituted generic. Hover display rewrites `Product` - * → `Product` in {@link computeTypeDisplayString} so the editor never - * surfaces a bare type-parameter name with no instantiation context. - */ -function candidateFromDeclaration(checker, ts, stmt) { - const isClassOrInterface = ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt); - if (!isClassOrInterface || !stmt.name) - return undefined; - const symbol = checker.getSymbolAtLocation(stmt.name); - if (!symbol) - return undefined; - const type = checker.getDeclaredTypeOfSymbol(symbol); - const memberNames = new Set(); - for (const prop of checker.getPropertiesOfType(type)) { - memberNames.add(prop.getName()); - } - if (memberNames.size === 0) - return undefined; - return { type, memberNames, name: stmt.name.text }; -} -/** - * Indexes every top-level class/interface declared in a `.d.ts` file visible - * to the program (the vendored dw.* Script API, plus whatever else a - * project's ambient types pull in) by its full member-name set, so - * {@link matchAmbientTypesByUsage} can look candidates up by shape. - */ -function buildAmbientClassIndex(ctx) { - const cached = classIndexCache.get(ctx.languageService); - if (cached) - return cached; - const { ts, checker } = ctx; - const candidates = []; - for (const sourceFile of ctx.program.getSourceFiles()) { - if (!sourceFile.isDeclarationFile) - continue; - for (const stmt of sourceFile.statements) { - const candidate = candidateFromDeclaration(checker, ts, stmt); - if (candidate) - candidates.push(candidate); - } - } - classIndexCache.set(ctx.languageService, candidates); - return candidates; -} -/** - * How many ambient classes declare `memberName`. Built once per - * matchAmbientTypesByUsage call so distinctiveness scoring stays O(matches × - * signature) rather than re-scanning the whole index per member per match. - */ -function buildMemberFrequency(candidates) { - const freq = new Map(); - for (const candidate of candidates) { - for (const name of candidate.memberNames) { - freq.set(name, (freq.get(name) ?? 0) + 1); - } - } - return freq; -} -/** - * Higher = the usage signature's members are rarer across the ambient index - * (and not just ubiquitous `.custom` / `.UUID` noise). Weak members still - * contribute, but at a steep discount so a distinctive co-member dominates. - */ -function distinctivenessScore(memberNames, frequency) { - let score = 0; - for (const name of memberNames) { - const weight = constants_1.WEAK_USAGE_MEMBERS.has(name) ? 0.15 : 1; - score += weight / Math.max(frequency.get(name) ?? 1, 1); - } - return score; -} -/** - * Collects the names of every member accessed directly on whatever `symbol` - * identifies, anywhere in `scope` (including inside nested closures — a - * `Transaction.wrap(function () {...})` callback still reads/writes an outer - * parameter or variable it closes over). Only direct `x.member` accesses - * count; a chained `x.custom.fromStoreId` only contributes `custom` — the - * deeper hop describes `custom`'s shape, not `x`'s. That one-hop rule is also - * what makes the SFRA `product.custom.foo` / `'foo' in product.custom` idiom - * usable as ExtensibleObject-family evidence without guessing attribute names. - * - * Also counts a `'member' in x` existence check as evidence of `member` — - * a very common real-world SFCC idiom for guarding an optional custom - * attribute or a conditionally-present property (`'appliedPromotions' in - * this`, `'Subsoort' in apiProduct.custom`) before reading it, sometimes - * with no direct property-access read anywhere nearby to otherwise carry the - * signal. - * - * Skips the one property access the current request's own cursor sits - * inside of (see {@link InferenceContext.triggerPosition}) — a dangling - * `shipment.` mid-edit, immediately followed by more code, parses as a - * (nonsensical but syntactically valid) access to whatever identifier comes - * next, and that phantom member name must not count as real usage evidence - * for resolving the very completion being asked for. - */ -function collectMemberUsageInScope(ctx, symbol, scope) { - const { ts, checker, triggerPosition } = ctx; - const members = new Set(); - const visit = (node) => { - if (ts.isPropertyAccessExpression(node) && - ts.isIdentifier(node.expression) && - checker.getSymbolAtLocation(node.expression) === symbol && - !(triggerPosition !== undefined && - node.expression.getEnd() <= triggerPosition && - triggerPosition <= node.name.getStart())) { - members.add(node.name.text); - } - else if (ts.isElementAccessExpression(node) && - ts.isIdentifier(node.expression) && - checker.getSymbolAtLocation(node.expression) === symbol && - node.argumentExpression && - ts.isStringLiteralLike(node.argumentExpression)) { - // `lineItem['productID']` / `profile['email']` — same evidence as a dot - // read; common when the member name is computed from a form key. - members.add(node.argumentExpression.text); - } - else if (ts.isBinaryExpression(node) && - node.operatorToken.kind === ts.SyntaxKind.InKeyword && - ts.isStringLiteralLike(node.left) && - ts.isIdentifier(node.right) && - checker.getSymbolAtLocation(node.right) === symbol) { - members.add(node.left.text); - } - ts.forEachChild(node, visit); - }; - visit(scope); - return members; -} -/** Walks up from `node` to the body of the nearest enclosing function-like declaration, if any. */ -function findEnclosingFunctionBody(node, ts) { - let current = node.parent; - while (current) { - if (ts.isFunctionLike(current)) - return current.body; - current = current.parent; - } - return undefined; -} -/** Collects `param`'s own member-usage signature — see {@link collectMemberUsageInScope}. */ -function collectParameterMemberUsage(ctx, param) { - const { ts, checker } = ctx; - const fn = param.parent; - if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) - return new Set(); - const body = fn.body; - if (!body) - return new Set(); - const symbol = checker.getSymbolAtLocation(param.name); - if (!symbol) - return new Set(); - return collectMemberUsageInScope(ctx, symbol, body); -} -/** - * Resolves the *instance* type tested by an `instanceof` right-hand side - * (`dw.order.ProductLineItem`, a local class binding, …). Prefer a construct - * signature's return type; otherwise the declared type of the RHS symbol. - */ -function instanceTypeFromInstanceOfRhs(ctx, rhs) { - const { checker, ts } = ctx; - const rhsType = checker.getTypeAtLocation(rhs); - for (const sig of rhsType.getConstructSignatures()) { - const instance = checker.getReturnTypeOfSignature(sig); - if (!(0, type_helpers_1.isOpenForUsageInference)(ts, instance)) - return instance; - } - const symbol = rhsType.getSymbol() ?? checker.getSymbolAtLocation(rhs); - if (!symbol) - return undefined; - const declared = checker.getDeclaredTypeOfSymbol(symbol); - if ((0, type_helpers_1.isOpenForUsageInference)(ts, declared)) - return undefined; - return declared; -} -/** - * Collects concrete types asserted via `param instanceof SomeType` in the - * parameter's enclosing function body. Real payment/cart helpers (and common - * calculate.js ports) branch on `lineItem instanceof dw.order.ProductLineItem` - * — JetBrains' JS evaluator narrows from that; without collecting it here, a - * polymorphic `lineItem` parameter stays ambient-ambiguous even when the body - * names the class explicitly. - */ -function collectParameterInstanceOfTypes(ctx, param) { - const { ts, checker } = ctx; - const fn = param.parent; - if (!ts.isFunctionLike(fn) || !ts.isIdentifier(param.name)) - return []; - const body = fn.body; - if (!body) - return []; - const symbol = checker.getSymbolAtLocation(param.name); - if (!symbol) - return []; - const types = []; - const visit = (node) => { - if (ts.isBinaryExpression(node) && - node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword && - ts.isIdentifier(node.left) && - checker.getSymbolAtLocation(node.left) === symbol) { - const instance = instanceTypeFromInstanceOfRhs(ctx, node.right); - if (instance) - types.push(instance); - } - ts.forEachChild(node, visit); - }; - visit(body); - return types; -} -/** - * Collects a local variable's own member-usage signature within its - * enclosing function (or the whole file, for a top-level variable) — the - * counterpart to {@link collectParameterMemberUsage} for the common - * manual-indexing loop shape TS can't type at all on its own: - * `for (var i = 0; i < items.length; i++) { var item = items[i]; ...item.foo }`. - * `items[i]` is `any` (items itself is undocumented), so nothing about - * `item`'s initializer helps — but `item`'s own usage further down does. - */ -function collectVariableMemberUsage(ctx, decl) { - const { ts, checker } = ctx; - if (!ts.isIdentifier(decl.name)) - return new Set(); - const symbol = checker.getSymbolAtLocation(decl.name); - if (!symbol) - return new Set(); - const scope = findEnclosingFunctionBody(decl, ts) ?? decl.getSourceFile(); - return collectMemberUsageInScope(ctx, symbol, scope); -} -/** - * Matches a member-name usage signature against every ambient class the - * program knows about, returning the type(s) of whichever candidate(s) expose - * all of them, most-specific first. - * - * Ranking (after an optional identifier-name short-circuit): - * 1. Highest distinctiveness score — rarer used members win over ubiquitous - * ones like `.custom` / `.UUID` (see {@link WEAK_USAGE_MEMBERS}). - * 2. Fewest total members among that top score tier — the tightest-fitting - * shape, not just any rare-member superset. - * - * Returns `[]` when the signature is too weak to be worth guessing from (see - * MIN_USAGE_SIGNATURE_MEMBERS) or when it still ties across too many - * unrelated candidates to be a useful hint (MAX_USAGE_MATCH_CANDIDATES). - * - * Exception: a signature below MIN_USAGE_SIGNATURE_MEMBERS is still trusted - * when it's globally unambiguous — exactly one ambient class in the whole - * program declares all of these members at all (not just tied for - * "tightest"). A member name that's this rare is as strong a signal as a - * multi-member signature; a signature that's both weak AND ambiguous is what - * MIN_USAGE_SIGNATURE_MEMBERS exists to filter out. - * - * @param identifierName - the parameter/variable's own name, when it's a - * plain identifier (`profile`, `shipment`). Real-world bug: a common field - * subset (`email`/`firstName`/`lastName`/`custom`) is shared by both the - * large `dw.customer.Profile` and the much smaller `dw.customer. - * ProductListRegistrant` — "fewest total members" alone picks the small, - * unrelated class every time purely because it has less surface area, never - * the large, contextually correct one. A variable conventionally named after - * the SFCC class it holds is a stronger, more specific signal than raw - * member count, so a name match short-circuits straight to that candidate - * (ambient class names are unique, so at most one can ever match this way) - * before size/distinctiveness ranking even runs — but only after the - * weak-only silence guard below. A parameter literally named `shipment` - * whose only evidence is `.custom` must still stay silent: the name alone - * must not override weak-only evidence. A single *strong* member plus a - * matching name (`customer` + `.profile`) is trusted, since one-hop usage - * collection often yields just the first property of a longer chain. - */ -function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { - if (memberNames.size === 0) - return []; - const candidates = buildAmbientClassIndex(ctx); - const matches = candidates.filter((candidate) => { - for (const name of memberNames) { - if (!candidate.memberNames.has(name)) - return false; - } - return true; - }); - if (matches.length === 0) - return []; - // A signature made only of ubiquitous members (`.custom` / `.UUID` / …) is - // never discriminative enough when more than one ambient class matches — - // distinctiveness scoring alone can't break the tie usefully because every - // match saw the same weak evidence. Silence rather than guessing the - // smallest ExtensibleObject. This guard MUST run before the identifier-name - // short-circuit: a parameter literally named `shipment` whose only evidence - // is `.custom` must stay silent — the name alone must not override - // "too weak" evidence (see usage-match tests). - const strongCount = [...memberNames].filter((n) => !constants_1.WEAK_USAGE_MEMBERS.has(n)).length; - if (strongCount === 0 && matches.length > 1) - return []; - // A conventionally named parameter (`customer`, `profile`, `shipment`, or - // an SFRA alias like `lineItem` / `pli` → ProductLineItem) that uniquely - // matches one of the ambient candidates short-circuits here — even when - // the usage signature is a single strong member. Real SFRA shape: - // `function getPasswordResetToken(customer) { customer.profile.credentials… }` - // only contributes `.profile` (one-hop member collection), which is shared - // by `dw.customer.Customer` and `dw.svc.ServiceConfig`, but the parameter - // name makes the intended class unambiguous. Weak-only signatures never - // reach this point (guard above). - if (identifierName) { - const conventional = conventionalAmbientNames(identifierName); - const byName = matches.filter((m) => conventional.has(m.name.toLowerCase())); - if (byName.length === 1) - return [byName[0].type]; - // The identifier named a real Script API class (or an SFRA alias of one), - // but that class isn't among the usage matches. A thin signature's - // "globally unique member" hit is then almost certainly a *different* - // class that happens to share one property — e.g. `lineItem.preorderable` - // uniquely matching `ProductInventoryRecord` while the author clearly - // meant a line item. Silence rather than override the naming hint. - if (byName.length === 0 && memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS) { - // `conventional.size > 1` means an alias or PascalCase suffix fired - // (`resettingCustomer` → Customer, `lineItem` → ProductLineItem). - const namedIntentionally = conventional.size > 1 || candidates.some((c) => c.name.toLowerCase() === identifierName.toLowerCase()); - if (namedIntentionally) - return []; - } - } - // Below-minimum signatures that are still ambiguous (no unique name match) - // stay silent — e.g. an unnamed/`obj` parameter that only touches `.profile`. - if (memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS && matches.length > 1) - return []; - const frequency = buildMemberFrequency(candidates); - const scored = matches.map((m) => ({ - candidate: m, - score: distinctivenessScore(memberNames, frequency), - })); - const bestScore = Math.max(...scored.map((s) => s.score)); - // Floating-point slack: weights are small rationals; equality is fine in - // practice but a tiny epsilon keeps ranking stable if a future weight isn't. - const topTier = scored.filter((s) => bestScore - s.score < 1e-9).map((s) => s.candidate); - const minSize = Math.min(...topTier.map((m) => m.memberNames.size)); - const tightest = topTier.filter((m) => m.memberNames.size === minSize); - if (tightest.length > constants_1.MAX_USAGE_MATCH_CANDIDATES) - return []; - return tightest.map((m) => m.type); -} diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js new file mode 100644 index 000000000..c43305c4a --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -0,0 +1,286 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.usageProfileOf = usageProfileOf; +const type_helpers_1 = require("./type-helpers"); +function emptyProfile() { + return { + memberNames: new Set(), + optionalMemberNames: new Set(), + guardTypes: [], + contextualTypes: [], + assignedValues: [], + }; +} +const EMPTY_PROFILE = emptyProfile(); +/** + * The instance type an `instanceof` right-hand side tests for + * (`dw.order.ProductLineItem`, a local class binding, ...): its construct + * signature's return type, or — for a JS constructor function, which has + * none — the declared type of the right-hand side's symbol. A test against a + * root type (`x instanceof Object`) says nothing and yields undefined. + */ +function instanceTypeOf(ctx, constructor) { + const { checker, ts } = ctx; + const constructorType = checker.getTypeAtLocation(constructor); + const [signature] = constructorType.getConstructSignatures(); + const symbol = constructorType.getSymbol() ?? checker.getSymbolAtLocation(constructor); + const instance = signature + ? checker.getReturnTypeOfSignature(signature) + : symbol && checker.getDeclaredTypeOfSymbol(symbol); + return instance && !(0, type_helpers_1.isOpenForUsageInference)(ts, instance) ? instance : undefined; +} +/** The primitive a `typeof x === 'string'` comparison names, if `typeOf` sits in one. */ +function primitiveOfTypeofCheck(ctx, typeOf) { + const { ts, checker } = ctx; + const comparison = typeOf.parent; + if (!ts.isBinaryExpression(comparison)) + return undefined; + const other = comparison.left === typeOf ? comparison.right : comparison.left; + if (!ts.isStringLiteralLike(other)) + return undefined; + if (other.text === 'string') + return checker.getStringType(); + if (other.text === 'number') + return checker.getNumberType(); + if (other.text === 'boolean') + return checker.getBooleanType(); + return undefined; +} +/** The string a presence test names, if `node` is a string literal. */ +function literalText(ctx, node) { + return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; +} +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx, reference) { + const { ts } = ctx; + const parent = reference.parent; + if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.InKeyword) { + return parent.right === reference ? literalText(ctx, parent.left) : undefined; + } + const isOwnReceiver = ts.isPropertyAccessExpression(parent) && parent.expression === reference && parent.name.text === 'hasOwnProperty'; + const call = isOwnReceiver ? parent.parent : parent; + if (!ts.isCallExpression(call)) + return undefined; + if (isOwnReceiver) + return call.expression === parent ? literalText(ctx, call.arguments[0]) : undefined; + const callee = call.expression; + const isHasOwnCall = call.arguments[0] === reference && + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'call' && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === 'hasOwnProperty'; + return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; +} +/** + * True when `access` is the member access the current request's own cursor + * sits in (see {@link InferenceContext.triggerPosition}): a member name being + * typed (`shipment.pro|`) is not evidence yet, and a dangling `shipment.` + * followed by more code parses as an access to whatever identifier comes + * next, which no real class has. + */ +function isTriggerAccess(ctx, access) { + const trigger = ctx.triggerPosition; + return trigger !== undefined && access.expression.getEnd() <= trigger && trigger <= access.name.getEnd(); +} +/** The type `signature` declares for argument `index` of `call`, unless a rest parameter collects it. */ +function declaredArgumentType(ctx, signature, index, call) { + const parameter = signature.getParameters()[index]; + const declaration = parameter?.valueDeclaration; + if (!parameter || (declaration && ctx.ts.isParameter(declaration) && declaration.dotDotDotToken)) + return undefined; + return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); +} +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +function acceptsArgumentCount(ctx, signature, count) { + const { ts, checker } = ctx; + const declaration = signature.getDeclaration(); + if (!declaration) + return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} +/** + * The type `reference` is used as. An argument to an overloaded function + * counts only when every overload taking that many arguments agrees on it + * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type + * the checker settles on the first such overload, which says nothing about + * the one the code means (`variationModel.getAllValues(attribute)` would + * make `attribute` an `ObjectAttributeDefinition`). + */ +function usedAsType(ctx, reference) { + const { ts, checker } = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const args = (isCall && call.arguments) || []; + const index = args.indexOf(reference); + if (!isCall || index < 0) + return checker.getContextualType(reference); + const callee = checker.getTypeAtLocation(call.expression); + const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); + const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, args.length)); + if (overloads.length <= 1) + return checker.getContextualType(reference); + const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); + return first && rest.every((type) => type === first) ? first : undefined; +} +function isReferenceTo(ctx, target, node) { + return (ctx.ts.isIdentifier(node) && + node !== target.declarationName && + node.text === target.declarationName.text && + ctx.checker.getSymbolAtLocation(node) === target.symbol); +} +/** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ +function testsPresence(ctx, target, condition) { + const visit = (node) => (isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true; + return visit(condition); +} +/** True when `statement` always leaves its block: a `return`/`throw`, or a block ending in one. */ +function alwaysExits(ctx, statement) { + const { ts } = ctx; + const last = ts.isBlock(statement) ? statement.statements[statement.statements.length - 1] : statement; + return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last)); +} +/** The statements after an early-exit presence test (`if ('m' in x) { return …; }`): its implicit `else`. */ +function statementsAfterPresenceExit(ctx, target, statements) { + const { ts } = ctx; + const guard = statements.findIndex((statement) => ts.isIfStatement(statement) && + !statement.elseStatement && + alwaysExits(ctx, statement.thenStatement) && + testsPresence(ctx, target, statement.expression)); + return guard < 0 ? [] : statements.slice(guard + 1); +} +/** + * The branches of `node` that only run for some variants of the value: both + * branches of an `if`/`?:` that tests it for a member first (an early + * `return` making the rest of the block the `else`), and the right side of + * `'m' in x && …`. The test tells the code which kind of value it holds, so + * a member either branch uses belongs to that kind alone + * (`if ('searchPhraseSuggestions' in s) … else s.hasNext()`). + */ +function variantBranches(ctx, target, node) { + const { ts } = ctx; + if (ts.isIfStatement(node)) { + if (!testsPresence(ctx, target, node.expression)) + return []; + return node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + } + if (ts.isConditionalExpression(node)) { + return testsPresence(ctx, target, node.condition) ? [node.whenTrue, node.whenFalse] : []; + } + if (ts.isBinaryExpression(node)) { + const isGuardedAnd = node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isGuardedAnd && testsPresence(ctx, target, node.left) ? [node.right] : []; + } + return ts.isBlock(node) || ts.isSourceFile(node) ? statementsAfterPresenceExit(ctx, target, node.statements) : []; +} +/** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ +function recordMember(profile, name, optional) { + profile.memberNames.add(name); + if (optional) + profile.optionalMemberNames.add(name); +} +/** + * Records what one reference to the profiled value says about it. + * `inVariantBranch` marks a reference inside a branch only some variants of + * the value reach (see {@link variantBranches}). A reference the checker + * narrowed (inside `if (typeof x === 'string')`) is used as what that one + * branch holds, which says nothing about every value. + */ +function recordReference(ctx, target, reference, inVariantBranch, profile) { + const { ts, checker } = ctx; + const parent = reference.parent; + const tested = presenceTestedMember(ctx, reference); + if (tested !== undefined) { + recordMember(profile, tested, true); + return; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === reference) { + if (!isTriggerAccess(ctx, parent)) + recordMember(profile, parent.name.text, inVariantBranch); + return; + } + if (ts.isElementAccessExpression(parent) && parent.expression === reference) { + const member = literalText(ctx, parent.argumentExpression); + if (member !== undefined) + recordMember(profile, member, inVariantBranch); + return; + } + if (ts.isTypeOfExpression(parent)) { + const primitive = primitiveOfTypeofCheck(ctx, parent); + if (primitive) + profile.guardTypes.push(primitive); + return; + } + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + if (operator === ts.SyntaxKind.InstanceOfKeyword && parent.left === reference) { + const instance = instanceTypeOf(ctx, parent.right); + if (instance) + profile.guardTypes.push(instance); + return; + } + if (operator === ts.SyntaxKind.EqualsToken && parent.left === reference) { + profile.assignedValues.push(parent.right); + return; + } + } + if (checker.getTypeAtLocation(reference) !== target.declaredType) + return; + const used = usedAsType(ctx, reference); + if (used) + profile.contextualTypes.push(used); +} +function collectProfile(ctx, symbol, declarationName, scope) { + const declaredType = ctx.checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const target = { symbol, declarationName, declaredType }; + const profile = emptyProfile(); + const visit = (node, inVariantBranch) => { + if (isReferenceTo(ctx, target, node)) + recordReference(ctx, target, node, inVariantBranch, profile); + const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); + ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); + }; + visit(scope, false); + return profile; +} +/** Walks up from `node` to the body of the nearest enclosing function, if any. */ +function enclosingFunctionBody(ctx, node) { + for (let current = node.parent; current; current = current.parent) { + if (ctx.ts.isFunctionLike(current)) + return current.body; + } + return undefined; +} +/** + * The usage profile of a parameter (scoped to its function body, nested + * closures included) or a variable (scoped to its enclosing function, or the + * whole file for a top-level variable). Destructured declarations have no + * single value to profile and get an empty profile. Memoized per request. + */ +function usageProfileOf(ctx, declaration) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(declaration.name)) + return EMPTY_PROFILE; + const symbol = checker.getSymbolAtLocation(declaration.name); + const scope = ts.isParameter(declaration) + ? declaration.parent.body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); + if (!symbol || !scope) + return EMPTY_PROFILE; + const cached = ctx.profiles.get(symbol); + if (cached) + return cached; + const profile = collectProfile(ctx, symbol, declaration.name, scope); + ctx.profiles.set(symbol, profile); + return profile; +} diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 04dd7ec7e..2ab34b6ce 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -5,20 +5,26 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ Object.defineProperty(exports, "__esModule", { value: true }); -exports.matchAmbientTypesByUsage = exports.collectVariableMemberUsage = exports.collectParameterMemberUsage = exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isOpenForUsageInference = exports.isAnyType = exports.getMemberOfType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; +exports.matchAmbientTypesByUsage = exports.usageProfileOf = exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isOpenForUsageInference = exports.inferredCompletionEntry = exports.getMemberOfType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; // Public entry point for the usage-inference engine. The implementation is // split across the ./inference/ modules by responsibility; this barrel just // re-exports the pieces the tsserver plugin (and the test suite) consume, so // callers have one stable import path and don't need to know the internal -// layout. Read the modules in this order to understand the engine: -// inference/constants - the tunable limits that keep a request bounded -// inference/context - the per-request scratchpad (program, budgets, memo) -// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) -// inference/call-sites - find where a function is called across the project -// inference/type-helpers - Type utilities + hover text / completion entries -// inference/super-module - module.superModule detection and export scanning -// inference/core - the recursive engine that ties it all together -// inference/usage-match - last-resort ambient-class matching from member usage +// layout. The engine gathers evidence (./inference/core) and then applies one +// decision policy (./inference/policy) at every parameter, return value and +// variable. Read the modules in this order to understand it: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo, guards) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/call-sites - find where a function is called across the project +// inference/super-module - module.superModule detection and export scanning +// inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/ambient-index - every ambient class by member names +// inference/naming - identifier-name tiebreak between usage matches +// inference/framework-contracts - parameters the platform itself passes (Page Designer) +// inference/policy - turning evidence into the type(s) shown +// inference/core - the recursive evidence-gathering engine 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"); @@ -29,7 +35,7 @@ Object.defineProperty(exports, "findEnclosingPropertyAccess", { enumerable: true 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, "inferredCompletionEntry", { enumerable: true, get: function () { return type_helpers_1.inferredCompletionEntry; } }); 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"); @@ -40,7 +46,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; } }); +var usage_profile_1 = require("./inference/usage-profile"); +Object.defineProperty(exports, "usageProfileOf", { enumerable: true, get: function () { return usage_profile_1.usageProfileOf; } }); +var policy_1 = require("./inference/policy"); +Object.defineProperty(exports, "matchAmbientTypesByUsage", { enumerable: true, get: function () { return policy_1.matchAmbientTypesByUsage; } }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index f02e6b901..0f40d6327 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -15,7 +15,7 @@ import { findEnclosingPropertyAccess, getMemberOfType, getNodeAtPosition, - INFERRED_COMPLETION_SOURCE, + inferredCompletionEntry, inferTypeForExpression, inferTypeForNode, isOpenForUsageInference, @@ -548,7 +548,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // re-deriving "nothing" costs the same reference searches as // re-deriving something. const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath, position); + const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); if (!ctx) return undefined; // Hovering the member name of a property access // (`shipment.productLineItems`, cursor on `productLineItems`) has @@ -561,7 +561,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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); + const description = describeTypes(ctx, 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 @@ -640,13 +640,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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, - })); + ).map((m) => inferredCompletionEntry(ts, m.name, m.isMethod)); 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/inference/ambient-index.ts b/packages/b2c-script-types/src/inference/ambient-index.ts new file mode 100644 index 000000000..81fa98357 --- /dev/null +++ b/packages/b2c-script-types/src/inference/ambient-index.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 + */ + +// An index of every ambient class and interface the program declares in a +// `.d.ts` file (the vendored dw.* Script API, chiefly), by member names. It +// answers "which classes have all of these members?" for usage-based +// matching (see ./policy) and "which class is called X?" for framework +// contracts (see ./framework-contracts). +// +// The index holds only names and declaration nodes, never a Type: a Type +// pins the checker, and through it the whole Program it came from. Types are +// resolved on demand with the current request's checker. Entries are cached +// per declaration SourceFile, which tsserver reuses across Programs for as +// long as the file is unchanged, so editing a cartridge `.js` file never +// re-indexes the Script API. A file is indexed with the members its classes +// had when it was first seen; a declaration file that changes is a new +// SourceFile and is indexed afresh. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; + +/** One indexed class or interface. */ +export interface AmbientClass { + readonly name: string; + readonly memberNames: ReadonlySet; + readonly declaration: tsserver.ClassDeclaration | tsserver.InterfaceDeclaration; +} + +const classesByFile = new WeakMap(); +const classesByProgram = new WeakMap(); + +function indexDeclaration(ctx: InferenceContext, statement: tsserver.Statement): AmbientClass | undefined { + const {ts, checker} = ctx; + if (!(ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) || !statement.name) return undefined; + const symbol = checker.getSymbolAtLocation(statement.name); + if (!symbol) return undefined; + const memberNames = new Set(checker.getPropertiesOfType(checker.getDeclaredTypeOfSymbol(symbol)).map((m) => m.name)); + if (memberNames.size === 0) return undefined; + return {name: statement.name.text, memberNames, declaration: statement}; +} + +function indexFile(ctx: InferenceContext, sourceFile: tsserver.SourceFile): readonly AmbientClass[] { + const cached = classesByFile.get(sourceFile); + if (cached) return cached; + const classes = sourceFile.statements.flatMap((statement) => indexDeclaration(ctx, statement) ?? []); + classesByFile.set(sourceFile, classes); + return classes; +} + +/** + * Every top-level class and interface in the program's declaration files, + * except TypeScript's own default library (DOM and ES classes such as + * `HTMLDataElement` would otherwise match ordinary SFCC usage). + */ +export function getAmbientClasses(ctx: InferenceContext): readonly AmbientClass[] { + const {program} = ctx; + const cached = classesByProgram.get(program); + if (cached) return cached; + const classes = program + .getSourceFiles() + .filter((sourceFile) => sourceFile.isDeclarationFile && !program.isSourceFileDefaultLibrary(sourceFile)) + .flatMap((sourceFile) => indexFile(ctx, sourceFile)); + classesByProgram.set(program, classes); + return classes; +} + +/** The class's declared (uninstantiated) type, resolved with the current checker. */ +export function ambientClassType(ctx: InferenceContext, ambientClass: AmbientClass): tsserver.Type | undefined { + const {checker} = ctx; + const name = ambientClass.declaration.name; + const symbol = name && checker.getSymbolAtLocation(name); + return symbol ? checker.getDeclaredTypeOfSymbol(symbol) : undefined; +} + +/** + * The declared type of the ambient class called `name`, preferring the + * vendored Script API's declaration (under a `/dw/` directory) when a + * project declares another class of the same name. + */ +export function findAmbientClassType(ctx: InferenceContext, name: string): tsserver.Type | undefined { + const named = getAmbientClasses(ctx).filter((ambientClass) => ambientClass.name === name); + const preferred = + named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; + return preferred && ambientClassType(ctx, preferred); +} diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 39e8dec31..0fd1b8cea 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -9,8 +9,9 @@ // arguments it receives. A reference search can land on a name that isn't a // direct call (a require() binding, a destructured import, an alias map), so // this layer follows a bounded number of those indirection hops to reach the -// real call expressions. "Call" includes `new Helper(x)` — SFRA's other very -// common invocation shape, for its constructor-function "class" models. +// real call expressions. "Call" includes `new Helper(x)` and +// `Helper.call(this, x)` — the shapes SFRA's constructor-function "class" +// models are invoked and inherited with. import type tsserver from 'typescript/lib/tsserverlibrary'; @@ -19,15 +20,44 @@ 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. + * One invocation of a function and the arguments its parameters receive, by + * parameter index. Besides an ordinary call (`helper(x)`) this covers a + * constructor invocation (`new Helper(x)`, SFRA's "class" models; a bare + * `new Helper` passes nothing), `helper.call(thisArg, x)` and + * `helper.apply(thisArg, [x])` — the shapes SFRA model inheritance uses + * (`BaseAttributeValue.call(this, productSearch, ...)`). */ -export type CallSite = tsserver.CallExpression | tsserver.NewExpression; +export interface CallSite { + readonly node: tsserver.CallExpression | tsserver.NewExpression; + readonly args: readonly tsserver.Expression[]; +} + +// Reference searches are what an inference request spends its time on, and +// a Program never changes, so their results are kept for as long as the +// Program lives: hovering one parameter after another in an unchanged +// project runs each search once. Entries are plain file names and spans, so +// they pin no checker. A search served from here still spends the request's +// search budget, so a request reaches the same call sites, and the same +// answer, whether or not an earlier request warmed the cache. +const referencesByProgram = new WeakMap< + tsserver.Program, + Map +>(); + +function findReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly tsserver.ReferenceEntry[] { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + const sourceFile = name.getSourceFile(); + references = ctx.languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; + cache.set(name, references); + } + return references; +} /** * Identifies the name to run findReferences on for a function-like @@ -58,27 +88,50 @@ export function getReferenceNameNode( return undefined; } +/** The function enclosing `node`, if any. */ +function enclosingFunction(node: tsserver.Node, ts: typeof tsserver): tsserver.SignatureDeclaration | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) return current; + } + return undefined; +} + +/** + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tsserver.Expression[] { + const list = call.arguments[1]; + if (!list) return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} + /** - * 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. + * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, + * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it + * invokes, if it sits in callee position. The name of a property access + * (`obj.helper`) is treated as the whole access. */ function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): CallSite | undefined { - const parent = node.parent; + const callee = + node.parent && ts.isPropertyAccessExpression(node.parent) && node.parent.name === node ? node.parent : node; + const parent = callee.parent; if (!parent) return undefined; - if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === node) return parent; - if (ts.isPropertyAccessExpression(parent) && parent.name === node) { - const grandparent = parent.parent; - if ( - grandparent && - (ts.isCallExpression(grandparent) || ts.isNewExpression(grandparent)) && - grandparent.expression === parent - ) { - return grandparent; - } + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === callee) { + return {node: parent, args: parent.arguments ?? []}; } + if (!ts.isPropertyAccessExpression(parent) || parent.expression !== callee) return undefined; + const call = parent.parent; + if (!call || !ts.isCallExpression(call) || call.expression !== parent) return undefined; + if (parent.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; + if (parent.name.text === 'apply') return {node: call, args: applyArguments(call, ts)}; return undefined; } @@ -97,13 +150,33 @@ function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node ); } +/** + * The name a value is bound to when `expression` initializes a variable + * (`var helper = …`) or an object property (`{helper: …}`) — the next name + * to search references for when the value's own references dead-end there. + */ +function bindingNameOf(expression: tsserver.Expression, ts: typeof tsserver): tsserver.Identifier | undefined { + const parent = expression.parent; + if (!parent || !(ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))) return undefined; + return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; +} + /** * 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`). + * binding indirection: + * + * - the module specifier of a `require(...)` whose result is bound to a + * variable (`var helper = require('./helper')`) or to a property of an + * export map (SFRA's `decorators/index.js`: + * `{images: require('./images')}`, called as `decorators.images(...)`); + * - a destructuring binding element (`const {helper} = require(...)` or + * `const {helper: local} = someObject`); + * - an alias of the function itself (`var run = helper`, or SFRA's canonical + * `module.exports = {getSalePrice: getSalePrice}` export shape): a search + * on the function name dead-ends at the alias, while the consumers + * (`productHelpers.getSalePrice(x)` in another file) are references of the + * alias name. * * @returns Either the further name to search references for, or — for an * immediately-invoked require (`require('./helper')(x)`) — the call site itself. @@ -114,38 +187,20 @@ function resolveIndirectReferenceTarget( ): {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier} | undefined { const parent = node.parent; if (!parent) return undefined; - - if (ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts)) { - const requireCall = parent; - const outer = requireCall.parent; - if (outer && ts.isCallExpression(outer) && outer.expression === requireCall) { - return {kind: 'call', call: outer}; // require('./helper')(x) - } - if (outer && ts.isVariableDeclaration(outer) && outer.initializer === requireCall && ts.isIdentifier(outer.name)) { - return {kind: 'name', name: outer.name}; // var helper = require('./helper') - } - return undefined; - } - if (ts.isBindingElement(parent) && ts.isIdentifier(parent.name)) { // Covers both `{helper}` (shorthand — name and propertyName are the same // node) and `{helper: local}` (renamed — redirect to the local binding). return {kind: 'name', name: parent.name}; } - - // `module.exports = {getSalePrice: getSalePrice}` — SFRA's canonical export - // shape, an alias map from property name to a separately-declared function. - // A reference search on the *function* name dead-ends at the alias-map - // initializer; the actual consumers (`productHelpers.getSalePrice(x)` in - // another file) are references of the property *name*, so redirect the - // search there. Not scoped to module.exports specifically: any - // `{run: helper}` alias whose property is later called is a genuine call - // site of the aliased function. - if (ts.isPropertyAssignment(parent) && parent.initializer === node && ts.isIdentifier(parent.name)) { - return {kind: 'name', name: parent.name}; + const isRequireSpecifier = + ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts); + const value = isRequireSpecifier ? parent : node; + const invocation = value.parent; + if (isRequireSpecifier && invocation && ts.isCallExpression(invocation) && invocation.expression === value) { + return {kind: 'call', call: {node: invocation, args: invocation.arguments}}; // require('./helper')(x) } - - return undefined; + const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; + return name && {kind: 'name', name}; } /** @@ -198,11 +253,9 @@ function collectCallsFromName( nextFrontier: tsserver.Identifier[], localBudget: number, ): number { - const {ts, languageService, program} = ctx; - const sourceFile = name.getSourceFile(); + const {ts, program} = ctx; ctx.searchBudget--; - const refs = languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; - for (const ref of refs) { + for (const ref of findReferences(ctx, name)) { if (localBudget <= 0) break; localBudget--; ctx.referenceBudget--; diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index a70e60c4f..ad11a0298 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -70,35 +70,26 @@ export const MAX_SEARCHES_PER_REQUEST = 12; // apart. Purely a label — it carries no path or other data. export const INFERRED_COMPLETION_SOURCE = '@salesforce/b2c-script-types/inferred-usage'; -// Last-resort fallback when call-site/return-expression inference (the whole -// rest of the engine) comes up empty: match the member names a parameter is -// actually accessed by (`shipment.custom`, `shipment.productLineItems`, ...) -// against every ambient class/interface visible in the program, and accept -// the most specific one(s) that expose all of them. A single accessed member -// name (e.g. just `.custom`) is carried by dozens of unrelated business -// objects, so it's too weak a signal on its own to guess from — UNLESS that -// single member happens to be globally unique across every ambient class -// (e.g. `.addresses`, which only `dw.customer.AddressBook` declares), in -// which case there's no ambiguity to be weak about. See -// matchAmbientTypesByUsage's unambiguous-single-member exception. +// Ambient usage matching (see ./ambient-index): a usage signature with fewer +// distinct member names than this is only trusted when exactly one ambient +// class declares it (`.addresses` is AddressBook's alone) or when the +// identifier name picks one of the matches (`customer` + `.profile`). export const MIN_USAGE_SIGNATURE_MEMBERS = 2; -// If the member-name signature still ties across more candidates than this -// after ranking by specificity (distinctiveness, then fewest total members), -// the match is too ambiguous to be a useful hint — silence beats a wall of -// unrelated candidates in the hover text. -export const MAX_USAGE_MATCH_CANDIDATES = 5; +// Most distinct types a hover or completion list shows as a union, IntelliJ +// style (`Product | Order`). More than this collapses to the candidates' +// closest shared ancestor class when one fits, and otherwise stays silent: a +// longer union is noise rather than a hint. +export const MAX_UNION_TYPES = 3; -// 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; +// Root classes nearly every Script API class inherits from. Offering them as +// the "common ancestor" of unrelated candidates tells the reader nothing. +export const UNINFORMATIVE_ANCESTORS: ReadonlySet = new Set(['Object', 'ExtensibleObject', 'PersistentObject']); // Member names so common across dw.* that they barely discriminate a class // on their own (nearly every ExtensibleObject exposes `.custom` / `.UUID`). -// They still count as usage evidence for matching, but contribute far less -// to the distinctiveness score used to rank ambient candidates. +// They still narrow an ambient match, but a signature made only of these +// never picks one class out of several. export const WEAK_USAGE_MEMBERS: ReadonlySet = new Set(['custom', 'UUID', 'toString', 'valueOf']); // Callee names whose callbacks lead with the collection element @@ -112,7 +103,7 @@ 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 (a storefront cartridge). + // shape; used heavily for address-book and line-item lookups. 'find', // Stock SFRA `collections.first` takes only the collection, but several // storefronts (and common calculate.js ports) call it with a predicate @@ -120,102 +111,3 @@ export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ // when present so the predicate parameter still gets a type. 'first', ]); - -/** - * SFRA/storefront parameter names that conventionally hold a Script API class - * whose declared name does not equal the identifier (case-insensitive). Used - * by ambient usage-matching's identifier short-circuit — `lineItem` must map - * to `ProductLineItem`, not look for a nonexistent ambient class named - * `LineItem`. Keys are lowercase; values are ambient class simple names. - * - * Keep this list conservative: only aliases that are unambiguous in real - * cartridges. Bare `address` is deliberately omitted (CustomerAddress vs - * OrderAddress vs Store address models). Prefer adding PascalCase suffixes to - * {@link CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES} for `resettingCustomer`-style - * names; this map is for short / all-lowercase tokens (`pli`, `pricemodel`). - */ -export const CONVENTIONAL_IDENTIFIER_ALIASES: ReadonlyMap = new Map([ - ['lineitem', 'ProductLineItem'], - ['pli', 'ProductLineItem'], - ['productlineitem', 'ProductLineItem'], - ['pricemodel', 'ProductPriceModel'], - ['availabilitymodel', 'ProductAvailabilityModel'], - ['shippingaddress', 'OrderAddress'], - ['billingaddress', 'OrderAddress'], - ['paymentinstrument', 'OrderPaymentInstrument'], - ['shippingmethod', 'ShippingMethod'], - ['shippinglineitem', 'ShippingLineItem'], - ['priceadjustment', 'PriceAdjustment'], - ['giftcertificatelineitem', 'GiftCertificateLineItem'], - ['couponlineitem', 'CouponLineItem'], - // Other concrete dw.order line-item subclasses. Without these, the bare - // `LineItem` PascalCase suffix (below) would force an all-lowercase - // `bonusdiscountlineitem` / `productshippinglineitem` to ProductLineItem — - // a wrong guess for a differently-named sibling class (see the matching - // PascalCase suffixes and the *LineItem note there). - ['bonusdiscountlineitem', 'BonusDiscountLineItem'], - ['productshippinglineitem', 'ProductShippingLineItem'], - ['customeraddress', 'CustomerAddress'], - ['orderaddress', 'OrderAddress'], - // High-frequency all-lowercase / compound forms seen across storefronts - // (when authors don't camelCase the class token). - ['currentbasket', 'Basket'], - ['currentcustomer', 'Customer'], - ['currentorder', 'Order'], - ['apiproduct', 'Product'], - ['apiorder', 'Order'], - ['apilineitem', 'ProductLineItem'], -]); - -/** - * Trailing PascalCase class tokens → ambient class simple name. Matched with - * `identifierName.endsWith(pascalSuffix)` (case-sensitive on the original - * identifier) so `resettingCustomer` / `apiProduct` / `currentBasket` resolve - * while all-lowercase noise like `border` / `emailaddress` does not. - * - * Ordered longest-first so `productLineItem` hits ProductLineItem rather than - * Product. Generic `Address` is omitted — too many false friends - * (`emailAddress`, `ipAddress`, store address models). - * - * The *LineItem subclasses (`ProductLineItem`, `BonusDiscountLineItem`, - * `CouponLineItem`, `GiftCertificateLineItem`, `ShippingLineItem`, - * `ProductShippingLineItem`) must ALL precede the bare `LineItem` → - * ProductLineItem fallback, and each longer name must precede any shorter one - * it ends with (`ProductShippingLineItem` before `ShippingLineItem`), because - * the matcher stops at the first `endsWith` hit in array order. Without the - * specific entries, a `bonusDiscountLineItem` / `productShippingLineItem` - * parameter would resolve to the wrong sibling class (ProductLineItem / - * ShippingLineItem) whenever its body only touches members shared through the - * common `LineItem` base — the classic silence-vs-wrong-guess trap. - */ -export const CONVENTIONAL_IDENTIFIER_PASCAL_SUFFIXES: ReadonlyArray = [ - ['GiftCertificateLineItem', 'GiftCertificateLineItem'], - ['BonusDiscountLineItem', 'BonusDiscountLineItem'], - ['CouponLineItem', 'CouponLineItem'], - ['ProductShippingLineItem', 'ProductShippingLineItem'], - ['ProductLineItem', 'ProductLineItem'], - ['ShippingLineItem', 'ShippingLineItem'], - ['OrderPaymentInstrument', 'OrderPaymentInstrument'], - ['PaymentInstrument', 'OrderPaymentInstrument'], - ['ProductAvailabilityModel', 'ProductAvailabilityModel'], - ['AvailabilityModel', 'ProductAvailabilityModel'], - ['ProductPriceModel', 'ProductPriceModel'], - ['PriceModel', 'ProductPriceModel'], - ['ShippingAddress', 'OrderAddress'], - ['BillingAddress', 'OrderAddress'], - ['CustomerAddress', 'CustomerAddress'], - ['OrderAddress', 'OrderAddress'], - ['ShippingMethod', 'ShippingMethod'], - ['PriceAdjustment', 'PriceAdjustment'], - ['LineItem', 'ProductLineItem'], - ['Customer', 'Customer'], - ['Profile', 'Profile'], - ['Product', 'Product'], - ['Basket', 'Basket'], - ['Shipment', 'Shipment'], - ['Category', 'Category'], - ['Order', 'Order'], - ['Store', 'Store'], - ['Variant', 'Variant'], - ['Money', 'Money'], -]; diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 4dd976ca8..4cf0a8bf9 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -13,9 +13,10 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import type {CallSite} from './call-sites'; -import {MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; +import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; +import type {UsageProfile} from './usage-profile'; -export interface MemoEntry { +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- @@ -79,12 +80,19 @@ export interface InferenceContext { * outlives its checker, so memoizing per request is sound. */ readonly typeDisplayStrings: Map; + /** + * Request-scoped memo of usage profiles (see ./usage-profile), keyed by the + * parameter's or variable's symbol. Every consumer of a profile (the + * body-usage filter, contextual constraints, ambient matching, reassigned + * values) reads the same single walk of the declaring scope. + */ + readonly profiles: Map; /** * Mutable, shared across the whole request — incremented every time a * cycle guard fires (a `visiting` hit). A result computed while this moved * is potentially incomplete *for this call stack only* (the cycle member it * skipped could resolve fine from a different entry point later in the same - * request), so such results must not be memoized — see inferReturnType. + * request), so such results must not be memoized — see withInferenceGuards. */ cycleHits: number; /** @@ -95,10 +103,11 @@ export interface InferenceContext { */ 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 + * A completion request's cursor position. Exists so usage profiling (see + * ./usage-profile) can exclude the property access the completion is + * itself sitting inside of from its own evidence. A member name still + * being typed (`shipment.pro|`) is not a member anything has, and a + * dangling `shipment.` immediately followed (after a line * break) by more code doesn't get automatic semicolon insertion — `.` * always demands a following identifier — so the parser merges it with * whatever statement comes next (`shipment.\n\nTransaction.wrap(...)` @@ -133,8 +142,56 @@ export function createInferenceContext( searchBudget: MAX_SEARCHES_PER_REQUEST, callSiteMemo: new Map(), typeDisplayStrings: new Map(), + profiles: new Map(), cycleHits: 0, resolveSuperModulePath, triggerPosition, }; } + +/** + * Runs `compute` with `node` marked as in progress, so a cycle that leads + * back to the same node (`var a = b; var b = a;`, or two helpers returning + * each other's result) gets `fallback` instead of recursing forever. Each hit + * is counted in ctx.cycleHits; see {@link withInferenceGuards}. + */ +export function withCycleGuard(ctx: InferenceContext, node: tsserver.Node, fallback: T, compute: () => T): T { + if (ctx.visiting.has(node)) { + ctx.cycleHits++; + return fallback; + } + ctx.visiting.add(node); + try { + return compute(); + } finally { + ctx.visiting.delete(node); + } +} + +/** + * The shared preamble for every memoized inference entry point: serve a memo + * hit, enforce MAX_INFERENCE_DEPTH, break cycles, and memoize the result. + * + * The memo is consulted before the depth cap: a result computed at an equal + * or shallower depth had at least as much budget as this call would, so it is + * reusable however deep the current path is. A result whose computation hit + * a cycle guard is not memoized: it was cut short by what happened to be on + * the current call stack, and the same node reached later from outside the + * cycle could resolve more. + */ +export function withInferenceGuards( + ctx: InferenceContext, + node: tsserver.Node, + depth: number, + compute: () => tsserver.Type[], +): tsserver.Type[] { + const cached = ctx.memo.get(node); + if (cached && cached.atDepth <= depth) return cached.types; + if (depth > MAX_INFERENCE_DEPTH) return []; + return withCycleGuard(ctx, node, [], () => { + const cycleHitsBefore = ctx.cycleHits; + const types = compute(); + if (ctx.cycleHits === cycleHitsBefore) ctx.memo.set(node, {atDepth: depth, types}); + return types; + }); +} diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 6c86f38ae..cf2b22eeb 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -5,23 +5,19 @@ */ // 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. +// gave up on (`any`), these functions gather evidence of what it really is: a +// parameter from its call-site arguments, a function from its return +// expressions, a variable from the values assigned to it, a method chain by +// resolving its receiver first, and superModule overlays by descending the +// cartridge path. They call each other (parameter -> return -> forwarding +// helper -> ...), so they live together in one module. What to *show* for the +// evidence is decided in ./policy, at every parameter, return and variable, +// so every path into the same value reaches the same answer. import type tsserver from 'typescript/lib/tsserverlibrary'; -import { - ELEMENT_FIRST_CALLBACK_CALLEES, - MAX_CALL_SITE_CANDIDATES, - MAX_CHAIN_HOPS, - MAX_INFERENCE_DEPTH, - MAX_SUPERMODULE_HOPS, -} from './constants'; +import {ELEMENT_FIRST_CALLBACK_CALLEES, MAX_CHAIN_HOPS} from './constants'; +import {withCycleGuard, withInferenceGuards} from './context'; import type {InferenceContext} from './context'; import { collectReturnExpressions, @@ -30,26 +26,21 @@ import { hasExplicitVariableType, } from './ast-helpers'; import {collectCallSites, getReferenceNameNode} from './call-sites'; +import {frameworkParameterTypes} from './framework-contracts'; +import {decideType, limitUnion, normalizeCandidates} from './policy'; import { collectExportAssignments, findSuperModuleFile, isConcreteExportAssignment, + superModuleLevels, traceSuperModuleAccess, } from './super-module'; -import { - collectionElementType, - dedupeTypes, - getMemberOfType, - isAnyType, - isOpenForUsageInference, - widenType, -} from './type-helpers'; -import { - collectParameterInstanceOfTypes, - collectParameterMemberUsage, - collectVariableMemberUsage, - matchAmbientTypesByUsage, -} from './usage-match'; +import {collectionElementType, dedupeTypes, getMemberOfType, informativeParts, isAnyType} from './type-helpers'; +import {usageProfileOf} from './usage-profile'; + +function identifierText(ctx: InferenceContext, name: tsserver.BindingName): string | undefined { + return ctx.ts.isIdentifier(name) ? name.text : undefined; +} /** * Resolves the function-like declaration a call expression's callee refers @@ -64,70 +55,68 @@ function resolveCalleeDeclaration( 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; + const sigDecl = checker.getResolvedSignature(call)?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } /** - * Chases a local variable's initializer expression — the missing link for the - * idiomatic SFCC style of splitting a chain across intermediate variables - * (`var priceModel = product.getPriceModel(); return priceModel.getPrice();`), - * which would otherwise dead-end at the variable reference even though the - * exact same logic written inline resolves fine. - * - * Guarded three ways: an explicit type/JSDoc annotation on the variable means - * its `any` is deliberate (same rule as parameters/returns); the `visiting` - * set breaks initializer cycles (`var a = b; var b = a;`) and records the hit - * in ctx.cycleHits; and the hop is charged to `chainHops` — following a - * variable never crosses a function boundary, so it's an in-expression hop, - * not a recursion-depth step. - * - * Falls back to matching the variable's own usage against ambient classes - * (see {@link collectVariableMemberUsage}) when the initializer itself - * resolves to nothing — the common shape for a manual-indexing loop variable - * (`var item = items[i]`), where `items[i]` stays `any` no matter what since - * `items` itself is undocumented. + * Everything a local variable can hold: its initializer plus every later + * `x = value` assignment (`var result = null; if (a) result = x; else result + * = y;`), decided as one candidate set. Covers the idiomatic SFCC style of + * splitting a chain across intermediate variables (`var priceModel = + * product.getPriceModel(); return priceModel.getPrice();`), which would + * otherwise dead-end at the variable even though the inline expression + * resolves fine. An explicit annotation means the variable's `any` is + * deliberate (same rule as parameters and returns). Following a variable + * never crosses a function boundary, so it is charged to `chainHops`, not to + * the recursion depth. */ -function resolveVariableInitializerTypes( +function resolveVariableTypes( ctx: InferenceContext, decl: tsserver.VariableDeclaration, depth: number, chainHops: number, ): tsserver.Type[] { - const {ts} = ctx; - if (!decl.initializer || hasExplicitVariableType(decl, ts)) return []; - if (ctx.visiting.has(decl)) { - ctx.cycleHits++; - return []; - } - ctx.visiting.add(decl); - try { - const resolved = resolveExpressionTypes(ctx, decl.initializer, depth, chainHops); - if (resolved.length > 0) return resolved; - return matchAmbientTypesByUsage( - ctx, - collectVariableMemberUsage(ctx, decl), - ts.isIdentifier(decl.name) ? decl.name.text : undefined, - ); - } finally { - ctx.visiting.delete(decl); + if (hasExplicitVariableType(decl, ctx.ts)) return []; + return withCycleGuard(ctx, decl, [], () => { + const profile = usageProfileOf(ctx, decl); + const values = decl.initializer ? [decl.initializer, ...profile.assignedValues] : profile.assignedValues; + const evidence = values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops)); + return decideType(ctx, evidence, profile, identifierText(ctx, decl.name), false); + }); +} + +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes( + ctx: InferenceContext, + assignment: tsserver.BinaryExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const concrete = isConcreteExportAssignment(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) { + types.push(...resolveExpressionTypes(ctx, assignment.right, depth, chainHops + 1)); } + return types; } /** * 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}. + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. */ function resolveSuperModuleTypes( ctx: InferenceContext, @@ -135,47 +124,24 @@ function resolveSuperModuleTypes( 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); - } + // The guard catches overlay cycles from a misconfigured cartridge path. + return withCycleGuard(ctx, superFile, [], () => + dedupeTypes( + ctx, + collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => + superModuleExportTypes(ctx, assignment, depth, chainHops), + ), + ), + ); } /** - * 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). + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. */ function resolveSuperModuleMemberTypes( ctx: InferenceContext, @@ -184,31 +150,14 @@ function resolveSuperModuleMemberTypes( 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, + for (const {members} of superModuleLevels(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) continue; + const types = matches.flatMap((member) => resolveExpressionTypes(ctx, member.expr, depth, chainHops + 1)); + return dedupeTypes( + ctx, + types.filter((type) => !isAnyType(ctx.ts, type)), ); - if (!passesThrough) return []; - fromFileName = superFile.fileName; } return []; } @@ -216,13 +165,12 @@ function resolveSuperModuleMemberTypes( /** * Infers the type of a callback's first parameter from sibling arguments of * the call the callback is passed to: `collections.forEach` / `map` / - * `filter` / `every` / `some` / `find` (see {@link ELEMENT_FIRST_CALLBACK_CALLEES}). - * A function expression in argument position has no name to run a reference - * search on, but the collection travelling alongside it names the element - * type. Only the first parameter is mapped (SFRA's collections util passes - * the element first). Unknown callees and `reduce` (accumulator first) are - * left alone — applying the heuristic to an arbitrary helper would guess wrong - * more often than it helps. + * `filter` / `every` / `some` / `find` / `first` (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. 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, @@ -230,43 +178,34 @@ function inferCallbackParameterTypes( paramIndex: number, depth: number, ): tsserver.Type[] { - const {ts, checker} = ctx; - if (paramIndex !== 0) return []; + const {ts} = ctx; const call = fn.parent; - if (!call || !ts.isCallExpression(call) || !call.arguments.some((arg) => arg === fn)) return []; - const calleeName = ts.isPropertyAccessExpression(call.expression) - ? call.expression.name.text - : ts.isIdentifier(call.expression) - ? call.expression.text - : undefined; - if (!calleeName || !ELEMENT_FIRST_CALLBACK_CALLEES.has(calleeName)) return []; - const types: tsserver.Type[] = []; - for (const arg of call.arguments) { - if (arg === fn) continue; - for (const argType of resolveExpressionTypes(ctx, arg, depth)) { - const element = collectionElementType(ctx, argType, arg); - if (element) types.push(widenType(checker, element)); - } + if (paramIndex !== 0 || !call || !ts.isCallExpression(call) || !call.arguments.includes(fn as tsserver.Expression)) { + return []; } - return types; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + if (!ts.isIdentifier(callee) || !ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text)) return []; + return call.arguments + .filter((arg) => arg !== fn) + .flatMap((arg) => resolveExpressionTypes(ctx, arg, depth).map((type) => collectionElementType(ctx, type, arg))) + .filter((element): element is tsserver.Type => element !== undefined); } /** - * 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`. + * Resolves the candidate type(s) of `expr`: the checker's own type when it is + * informative, otherwise a type recovered through whatever `expr` is built + * from (an undocumented callee's returns, a receiver's real members, a + * variable's values, a parameter's call sites). * * @param chainHops - how many `.method()`/`.prop` hops within the *same* * static expression have already been chased (e.g. the `2` in * `a.b().c().d()` when resolving `d`'s receiver `a.b().c()`). This is * distinct from `depth`, which only advances when crossing into another - * undocumented helper's own 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. + * undocumented helper's own inference — chain-hopping never crosses a + * function boundary, so it needs its own bound (`MAX_CHAIN_HOPS`) to keep the + * cost of a very long inline chain predictable. * @returns An array (rather than a single unioned Type) because the public - * TypeChecker API exposed via tsserverlibrary has no way to synthesize a - * union Type — callers merge candidates for display/completions themselves. + * TypeChecker API has no way to synthesize a union Type. */ function resolveExpressionTypes( ctx: InferenceContext, @@ -275,50 +214,102 @@ 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); - // 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)]; + // module.superModule (or a `var base = module.superModule` alias) first: + // TS knows nothing about SFCC overlay semantics, and its type for these + // expressions is never meaningful — sometimes `any`, sometimes an opaque + // circular `typeof base` that would pass for informative. + const superAccess = traceSuperModuleAccess(ts, checker, expr); + if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, depth, chainHops); + const direct = informativeParts(ctx, checker.getTypeAtLocation(expr)); + if (direct.length > 0) return direct; if (chainHops >= MAX_CHAIN_HOPS) return []; - // The checker gave up. Dispatch on the kind of expression to a focused - // resolver. Each returns [] when it can't do better, so an unhandled kind - // (or an exhausted branch) falls through to []. if (ts.isCallExpression(expr)) return resolveCallResultTypes(ctx, expr, depth, chainHops); if (ts.isPropertyAccessExpression(expr)) return resolvePropertyTypes(ctx, expr, depth, chainHops); if (ts.isIdentifier(expr)) return resolveIdentifierTypes(ctx, expr, depth, chainHops); - // SFRA helpers often return through a ternary (`return it.hasNext() ? it.next() - // : null` — the body of `collections.first`) or a parenthesized subexpression. - // Without chasing both branches the whole return collapses to `any` even when - // the collection argument at the call site is fully typed. - if (ts.isConditionalExpression(expr)) { - return dedupeTypes(ctx, [ - ...resolveExpressionTypes(ctx, expr.whenTrue, depth, chainHops + 1), - ...resolveExpressionTypes(ctx, expr.whenFalse, depth, chainHops + 1), - ]); - } - if (ts.isParenthesizedExpression(expr)) { - return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); - } + if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) + // and `a || b` / `a ?? b` can each evaluate to either side. + if (ts.isConditionalExpression(expr)) + return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); + if (ts.isBinaryExpression(expr)) return resolveLogicalTypes(ctx, expr, depth, chainHops); return []; } +function resolveAlternatives( + ctx: InferenceContext, + alternatives: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + return dedupeTypes( + ctx, + alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1)), + ); +} + +/** `a || b` and `a ?? b` evaluate to either side; `a && b` to `b` when it's worth showing at all. */ +function resolveLogicalTypes( + ctx: InferenceContext, + expr: tsserver.BinaryExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {SyntaxKind} = ctx.ts; + switch (expr.operatorToken.kind) { + case SyntaxKind.BarBarToken: + case SyntaxKind.QuestionQuestionToken: + return resolveAlternatives(ctx, [expr.left, expr.right], depth, chainHops); + case SyntaxKind.AmpersandAmpersandToken: + return resolveExpressionTypes(ctx, expr.right, depth, chainHops + 1); + default: + return []; + } +} + +/** + * The declared types of `access`'s member on each candidate type of its + * receiver (`x.getPriceModel` when `x` is undocumented but inferable). When + * no receiver type carries the member and the receiver is (an alias of) + * `module.superModule`, the member may be an export augmentation added by a + * pass-through overlay level, which no export type can carry. + */ +function memberTypesOfReceiver( + ctx: InferenceContext, + access: tsserver.PropertyAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts, checker} = ctx; + const memberName = access.name.text; + const types = resolveExpressionTypes(ctx, access.expression, depth, chainHops + 1).flatMap((receiverType) => { + const member = getMemberOfType(checker, receiverType, memberName); + return member ? [checker.getTypeOfSymbolAtLocation(member, access.name)] : []; + }); + if (types.length > 0) return types; + const superAccess = traceSuperModuleAccess(ts, checker, access.expression); + return superAccess ? resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) : []; +} + +/** + * The return types of `methodType`'s call signatures. A signature returning + * `any` (an undocumented function reached through a superModule export type) + * is inferred from its own declaration instead. + */ +function signatureReturnTypes(ctx: InferenceContext, methodType: tsserver.Type, depth: number): tsserver.Type[] { + const {ts, checker} = ctx; + return methodType.getCallSignatures().flatMap((signature) => { + const returnType = checker.getReturnTypeOfSignature(signature); + if (!isAnyType(ts, returnType)) return [returnType]; + const declaration = signature.declaration; + return declaration && ts.isFunctionLike(declaration) ? inferReturnType(ctx, declaration, depth + 1) : []; + }); +} + /** * 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`. + * return type, then — for a method call on an undocumented receiver + * (`x.getPriceModel().getPrice()`) — from the method's real signature(s) on + * the receiver's inferred type(s). */ function resolveCallResultTypes( ctx: InferenceContext, @@ -326,103 +317,31 @@ function resolveCallResultTypes( depth: number, chainHops: number, ): tsserver.Type[] { - const {ts, checker} = ctx; const calleeFn = resolveCalleeDeclaration(ctx, expr); - if (calleeFn) { - const inferred = inferReturnType(ctx, calleeFn, depth + 1); - if (inferred.length > 0) return inferred; - } - // resolveCalleeDeclaration can't find a real declaration for a method whose - // receiver base is undocumented (the checker never resolved the method), so - // infer the receiver's type first, then look this method up by name on it. - if (!ts.isPropertyAccessExpression(expr.expression)) return []; - const methodAccess = expr.expression; - const methodName = methodAccess.name.text; - const returnTypes: tsserver.Type[] = []; - const pushSignatureReturns = (methodType: tsserver.Type) => { - for (const sig of methodType.getCallSignatures()) { - const returnType = checker.getReturnTypeOfSignature(sig); - if (!isAnyType(ts, returnType)) { - returnTypes.push(widenType(checker, returnType)); - continue; - } - // The member resolved but its own return type is `any` — the - // superModule case, where the base module's export type carries an - // undocumented function. `any` is never a useful candidate to surface; - // recurse into the function's actual declaration instead, the same - // fallback resolveCalleeDeclaration provides for direct calls. - const sigDecl = sig.declaration; - if (sigDecl && ts.isFunctionLike(sigDecl)) { - returnTypes.push(...inferReturnType(ctx, sigDecl, depth + 1)); - } - } - }; - for (const receiverType of resolveExpressionTypes(ctx, methodAccess.expression, depth, chainHops + 1)) { - const methodSymbol = getMemberOfType(checker, receiverType, methodName); - if (!methodSymbol) continue; - pushSignatureReturns(checker.getTypeOfSymbolAtLocation(methodSymbol, methodAccess.name)); - } - if (returnTypes.length === 0) { - // No candidate type carried this method — but if the receiver is (an - // alias of) module.superModule, the method may be an export - // *augmentation* added by a pass-through overlay level, which no - // candidate type can carry. - const superAccess = traceSuperModuleAccess(ts, checker, methodAccess.expression); - if (superAccess) { - for (const memberType of resolveSuperModuleMemberTypes(ctx, superAccess, methodName, depth, chainHops)) { - pushSignatureReturns(memberType); - } - } - } - return returnTypes.length > 0 ? dedupeTypes(ctx, returnTypes) : []; + const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; + if (inferred.length > 0 || !ctx.ts.isPropertyAccessExpression(expr.expression)) return inferred; + return dedupeTypes( + ctx, + memberTypesOfReceiver(ctx, expr.expression, depth, chainHops).flatMap((methodType) => + signatureReturnTypes(ctx, methodType, depth), + ), + ); } -/** - * 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. - */ +/** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ function resolvePropertyTypes( ctx: InferenceContext, expr: tsserver.PropertyAccessExpression, depth: number, chainHops: number, ): tsserver.Type[] { - 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) : []; + return dedupeTypes( + ctx, + memberTypesOfReceiver(ctx, expr, depth, chainHops).filter((type) => !isAnyType(ctx.ts, type)), + ); } -/** - * 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. - */ +/** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ function resolveIdentifierTypes( ctx: InferenceContext, expr: tsserver.Identifier, @@ -432,101 +351,36 @@ function resolveIdentifierTypes( 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); + if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(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. + * The argument types a parameter receives: at every call site of its + * function across the project (`helper(x)`, `new Helper(x)`, + * `Helper.call(this, x)`), or — for an anonymous callback with no name to + * search for — from the collection it iterates. */ -function collectArgumentTypesFromCallSites( +function parameterEvidence( ctx: InferenceContext, - nameNode: tsserver.Identifier, + fn: tsserver.SignatureDeclaration, paramIndex: number, depth: number, ): tsserver.Type[] { - const types: tsserver.Type[] = []; - for (const call of collectCallSites(ctx, nameNode)) { - const arg = call.arguments?.[paramIndex]; - if (!arg) continue; - types.push(...resolveExpressionTypes(ctx, arg, depth)); - } - return types; -} - -/** - * True when `type` exposes every member name in `memberNames`. Used to drop - * call-site candidates that can't actually support the parameter's own body - * — the classic SFRA duck-typing trap where a Store *model* is passed into a - * helper that also reads CustomerAddress-only fields (`companyName`, - * `postBox`, …). Without this filter the resolvable model wins the hover - * even though the body is not a Store. - */ -function typeSatisfiesMemberUsage( - ctx: InferenceContext, - type: tsserver.Type, - memberNames: ReadonlySet, -): boolean { - if (memberNames.size === 0) return true; - for (const name of memberNames) { - if (!getMemberOfType(ctx.checker, type, name)) return false; - } - return true; -} - -/** - * Turns raw call-site/callback candidates into the final answer for a - * parameter: drop types the body can't use, dedupe, silence conflicting - * top-level unions, otherwise fall back to ambient usage matching when - * nothing resolved. - */ -function finalizeParameterCandidates( - ctx: InferenceContext, - param: tsserver.ParameterDeclaration, - types: tsserver.Type[], - depth: number, -): tsserver.Type[] { - const {ts} = ctx; - const usage = collectParameterMemberUsage(ctx, param); - const raw = dedupeTypes(ctx, types); - // Conflicting call-site arguments (e.g. Product at one site, Order at - // another) are not a useful hover — silence rather than a noisy union, - // and do NOT fall through to ambient matching: we already have evidence, - // it just doesn't converge. - // - // Only enforce this at depth 0 (a top-level hover/completion on the - // parameter itself). Recursive callers that chase through a forwarding - // helper still need the full candidate set so return-type inference and - // the typeToString memo baselines keep working; the editor never shows - // those intermediate unions unlabeled. - if (depth === 0 && raw.length > MAX_CALL_SITE_CANDIDATES) return []; - // Keep only call-site types that expose every member the parameter body - // actually touches. A partial resolution (one duck-typed Store model call - // site resolves, an untyped preferredAddress site doesn't) must not surface - // "Store" on a helper whose body also reads address-only fields the model - // never declares. - const result = dedupeTypes( - ctx, - raw.filter((t) => typeSatisfiesMemberUsage(ctx, t, usage)), - ); - if (result.length > 0) return result; - // No usable call-site type (none found, none resolved, or none that fit - // the body). Match how the parameter's own body uses it against the - // program's ambient classes instead. - return matchAmbientTypesByUsage(ctx, usage, ts.isIdentifier(param.name) ? param.name.text : undefined); + const nameNode = getReferenceNameNode(fn, ctx.ts); + if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); + return collectCallSites(ctx, nameNode).flatMap((site) => { + const arg = site.args[paramIndex]; + return arg ? resolveExpressionTypes(ctx, arg, depth) : []; + }); } /** - * Infers a parameter's candidate type(s) from the arguments it's actually - * called with across the project — a plain call (`helper(x)`) or a - * constructor invocation (`new Helper(x)`, SFRA's other common "class" model - * shape) — since plain un-annotated JS parameters default to `any` with no - * back-inference from call sites. Falls back to matching the parameter's own - * usage (which members it's accessed by) against the program's ambient - * classes when no call site could be found or resolved at all — see - * {@link matchAmbientTypesByUsage}. + * Infers a parameter's type(s): from a platform contract when the function + * is one the platform calls (see ./framework-contracts), otherwise from the + * arguments it receives and its own usage (see ./policy for how the two are + * weighed). Plain un-annotated JS parameters default to `any` with no + * back-inference from call sites, which is the gap this fills. * * @param depth - Recursion budget already consumed by the call chain that * led here; defaults to 0 for a top-level request. @@ -537,131 +391,55 @@ export function inferParameterType( 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 fn = param.parent; + if (hasExplicitParameterType(param, ts) || !ts.isFunctionLike(fn)) return []; + return withInferenceGuards(ctx, param, depth, () => { const paramIndex = fn.parameters.indexOf(param); - if (paramIndex < 0) return []; - - const nameNode = getReferenceNameNode(fn, ts); - // `instanceof dw.order.ProductLineItem` (and friends) is concrete class - // evidence from the body itself — merge it with call-site candidates so a - // helper that never sees a typed call site still recovers the class the - // author named. finalizeParameterCandidates still silences multi-type - // unions at depth 0, so a polymorphic Adyen-style branch stays quiet. - const types = [ - ...(nameNode - ? collectArgumentTypesFromCallSites(ctx, nameNode, paramIndex, depth) - : // No name to search references for — an anonymous callback passed - // directly in argument position. Its element type may still be - // recoverable from the collection argument travelling alongside it. - inferCallbackParameterTypes(ctx, fn, paramIndex, depth)), - ...collectParameterInstanceOfTypes(ctx, param), - ]; - - const result = finalizeParameterCandidates(ctx, param, types, depth); - // Don't memoize a result whose computation hit a cycle guard: it was - // truncated by what happened to be on the *current* call stack, and the - // same node queried later in this request from outside the cycle could - // legitimately resolve more. (Depth-cap truncation, by contrast, IS - // safely memoized — the atDepth field encodes exactly how truncated it - // can be, and reuse is restricted accordingly.) - if (ctx.cycleHits === cycleHitsBefore) { - ctx.memo.set(param, {atDepth: depth, types: result}); - } - return result; - } finally { - ctx.visiting.delete(param); - } + const contract = frameworkParameterTypes(ctx, fn, paramIndex); + if (contract.length > 0) return contract; + const evidence = parameterEvidence(ctx, fn, paramIndex, depth); + return decideType(ctx, evidence, usageProfileOf(ctx, param), identifierText(ctx, param.name), true); + }); } /** - * 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 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); - } + if (hasExplicitReturnType(fn, ctx.ts)) return []; + return withInferenceGuards(ctx, fn, depth, () => { + const types = collectReturnExpressions(fn, ctx.ts).flatMap((expr) => resolveExpressionTypes(ctx, expr, depth)); + return limitUnion(ctx, normalizeCandidates(ctx, types)); + }); } /** - * Entry point for 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 hover and completions on an identifier: infers the type of + * the parameter or variable it names, or the return type of the function it + * names. */ export function inferTypeForNode(ctx: InferenceContext, node: tsserver.Node): tsserver.Type[] { const {ts, checker} = ctx; if (!ts.isIdentifier(node)) return []; - const sym = checker.getSymbolAtLocation(node); - const decl = sym?.valueDeclaration; + const decl = checker.getSymbolAtLocation(node)?.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.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, 0, 0); if (ts.isFunctionLike(decl)) return inferReturnType(ctx, decl); return []; } /** - * Like {@link inferTypeForNode}, but for an arbitrary expression 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. + * Like {@link inferTypeForNode}, but for an arbitrary expression — the + * completion case `product.getPriceModel().|` and member hovers, where the + * thing to type is a call or chain rather than a declared name. */ export function inferTypeForExpression(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type[] { - const {ts} = ctx; - if (ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); - return dedupeTypes(ctx, resolveExpressionTypes(ctx, expr, 0)); + if (ctx.ts.isIdentifier(expr)) return inferTypeForNode(ctx, expr); + return limitUnion(ctx, normalizeCandidates(ctx, resolveExpressionTypes(ctx, expr, 0))); } diff --git a/packages/b2c-script-types/src/inference/framework-contracts.ts b/packages/b2c-script-types/src/inference/framework-contracts.ts new file mode 100644 index 000000000..8f0f2324a --- /dev/null +++ b/packages/b2c-script-types/src/inference/framework-contracts.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Functions the platform itself calls, so no call site exists in the project +// to infer from. Their parameter types are fixed by the platform contract +// rather than guessed: Page Designer invokes the `render(context, modelIn)` +// export of every component and page script with a script context and an +// optional model map. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {findAmbientClassType} from './ambient-index'; +import {getReferenceNameNode} from './call-sites'; +import type {InferenceContext} from './context'; + +interface FrameworkContract { + /** Path segment identifying the scripts the platform calls. */ + readonly directory: string; + /** Name of the exported function the platform calls. */ + readonly functionName: string; + /** Ambient class names of its parameters, by index. */ + readonly parameterTypes: readonly string[]; +} + +const FRAMEWORK_CONTRACTS: readonly FrameworkContract[] = [ + { + directory: '/cartridge/experience/components/', + functionName: 'render', + parameterTypes: ['ComponentScriptContext', 'Map'], + }, + { + directory: '/cartridge/experience/pages/', + functionName: 'render', + parameterTypes: ['PageScriptContext', 'Map'], + }, +]; + +/** The contract-defined type of `fn`'s parameter at `paramIndex`, if `fn` is a platform entry point. */ +export function frameworkParameterTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + paramIndex: number, +): tsserver.Type[] { + const functionName = getReferenceNameNode(fn, ctx.ts)?.text; + if (!functionName) return []; + const fileName = fn.getSourceFile().fileName.replace(/\\/g, '/'); + const contract = FRAMEWORK_CONTRACTS.find( + (candidate) => candidate.functionName === functionName && fileName.includes(candidate.directory), + ); + const typeName = contract?.parameterTypes[paramIndex]; + const type = typeName ? findAmbientClassType(ctx, typeName) : undefined; + return type ? [type] : []; +} diff --git a/packages/b2c-script-types/src/inference/naming.ts b/packages/b2c-script-types/src/inference/naming.ts new file mode 100644 index 000000000..5fbece644 --- /dev/null +++ b/packages/b2c-script-types/src/inference/naming.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Identifier-name hints. SFCC code conventionally names a variable after the +// Script API class it holds (`customer`, `currentBasket`, `apiProduct`, +// `pli`). Usage alone often fits several classes (`.email` + `.firstName` +// fits both Profile and ProductListRegistrant), so the name is used to pick +// one — but only ever among classes the usage already fits: a name never adds +// a candidate of its own. + +// Abbreviations whose class the identifier does not spell out, keyed and +// valued in lowercase. Everything else is derived from the identifier's own +// camelCase words, so this stays short. +const ABBREVIATIONS: ReadonlyMap = new Map([ + ['pli', 'productlineitem'], + ['lineitem', 'productlineitem'], + ['paymentinstrument', 'orderpaymentinstrument'], + ['shippingaddress', 'orderaddress'], + ['billingaddress', 'orderaddress'], +]); + +// All-lowercase qualifiers SFRA code glues onto a class name +// (`apiproduct`, `currentbasket`): stripping them leaves the class. +const QUALIFIER_PREFIXES = ['api', 'current', 'default', 'selected', 'new']; + +interface Named { + readonly name: string; +} + +// The expansion goes first: SFRA's `lineItem` parameters hold a +// ProductLineItem, even where the usage would also fit the LineItem base. +function withAbbreviation(key: string): string[] { + const expanded = ABBREVIATIONS.get(key); + return expanded ? [expanded, key] : [key]; +} + +/** + * Lowercased class names `identifier` conventionally denotes, most specific + * first: the whole name, then each shorter camelCase word suffix + * (`resettingCustomer` → `customer`, `productShippingLineItem` → + * `shippinglineitem` → `lineitem` → `item`), each preceded by its + * abbreviation expansion, then the name with a qualifier prefix stripped. + * Splitting on camelCase boundaries only is what keeps an all-lowercase + * `border` from reading as `Order`. + */ +function conventionalClassNames(identifier: string): string[] { + const words = identifier.split(/(?=[A-Z])/); + const keys = words.flatMap((_, start) => withAbbreviation(words.slice(start).join('').toLowerCase())); + const lower = identifier.toLowerCase(); + for (const prefix of QUALIFIER_PREFIXES) { + if (lower.length > prefix.length && lower.startsWith(prefix)) + keys.push(...withAbbreviation(lower.slice(prefix.length))); + } + return keys; +} + +/** + * Picks the one candidate `identifier` names, or `undefined` when the name + * points at none of them (or at several equally). Besides + * {@link conventionalClassNames}, a camelCase identifier that is the tail of + * exactly one candidate's name also counts (`priceModel` → + * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). + */ +export function pickByName(identifier: string, candidates: readonly T[]): T | undefined { + const byName = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = byName.get(key); + if (named) named.push(candidate); + else byName.set(key, [candidate]); + } + for (const key of conventionalClassNames(identifier)) { + const named = byName.get(key); + if (named?.length === 1) return named[0]; + } + if (identifier === identifier.toLowerCase()) return undefined; + const lower = identifier.toLowerCase(); + const tails = candidates.filter((candidate) => candidate.name.toLowerCase().endsWith(lower)); + return tails.length === 1 ? tails[0] : undefined; +} diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts new file mode 100644 index 000000000..f20265ccf --- /dev/null +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Decision rules: turning the raw evidence ./core gathers into the type(s) a +// hover or completion shows. The same rules run for every parameter, return +// value and variable at every depth, so a parameter hover, a member hover on +// it and a completion after it can never disagree. In order: +// +// 1. Normalize: split unions, widen literals, drop types that say nothing +// (`null`, `any`, `T`, `{}`), dedupe by display. +// 2. Fit: a parameter keeps only candidates exposing every member its body +// relies on — members it tests for first (`'ID' in x`) aside — and able +// to stand wherever the body passes the value to a documented helper, or +// a class its body tests for with `instanceof`/`typeof`. A duck-typed view +// model, or a wrong argument at one buggy call site, can't masquerade as +// the Script API class the body actually needs. +// 3. Union like IntelliJ: up to MAX_UNION_TYPES, after dropping any candidate +// whose superclass is also a candidate (`Variant | Product` is `Product`). +// Wider evidence collapses to the closest shared superclass that still +// fits, or stays silent. +// 4. No usable evidence: the most specific type the value is used as +// (`ProductMgr.getProduct(pid)` makes `pid` a `string`), then matching the +// usage against every ambient class, with the identifier name as a +// tiebreak (see ./naming). + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {ambientClassType, getAmbientClasses} from './ambient-index'; +import type {AmbientClass} from './ambient-index'; +import {MAX_UNION_TYPES, MIN_USAGE_SIGNATURE_MEMBERS, UNINFORMATIVE_ANCESTORS, WEAK_USAGE_MEMBERS} from './constants'; +import type {InferenceContext} from './context'; +import {pickByName} from './naming'; +import {dedupeTypes, hasAllMembers, informativeParts, typeDisplayString} from './type-helpers'; +import type {UsageProfile} from './usage-profile'; + +const NO_MEMBERS: ReadonlySet = new Set(); + +/** The members every value the profile describes must have: those used without a presence test first. */ +function requiredMembers(profile: UsageProfile): ReadonlySet { + if (profile.optionalMemberNames.size === 0) return profile.memberNames; + return new Set([...profile.memberNames].filter((name) => !profile.optionalMemberNames.has(name))); +} + +/** Step 1: split unions, widen literals, drop uninformative types, dedupe by display. */ +export function normalizeCandidates(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + return dedupeTypes( + ctx, + types.flatMap((type) => informativeParts(ctx, type)), + ); +} + +/** The class or interface behind `type` (`Product` for `Product`), if it is one. */ +function classOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType | undefined { + const {ts} = ctx; + if (!(type.flags & ts.TypeFlags.Object)) return undefined; + const objectType = type as tsserver.ObjectType; + const target = + objectType.objectFlags & ts.ObjectFlags.Reference ? (type as tsserver.TypeReference).target : objectType; + return target.objectFlags & ts.ObjectFlags.ClassOrInterface ? (target as tsserver.InterfaceType) : undefined; +} + +/** Every superclass and implemented interface of `type`, nearest first (excluding `type` itself). */ +function ancestorsOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType[] { + const ancestors: tsserver.InterfaceType[] = []; + const start = classOf(ctx, type); + for (let queue = start ? [start] : []; queue.length > 0; ) { + const current = queue.shift()!; + for (const base of ctx.checker.getBaseTypes(current)) { + const baseClass = classOf(ctx, base); + if (baseClass && baseClass !== start && !ancestors.includes(baseClass)) { + ancestors.push(baseClass); + queue.push(baseClass); + } + } + } + return ancestors; +} + +function isAncestorOf(ctx: InferenceContext, ancestor: tsserver.Type, type: tsserver.Type): boolean { + const ancestorClass = classOf(ctx, ancestor); + return ancestorClass !== undefined && ancestorsOf(ctx, type).includes(ancestorClass); +} + +/** The class behind `type` and its ancestors, root classes (`PersistentObject`, ...) aside. */ +function lineageOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType[] { + const own = classOf(ctx, type); + return [...(own ? [own] : []), ...ancestorsOf(ctx, type)].filter( + (lineageClass) => !UNINFORMATIVE_ANCESTORS.has(lineageClass.symbol?.name ?? ''), + ); +} + +/** + * What the value is passed or assigned as, each use as the informative + * alternatives it accepts (`Product | null` accepts a `Product`; an `any`, + * `Object` or `any[]` use accepts anything and is left out). + */ +function constrainingUses(ctx: InferenceContext, profile: UsageProfile): tsserver.Type[][] { + return profile.contextualTypes.map((use) => informativeParts(ctx, use)).filter((parts) => parts.length > 0); +} + +/** + * True when `type` can stand wherever the body uses the value: assignable to + * (an alternative of) each use, or at least from the same class family (an + * `Order` passed to a helper documented as taking a `Basket` that really + * takes any `LineItemCtnr`, a common over-narrow JSDoc). A different kind of + * value never fits: a `string` argument at one buggy call site of + * `getBonusUnitPrice(apiProduct)`, whose body passes it on as a `Product`. + * TypeScript builds without the public `isTypeAssignableTo` skip the check. + */ +function fitsEveryUse(ctx: InferenceContext, type: tsserver.Type, uses: readonly tsserver.Type[][]): boolean { + const {checker} = ctx; + if (uses.length === 0 || typeof checker.isTypeAssignableTo !== 'function') return true; + const lineage = lineageOf(ctx, type); + const fits = (use: tsserver.Type): boolean => + checker.isTypeAssignableTo(type, use) || lineageOf(ctx, use).some((useClass) => lineage.includes(useClass)); + return uses.every((alternatives) => alternatives.some(fits)); +} + +/** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ +function mostGeneral(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); + return types.filter((type) => !ancestorsOf(ctx, type).some((ancestor) => candidateClasses.has(ancestor))); +} + +/** The nearest superclass all `types` share that still has `memberNames`, ignoring root classes. */ +function closestCommonAncestor( + ctx: InferenceContext, + types: readonly tsserver.Type[], + memberNames: ReadonlySet, +): tsserver.Type | undefined { + const [first, ...rest] = types; + const restAncestors = rest.map((type) => ancestorsOf(ctx, type)); + return ancestorsOf(ctx, first).find( + (ancestor) => + !UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && + restAncestors.every((ancestors) => ancestors.includes(ancestor)) && + hasAllMembers(ctx.checker, ancestor, memberNames), + ); +} + +/** Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, else the closest shared superclass, else silence. */ +export function limitUnion( + ctx: InferenceContext, + types: readonly tsserver.Type[], + memberNames: ReadonlySet = NO_MEMBERS, +): tsserver.Type[] { + const general = mostGeneral(ctx, types); + if (general.length <= MAX_UNION_TYPES) return general; + const ancestor = closestCommonAncestor(ctx, general, memberNames); + return ancestor ? [ancestor] : []; +} + +/** + * The single most specific type the value is used as: one every other such + * type is a superclass of (`basket` passed both where a `Basket` and where a + * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. + */ +function mostSpecificUse(ctx: InferenceContext, profile: UsageProfile, required: ReadonlySet): tsserver.Type[] { + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => + hasAllMembers(ctx.checker, type, required), + ); + const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); + return mostSpecific.length === 1 ? mostSpecific : []; +} + +interface AmbientMatch { + readonly name: string; + readonly type: tsserver.Type; +} + +/** Resolves matched index entries to types, merging declarations of the same symbol. */ +function resolveMatches(ctx: InferenceContext, classes: readonly AmbientClass[]): AmbientMatch[] { + const bySymbol = new Map(); + for (const ambientClass of classes) { + const type = ambientClassType(ctx, ambientClass); + const symbol = type?.getSymbol(); + if (type && symbol && !bySymbol.has(symbol)) bySymbol.set(symbol, {name: ambientClass.name, type}); + } + return [...bySymbol.values()]; +} + +/** + * Step 4b: matches a usage signature against every ambient class (see + * ./ambient-index). Silent when no class has every member; when only + * ubiquitous members (`.custom`, `.UUID`) were used and several classes fit; + * and when a single member fits several classes the identifier name can't + * choose between. A name that denotes a class the usage does *not* fit + * never falls back to a vaguer reading of the same name + * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and + * silences a single-member signature outright: `lineItem.preorderable` + * uniquely matching ProductInventoryRecord is a coincidence, not a hint. + * + * @param identifierName - the parameter's or variable's own name, used only + * to pick among classes the usage already fits (see ./naming). + */ +export function matchAmbientTypesByUsage( + ctx: InferenceContext, + memberNames: ReadonlySet, + identifierName?: string, +): tsserver.Type[] { + if (memberNames.size === 0) return []; + const classes = getAmbientClasses(ctx); + const fitting = classes.filter((ambientClass) => + [...memberNames].every((name) => ambientClass.memberNames.has(name)), + ); + const onlyWeakMembers = [...memberNames].every((name) => WEAK_USAGE_MEMBERS.has(name)); + if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; + const matches = resolveMatches(ctx, fitting); + const denoted = identifierName ? pickByName(identifierName, classes) : undefined; + const named = denoted + ? matches.find((match) => match.name === denoted.name) + : identifierName && pickByName(identifierName, matches); + if (named) return [named.type]; + const thinSignature = memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS; + if (thinSignature && (denoted || matches.length > 1)) return []; + return limitUnion( + ctx, + matches.map((match) => match.type), + memberNames, + ); +} + +/** + * Decides what a parameter or variable holds from the evidence ./core + * gathered for it (call-site arguments, assigned values, framework + * callbacks) plus its own usage profile. + * + * @param fitUsage - drop candidates lacking a member the profile shows in + * use. On for parameters, whose call sites are independent samples that may + * pass a look-alike; off for a variable's own assigned values. + */ +export function decideType( + ctx: InferenceContext, + evidence: readonly tsserver.Type[], + profile: UsageProfile, + identifierName: string | undefined, + fitUsage: boolean, +): tsserver.Type[] { + const guards = normalizeCandidates(ctx, profile.guardTypes); + const guardKeys = new Set(guards.map((type) => typeDisplayString(ctx, type))); + const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); + const required = requiredMembers(profile); + const uses = constrainingUses(ctx, profile); + const fits = (type: tsserver.Type): boolean => + guardKeys.has(typeDisplayString(ctx, type)) || + (hasAllMembers(ctx.checker, type, required) && fitsEveryUse(ctx, type, uses)); + const fitting = fitUsage ? candidates.filter(fits) : candidates; + if (fitting.length > 0) return limitUnion(ctx, fitting, required); + const used = mostSpecificUse(ctx, profile, required); + if (used.length > 0) return used; + return matchAmbientTypesByUsage(ctx, profile.memberNames, identifierName); +} diff --git a/packages/b2c-script-types/src/inference/super-module.ts b/packages/b2c-script-types/src/inference/super-module.ts index 1cf479fe3..1589f8ba1 100644 --- a/packages/b2c-script-types/src/inference/super-module.ts +++ b/packages/b2c-script-types/src/inference/super-module.ts @@ -49,6 +49,12 @@ export function findSuperModuleFile(ctx: InferenceContext, fromFileName: string) return program.getSourceFiles().find((sf) => sf.fileName.toLowerCase() === target); } +/** A module's top-level export assignments — see {@link collectExportAssignments}. */ +export interface ExportAssignments { + readonly full: readonly tsserver.BinaryExpression[]; + readonly members: ReadonlyArray<{readonly name: string; readonly expr: tsserver.Expression}>; +} + /** * A module's top-level export assignments, gathered structurally: * `full` — every `module.exports = X` right-hand side; @@ -56,35 +62,31 @@ export function findSuperModuleFile(ctx: InferenceContext, fromFileName: string) * 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}>} { +export function collectExportAssignments(sf: tsserver.SourceFile, ts: typeof tsserver): ExportAssignments { const full: tsserver.BinaryExpression[] = []; const members: Array<{name: string; expr: tsserver.Expression}> = []; for (const stmt of sf.statements) { 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}); - } + if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(bin.left)) continue; + const base = bin.left.expression; + if (isModuleExports(base, ts)) members.push({name: bin.left.name.text, expr: bin.right}); + else if (ts.isIdentifier(base) && base.text === 'exports') + members.push({name: bin.left.name.text, expr: bin.right}); + else if (isModuleExports(bin.left, ts)) full.push(bin); } return {full, members}; } +/** `module.exports`, identified structurally. */ +function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports' + ); +} /** * True when a `module.exports = X` assignment gives the checker a genuinely * usable exports type: not `any`, and actually exposing members. A @@ -130,13 +132,44 @@ export function traceSuperModuleAccess( return undefined; } +/** True when a level re-exports what is below it (`module.exports = base`) rather than replacing it. */ +function passesThrough(ctx: InferenceContext, exports: ExportAssignments): boolean { + const {ts, checker} = ctx; + return exports.full.some( + (bin) => !isConcreteExportAssignment(ctx, bin) || traceSuperModuleAccess(ts, checker, bin.right) !== undefined, + ); +} + +/** + * Walks the superModule chain below the file containing `superAccess`, one + * cartridge level at a time, yielding each level's export assignments. The + * walk continues downward only through a pass-through level + * (`module.exports = base`): a level with a concrete `module.exports` + * replaces everything below it at runtime, unless it carries the base along. + */ +export function* superModuleLevels( + ctx: InferenceContext, + superAccess: tsserver.PropertyAccessExpression, +): Generator { + const seen = new Set(); + let fromFileName = superAccess.getSourceFile().fileName; + for (let hop = 0; hop < MAX_SUPERMODULE_HOPS; hop++) { + const superFile = findSuperModuleFile(ctx, fromFileName); + if (!superFile || seen.has(superFile)) return; + seen.add(superFile); + const exports = collectExportAssignments(superFile, ctx.ts); + yield exports; + if (!passesThrough(ctx, exports)) return; + fromFileName = superFile.fileName; + } +} + /** * 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. + * pass-through levels — the members the superModule's export types cannot + * carry. Used to complete after `base.` in an overlay; the first (highest) + * level defining a name wins, matching runtime override order. */ export function collectSuperModuleAugmentedMembers( ctx: InferenceContext, @@ -147,24 +180,12 @@ export function collectSuperModuleAugmentedMembers( 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}); + for (const {members} of superModuleLevels(ctx, superAccess)) { + for (const member of members) { + if (seenNames.has(member.name)) continue; + seenNames.add(member.name); + out.push({name: member.name, isMethod: checker.getTypeAtLocation(member.expr).getCallSignatures().length > 0}); } - 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 index c7534940f..b380ad66f 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -4,12 +4,13 @@ * 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. +// Helpers for working with the checker's Type objects: recognizing `any` and +// other types that carry no information, rendering a type the way a reader +// expects to see it, de-duplicating candidates by that rendering, reaching a +// type's real members (stripping nullability), pulling the element type out +// of a collection, and turning candidate types into completion entries. +// These are the "leaf" operations the recursive engine in ./core and the +// decision rules in ./policy build on. import type tsserver from 'typescript/lib/tsserverlibrary'; @@ -23,124 +24,166 @@ export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { /** * 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. + * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or + * the global `Object` interface. Used as the hover/completion gate. * * Deliberately excludes named classes (even wrong ones like a mis-documented * `Request`) — those are strong enough that overriding them would fight both * TypeScript and IntelliJ's JSDoc-first model. * - * 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. + * `Object` is what checkJs resolves the ubiquitous SFRA `@param {Object}` + * placeholder to (capital-O, distinct from the lowercase `object` + * non-primitive and from `{*}`/`{}`/`{obj}`, which widen to `any` or an empty + * type). It carries no Script API information, so a value typed as bare + * `Object` is effectively undocumented — exactly the case usage inference + * exists for. No dw.* class is named plain `Object`, so keying on the name + * can't shadow a real Script API type. */ export function isOpenForUsageInference(ts: typeof tsserver, type: tsserver.Type): boolean { if (isAnyType(ts, type)) return true; if (type.flags & ts.TypeFlags.NonPrimitive) return true; const symbol = type.getSymbol(); if (symbol?.getName() === '__type' && type.getProperties().length === 0) return true; - if (symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0) return true; - return false; + return symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; +} + +// Type flags that never make a useful hover or completion candidate on their +// own: `null`/`undefined` (the empty branch of `x ? y : null`), `void`, +// `never`, `unknown`, and an unbound type parameter (`T` from a generic +// declaration nobody instantiated). +function uninformativeFlags(ts: typeof tsserver): number { + return ( + ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Never | + ts.TypeFlags.Void | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null | + ts.TypeFlags.TypeParameter + ); +} + +/** An object type with no members and no signatures: the `{}` of `options || {}`. */ +function isEmptyObjectType(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + return ( + (type.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(type).length === 0 && + type.getCallSignatures().length === 0 && + type.getConstructSignatures().length === 0 && + checker.getIndexInfosOfType(type).length === 0 + ); } /** - * 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. + * True when `type` tells a reader something: not one of the + * {@link uninformativeFlags} kinds, not open for inference (see + * {@link isOpenForUsageInference}), not an empty object, and — for an array — + * an array of something informative (`any[]`, `T[]` and `undefined[]` are as + * empty as `any`). */ -export function widenType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { - return checker.getBaseTypeOfLiteralType(type); +function isInformativeType(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + if (type.flags & uninformativeFlags(ts)) return false; + if (isOpenForUsageInference(ts, type) || isEmptyObjectType(ts, checker, type)) return false; + if (!checker.isArrayType(type)) return true; + const [element] = checker.getTypeArguments(type as tsserver.TypeReference); + return element !== undefined && isInformativeType(ts, checker, element); } /** - * `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. + * Splits `type` into the parts worth showing: union members are considered + * one by one (so `Product | null` keeps `Product`), literals are widened to + * their primitive (`"a" | "b"` reads as `string`), and every + * non-informative part is dropped. */ -function computeTypeDisplayString(checker: tsserver.TypeChecker, type: tsserver.Type): string { - let simple = checker.typeToString(type); - // Ambient usage-matching indexes generic Script API classes via their - // unsubstituted declared type (`Product`). With no call-site - // instantiation to substitute from, surface the conventional SFCC form - // `Product` instead of a dangling type-parameter name. Only rewrite - // single-letter param slots (`T`, `T, U`) — never real arguments like - // `Product`. - simple = simple.replace(/<([A-Z](?:\s*,\s*[A-Z])*)>/g, (_match, inner: string) => { - const params = inner.split(/\s*,\s*/); - return `<${params.map(() => 'any').join(', ')}>`; +export function informativeParts(ctx: InferenceContext, type: tsserver.Type): tsserver.Type[] { + const {ts, checker} = ctx; + const parts = type.isUnion() ? type.types : [type]; + return parts + .map((part) => checker.getBaseTypeOfLiteralType(part)) + .filter((part) => isInformativeType(ts, checker, part)); +} + +/** + * True when a generic instantiation's type arguments add nothing a reader + * needs: each one is still an unbound type parameter (the declared type of + * `Product`, as ambient matching produces it), `any`, or exactly the + * parameter's declared default (`Product`). + */ +function hasOnlyDefaultTypeArguments(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): boolean { + if (!(type.flags & ts.TypeFlags.Object)) return true; + if (!((type as tsserver.ObjectType).objectFlags & ts.ObjectFlags.Reference)) return true; + const reference = type as tsserver.TypeReference; + const parameters = reference.target.typeParameters ?? []; + return checker.getTypeArguments(reference).every((argument, index) => { + if (argument.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any)) return true; + const parameter = parameters[index]; + return parameter !== undefined && checker.getDefaultFromTypeParameter(parameter) === argument; }); +} + +/** + * The name a reader knows a class or interface by. Usually its own name; a + * declaration nested in a namespace keeps the namespace (the vendored + * `ICustomAttributes.Shipment`, the type of `shipment.custom`, must not read + * as the unrelated `Shipment` class). The `global.` wrapper of + * `declare global` and a quoted ambient-module prefix + * (`"server/server".Response`) are implementation details and are dropped. + */ +function classDisplayName(checker: tsserver.TypeChecker, symbol: tsserver.Symbol): string { + return checker + .getFullyQualifiedName(symbol) + .replace(/^global\./, '') + .replace(/^"[^"]*"\./, ''); +} + +/** + * Renders a candidate type for hover text and dedupe keys. A class or + * interface instantiated only with default type arguments reads as the bare + * class name (`Product`, `LineItemCtnr`); everything else — real generic + * arguments (`Collection`), primitives, object literals, lib types — + * is rendered by the checker as TypeScript itself would. + */ +function computeTypeDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): string { const symbol = type.getSymbol(); - if (!symbol) return simple; - const qualified = checker.getFullyQualifiedName(symbol).replace(/^global\./, ''); - return qualified === symbol.getName() ? simple : qualified; + const isClassOrInterface = + symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; + if (isClassOrInterface && hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); + return checker.typeToString(type); } /** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ export function typeDisplayString(ctx: InferenceContext, type: tsserver.Type): string { const cached = ctx.typeDisplayStrings.get(type); if (cached !== undefined) return cached; - const str = computeTypeDisplayString(ctx.checker, type); - ctx.typeDisplayStrings.set(type, str); - return str; + const display = computeTypeDisplayString(ctx.ts, ctx.checker, type); + ctx.typeDisplayStrings.set(type, display); + return display; } /** * Deduplicates candidate types by their display string. Two distinct types - * that happen to render identically (e.g. same-named classes from different - * modules) collapse into one — acceptable here because every consumer of the + * that render identically (`Product` and `Product` both read as + * `Product`) collapse into one — acceptable because every consumer of the * result is display-oriented (hover text, completion-member names). */ -export function dedupeTypes(ctx: InferenceContext, types: tsserver.Type[]): tsserver.Type[] { +export function dedupeTypes(ctx: InferenceContext, types: readonly 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; + return types.filter((type) => { + const key = typeDisplayString(ctx, type); + if (seen.has(key)) return false; seen.add(key); - out.push(t); - } - return out; + return true; + }); } /** - * 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. + * Strips any nullable part from `type` and computes its apparent type before + * a member lookup. `getPropertyOfType` on a union only returns members common + * to *every* constituent, so an un-stripped `Product | null` (the shape of + * `ProductMgr.getProduct()`) would never resolve any member; the apparent + * type also exposes a primitive's wrapper-object members (`.length`). */ -export function getNonNullableApparentType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { +function getNonNullableApparentType(checker: tsserver.TypeChecker, type: tsserver.Type): tsserver.Type { return checker.getApparentType(checker.getNonNullableType(type)); } @@ -153,6 +196,18 @@ export function getMemberOfType( return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); } +/** True when `type` exposes every member name in `memberNames` (vacuously true for none). */ +export function hasAllMembers( + checker: tsserver.TypeChecker, + type: tsserver.Type, + memberNames: ReadonlySet, +): boolean { + for (const name of memberNames) { + if (!getMemberOfType(checker, type, name)) return false; + } + return true; +} + /** * Extracts the element type from a collection-like `type`: something with an * `iterator()` method whose result has a typed `next()` (dw.util.Collection @@ -170,13 +225,10 @@ export function collectionElementType( ): 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); + const sym = getMemberOfType(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 [signature] = checker.getTypeOfSymbolAtLocation(sym, location).getCallSignatures(); + return signature && checker.getReturnTypeOfSignature(signature); }; const iteratorType = firstCallReturn(type, 'iterator') ?? type; const element = firstCallReturn(iteratorType, 'next'); @@ -185,25 +237,35 @@ export function collectionElementType( return element; } +/** Renders candidate types as hover text, e.g. `"Product | Category"`. */ +export function describeTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): string { + return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); +} + /** - * 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. + * One synthesized member completion. `sortText` '11' mirrors TS's own + * SortText.LocationPriority — the rank ordinary resolved members get — so + * inferred members sort alongside real ones rather than above or below them. */ -export function describeTypes(checker: tsserver.TypeChecker, types: tsserver.Type[]): string { - const seen = new Set(); - for (const t of types) { - seen.add(computeTypeDisplayString(checker, t)); - } - return [...seen].join(' | '); +export function inferredCompletionEntry( + ts: typeof tsserver, + name: string, + isMethod: boolean, +): tsserver.CompletionEntry { + return { + name, + kind: isMethod ? ts.ScriptElementKind.memberFunctionElement : ts.ScriptElementKind.memberVariableElement, + kindModifiers: '', + sortText: '11', + source: INFERRED_COMPLETION_SOURCE, + }; } /** Synthesizes completion entries for candidate types' members, deduplicated by property name. */ export function typesToCompletionEntries( ts: typeof tsserver, checker: tsserver.TypeChecker, - types: tsserver.Type[], + types: readonly tsserver.Type[], ): tsserver.CompletionEntry[] { const seen = new Set(); const entries: tsserver.CompletionEntry[] = []; @@ -212,20 +274,7 @@ export function typesToCompletionEntries( 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, - }); + entries.push(inferredCompletionEntry(ts, name, (sym.flags & ts.SymbolFlags.Method) !== 0)); } } return entries; diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts new file mode 100644 index 000000000..3036ae713 --- /dev/null +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -0,0 +1,359 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// What a parameter's or variable's own scope says about it, gathered in one +// walk: the members it is accessed by (duck typing), the classes it is +// tested against (`instanceof`, `typeof`), the types the code passes it as +// (a typed call argument or assignment target), and the values assigned to +// it. Every rule in ./policy reads this same profile, so a hover, a member +// hover and a completion all see the same evidence. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {isOpenForUsageInference} from './type-helpers'; + +export interface UsageProfile { + /** + * Member names accessed directly on the value: `x.member`, + * `x['member']`, and the presence tests in {@link optionalMemberNames}. + * Only one hop counts — a chained `x.custom.fromStoreId` contributes + * `custom`, since the deeper name describes `custom`'s shape, not `x`'s. + */ + readonly memberNames: ReadonlySet; + /** + * Members the code tests for before relying on them (`'ID' in x`, + * `x.hasOwnProperty('ID')`, `Object.hasOwnProperty.call(x, 'ID')`), and + * members used only in a branch such a test picks: a hint at what the + * value is, but not a member every value passed in must have. + */ + readonly optionalMemberNames: ReadonlySet; + /** Types the value is tested against: `x instanceof dw.order.ProductLineItem`, `typeof x === 'string'`. */ + readonly guardTypes: readonly tsserver.Type[]; + /** Types the value is used as: the parameter type of a typed call it is passed to, or the type of what it is assigned to. */ + readonly contextualTypes: readonly tsserver.Type[]; + /** Right-hand sides of `x = value` assignments to the value after its declaration. */ + readonly assignedValues: readonly tsserver.Expression[]; +} + +interface ProfileBuilder { + readonly memberNames: Set; + readonly optionalMemberNames: Set; + readonly guardTypes: tsserver.Type[]; + readonly contextualTypes: tsserver.Type[]; + readonly assignedValues: tsserver.Expression[]; +} + +function emptyProfile(): ProfileBuilder { + return { + memberNames: new Set(), + optionalMemberNames: new Set(), + guardTypes: [], + contextualTypes: [], + assignedValues: [], + }; +} + +const EMPTY_PROFILE: UsageProfile = emptyProfile(); + +/** + * The instance type an `instanceof` right-hand side tests for + * (`dw.order.ProductLineItem`, a local class binding, ...): its construct + * signature's return type, or — for a JS constructor function, which has + * none — the declared type of the right-hand side's symbol. A test against a + * root type (`x instanceof Object`) says nothing and yields undefined. + */ +function instanceTypeOf(ctx: InferenceContext, constructor: tsserver.Expression): tsserver.Type | undefined { + const {checker, ts} = ctx; + const constructorType = checker.getTypeAtLocation(constructor); + const [signature] = constructorType.getConstructSignatures(); + const symbol = constructorType.getSymbol() ?? checker.getSymbolAtLocation(constructor); + const instance = signature + ? checker.getReturnTypeOfSignature(signature) + : symbol && checker.getDeclaredTypeOfSymbol(symbol); + return instance && !isOpenForUsageInference(ts, instance) ? instance : undefined; +} + +/** The primitive a `typeof x === 'string'` comparison names, if `typeOf` sits in one. */ +function primitiveOfTypeofCheck(ctx: InferenceContext, typeOf: tsserver.TypeOfExpression): tsserver.Type | undefined { + const {ts, checker} = ctx; + const comparison = typeOf.parent; + if (!ts.isBinaryExpression(comparison)) return undefined; + const other = comparison.left === typeOf ? comparison.right : comparison.left; + if (!ts.isStringLiteralLike(other)) return undefined; + if (other.text === 'string') return checker.getStringType(); + if (other.text === 'number') return checker.getNumberType(); + if (other.text === 'boolean') return checker.getBooleanType(); + return undefined; +} + +/** The string a presence test names, if `node` is a string literal. */ +function literalText(ctx: InferenceContext, node: tsserver.Node | undefined): string | undefined { + return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; +} + +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { + const {ts} = ctx; + const parent = reference.parent; + if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.InKeyword) { + return parent.right === reference ? literalText(ctx, parent.left) : undefined; + } + const isOwnReceiver = + ts.isPropertyAccessExpression(parent) && parent.expression === reference && parent.name.text === 'hasOwnProperty'; + const call = isOwnReceiver ? parent.parent : parent; + if (!ts.isCallExpression(call)) return undefined; + if (isOwnReceiver) return call.expression === parent ? literalText(ctx, call.arguments[0]) : undefined; + const callee = call.expression; + const isHasOwnCall = + call.arguments[0] === reference && + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'call' && + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.name.text === 'hasOwnProperty'; + return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; +} + +/** + * True when `access` is the member access the current request's own cursor + * sits in (see {@link InferenceContext.triggerPosition}): a member name being + * typed (`shipment.pro|`) is not evidence yet, and a dangling `shipment.` + * followed by more code parses as an access to whatever identifier comes + * next, which no real class has. + */ +function isTriggerAccess(ctx: InferenceContext, access: tsserver.PropertyAccessExpression): boolean { + const trigger = ctx.triggerPosition; + return trigger !== undefined && access.expression.getEnd() <= trigger && trigger <= access.name.getEnd(); +} + +/** The type `signature` declares for argument `index` of `call`, unless a rest parameter collects it. */ +function declaredArgumentType( + ctx: InferenceContext, + signature: tsserver.Signature, + index: number, + call: tsserver.Node, +): tsserver.Type | undefined { + const parameter = signature.getParameters()[index]; + const declaration = parameter?.valueDeclaration; + if (!parameter || (declaration && ctx.ts.isParameter(declaration) && declaration.dotDotDotToken)) return undefined; + return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); +} + +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver.Signature, count: number): boolean { + const {ts, checker} = ctx; + const declaration = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; + if (!declaration) return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} + +/** + * The type `reference` is used as. An argument to an overloaded function + * counts only when every overload taking that many arguments agrees on it + * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type + * the checker settles on the first such overload, which says nothing about + * the one the code means (`variationModel.getAllValues(attribute)` would + * make `attribute` an `ObjectAttributeDefinition`). + */ +function usedAsType(ctx: InferenceContext, reference: tsserver.Identifier): tsserver.Type | undefined { + const {ts, checker} = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const args: readonly tsserver.Expression[] = (isCall && call.arguments) || []; + const index = args.indexOf(reference); + if (!isCall || index < 0) return checker.getContextualType(reference); + const callee = checker.getTypeAtLocation(call.expression); + const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); + const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, args.length)); + if (overloads.length <= 1) return checker.getContextualType(reference); + const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); + return first && rest.every((type) => type === first) ? first : undefined; +} + +/** The value a profile describes: its symbol, the name it is declared by, and its own (unnarrowed) type. */ +interface ProfileTarget { + readonly symbol: tsserver.Symbol; + readonly declarationName: tsserver.Identifier; + readonly declaredType: tsserver.Type; +} + +function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): node is tsserver.Identifier { + return ( + ctx.ts.isIdentifier(node) && + node !== target.declarationName && + node.text === target.declarationName.text && + ctx.checker.getSymbolAtLocation(node) === target.symbol + ); +} + +/** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ +function testsPresence(ctx: InferenceContext, target: ProfileTarget, condition: tsserver.Node): boolean { + const visit = (node: tsserver.Node): boolean => + (isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true; + return visit(condition); +} + +/** True when `statement` always leaves its block: a `return`/`throw`, or a block ending in one. */ +function alwaysExits(ctx: InferenceContext, statement: tsserver.Statement): boolean { + const {ts} = ctx; + const last = ts.isBlock(statement) ? statement.statements[statement.statements.length - 1] : statement; + return last !== undefined && (ts.isReturnStatement(last) || ts.isThrowStatement(last)); +} + +/** The statements after an early-exit presence test (`if ('m' in x) { return …; }`): its implicit `else`. */ +function statementsAfterPresenceExit( + ctx: InferenceContext, + target: ProfileTarget, + statements: readonly tsserver.Statement[], +): tsserver.Node[] { + const {ts} = ctx; + const guard = statements.findIndex( + (statement) => + ts.isIfStatement(statement) && + !statement.elseStatement && + alwaysExits(ctx, statement.thenStatement) && + testsPresence(ctx, target, statement.expression), + ); + return guard < 0 ? [] : statements.slice(guard + 1); +} + +/** + * The branches of `node` that only run for some variants of the value: both + * branches of an `if`/`?:` that tests it for a member first (an early + * `return` making the rest of the block the `else`), and the right side of + * `'m' in x && …`. The test tells the code which kind of value it holds, so + * a member either branch uses belongs to that kind alone + * (`if ('searchPhraseSuggestions' in s) … else s.hasNext()`). + */ +function variantBranches(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): readonly tsserver.Node[] { + const {ts} = ctx; + if (ts.isIfStatement(node)) { + if (!testsPresence(ctx, target, node.expression)) return []; + return node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + } + if (ts.isConditionalExpression(node)) { + return testsPresence(ctx, target, node.condition) ? [node.whenTrue, node.whenFalse] : []; + } + if (ts.isBinaryExpression(node)) { + const isGuardedAnd = node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isGuardedAnd && testsPresence(ctx, target, node.left) ? [node.right] : []; + } + return ts.isBlock(node) || ts.isSourceFile(node) ? statementsAfterPresenceExit(ctx, target, node.statements) : []; +} + +/** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ +function recordMember(profile: ProfileBuilder, name: string, optional: boolean): void { + profile.memberNames.add(name); + if (optional) profile.optionalMemberNames.add(name); +} + +/** + * Records what one reference to the profiled value says about it. + * `inVariantBranch` marks a reference inside a branch only some variants of + * the value reach (see {@link variantBranches}). A reference the checker + * narrowed (inside `if (typeof x === 'string')`) is used as what that one + * branch holds, which says nothing about every value. + */ +function recordReference( + ctx: InferenceContext, + target: ProfileTarget, + reference: tsserver.Identifier, + inVariantBranch: boolean, + profile: ProfileBuilder, +): void { + const {ts, checker} = ctx; + const parent = reference.parent; + const tested = presenceTestedMember(ctx, reference); + if (tested !== undefined) { + recordMember(profile, tested, true); + return; + } + if (ts.isPropertyAccessExpression(parent) && parent.expression === reference) { + if (!isTriggerAccess(ctx, parent)) recordMember(profile, parent.name.text, inVariantBranch); + return; + } + if (ts.isElementAccessExpression(parent) && parent.expression === reference) { + const member = literalText(ctx, parent.argumentExpression); + if (member !== undefined) recordMember(profile, member, inVariantBranch); + return; + } + if (ts.isTypeOfExpression(parent)) { + const primitive = primitiveOfTypeofCheck(ctx, parent); + if (primitive) profile.guardTypes.push(primitive); + return; + } + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + if (operator === ts.SyntaxKind.InstanceOfKeyword && parent.left === reference) { + const instance = instanceTypeOf(ctx, parent.right); + if (instance) profile.guardTypes.push(instance); + return; + } + if (operator === ts.SyntaxKind.EqualsToken && parent.left === reference) { + profile.assignedValues.push(parent.right); + return; + } + } + if (checker.getTypeAtLocation(reference) !== target.declaredType) return; + const used = usedAsType(ctx, reference); + if (used) profile.contextualTypes.push(used); +} + +function collectProfile( + ctx: InferenceContext, + symbol: tsserver.Symbol, + declarationName: tsserver.Identifier, + scope: tsserver.Node, +): UsageProfile { + const declaredType = ctx.checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const target: ProfileTarget = {symbol, declarationName, declaredType}; + const profile = emptyProfile(); + const visit = (node: tsserver.Node, inVariantBranch: boolean): void => { + if (isReferenceTo(ctx, target, node)) recordReference(ctx, target, node, inVariantBranch, profile); + const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); + ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); + }; + visit(scope, false); + return profile; +} + +/** Walks up from `node` to the body of the nearest enclosing function, if any. */ +function enclosingFunctionBody(ctx: InferenceContext, node: tsserver.Node): tsserver.Node | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ctx.ts.isFunctionLike(current)) return (current as tsserver.FunctionLikeDeclaration).body; + } + return undefined; +} + +/** + * The usage profile of a parameter (scoped to its function body, nested + * closures included) or a variable (scoped to its enclosing function, or the + * whole file for a top-level variable). Destructured declarations have no + * single value to profile and get an empty profile. Memoized per request. + */ +export function usageProfileOf( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, +): UsageProfile { + const {ts, checker} = ctx; + if (!ts.isIdentifier(declaration.name)) return EMPTY_PROFILE; + const symbol = checker.getSymbolAtLocation(declaration.name); + const scope = ts.isParameter(declaration) + ? (declaration.parent as tsserver.FunctionLikeDeclaration).body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); + if (!symbol || !scope) return EMPTY_PROFILE; + const cached = ctx.profiles.get(symbol); + if (cached) return cached; + const profile = collectProfile(ctx, symbol, declaration.name, scope); + ctx.profiles.set(symbol, profile); + return profile; +} diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 77f672807..8bf339486 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -8,15 +8,21 @@ // split across the ./inference/ modules by responsibility; this barrel just // re-exports the pieces the tsserver plugin (and the test suite) consume, so // callers have one stable import path and don't need to know the internal -// layout. Read the modules in this order to understand the engine: -// inference/constants - the tunable limits that keep a request bounded -// inference/context - the per-request scratchpad (program, budgets, memo) -// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) -// inference/call-sites - find where a function is called across the project -// inference/type-helpers - Type utilities + hover text / completion entries -// inference/super-module - module.superModule detection and export scanning -// inference/core - the recursive engine that ties it all together -// inference/usage-match - last-resort ambient-class matching from member usage +// layout. The engine gathers evidence (./inference/core) and then applies one +// decision policy (./inference/policy) at every parameter, return value and +// variable. Read the modules in this order to understand it: +// inference/constants - the tunable limits that keep a request bounded +// inference/context - the per-request scratchpad (program, budgets, memo, guards) +// inference/ast-helpers - pure AST navigation (find node, return exprs, ...) +// inference/type-helpers - Type utilities + hover text / completion entries +// inference/call-sites - find where a function is called across the project +// inference/super-module - module.superModule detection and export scanning +// inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/ambient-index - every ambient class by member names +// inference/naming - identifier-name tiebreak between usage matches +// inference/framework-contracts - parameters the platform itself passes (Page Designer) +// inference/policy - turning evidence into the type(s) shown +// inference/core - the recursive evidence-gathering engine export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; export {createInferenceContext} from './inference/context'; @@ -24,16 +30,13 @@ export {getNodeAtPosition, findEnclosingPropertyAccess} from './inference/ast-he export { describeTypes, getMemberOfType, - isAnyType, + inferredCompletionEntry, isOpenForUsageInference, typesToCompletionEntries, } from './inference/type-helpers'; export {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './inference/super-module'; export {inferParameterType, inferReturnType, inferTypeForExpression, inferTypeForNode} from './inference/core'; -export { - collectParameterMemberUsage, - collectVariableMemberUsage, - matchAmbientTypesByUsage, -} from './inference/usage-match'; +export {usageProfileOf} from './inference/usage-profile'; +export {matchAmbientTypesByUsage} from './inference/policy'; export type {InferenceContext} from './inference/context'; diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index f42053f33..c4ef62dab 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -189,15 +189,15 @@ "expect": "Category" }, { - "id": "order-address-setter-surface", + "id": "address-setter-surface-union", "source": "storefront checkoutHelpers.escapeAddress / copyAddress", - "description": "OrderAddress inferred from dense setter + getter surface", + "description": "A dense setter + getter surface both address classes share infers their union", "dwTypes": ["OrderAddress", "CustomerAddress", "Shipment"], "files": { "/helpers.js": "function escapeAddress(address) {\n address.setFirstName(address.firstName);\n address.setLastName(address.lastName);\n address.setAddress1(address.address1);\n address.setAddress2(address.address2);\n address.setCity(address.city);\n address.setPostalCode(address.postalCode);\n address.setCompanyName(address.companyName);\n return address.suite;\n}\n" }, "target": {"file": "/helpers.js", "function": "escapeAddress", "param": 0}, - "expect": "OrderAddress" + "expect": "OrderAddress | CustomerAddress" }, { "id": "profile-getter-methods", @@ -230,7 +230,7 @@ "/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" + "expect": "{ optionID: string; optionValueID: string; }" }, { "id": "new-helper-constructor-callsite", @@ -256,16 +256,16 @@ "expectMembers": ["custom"] }, { - "id": "silence-conflicting-call-sites", + "id": "union-conflicting-call-sites", "source": "synthetic — conflicting Product vs Order call sites", - "description": "Must stay silent when call-site argument types do not converge", + "description": "Call sites passing unrelated classes infer their union, like IntelliJ", "dwTypes": ["Product", "Order"], "files": { "/helpers.js": "function describeThing(thing) {\n return thing;\n}\ndescribeThing(getSomeProduct());\ndescribeThing(getSomeOrder());\n" }, "globals": " function getSomeProduct(): Product;\n function getSomeOrder(): Order;", "target": {"file": "/helpers.js", "function": "describeThing", "param": 0}, - "expect": null + "expect": "Product | Order" }, { "id": "silence-weak-custom-uuid-only", @@ -305,16 +305,16 @@ "expect": null }, { - "id": "silence-string-or-product-call-sites", + "id": "union-string-or-product-call-sites", "source": "storefront productHelpers getProductType-style helper", - "description": "Call sites passing string IDs and Product objects must stay silent", + "description": "Call sites passing string IDs and Product objects (with a typeof guard) infer string | Product", "dwTypes": ["Product"], "files": { "/helpers.js": "function resolveProductKey(product) {\n if (typeof product === 'string') return product;\n return product.ID;\n}\nresolveProductKey('sku-1');\nresolveProductKey(getSomeProduct());\n" }, "globals": " function getSomeProduct(): Product;", "target": {"file": "/helpers.js", "function": "resolveProductKey", "param": 0}, - "expect": null + "expect": "string | Product" }, { "id": "silence-unnamed-shared-strong-member", diff --git a/packages/b2c-script-types/test/corpus/corpus.test.js b/packages/b2c-script-types/test/corpus/corpus.test.js index 66925d5aa..7e1f53c8e 100644 --- a/packages/b2c-script-types/test/corpus/corpus.test.js +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -12,12 +12,12 @@ const path = require('node:path'); const ts = require('typescript'); const { - collectParameterMemberUsage, + usageProfileOf, createInferenceContext, describeTypes, inferParameterType, inferTypeForNode, -} = require('../../plugin/usage-inference'); +} = require('../../src/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('../helpers/fixture-language-service'); const {realTypesPrelude} = require('../helpers/real-dw-types'); @@ -84,7 +84,7 @@ describe('usage-inference golden corpus (real-storefront shapes)', () => { if (corpusCase.expectMembers) { assert.equal(target.kind, 'param', `${corpusCase.id}: expectMembers requires a parameter target`); - const members = [...collectParameterMemberUsage(ctx, target.node)].sort(); + const members = [...usageProfileOf(ctx, target.node).memberNames].sort(); assert.deepEqual(members, [...corpusCase.expectMembers].sort()); return; } @@ -95,19 +95,11 @@ describe('usage-inference golden corpus (real-storefront shapes)', () => { // 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)}`, - ); + assert.equal(types.length, 0, `expected silence for ${corpusCase.id}, got: ${describeTypes(ctx, types)}`); return; } - const described = describeTypes(ctx.checker, types); - assert.ok( - described.includes(corpusCase.expect), - `expected type mentioning ${corpusCase.expect}, got: ${described || '(empty)'}`, - ); + assert.equal(describeTypes(ctx, types), corpusCase.expect); }); } }); diff --git a/packages/b2c-script-types/test/index.security.test.js b/packages/b2c-script-types/test/index.security.test.js index f93010967..2f027d08d 100644 --- a/packages/b2c-script-types/test/index.security.test.js +++ b/packages/b2c-script-types/test/index.security.test.js @@ -22,7 +22,7 @@ const path = require('node:path'); const ts = require('typescript'); -const init = require('../plugin/index'); +const init = require('../src/index'); const TYPES_DIR = path.resolve(__dirname, '..', 'types'); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 3fe30b77e..12c0f2736 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -9,8 +9,8 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); -const init = require('../plugin/index'); -const {INFERRED_COMPLETION_SOURCE} = require('../plugin/usage-inference'); +const init = require('../src/index'); +const {INFERRED_COMPLETION_SOURCE} = require('../src/usage-inference'); const {createFixtureHost, sharedDocumentRegistry} = require('./helpers/fixture-language-service'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); @@ -235,18 +235,20 @@ describe('create() proxy — usage inference wiring', () => { assert.ok(beforeText.includes('{ ID: string; name: string; }')); assert.ok(!beforeText.includes('quantity')); - // Simulate an edit: add a second call site with a different argument - // type, and bump both the file's script version and the project version - // (as a real host would) so the cache can't keep serving the old answer. - // Conflicting call sites must not keep serving the stale Product-shaped - // inference — and must stay silent rather than union a noisy hover. - files['/helper.js'] += '\nhelper(getInventory());\n'; + // Simulate an edit: the body now reads a member only the inventory shape + // has, and a new call site passes one. Bump both the file's script + // version and the project version (as a real host would) so the cache + // can't keep serving the old Product-shaped answer. + files['/helper.js'] = files['/helper.js'].replace('product.ID', 'product.quantity') + '\nhelper(getInventory());\n'; versions['/helper.js'] += 1; projectVersion += 1; const after = proxy.getQuickInfoAtPosition('/helper.js', paramPos); const afterText = (after?.documentation ?? []).map((p) => p.text).join(''); - assert.ok(!afterText.includes('Inferred from usage'), `expected silence after conflicting edit, got: ${afterText}`); + assert.ok( + afterText.includes('Inferred from usage: { quantity: number; }'), + `expected the edited inference, got: ${afterText}`, + ); assert.ok(!afterText.includes('{ ID: string; name: string; }')); }); 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 76372abd8..abe8560de 100644 --- a/packages/b2c-script-types/test/usage-inference.hardening.test.js +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -9,8 +9,13 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); -const init = require('../plugin/index'); -const {createInferenceContext, inferParameterType, matchAmbientTypesByUsage} = require('../plugin/usage-inference'); +const init = require('../src/index'); +const { + createInferenceContext, + describeTypes, + inferParameterType, + matchAmbientTypesByUsage, +} = require('../src/usage-inference'); const {assertInferredHover, assertNoInferredHover, positionOf} = require('./helpers/assert-inference'); const {absoluteCartridgePath, createCartridgeFixture} = require('./helpers/cartridge-fixture'); const { @@ -114,7 +119,7 @@ describe('usage-inference hardening', () => { const languageService = createFixtureLanguageService(files); const ctx = createInferenceContext(ts, languageService); const types = matchAmbientTypesByUsage(ctx, new Set(['custom', 'UUID'])); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it('does not let an identifier-name match rescue a signature that matches zero classes', () => { @@ -124,7 +129,7 @@ describe('usage-inference hardening', () => { }); const ctx = createInferenceContext(ts, languageService); const types = matchAmbientTypesByUsage(ctx, new Set(['notARealMember']), 'profile'); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it('stays silent when a duck-typed Store model call site resolves but the body also uses address-only fields (a storefront cartridge copyCustomerAddressToShipment)', () => { 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 bc8a4cbcb..53d189beb 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -9,13 +9,8 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); -const { - createInferenceContext, - describeTypes, - inferParameterType, - inferReturnType, -} = require('../plugin/usage-inference'); -const init = require('../plugin/index'); +const {createInferenceContext, describeTypes, inferParameterType, inferReturnType} = require('../src/usage-inference'); +const init = require('../src/index'); const { createFixtureHost, createFixtureLanguageService, @@ -78,6 +73,10 @@ const BASELINE = { // reference-search set (the request-scoped call-site memo), not re-run the // identical searches once per parameter. multiParamHelper: 2, + // A later request on the same, unchanged Program (hovering one parameter + // after another while reading code) re-runs none of the reference searches + // an earlier request already ran. + repeatedRequestSameProgram: 0, // Thousands of call sites packed into one generated file: one scan, and // the per-call result budget must bound how many of its hits get processed. hugeGeneratedFile: 2, @@ -92,10 +91,11 @@ const BASELINE = { // 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, + // dw.* class's member-name set) is cached per declaration SourceFile, + // which tsserver reuses across Programs while the file is unchanged — so + // a request after an edit to a cartridge .js file (a new Program) must add + // zero getPropertiesOfType calls, not re-index the Script API. + ambientClassIndexRebuildAfterJsEdit: 0, }; /** @@ -120,11 +120,11 @@ function withReferenceCounter(languageService) { } /** - * Counts calls to `checker.getPropertiesOfType` — the per-candidate cost of + * Counts calls to `checker.getPropertiesOfType` — the per-class cost of * building the ambient-class index the no-call-site usage-match fallback * (matchAmbientTypesByUsage) matches against. That index is cached per - * LanguageService (see buildAmbientClassIndex's WeakMap), so a warm cache - * must add zero further calls on a repeated request. + * declaration SourceFile (see ./src/inference/ambient-index), so a warm + * cache must add zero further calls. */ function withPropertiesOfTypeCounter(checker) { let count = 0; @@ -165,7 +165,7 @@ describe('usage-inference — performance baselines', () => { // 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.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); assert.ok( counter.referenceSearches() <= BASELINE.widelyReferencedHelper, `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.referenceSearches()}`, @@ -230,7 +230,7 @@ describe('usage-inference — performance baselines', () => { const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); assert.equal(counter.referenceSearches(), BASELINE.nativeHelperChain); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); @@ -302,7 +302,7 @@ describe('usage-inference — performance baselines', () => { 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(describeTypes(ctx, 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()}`, @@ -386,7 +386,7 @@ describe('usage-inference — performance baselines', () => { // 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.equal(describeTypes(ctx, 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()}`, @@ -421,7 +421,7 @@ describe('usage-inference — performance baselines', () => { const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; } | { displayName: string; }'); + assert.equal(describeTypes(ctx, 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()}`, @@ -429,6 +429,38 @@ describe('usage-inference — performance baselines', () => { assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); + it(`reuses reference searches across requests on an unchanged Program, with the same answer and budget (${BASELINE.repeatedRequestSameProgram} new searches)`, () => { + const files = { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/helper.js': ` + function helper(product) { + return product.ID; + } + module.exports = {helper: helper}; + `, + '/consumer.js': `var helpers = require('./helper'); helpers.helper(getProduct());`, + }; + const counter = withReferenceCounter(createFixtureLanguageService(files)); + const infer = () => { + const ctx = createInferenceContext(ts, counter.languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + return {ctx, description: describeTypes(ctx, inferParameterType(ctx, fn.parameters[0]))}; + }; + + const cold = infer(); + const coldSearches = counter.referenceSearches(); + counter.reset(); + const warm = infer(); + + assert.ok(coldSearches > 0, 'the first request must search for call sites'); + assert.equal(counter.referenceSearches(), BASELINE.repeatedRequestSameProgram); + assert.equal(warm.description, '{ ID: string; }'); + assert.equal(warm.description, cold.description); + // A cached search still spends budget, so a warm request reaches exactly + // the call sites a cold one would. + assert.equal(warm.ctx.searchBudget, cold.ctx.searchBudget); + }); + it(`bounds a helper with thousands of call sites in one generated file (<= ${BASELINE.hugeGeneratedFile} searches, <= 50 hits processed)`, () => { // A generated data file whose big array literal contains a call site per // row. One scan finds all of them; the per-call result budget must stop @@ -455,7 +487,7 @@ describe('usage-inference — performance baselines', () => { const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); assert.ok( counter.referenceSearches() <= BASELINE.hugeGeneratedFile, `expected <= ${BASELINE.hugeGeneratedFile} searches, got ${counter.referenceSearches()}`, @@ -514,10 +546,12 @@ describe('usage-inference — performance baselines', () => { 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). + // on wrap2 pulls all 30 distinct large object-literal candidates into + // fmtOpts's parameter, where they are deduped by display string and then, + // being more than MAX_UNION_TYPES unrelated shapes, collapse to silence. + // Before the per-request typeToString memo every dedupe level re-rendered + // every type (120 calls, measured at 13ms of a 34ms request with + // 50x150-property literals). const N = 30; const literal = (i) => '{' + Array.from({length: 40}, (_, p) => `k${i}_${p}: ${p}`).join(', ') + '}'; const calls = Array.from({length: N}, (_, i) => `fmtOpts(${literal(i)});`).join('\n'); @@ -547,7 +581,7 @@ describe('usage-inference — performance baselines', () => { const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); - assert.equal(types.length, N, 'all candidate object-literal types must survive dedupe'); + assert.equal(types.length, 0, 'more than MAX_UNION_TYPES unrelated shapes must collapse to silence'); assert.ok( stringifications <= BASELINE.nestedForwardingStringifications, `expected the typeToString memo to bound stringifications at <= ${BASELINE.nestedForwardingStringifications}, got ${stringifications}`, @@ -577,7 +611,7 @@ describe('usage-inference — performance baselines', () => { 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)', () => { + it('reuses the ambient-class index across Programs when only a cartridge .js file changes (addressBook.addresses)', () => { // Real-world shape from a storefront cartridge's addressHelpers.js: an uncalled // (from this file's perspective) helper whose only parameter usage is a // single, globally-unique member access — the ambient-class matching @@ -595,26 +629,32 @@ describe('usage-inference — performance baselines', () => { `, }; 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'); + const before = createInferenceContext(ts, languageService); + const fnBefore = findFunctionDeclaration( + before.program.getSourceFile('/addressHelpers.js'), + 'getAddressBookAddressByForm', + ); + assert.equal(describeTypes(before, inferParameterType(before, fnBefore.parameters[0])), 'AddressBook'); + + // Typing in the cartridge file produces a new Program over the same, + // unchanged declaration SourceFiles. + files['/addressHelpers.js'] += '\n// edited\n'; + const after = createInferenceContext(ts, languageService); + assert.notEqual(after.program, before.program, 'expected the edit to produce a new Program'); + const counter = withPropertiesOfTypeCounter(after.checker); + const fnAfter = findFunctionDeclaration( + after.program.getSourceFile('/addressHelpers.js'), + 'getAddressBookAddressByForm', + ); + + const {result, elapsedMs} = timed(() => inferParameterType(after, fnAfter.parameters[0])); + + assert.equal(describeTypes(after, result), 'AddressBook'); assert.equal( - counter.count() - scansAfterFirstHover, - BASELINE.ambientClassIndexRebuildOnRepeatedRequest, - `expected the warm ambient-class index to add ${BASELINE.ambientClassIndexRebuildOnRepeatedRequest} getPropertiesOfType calls, got ${counter.count() - scansAfterFirstHover}`, + counter.count(), + BASELINE.ambientClassIndexRebuildAfterJsEdit, + `expected the warm ambient-class index to add ${BASELINE.ambientClassIndexRebuildAfterJsEdit} getPropertiesOfType calls, got ${counter.count()}`, ); - assert.ok(second.elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(second.elapsedMs)}ms`); + 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 8af960180..8ee04d7b8 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 @@ -16,7 +16,7 @@ const { inferReturnType, inferTypeForNode, typesToCompletionEntries, -} = require('../plugin/usage-inference'); +} = require('../src/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); @@ -65,7 +65,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'); + assert.equal(describeTypes(ctx, types), 'Product'); }); it('offers real dw.catalog.Product members (getID, getName, getPriceModel) as synthesized completions', () => { @@ -104,7 +104,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'); + assert.equal(describeTypes(ctx, types), 'Product'); }); it('infers dw.order.Order for an undocumented parameter from an OrderMgr.getOrder() call site', () => { @@ -124,7 +124,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Order'); + assert.equal(describeTypes(ctx, types), 'Order'); }); }); @@ -147,7 +147,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'Money'); + assert.equal(describeTypes(ctx, types), 'Money'); }); it('offers real dw.value.Money members for the deep-chain-inferred return type', () => { @@ -183,7 +183,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'Money'); + assert.equal(describeTypes(ctx, types), 'Money'); }); it('resolves the same chain split across an intermediate local variable — the idiomatic SFCC style', () => { @@ -208,7 +208,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'Money'); + assert.equal(describeTypes(ctx, types), 'Money'); }); it('resolves a three-hop chain (order.getCustomer().getProfile().getEmail()) through an undocumented helper', () => { @@ -228,7 +228,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('chases a chain through two forwarding undocumented helpers before reaching the real method call', () => { @@ -251,7 +251,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'Money'); + assert.equal(describeTypes(ctx, types), 'Money'); }); }); @@ -325,7 +325,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, cbParam); const names = completionNames(ts, ctx.checker, types); - assert.equal(describeTypes(ctx.checker, types), 'Variant'); + assert.equal(describeTypes(ctx, types), 'Variant'); assert.ok(names.includes('getID')); assert.ok(names.includes('getUPC')); }); @@ -336,11 +336,11 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const sourceFile = ctx.program.getSourceFile('/variantHelpers.js'); const iterTypes = inferTypeForNode(ctx, findIdentifierUse(sourceFile, 'iter')); - assert.equal(describeTypes(ctx.checker, iterTypes), 'Iterator'); + assert.equal(describeTypes(ctx, iterTypes), 'Iterator'); const ctx2 = createInferenceContext(ts, languageService); const candidateTypes = inferTypeForNode(ctx2, findIdentifierUse(sourceFile, 'candidate')); - assert.equal(describeTypes(ctx2.checker, candidateTypes), 'Variant'); + assert.equal(describeTypes(ctx2, candidateTypes), 'Variant'); }); }); @@ -391,7 +391,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'Money'); + assert.equal(describeTypes(ctx, types), 'Money'); }); }); @@ -422,7 +422,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = assert.ok(names.includes('getName')); }); - it('stays silent when call sites pass different real dw.* classes (Product vs Category)', () => { + it('infers a union when call sites pass different real dw.* classes (Product | Category)', () => { const files = { '/types.d.ts': realTypesPrelude( ['Product', 'ProductMgr', 'Category'], @@ -440,11 +440,58 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, fn.parameters[0]); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), 'Product | Category'); + }); + + it('drops a call-site argument of a different kind than the Product the body passes the parameter on as', () => { + // SFRA's product bonusUnitPrice decorator is called once with the + // discount line item UUID in the product slot; the body hands the + // parameter to BonusDiscountLineItem.getBonusProductPrice(Product). + const files = { + '/types.d.ts': realTypesPrelude( + ['Product', 'BonusDiscountLineItem'], + ' function getSomeProduct(): Product;\n function getBonusLineItem(): BonusDiscountLineItem;', + ), + '/consumer.js': ` + function getBonusUnitPrice(apiProduct) { + return getBonusLineItem().getBonusProductPrice(apiProduct); + } + getBonusUnitPrice(getSomeProduct()); + getBonusUnitPrice('discount-line-item-uuid'); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'getBonusUnitPrice'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Product'); + }); + + it('keeps a sibling class passed to a helper documented with a narrower class of the same family (Order | Basket)', () => { + // SFRA documents many LineItemCtnr helpers as taking a Basket; an Order + // passed to one is a too-narrow JSDoc, not a reason to drop the Order. + const files = { + '/types.d.ts': realTypesPrelude( + ['Order', 'Basket'], + ' function getSomeOrder(): Order;\n function getSomeBasket(): Basket;\n function ensureValidShipments(basket: Basket): boolean;', + ), + '/consumer.js': ` + function getCheckoutStepInformation(lineItemContainer) { + return ensureValidShipments(lineItemContainer); + } + getCheckoutStepInformation(getSomeOrder()); + getCheckoutStepInformation(getSomeBasket()); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'getCheckoutStepInformation'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), 'Order | Basket'); }); it('infers Product from ambient usage when a never-called helper is named product and uses Product API methods', () => { - // Generic Product is indexed for ambient matching (hover shows Product). + // 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 = { @@ -459,7 +506,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'); + assert.equal(describeTypes(ctx, types), 'Product'); }); it('chases a var-of-var deep property chain with a real nullable middle step (availabilityModel.inventoryRecord)', () => { @@ -501,7 +548,9 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferTypeForNode(ctx, recordIdentifier); - assert.equal(describeTypes(ctx.checker, types), 'ProductInventoryRecord | null'); + // `| null` is dropped like any other uninformative part: the hover names + // the class, the way TypeScript shows it without strictNullChecks. + assert.equal(describeTypes(ctx, types), 'ProductInventoryRecord'); }); it('synthesizes real members for a generic collection candidate type (Collection) without special-casing generics', () => { @@ -525,7 +574,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = const types = inferParameterType(ctx, fn.parameters[0]); const names = completionNames(ts, ctx.checker, types); - assert.ok(describeTypes(ctx.checker, types).startsWith('Collection<')); + assert.ok(describeTypes(ctx, types).startsWith('Collection<')); assert.ok(names.includes('getLength')); assert.ok(names.includes('toArray')); }); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index b6d854878..31794dab3 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -18,7 +18,7 @@ const { inferReturnType, inferTypeForNode, typesToCompletionEntries, -} = require('../plugin/usage-inference'); +} = require('../src/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); const {realTypesPrelude} = require('./helpers/real-dw-types'); @@ -48,7 +48,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('infers a parameter type from a `new Helper(x)` constructor call site (SFRA constructor-function model pattern)', () => { @@ -75,10 +75,10 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); - it('stays silent when plain-call and `new` constructor call sites disagree on the argument type', () => { + it('infers a union when plain-call and `new` constructor call sites pass different types', () => { const files = { '/types.d.ts': AMBIENT_TYPES, '/helper.js': ` @@ -98,7 +98,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; } | { quantity: number; }'); }); it('infers through a mix of plain-call and `new` when every site passes the same type', () => { @@ -121,7 +121,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('does not throw on a bare `new Helper` constructor call with no parentheses/arguments', () => { @@ -145,15 +145,17 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); - it('stays silent when call-site argument types conflict (no noisy union)', () => { + it('keeps only the call-site types that have every member the body uses', () => { + // One call site passes a look-alike the body can't actually use: the + // body reads `.quantity`, which only the inventory shape has. const files = { '/types.d.ts': AMBIENT_TYPES, '/helper.js': ` function helper(input) { - return input; + return input.quantity; } helper(getProduct()); helper(getInventory()); @@ -168,7 +170,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), '{ quantity: number; }'); }); it('keeps a single converged type when every call site agrees', () => { @@ -191,7 +193,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves references through CommonJS `exports.foo = function(){}` assignment', () => { @@ -219,7 +221,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('returns no candidates when the function is never called', () => { @@ -282,7 +284,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves an immediately-invoked `require(...)(x)` call', () => { @@ -297,7 +299,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves a destructured `const {helper} = require(...)` call site', () => { @@ -312,7 +314,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves a renamed destructure `const {helper: h} = require(...)`', () => { @@ -327,7 +329,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves the SFRA-canonical alias-map export (`module.exports = {helper: helper}`) called from another file', () => { @@ -353,7 +355,45 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a module required into an export map (`{images: require(...)}`) and called through it', () => { + // SFRA's decorators/index.js shape: each decorator module is required + // into one object and consumers call `decorators.images(...)`. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/images.js': `module.exports = function (product) { return product.ID; };`, + '/index.js': `module.exports = { images: require('./images') };`, + '/consumer.js': `var decorators = require('./index'); decorators.images(getProduct());`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const param = findFunctionExpressionParam(ctx.program.getSourceFile('/images.js')); + + const types = inferParameterType(ctx, param); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + + it('resolves a call through a local alias of the function (`var run = helper`)', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product.ID; + } + var run = helper; + run(getProduct()); + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('resolves an ES6 method-shorthand export called via property access', () => { @@ -368,7 +408,145 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + }); + + describe('inferParameterType — fitting call sites to how the body uses the value', () => { + // Infers the first parameter of `fnName` in /helper.js. + function inferHelperParam(files, fnName) { + const ctx = createInferenceContext(ts, createFixtureLanguageService(files)); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), fnName); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + it('drops a call-site argument the body could not pass on to the typed helper it calls', () => { + const files = { + '/types.d.ts': `${AMBIENT_TYPES}declare function priceOf(product: {ID: string; name: string}): number;`, + '/helper.js': ` + function unitPrice(product) { + return priceOf(product); + } + unitPrice(getProduct()); + unitPrice('a-line-item-uuid'); + `, + }; + + assert.equal(inferHelperParam(files, 'unitPrice'), '{ ID: string; name: string; }'); + }); + + it('keeps a call-site argument whose use only a branch that narrowed it constrains', () => { + const files = { + '/types.d.ts': `${AMBIENT_TYPES}declare function lookup(id: string): {ID: string; name: string};`, + '/helper.js': ` + function resolve(productOrId) { + return typeof productOrId === 'string' ? lookup(productOrId) : productOrId; + } + resolve(getProduct()); + resolve('some-id'); + `, + }; + + assert.equal(inferHelperParam(files, 'resolve'), '{ ID: string; name: string; } | string'); + }); + + it('uses an argument to an overloaded function only when every overload of that arity agrees on it', () => { + const files = { + '/types.d.ts': ` + declare function parse(text: string): number; + declare function parse(text: string, radix: number): number; + declare function show(value: string): string; + declare function show(value: number): string; + declare function render(id: string, params: string): string; + declare function render(id: string, attributes: {size: number}, params: string): string; + `, + '/helper.js': ` + function parsed(text) { return parse(text); } + function shown(value) { return show(value); } + function rendered(attributes) { return render('page', attributes, '{}'); } + `, + }; + + assert.equal(inferHelperParam(files, 'parsed'), 'string'); + assert.equal(inferHelperParam(files, 'shown'), ''); + assert.equal(inferHelperParam(files, 'rendered'), '{ size: number; }'); + }); + + it('does not require a member the body tests for before using it', () => { + const files = { + '/types.d.ts': ` + declare function getOrderAddress(): {city: string}; + declare function getCustomerAddress(): {ID: string; city: string}; + `, + '/helper.js': ` + function label(address) { + var id = Object.prototype.hasOwnProperty.call(address, 'ID') ? address.ID : ''; + return id + address.city; + } + label(getOrderAddress()); + label(getCustomerAddress()); + `, + }; + + assert.equal(inferHelperParam(files, 'label'), '{ city: string; } | { ID: string; city: string; }'); + }); + + it('does not require a member used only in one branch of a presence test (`if ("m" in x) … else …`)', () => { + // SFRA's SearchPhraseSuggestions takes either a suggestions object or a + // bare phrase iterator and tells them apart with an `in` test. + const files = { + '/types.d.ts': ` + declare function getPhraseIterator(): {hasNext(): boolean}; + declare function getBrandSuggestions(): {searchPhraseSuggestions: {hasNext(): boolean}}; + `, + '/helper.js': ` + function available(suggestions) { + if ('searchPhraseSuggestions' in suggestions) { + return suggestions.searchPhraseSuggestions.hasNext(); + } + return suggestions.hasNext(); + } + available(getPhraseIterator()); + available(getBrandSuggestions()); + `, + }; + + assert.equal( + inferHelperParam(files, 'available'), + '{ hasNext(): boolean; } | { searchPhraseSuggestions: { hasNext(): boolean; }; }', + ); + }); + + it('still requires a member used outside the branches of a presence test', () => { + const files = { + '/types.d.ts': ` + declare function getOrderAddress(): {city: string}; + declare function getLookAlike(): {ID: string}; + `, + '/helper.js': ` + function label(address) { + var id = 'ID' in address && address.ID; + return id + address.city; + } + label(getOrderAddress()); + label(getLookAlike()); + `, + }; + + assert.equal(inferHelperParam(files, 'label'), '{ city: string; }'); + }); + + it('treats `x instanceof Object` as no evidence of what x is', () => { + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function valueOf(field) { + return field instanceof Object ? field.value : field; + } + `, + }; + + assert.equal(inferHelperParam(files, 'valueOf'), ''); }); }); @@ -439,7 +617,7 @@ describe('usage-inference', () => { const sourceFile = ctx.program.getSourceFile('/helper.js'); const fn = findFunctionDeclaration(sourceFile, 'getPasswordResetToken'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); }); } @@ -542,7 +720,7 @@ describe('usage-inference', () => { const types = inferReturnType(ctx, caller); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('chases both branches of a ternary return (collections.first shape)', () => { @@ -577,7 +755,7 @@ describe('usage-inference', () => { const sourceFile = ctx.program.getSourceFile('/collections.js'); const caller = findFunctionDeclaration(sourceFile, 'caller'); - const described = describeTypes(ctx.checker, inferReturnType(ctx, caller)); + const described = describeTypes(ctx, inferReturnType(ctx, caller)); assert.ok(described.includes('{ ID: string; }'), `expected element type, got: ${described}`); }); @@ -624,7 +802,7 @@ describe('usage-inference', () => { const types = inferReturnType(ctx, caller); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('chases a method-chain (`x.next().next()...`) within MAX_CHAIN_HOPS', () => { @@ -652,7 +830,7 @@ describe('usage-inference', () => { const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('gives up (without hanging) on a method-chain longer than MAX_CHAIN_HOPS, rather than chasing it unbounded', () => { @@ -715,10 +893,10 @@ describe('usage-inference', () => { const topFn = findFunctionDeclaration(sourceFile, 'top'); const shortTypes = inferReturnType(ctx, shortFn); - assert.equal(describeTypes(ctx.checker, shortTypes), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, shortTypes), '{ ID: string; name: string; }'); const topTypes = inferReturnType(ctx, topFn); - assert.equal(describeTypes(ctx.checker, topTypes), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, topTypes), '{ ID: string; name: string; }'); }); }); @@ -747,7 +925,7 @@ describe('usage-inference', () => { const types = inferReturnType(ctx, fn); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('infers the type of a variable initialized from a property access on an undocumented parameter', () => { @@ -777,7 +955,7 @@ describe('usage-inference', () => { const types = inferTypeForNode(ctx, idIdentifier); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('leaves a variable with an explicit `@type {any}` JSDoc annotation alone', () => { @@ -913,7 +1091,7 @@ describe('usage-inference', () => { const types = inferReturnType(ctx, wrapped); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('returns no candidates when no lower cartridge provides the module', () => { @@ -1028,7 +1206,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('infers the element type for collections.first(coll, function (item) …) predicates', () => { @@ -1048,7 +1226,7 @@ describe('usage-inference', () => { const sourceFile = ctx.program.getSourceFile('/consumer.js'); const param = findCallbackParam(sourceFile); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); }); it('resolves the collection argument through inference when it is itself undocumented', () => { @@ -1075,7 +1253,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, param); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('does not apply the element heuristic to reduce-style callbacks (accumulator comes first)', () => { @@ -1130,7 +1308,7 @@ describe('usage-inference', () => { const sourceFile = ctx.program.getSourceFile('/consumer.js'); const param = findCallbackParam(sourceFile); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); }); } @@ -1225,7 +1403,7 @@ describe('usage-inference', () => { const types = inferTypeForNode(ctx, findVarUse(top, 'memberPrice')); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('still resolves a deep base member through the pass-through levels (base getSalePrice from top)', () => { @@ -1235,11 +1413,11 @@ describe('usage-inference', () => { const types = inferTypeForNode(ctx, findVarUse(top, 'salePrice')); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); it('lists augmented members from every pass-through level for completions', () => { - const {collectSuperModuleAugmentedMembers} = require('../plugin/usage-inference'); + const {collectSuperModuleAugmentedMembers} = require('../src/usage-inference'); const languageService = createFixtureLanguageService(STACK_FILES); const ctx = createInferenceContext(ts, languageService, (f) => STACK_ORDER[f]); const top = ctx.program.getSourceFile('/top/x.js'); @@ -1334,7 +1512,7 @@ describe('usage-inference', () => { const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'string'); + assert.equal(describeTypes(ctx, types), 'string'); }); it('does not hang on a self-forwarding helper called with itself as an argument', () => { @@ -1388,7 +1566,7 @@ describe('usage-inference', () => { const types = inferTypeForNode(ctx, resultIdentifier); - assert.equal(describeTypes(ctx.checker, types), '{ ID: string; name: string; }'); + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); }); diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 3ed28d1e9..8d09509ee 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -15,8 +15,8 @@ const { inferParameterType, inferTypeForNode, matchAmbientTypesByUsage, - collectParameterMemberUsage, -} = require('../plugin/usage-inference'); + usageProfileOf, +} = require('../src/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); const {realTypesPrelude} = require('./helpers/real-dw-types'); @@ -51,10 +51,10 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Shipment'); + assert.equal(describeTypes(ctx, types), 'Shipment'); }); - it('collectParameterMemberUsage sees a member accessed only inside a nested closure', () => { + it('usageProfileOf sees a member accessed only inside a nested closure', () => { const files = { '/types.d.ts': realTypesPrelude(['Shipment'], ''), '/shippingHelpers.js': ` @@ -68,7 +68,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'markShipmentForShipping'); - const members = collectParameterMemberUsage(ctx, fn.parameters[0]); + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; assert.deepEqual([...members].sort(), ['custom', 'setShippingMethod']); }); @@ -90,7 +90,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it('does not let an identifier-name match rescue a weak-only custom+UUID signature', () => { @@ -125,7 +125,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'getPasswordResetToken'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); }); it('stays silent for a single strong member shared by multiple classes when the identifier name does not disambiguate', () => { @@ -165,7 +165,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'AddressBook'); + assert.equal(describeTypes(ctx, types), 'AddressBook'); }); it('matchAmbientTypesByUsage returns [] for a single member name that ties across multiple ambient classes', () => { @@ -177,7 +177,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // above. const types = matchAmbientTypesByUsage(ctx, new Set(['custom'])); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it('matchAmbientTypesByUsage returns [] for a usage signature no ambient class satisfies', () => { @@ -185,7 +185,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = matchAmbientTypesByUsage(ctx, new Set(['thisMemberDoesNotExistAnywhere', 'norDoesThisOne'])); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it("infers a manual-indexing loop variable's type from its own usage (var item = items[i])", () => { @@ -221,7 +221,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferTypeForNode(ctx, lineItemDecl.name); - assert.equal(describeTypes(ctx.checker, types), 'ProductLineItem'); + assert.equal(describeTypes(ctx, types), 'ProductLineItem'); }); it('stays quiet for a real-world single-member loop variable (hasPreorderableLineItem shape)', () => { @@ -257,7 +257,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // 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)}`); + assert.equal(types.length, 0, `expected silence, got: ${describeTypes(ctx, types)}`); }); it('still prefers call-site inference over usage matching when a real call site exists', () => { @@ -278,7 +278,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Shipment'); + assert.equal(describeTypes(ctx, types), 'Shipment'); }); describe("the `'member' in x` existence-check idiom as usage evidence", () => { @@ -289,7 +289,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // `'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", () => { + it("usageProfileOf counts a bare `'member' in param` check", () => { const files = { '/types.d.ts': realTypesPrelude(['Shipment'], ''), '/shippingHelpers.js': ` @@ -303,7 +303,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'describeShipment'); - const members = collectParameterMemberUsage(ctx, fn.parameters[0]); + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; assert.deepEqual([...members], ['custom']); }); @@ -328,7 +328,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Shipment'); + assert.equal(describeTypes(ctx, types), 'Shipment'); }); it('combines an `in` check with a direct property-access read on the same member without double-counting (category.parent tree-walk shape)', () => { @@ -347,11 +347,11 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'walkUp'); - const members = collectParameterMemberUsage(ctx, fn.parameters[0]); + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; assert.deepEqual([...members].sort(), ['custom', 'productLineItems']); const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Shipment'); + assert.equal(describeTypes(ctx, types), 'Shipment'); }); it("attributes a chained `'member' in x.y` check to x.y's own one-hop access (`y`), not the checked name itself", () => { @@ -371,7 +371,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/shippingHelpers.js', 'describeShipment'); - const members = collectParameterMemberUsage(ctx, fn.parameters[0]); + const members = usageProfileOf(ctx, fn.parameters[0]).memberNames; assert.deepEqual([...members], ['custom']); }); @@ -412,7 +412,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[0]); - assert.equal(describeTypes(ctx.checker, types), 'Category'); + assert.equal(describeTypes(ctx, types), 'Category'); }); it('infers a parameter from a member-built object literal passed to a call argument, not returned (pushReview shape)', () => { @@ -438,7 +438,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferParameterType(ctx, fn.parameters[1]); - assert.equal(describeTypes(ctx.checker, types), 'ProductLineItem'); + assert.equal(describeTypes(ctx, types), 'ProductLineItem'); }); describe('identifier-name tiebreak (prefers the class matching the variable/parameter name)', () => { @@ -471,7 +471,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sentAccountActivationEmail'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'Customer'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Customer'); let profileDecl; const visit = (n) => { @@ -482,13 +482,13 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferTypeForNode(ctx, profileDecl.name); - assert.equal(describeTypes(ctx.checker, types), 'Profile'); + assert.equal(describeTypes(ctx, types), 'Profile'); }); - it('still returns the smallest-total-members candidate when no candidate name matches the identifier', () => { + it('returns the union of every fitting class 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. + // name: with no name to break the tie, every class the usage fits is + // shown, the way IntelliJ lists each candidate. // Deliberate `@param {any}` (not the weak `{obj}` placeholder) blocks // inference on `resettingCustomer`: without it, the parameter's own // single-member usage (`.profile`) uniquely matches Customer in this @@ -524,7 +524,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage const types = inferTypeForNode(ctx, contactInfoDecl.name); - assert.equal(describeTypes(ctx.checker, types), 'ProductListRegistrant'); + assert.equal(describeTypes(ctx, types), 'ProductListRegistrant | Profile'); }); it('does not let an identifier-name match rescue a signature that matches zero ambient classes', () => { @@ -536,7 +536,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 'shipment', ); - assert.deepEqual(types, []); + assert.equal(describeTypes(ctx, types), ''); }); it('maps SFRA alias lineItem → ProductLineItem for a single strong member', () => { @@ -553,7 +553,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/priceTotal.js', 'getTotalPrice'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); }); it('maps short alias pli → ProductLineItem', () => { @@ -567,7 +567,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/order.js', 'handlePliAttributes'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); }); it('maps PascalCase suffix resettingCustomer → Customer (SFRA accountHelpers)', () => { @@ -584,7 +584,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/accountHelpers.js', 'sendPasswordResetEmail'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[1])), 'Customer'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[1])), 'Customer'); }); it('maps paymentInstrument alias → OrderPaymentInstrument', () => { @@ -597,9 +597,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage `, }; const {ctx, fn} = setupInference(files, '/helpers.js', 'amountOf'); - assert.ok( - describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])).includes('OrderPaymentInstrument'), - ); + assert.ok(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])).includes('OrderPaymentInstrument')); }); it('maps PascalCase Profile suffix registeredCustomerProfile → Profile', () => { @@ -612,7 +610,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage `, }; const {ctx, fn} = setupInference(files, '/helpers.js', 'greet'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'Profile'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'Profile'); }); it('maps PascalCase suffixes apiProduct / currentBasket / defaultShipment', () => { @@ -631,7 +629,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/helpers.js', fnName); assert.ok( - describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])).includes(expect), + describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])).includes(expect), `${param} should infer ${expect}`, ); } @@ -686,10 +684,10 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'isProductLine'); - assert.equal(describeTypes(ctx.checker, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'ProductLineItem'); }); - it('stays silent when the body instanceof-checks multiple unrelated classes', () => { + it('infers the union of the classes the body instanceof-checks', () => { const files = { '/types.d.ts': realTypesPrelude( ['ProductLineItem', 'ShippingLineItem', 'PriceAdjustment'], @@ -710,7 +708,63 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage }; const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'describeLine'); - assert.equal(inferParameterType(ctx, fn.parameters[0]).length, 0); + assert.equal( + describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), + 'ProductLineItem | ShippingLineItem | PriceAdjustment', + ); }); + + it('collapses more than three instanceof-checked classes to their closest shared superclass', () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['ProductLineItem', 'ShippingLineItem', 'PriceAdjustment', 'ProductShippingLineItem'], + ` + const ProductLineItem: { new (): ProductLineItem }; + const ShippingLineItem: { new (): ShippingLineItem }; + const PriceAdjustment: { new (): PriceAdjustment }; + const ProductShippingLineItem: { new (): ProductShippingLineItem }; + `, + ), + '/lineItemHelper.js': ` + function describeLine(lineItem) { + if (lineItem instanceof ProductLineItem) return 'product'; + if (lineItem instanceof ShippingLineItem) return 'shipping'; + if (lineItem instanceof PriceAdjustment) return 'adjustment'; + if (lineItem instanceof ProductShippingLineItem) return 'product shipping'; + return lineItem.lineItemText; + } + `, + }; + const {ctx, fn} = setupInference(files, '/lineItemHelper.js', 'describeLine'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), 'LineItem'); + }); + }); +}); + +describe('usage-inference — ambient-class index', () => { + it('sees a declaration file added after an earlier request (no index pinned to the first Program)', () => { + const files = { + '/types.d.ts': 'interface Gadget { sprocket: string; widgetize(): void; }\n', + '/helpers.js': 'function noop() {}\n', + }; + const languageService = createFixtureLanguageService(files, {strict: true}); + const before = createInferenceContext(ts, languageService); + assert.equal(describeTypes(before, matchAmbientTypesByUsage(before, new Set(['sprocket', 'widgetize']))), 'Gadget'); + assert.equal(describeTypes(before, matchAmbientTypesByUsage(before, new Set(['cog', 'spin']))), ''); + + files['/more.d.ts'] = 'interface Gizmo { cog: number; spin(): void; }\n'; + const after = createInferenceContext(ts, languageService); + + assert.equal(describeTypes(after, matchAmbientTypesByUsage(after, new Set(['cog', 'spin']))), 'Gizmo'); + }); + + it("never matches TypeScript's own library classes (ES / DOM)", () => { + const {ctx} = setupInference({'/helpers.js': 'function noop() {}\n'}, '/helpers.js', 'noop'); + + // Only lib.es*.d.ts's Date has both members. + const types = matchAmbientTypesByUsage(ctx, new Set(['getUTCFullYear', 'toISOString'])); + + assert.equal(describeTypes(ctx, types), ''); }); }); From 90473a9aa0438d6f19f9c536aa820d00b6d4493c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:23:12 +0000 Subject: [PATCH 48/76] refactor(script-types): move hover and completion inference into editor hooks The plugin's hover and completion wrappers now delegate to inference/editor-hooks, which builds one inference context per request and caches finished results per Program in a WeakMap. Behaviour fixes: - Member completions are offered only right after a dot or inside the member name. Previously, any position inside an enclosing property access counted, so inferred members showed up while the user was typing an argument (`helper(|).name`) or a receiver. - The hover header is rewritten for a trailing `object`, `Object` or `{}`, not only for `any`. - Documentation is borrowed only from a single inferred class, or from a member every receiver resolves to the same declaration. - Changing the plugin configuration clears the cached results, so a new cartridge path takes effect without an edit. Tests now share one plugin-proxy helper instead of 21 inline setups. The two test helpers that lacked one now carry the copyright header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- packages/b2c-script-types/plugin/index.js | 245 ++----------- .../plugin/inference/ast-helpers.js | 34 +- .../plugin/inference/constants.js | 8 +- .../plugin/inference/editor-hooks.js | 224 ++++++++++++ .../plugin/usage-inference.js | 13 +- packages/b2c-script-types/src/index.ts | 277 ++------------- .../src/inference/ast-helpers.ts | 40 ++- .../src/inference/constants.ts | 7 + .../src/inference/editor-hooks.ts | 321 ++++++++++++++++++ .../b2c-script-types/src/usage-inference.ts | 18 +- .../test/eval/ground-truth.test.js | 12 +- .../test/helpers/assert-inference.js | 5 + .../test/helpers/cartridge-fixture.js | 5 + .../test/helpers/plugin-proxy.js | 53 +++ .../test/index.security.test.js | 13 +- packages/b2c-script-types/test/index.test.js | 291 +++++++--------- .../test/usage-inference.hardening.test.js | 36 +- .../test/usage-inference.perf.test.js | 23 +- .../test/usage-inference.test.js | 38 ++- 19 files changed, 896 insertions(+), 767 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/editor-hooks.js create mode 100644 packages/b2c-script-types/src/inference/editor-hooks.ts create mode 100644 packages/b2c-script-types/test/helpers/plugin-proxy.js diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 6d341b9ba..310a57b90 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -12,23 +12,6 @@ const usage_inference_1 = require("./usage-inference"); const constants_1 = require("./resolver/constants"); const cartridge_discovery_1 = require("./resolver/cartridge-discovery"); const module_resolution_1 = require("./resolver/module-resolution"); -/** - * Swaps the trailing `any` keyword part of a QuickInfo's display parts (the - * shape TS renders for an undocumented parameter/property, e.g. `(parameter) - * shipment: any`) for the inferred type's description, so the bolded hover - * header reads `(parameter) shipment: Shipment` instead of `... : any` — - * while leaving everything else (the `(parameter) shipment: ` prefix TS - * already rendered) untouched. Only ever touches a display exactly ending in - * that keyword; any other shape is returned as-is rather than guessed at. - */ -function replaceTrailingAnyDisplayPart(displayParts, description) { - if (!displayParts || displayParts.length === 0) - return displayParts; - const last = displayParts[displayParts.length - 1]; - if (last.kind !== 'keyword' || last.text !== 'any') - return displayParts; - return [...displayParts.slice(0, -1), { kind: 'text', text: description }]; -} const TYPES_DIR = node_path_1.default.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, // `customer`, `empty(...)`, the `dw.*` namespace alias, etc.). The plugin @@ -53,6 +36,8 @@ function init({ typescript: ts }) { // Whether the most recent applyConfig() received an explicit cartridges list. // When true, we skip auto-discovery; when false, create() may auto-populate. let cartridgesFromHost = false; + // The cache resets of every language service create() decorated. + const usageInferenceResets = new Set(); // tsserver internally canonicalizes file paths to forward slashes regardless of // platform (so containingFile is "C:/proj/..." on Windows). The cartridge roots // we receive from the extension come from Node's path.resolve(), which returns @@ -366,225 +351,35 @@ function init({ typescript: ts }) { const result = info.languageService.getImplementationAtPosition(fileName, position); return result?.map(remapDefinition); }; - // Usage-based inference (opt-in, `inferUsage`): when hover/completion hits - // a type the checker has already given up on (`any` — typically an - // undocumented helper function), infer a better answer from call sites - // elsewhere in the project instead of leaving the editor with nothing. - // - // Cached per (file, node position). Entries are finished DISPLAY products - // (the hover note string, the synthesized completion entries) rather than - // checker Type objects: a Type pins its checker and, through it, the whole - // program it came from, so caching types would keep an entire stale - // program graph alive from the last edit until the next inference-eligible - // request — potentially forever if the user stops hovering. Strings and - // plain completion entries retain nothing. - // - // HoverInferenceResult is likewise plain data only: `documentation` and - // `tags` are copied out of a real Symbol's own getDocumentationComment()/ - // getJsDocTags() (SymbolDisplayPart[] / JSDocTagInfo[] are just text — - // they don't reference the Symbol, Type, or Node they came from), never - // the Symbol/Type/Node itself. - // - // The whole cache is invalidated when the language service hands back a - // different Program instance (TS builds a new Program object for any - // semantic change, and reuses the same instance otherwise), rather than - // tracking per-entry validity. Program identity is more precise than the - // previously-used project version string, which also bumps on events that - // don't produce a new program — each such bump needlessly re-ran a full - // inference (measured ~13ms per hover on an SFRA-sized project) that the - // cache should have answered. - let inferenceCacheProgram; - const inferenceCache = new Map(); - // Bounds the cache during a long no-edit session (e.g. hours of hovering - // around at the same program): entries are small (strings / plain entry - // arrays), so this is belt-and-braces, and a wholesale clear is honest — - // no LRU bookkeeping for a cache this cheap to refill. - const MAX_INFERENCE_CACHE_ENTRIES = 512; - const getCachedInference = (cacheKey, program, compute) => { - if (program !== inferenceCacheProgram) { - inferenceCache.clear(); - inferenceCacheProgram = program; - } - if (inferenceCache.has(cacheKey)) - return inferenceCache.get(cacheKey); - const result = compute(); - if (inferenceCache.size >= MAX_INFERENCE_CACHE_ENTRIES) - inferenceCache.clear(); - inferenceCache.set(cacheKey, result); - return result; - }; - // Runs our own inference logic and degrades to `fallback` (the untouched - // underlying result) if it throws, so a bug in this plugin's additions - // can't take the whole tsserver request down with it. Deliberately wraps - // ONLY the inference augmentation, never the underlying language-service - // call itself: an exception from vanilla TS must keep propagating to - // tsserver's own error reporting exactly as it would without this plugin - // installed — swallowing it here would turn a real TS crash into a - // silent "hover stopped working" for every file in the project. - // `ts.OperationCanceledException` is exempted and always rethrown: TS - // throws it cooperatively whenever the host's CancellationToken fires - // (e.g. the user kept typing while this hover or completion request was - // still in flight), which is ordinary, frequent behavior, not a real - // failure — tsserver's request pipeline handles a propagated - // cancellation very differently from a completed-but-empty response, so - // swallowing it here would misreport "cancelled" as "resolved to - // nothing" every time. - const guarded = (label, fn, fallback) => { - try { - return fn(); - } - catch (e) { - if (e instanceof ts.OperationCanceledException) - throw e; - log(`usage-inference ${label} failed: ${e.message}`); - return fallback; - } - }; + // Usage-based inference (opt-in, `inferUsage`): when hover or member + // completion hits a value the checker has given up on (typically a + // parameter of an undocumented helper), add what call sites and the + // value's own usage say about it. See inference/editor-hooks. + const usageInference = (0, usage_inference_1.createUsageInferenceHooks)({ + ts, + languageService: info.languageService, + resolveSuperModulePath, + log, + }); + usageInferenceResets.add(usageInference.reset); + const inferenceActive = (fileName) => inferUsageEnabled && isCartridgeFile(fileName); proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); - if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) - return original; - return guarded('hover', () => { - const program = info.languageService.getProgram(); - const sourceFile = program?.getSourceFile(fileName); - if (!program || !sourceFile) - return original; - const node = (0, usage_inference_1.getNodeAtPosition)(sourceFile, ts, position); - if (!node || !ts.isIdentifier(node)) - return original; - const checker = program.getTypeChecker(); - // superModule-derived expressions get past the open-type gate: the - // checker's type for them is garbage either way (any or an opaque - // circular typeof), never something worth leaving untouched. Weak - // placeholder types (`object` / `{}`) are open too — see - // isOpenForUsageInference. - if (!(0, usage_inference_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(node)) && - !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, node)) { - return original; - } - // `undefined` (inference found nothing) is a cached answer too — - // re-deriving "nothing" costs the same reference searches as - // re-deriving something. - const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); - 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)(ctx, types); - // The receiver's type was undocumented, but the *member itself* - // (or the inferred type's own declaration) is real and usually - // documented — borrow its doc comment/tags so hover reads like a - // native, fully-resolved hover instead of just a bare type name. - let symbol; - if (isMemberName && propAccess) { - for (const baseType of (0, usage_inference_1.inferTypeForExpression)(ctx, propAccess.expression)) { - symbol = (0, usage_inference_1.getMemberOfType)(checker, baseType, node.text); - if (symbol) - break; - } - } - else { - symbol = types[0].getSymbol(); - } - const documentation = symbol?.getDocumentationComment(checker); - const tags = symbol?.getJsDocTags(checker); - return { - description, - documentation: documentation && documentation.length > 0 ? documentation : undefined, - tags: tags && tags.length > 0 ? tags : undefined, - }; - }); - if (!inferred) - return original; - const note = { - text: `\n\nInferred from usage: ${inferred.description}`, - kind: 'text', - }; - return { - ...original, - displayParts: replaceTrailingAnyDisplayPart(original.displayParts, inferred.description), - documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], - tags: inferred.tags && inferred.tags.length > 0 ? [...inferred.tags] : original.tags, - }; - }, original); + return inferenceActive(fileName) ? usageInference.decorateQuickInfo(fileName, position, original) : original; }; proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) - return original; - return guarded('completions', () => { - const program = info.languageService.getProgram(); - const sourceFile = program?.getSourceFile(fileName); - if (!program || !sourceFile) - return original; - const node = (0, usage_inference_1.getNodeAtPosition)(sourceFile, ts, Math.max(position - 1, 0)); - if (!node) - return original; - const propAccess = (0, usage_inference_1.findEnclosingPropertyAccess)(node, ts); - if (!propAccess) - return original; - const checker = program.getTypeChecker(); - // See the hover gate above for the superModule / weak-type exception. - if (!(0, usage_inference_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(propAccess.expression)) && - !(0, usage_inference_1.traceSuperModuleAccess)(ts, checker, propAccess.expression)) { - return original; - } - // The receiver can be any expression, not just a plain identifier: - // `product.getPriceModel().|` needs the chain resolved the same way - // hover-driven return inference already resolves it. - const baseNode = propAccess.expression; - const typeEntries = getCachedInference(`completions:${fileName}:${baseNode.getStart(sourceFile)}`, program, () => { - const ctx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath, position); - const types = ctx ? (0, usage_inference_1.inferTypeForExpression)(ctx, baseNode) : []; - return (0, usage_inference_1.typesToCompletionEntries)(ts, checker, types); - }); - // Members added by pass-through superModule overlay levels - // (`module.exports = base; module.exports.extra = fn;`) can't be - // carried by any candidate type — collect them separately. Cheap - // (statement scans only, no reference search), so uncached. - const augmentedCtx = (0, usage_inference_1.createInferenceContext)(ts, info.languageService, resolveSuperModulePath); - const augmentedEntries = (augmentedCtx ? (0, usage_inference_1.collectSuperModuleAugmentedMembers)(augmentedCtx, baseNode) : []).map((m) => (0, usage_inference_1.inferredCompletionEntry)(ts, m.name, m.isMethod)); - const inferredEntries = [...typeEntries, ...augmentedEntries]; - if (inferredEntries.length === 0) - return original; - // Dedupe against the original entries AND within the inferred set - // (a name can come from both a candidate type and an overlay - // augmentation). - const seenNames = new Set((original?.entries ?? []).map((e) => e.name)); - const merged = [ - ...(original?.entries ?? []), - ...inferredEntries.filter((e) => !seenNames.has(e.name) && (seenNames.add(e.name), true)), - ]; - // Preserve every other field TS set on the original result (isIncomplete, - // optionalReplacementSpan, metadata, defaultCommitCharacters, flags) — - // only entries actually changed. Only synthesize a fresh CompletionInfo - // in the rare case TS returned nothing at all for this position. - if (original) - return { ...original, entries: merged }; - return { - isGlobalCompletion: false, - isMemberCompletion: true, - isNewIdentifierLocation: false, - entries: merged, - }; - }, original); + return inferenceActive(fileName) ? usageInference.decorateCompletions(fileName, position, original) : original; }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); return proxy; } function onConfigurationChanged(config) { applyConfig(config); + // A new configuration can change what inference sees (cartridge order + // decides `module.superModule`), so drop every cached hover and list. + for (const reset of usageInferenceResets) + reset(); } return { create, onConfigurationChanged }; } diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index 08bcb2647..c746b1436 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -6,7 +6,8 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.getNodeAtPosition = getNodeAtPosition; -exports.findEnclosingPropertyAccess = findEnclosingPropertyAccess; +exports.propertyAccessNamedBy = propertyAccessNamedBy; +exports.memberCompletionAccess = memberCompletionAccess; exports.hasExplicitParameterType = hasExplicitParameterType; exports.hasExplicitReturnType = hasExplicitReturnType; exports.hasExplicitVariableType = hasExplicitVariableType; @@ -38,15 +39,28 @@ 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) { - if (ts.isPropertyAccessExpression(current)) - return current; - current = current.parent; - } - return undefined; +/** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ +function propertyAccessNamedBy(node, ts) { + const { parent } = node; + return parent && ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : undefined; +} +/** + * The property access a member completion at `position` is completing: the + * cursor sits right after its dot (`shipment.|`) or inside its member name + * (`shipment.pro|`). Anywhere else — inside the receiver, or inside an + * argument list further along the chain (`basket.getShipment(|).ID`) — the + * enclosing access's members are not what is being typed, so this returns + * `undefined`. + */ +function memberCompletionAccess(sourceFile, ts, position) { + const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) + return undefined; + // The node is the access itself only when position - 1 falls on a token it + // owns directly (its dot), never inside the receiver or the name. + if (ts.isPropertyAccessExpression(node)) + return position >= node.name.pos ? node : undefined; + return propertyAccessNamedBy(node, ts); } /** * SFRA helpers are often "documented" with a placeholder type that carries no diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 656dafd99..aae0169cb 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.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = 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.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_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. @@ -59,6 +59,12 @@ exports.MAX_SUPERMODULE_HOPS = 8; // 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; +// Bounds the editor's per-Program cache of finished hovers and completion +// lists during a long session without edits (hours of hovering around one +// Program). Entries are small plain data, so this is belt-and-braces, and a +// wholesale clear is honest: no LRU bookkeeping for a cache this cheap to +// refill. +exports.MAX_DISPLAY_CACHE_ENTRIES = 512; // Marks the completion entries this plugin synthesizes (as opposed to ones the // TypeScript language service produced itself), so the editor can tell them // apart. Purely a label — it carries no path or other data. diff --git a/packages/b2c-script-types/plugin/inference/editor-hooks.js b/packages/b2c-script-types/plugin/inference/editor-hooks.js new file mode 100644 index 000000000..91b0ce74c --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/editor-hooks.js @@ -0,0 +1,224 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createUsageInferenceHooks = createUsageInferenceHooks; +const ast_helpers_1 = require("./ast-helpers"); +const constants_1 = require("./constants"); +const context_1 = require("./context"); +const core_1 = require("./core"); +const super_module_1 = require("./super-module"); +const type_helpers_1 = require("./type-helpers"); +function createDisplayCache() { + let byProgram = new WeakMap(); + return { + get(key, program, compute) { + const entries = byProgram.get(program) ?? new Map(); + byProgram.set(program, entries); + if (entries.has(key)) + return entries.get(key); + const value = compute(); + if (entries.size >= constants_1.MAX_DISPLAY_CACHE_ENTRIES) + entries.clear(); + entries.set(key, value); + return value; + }, + clear() { + byProgram = new WeakMap(); + }, + }; +} +/** + * Runs the inference augmentation and degrades to `fallback` (the untouched + * underlying result) if it throws, so a bug here can't take the whole + * tsserver request down. Deliberately wraps only the augmentation, never the + * underlying language-service call: an exception from TypeScript itself must + * reach tsserver's own error reporting exactly as it would without the + * plugin. `OperationCanceledException` always propagates: TS throws it + * cooperatively when the host cancels a request (the user kept typing), and + * tsserver handles a cancellation very differently from an empty response. + */ +function guarded(options, label, fallback, compute) { + try { + return compute(); + } + catch (error) { + if (error instanceof options.ts.OperationCanceledException) + throw error; + options.log(`usage-inference ${label} failed: ${error.message}`); + return fallback; + } +} +function openFile(env, fileName) { + const program = env.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + return program && sourceFile ? { program, sourceFile, checker: program.getTypeChecker() } : undefined; +} +/** + * True when the checker's own type for `expr` leaves room for inference: an + * open type (see isOpenForUsageInference), or a value derived from + * `module.superModule`, whose checker type is never meaningful (sometimes + * `any`, sometimes an opaque circular `typeof base`). + */ +function isInferenceTarget(ts, checker, expr) { + return (0, type_helpers_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(expr)) || !!(0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); +} +function newContext(env, triggerPosition) { + return (0, context_1.createInferenceContext)(env.ts, env.languageService, env.resolveSuperModulePath, triggerPosition); +} +/** + * The declaration a member-name hover documents: the member every inferred + * receiver type resolves the name to. Receivers that resolve it to different + * declarations (an override, or unrelated classes) document nothing rather + * than an arbitrary one of them. + */ +function memberSymbol(ctx, access) { + const symbols = new Set((0, core_1.inferTypeForExpression)(ctx, access.expression).map((type) => (0, type_helpers_1.getMemberOfType)(ctx.checker, type, access.name.text))); + const [symbol] = symbols; + return symbols.size === 1 ? symbol : undefined; +} +/** The class a hover documents when inference resolved exactly one; a union has no single doc comment. */ +function soleTypeSymbol(types) { + return types.length === 1 ? types[0].getSymbol() : undefined; +} +function documentationOf(checker, symbol) { + const documentation = symbol?.getDocumentationComment(checker) ?? []; + const tags = symbol?.getJsDocTags(checker) ?? []; + return { + documentation: documentation.length > 0 ? documentation : undefined, + tags: tags.length > 0 ? tags : undefined, + }; +} +/** + * Infers what a hover on `node` shows. A member name (`productLineItems` in + * `shipment.productLineItems`) has no declaration of its own until the + * receiver's type is known, so the whole access is resolved instead. + * Documentation comes from the member's declaration, or from the inferred + * class when there is exactly one. + */ +function inferHover(env, node) { + const ctx = newContext(env); + if (!ctx) + return undefined; + const access = (0, ast_helpers_1.propertyAccessNamedBy)(node, env.ts); + const types = access ? (0, core_1.inferTypeForExpression)(ctx, access) : (0, core_1.inferTypeForNode)(ctx, node); + if (types.length === 0) + return undefined; + const symbol = access ? memberSymbol(ctx, access) : soleTypeSymbol(types); + return { description: (0, type_helpers_1.describeTypes)(ctx, types), ...documentationOf(ctx.checker, symbol) }; +} +// Header type texts TypeScript renders for a value it could not type: `any`, +// the `object` keyword, and the bare `Object` interface. +const OPEN_TYPE_TEXTS = new Set(['any', 'object', 'Object']); +/** How many trailing display parts spell an open type: `any` / `object` / `Object`, or the two-part `{}`. */ +function openTypeTailLength(parts) { + const last = parts[parts.length - 1]; + if (!last) + return 0; + if (OPEN_TYPE_TEXTS.has(last.text) && last.kind !== 'text') + return 1; + return last.text === '}' && parts[parts.length - 2]?.text === '{' ? 2 : 0; +} +/** + * Swaps the open type at the end of a hover header (`(parameter) shipment: + * any`) for the inferred description, so the bold header reads + * `(parameter) shipment: Shipment`. Any other header shape is left as it is + * rather than guessed at. + */ +function replaceOpenTypeTail(parts, description) { + const tail = openTypeTailLength(parts ?? []); + if (!parts || tail === 0) + return parts; + return [...parts.slice(0, -tail), { kind: 'text', text: description }]; +} +function withHoverInference(original, inferred) { + const note = { kind: 'text', text: `\n\nInferred from usage: ${inferred.description}` }; + return { + ...original, + displayParts: replaceOpenTypeTail(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags ? [...inferred.tags] : original.tags, + }; +} +function decorateQuickInfo(env, cache, fileName, position, original) { + const { ts } = env; + const file = openFile(env, fileName); + const node = file && (0, ast_helpers_1.getNodeAtPosition)(file.sourceFile, ts, position); + if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) + return original; + const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; + const inferred = cache.get(key, file.program, () => inferHover(env, node)); + return inferred ? withHoverInference(original, inferred) : original; +} +/** + * Member entries for `receiver`: the members of its inferred types, plus the + * ones pass-through superModule overlay levels add + * (`module.exports = base; module.exports.extra = fn;`), which no candidate + * type can carry. + */ +function inferMemberEntries(env, receiver, position) { + const ctx = newContext(env, position); + if (!ctx) + return []; + const augmented = (0, super_module_1.collectSuperModuleAugmentedMembers)(ctx, receiver).map((member) => (0, type_helpers_1.inferredCompletionEntry)(env.ts, member.name, member.isMethod)); + return [...(0, type_helpers_1.typesToCompletionEntries)(env.ts, ctx.checker, (0, core_1.inferTypeForExpression)(ctx, receiver)), ...augmented]; +} +/** + * Appends the inferred entries TypeScript did not already offer (first one + * per name wins), preserving every other field of its result (isIncomplete, + * optionalReplacementSpan, metadata, ...). A fresh member CompletionInfo is + * synthesized only when TypeScript returned nothing at all. + */ +function mergeCompletions(original, inferred) { + const seen = new Set((original?.entries ?? []).map((entry) => entry.name)); + const added = []; + for (const entry of inferred) { + if (seen.has(entry.name)) + continue; + seen.add(entry.name); + added.push(entry); + } + if (added.length === 0) + return original; + if (original) + return { ...original, entries: [...original.entries, ...added] }; + return { isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, entries: added }; +} +function decorateCompletions(env, cache, fileName, position, original) { + const file = openFile(env, fileName); + const access = file && (0, ast_helpers_1.memberCompletionAccess)(file.sourceFile, env.ts, position); + if (!file || !access || !isInferenceTarget(env.ts, file.checker, access.expression)) + return original; + // The receiver can be any expression (`product.getPriceModel().|`), and + // nested receivers share a start (`a` and `a.b`), so the key spans it. + const receiver = access.expression; + const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; + return mergeCompletions(original, cache.get(key, file.program, () => inferMemberEntries(env, receiver, position))); +} +/** + * Creates the hover and completion decorators for one language service. Each + * request builds one inference context; finished results are cached per + * Program, and `reset()` drops them (the plugin calls it when its + * configuration changes). + */ +function createUsageInferenceHooks(options) { + const hoverCache = createDisplayCache(); + const completionCache = createDisplayCache(); + return { + decorateQuickInfo(fileName, position, original) { + if (!original) + return original; + return guarded(options, 'hover', original, () => decorateQuickInfo(options, hoverCache, fileName, position, original)); + }, + decorateCompletions(fileName, position, original) { + return guarded(options, 'completions', original, () => decorateCompletions(options, completionCache, fileName, position, original)); + }, + reset() { + hoverCache.clear(); + completionCache.clear(); + }, + }; +} diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 2ab34b6ce..beca9bb20 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.usageProfileOf = exports.inferTypeForNode = exports.inferTypeForExpression = exports.inferReturnType = exports.inferParameterType = exports.traceSuperModuleAccess = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.isOpenForUsageInference = exports.inferredCompletionEntry = exports.getMemberOfType = exports.describeTypes = exports.findEnclosingPropertyAccess = exports.getNodeAtPosition = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; +exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.usageProfileOf = exports.inferTypeForNode = exports.inferReturnType = exports.inferParameterType = exports.collectSuperModuleAugmentedMembers = exports.typesToCompletionEntries = exports.describeTypes = exports.memberCompletionAccess = exports.createInferenceContext = exports.INFERRED_COMPLETION_SOURCE = void 0; // Public entry point for the usage-inference engine. The implementation is // split across the ./inference/ modules by responsibility; this barrel just // re-exports the pieces the tsserver plugin (and the test suite) consume, so @@ -25,28 +25,25 @@ exports.matchAmbientTypesByUsage = exports.usageProfileOf = exports.inferTypeFor // inference/framework-contracts - parameters the platform itself passes (Page Designer) // inference/policy - turning evidence into the type(s) shown // inference/core - the recursive evidence-gathering engine +// inference/editor-hooks - hover and completion decoration for the plugin var constants_1 = require("./inference/constants"); Object.defineProperty(exports, "INFERRED_COMPLETION_SOURCE", { enumerable: true, get: function () { return constants_1.INFERRED_COMPLETION_SOURCE; } }); var context_1 = require("./inference/context"); Object.defineProperty(exports, "createInferenceContext", { enumerable: true, get: function () { return context_1.createInferenceContext; } }); var ast_helpers_1 = require("./inference/ast-helpers"); -Object.defineProperty(exports, "getNodeAtPosition", { enumerable: true, get: function () { return ast_helpers_1.getNodeAtPosition; } }); -Object.defineProperty(exports, "findEnclosingPropertyAccess", { enumerable: true, get: function () { return ast_helpers_1.findEnclosingPropertyAccess; } }); +Object.defineProperty(exports, "memberCompletionAccess", { enumerable: true, get: function () { return ast_helpers_1.memberCompletionAccess; } }); var type_helpers_1 = require("./inference/type-helpers"); Object.defineProperty(exports, "describeTypes", { enumerable: true, get: function () { return type_helpers_1.describeTypes; } }); -Object.defineProperty(exports, "getMemberOfType", { enumerable: true, get: function () { return type_helpers_1.getMemberOfType; } }); -Object.defineProperty(exports, "inferredCompletionEntry", { enumerable: true, get: function () { return type_helpers_1.inferredCompletionEntry; } }); -Object.defineProperty(exports, "isOpenForUsageInference", { enumerable: true, get: function () { return type_helpers_1.isOpenForUsageInference; } }); Object.defineProperty(exports, "typesToCompletionEntries", { enumerable: true, get: function () { return type_helpers_1.typesToCompletionEntries; } }); var super_module_1 = require("./inference/super-module"); Object.defineProperty(exports, "collectSuperModuleAugmentedMembers", { enumerable: true, get: function () { return super_module_1.collectSuperModuleAugmentedMembers; } }); -Object.defineProperty(exports, "traceSuperModuleAccess", { enumerable: true, get: function () { return super_module_1.traceSuperModuleAccess; } }); var core_1 = require("./inference/core"); Object.defineProperty(exports, "inferParameterType", { enumerable: true, get: function () { return core_1.inferParameterType; } }); Object.defineProperty(exports, "inferReturnType", { enumerable: true, get: function () { return core_1.inferReturnType; } }); -Object.defineProperty(exports, "inferTypeForExpression", { enumerable: true, get: function () { return core_1.inferTypeForExpression; } }); Object.defineProperty(exports, "inferTypeForNode", { enumerable: true, get: function () { return core_1.inferTypeForNode; } }); var usage_profile_1 = require("./inference/usage-profile"); Object.defineProperty(exports, "usageProfileOf", { enumerable: true, get: function () { return usage_profile_1.usageProfileOf; } }); var policy_1 = require("./inference/policy"); Object.defineProperty(exports, "matchAmbientTypesByUsage", { enumerable: true, get: function () { return policy_1.matchAmbientTypesByUsage; } }); +var editor_hooks_1 = require("./inference/editor-hooks"); +Object.defineProperty(exports, "createUsageInferenceHooks", { enumerable: true, get: function () { return editor_hooks_1.createUsageInferenceHooks; } }); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 0f40d6327..400fd17f7 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -7,20 +7,7 @@ import path from 'node:path'; import type tsserver from 'typescript/lib/tsserverlibrary'; -import { - collectSuperModuleAugmentedMembers, - traceSuperModuleAccess, - createInferenceContext, - describeTypes, - findEnclosingPropertyAccess, - getMemberOfType, - getNodeAtPosition, - inferredCompletionEntry, - inferTypeForExpression, - inferTypeForNode, - isOpenForUsageInference, - typesToCompletionEntries, -} from './usage-inference'; +import {createUsageInferenceHooks} from './usage-inference'; import {PLUGIN_NAME} from './resolver/constants'; import type {ConfiguredCartridge, NormalizedCartridge, PluginConfig} from './resolver/constants'; import { @@ -36,35 +23,6 @@ import { resolveModulesCartridge as resolveModulesCartridgeImpl, } from './resolver/module-resolution'; -// Rich hover data borrowed from a real ambient declaration (e.g. the `custom` -// property on `dw.object.ExtensibleObject`) once usage inference has resolved -// which one an undocumented value's usage matches. Plain data only — see the -// caching note where this is produced for why. -interface HoverInferenceResult { - readonly description: string; - readonly documentation?: readonly tsserver.SymbolDisplayPart[]; - readonly tags?: readonly tsserver.JSDocTagInfo[]; -} - -/** - * 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 @@ -90,6 +48,8 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // Whether the most recent applyConfig() received an explicit cartridges list. // When true, we skip auto-discovery; when false, create() may auto-populate. let cartridgesFromHost = false; + // The cache resets of every language service create() decorated. + const usageInferenceResets = new Set<() => void>(); // tsserver internally canonicalizes file paths to forward slashes regardless of // platform (so containingFile is "C:/proj/..." on Windows). The cartridge roots @@ -445,226 +405,26 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return result?.map(remapDefinition); }; - // Usage-based inference (opt-in, `inferUsage`): when hover/completion hits - // a type the checker has already given up on (`any` — typically an - // undocumented helper function), infer a better answer from call sites - // elsewhere in the project instead of leaving the editor with nothing. - // - // Cached per (file, node position). Entries are finished DISPLAY products - // (the hover note string, the synthesized completion entries) rather than - // checker Type objects: a Type pins its checker and, through it, the whole - // program it came from, so caching types would keep an entire stale - // program graph alive from the last edit until the next inference-eligible - // request — potentially forever if the user stops hovering. Strings and - // plain completion entries retain nothing. - // - // HoverInferenceResult is likewise plain data only: `documentation` and - // `tags` are copied out of a real Symbol's own getDocumentationComment()/ - // getJsDocTags() (SymbolDisplayPart[] / JSDocTagInfo[] are just text — - // they don't reference the Symbol, Type, or Node they came from), never - // the Symbol/Type/Node itself. - // - // The whole cache is invalidated when the language service hands back a - // different Program instance (TS builds a new Program object for any - // semantic change, and reuses the same instance otherwise), rather than - // tracking per-entry validity. Program identity is more precise than the - // previously-used project version string, which also bumps on events that - // don't produce a new program — each such bump needlessly re-ran a full - // inference (measured ~13ms per hover on an SFRA-sized project) that the - // cache should have answered. - let inferenceCacheProgram: tsserver.Program | undefined; - const inferenceCache = new Map(); - // Bounds the cache during a long no-edit session (e.g. hours of hovering - // around at the same program): entries are small (strings / plain entry - // arrays), so this is belt-and-braces, and a wholesale clear is honest — - // no LRU bookkeeping for a cache this cheap to refill. - const MAX_INFERENCE_CACHE_ENTRIES = 512; - const getCachedInference = ( - cacheKey: string, - program: tsserver.Program, - compute: () => T, - ): T => { - if (program !== inferenceCacheProgram) { - inferenceCache.clear(); - inferenceCacheProgram = program; - } - if (inferenceCache.has(cacheKey)) return inferenceCache.get(cacheKey) as T; - const result = compute(); - if (inferenceCache.size >= MAX_INFERENCE_CACHE_ENTRIES) inferenceCache.clear(); - inferenceCache.set(cacheKey, result); - return result; - }; - - // Runs our own inference logic and degrades to `fallback` (the untouched - // underlying result) if it throws, so a bug in this plugin's additions - // can't take the whole tsserver request down with it. Deliberately wraps - // ONLY the inference augmentation, never the underlying language-service - // call itself: an exception from vanilla TS must keep propagating to - // tsserver's own error reporting exactly as it would without this plugin - // installed — swallowing it here would turn a real TS crash into a - // silent "hover stopped working" for every file in the project. - // `ts.OperationCanceledException` is exempted and always rethrown: TS - // throws it cooperatively whenever the host's CancellationToken fires - // (e.g. the user kept typing while this hover or completion request was - // still in flight), which is ordinary, frequent behavior, not a real - // failure — tsserver's request pipeline handles a propagated - // cancellation very differently from a completed-but-empty response, so - // swallowing it here would misreport "cancelled" as "resolved to - // nothing" every time. - const guarded = (label: string, fn: () => T, fallback: T): T => { - try { - return fn(); - } catch (e) { - if (e instanceof ts.OperationCanceledException) throw e; - log(`usage-inference ${label} failed: ${(e as Error).message}`); - return fallback; - } - }; + // Usage-based inference (opt-in, `inferUsage`): when hover or member + // completion hits a value the checker has given up on (typically a + // parameter of an undocumented helper), add what call sites and the + // value's own usage say about it. See inference/editor-hooks. + const usageInference = createUsageInferenceHooks({ + ts, + languageService: info.languageService, + resolveSuperModulePath, + log, + }); + usageInferenceResets.add(usageInference.reset); + const inferenceActive = (fileName: string) => inferUsageEnabled && isCartridgeFile(fileName); proxy.getQuickInfoAtPosition = (fileName, position, maximumLength) => { const original = info.languageService.getQuickInfoAtPosition(fileName, position, maximumLength); - if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName) || !original) return original; - return guarded( - 'hover', - () => { - const program = info.languageService.getProgram(); - const sourceFile = program?.getSourceFile(fileName); - if (!program || !sourceFile) return original; - const node = getNodeAtPosition(sourceFile, ts, position); - if (!node || !ts.isIdentifier(node)) return original; - const checker = program.getTypeChecker(); - // superModule-derived expressions get past the open-type gate: the - // checker's type for them is garbage either way (any or an opaque - // circular typeof), never something worth leaving untouched. Weak - // placeholder types (`object` / `{}`) are open too — see - // isOpenForUsageInference. - if ( - !isOpenForUsageInference(ts, checker.getTypeAtLocation(node)) && - !traceSuperModuleAccess(ts, checker, node) - ) { - return original; - } - // `undefined` (inference found nothing) is a cached answer too — - // re-deriving "nothing" costs the same reference searches as - // re-deriving something. - const inferred = getCachedInference(`hover:${fileName}:${node.getStart(sourceFile)}`, program, () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); - 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(ctx, types); - // The receiver's type was undocumented, but the *member itself* - // (or the inferred type's own declaration) is real and usually - // documented — borrow its doc comment/tags so hover reads like a - // native, fully-resolved hover instead of just a bare type name. - let symbol: tsserver.Symbol | undefined; - if (isMemberName && propAccess) { - for (const baseType of inferTypeForExpression(ctx, propAccess.expression)) { - symbol = getMemberOfType(checker, baseType, node.text); - if (symbol) break; - } - } else { - symbol = types[0].getSymbol(); - } - const documentation = symbol?.getDocumentationComment(checker); - const tags = symbol?.getJsDocTags(checker); - return { - description, - documentation: documentation && documentation.length > 0 ? documentation : undefined, - tags: tags && tags.length > 0 ? tags : undefined, - }; - }); - if (!inferred) return original; - const note: tsserver.SymbolDisplayPart = { - text: `\n\nInferred from usage: ${inferred.description}`, - kind: 'text', - }; - return { - ...original, - displayParts: replaceTrailingAnyDisplayPart(original.displayParts, inferred.description), - documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], - tags: inferred.tags && inferred.tags.length > 0 ? [...inferred.tags] : original.tags, - }; - }, - original, - ); + return inferenceActive(fileName) ? usageInference.decorateQuickInfo(fileName, position, original) : original; }; - proxy.getCompletionsAtPosition = (fileName, position, options, formattingSettings) => { const original = info.languageService.getCompletionsAtPosition(fileName, position, options, formattingSettings); - if (!enabled || !inferUsageEnabled || !isCartridgeFile(fileName)) return original; - return guarded( - 'completions', - () => { - const program = info.languageService.getProgram(); - const sourceFile = program?.getSourceFile(fileName); - if (!program || !sourceFile) return original; - const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); - if (!node) return original; - const propAccess = findEnclosingPropertyAccess(node, ts); - if (!propAccess) return original; - const checker = program.getTypeChecker(); - // See the hover gate above for the superModule / weak-type exception. - if ( - !isOpenForUsageInference(ts, checker.getTypeAtLocation(propAccess.expression)) && - !traceSuperModuleAccess(ts, checker, propAccess.expression) - ) { - return original; - } - // The receiver can be any expression, not just a plain identifier: - // `product.getPriceModel().|` needs the chain resolved the same way - // hover-driven return inference already resolves it. - const baseNode = propAccess.expression; - const typeEntries = getCachedInference( - `completions:${fileName}:${baseNode.getStart(sourceFile)}`, - program, - () => { - const ctx = createInferenceContext(ts, info.languageService, resolveSuperModulePath, position); - const types = ctx ? inferTypeForExpression(ctx, baseNode) : []; - return typesToCompletionEntries(ts, checker, types); - }, - ); - // Members added by pass-through superModule overlay levels - // (`module.exports = base; module.exports.extra = fn;`) can't be - // carried by any candidate type — collect them separately. Cheap - // (statement scans only, no reference search), so uncached. - const augmentedCtx = createInferenceContext(ts, info.languageService, resolveSuperModulePath); - const augmentedEntries: tsserver.CompletionEntry[] = ( - augmentedCtx ? collectSuperModuleAugmentedMembers(augmentedCtx, baseNode) : [] - ).map((m) => inferredCompletionEntry(ts, m.name, m.isMethod)); - const inferredEntries = [...typeEntries, ...augmentedEntries]; - if (inferredEntries.length === 0) return original; - // Dedupe against the original entries AND within the inferred set - // (a name can come from both a candidate type and an overlay - // augmentation). - const seenNames = new Set((original?.entries ?? []).map((e) => e.name)); - const merged = [ - ...(original?.entries ?? []), - ...inferredEntries.filter((e) => !seenNames.has(e.name) && (seenNames.add(e.name), true)), - ]; - // Preserve every other field TS set on the original result (isIncomplete, - // optionalReplacementSpan, metadata, defaultCommitCharacters, flags) — - // only entries actually changed. Only synthesize a fresh CompletionInfo - // in the rare case TS returned nothing at all for this position. - if (original) return {...original, entries: merged}; - return { - isGlobalCompletion: false, - isMemberCompletion: true, - isNewIdentifierLocation: false, - entries: merged, - }; - }, - original, - ); + return inferenceActive(fileName) ? usageInference.decorateCompletions(fileName, position, original) : original; }; log(`plugin initialized (cartridges=${cartridges.length}, enabled=${enabled})`); @@ -673,6 +433,9 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { function onConfigurationChanged(config: unknown) { applyConfig(config); + // A new configuration can change what inference sees (cartridge order + // decides `module.superModule`), so drop every cached hover and list. + for (const reset of usageInferenceResets) reset(); } return {create, onConfigurationChanged}; diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index 70908f2c9..7d6130b76 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -5,9 +5,10 @@ */ // 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 +// node under the cursor, the property access a member name or completion +// belongs to, 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. @@ -43,17 +44,34 @@ export function getNodeAtPosition( return result; } -/** Walks up from `node` to the nearest enclosing PropertyAccessExpression, or `undefined` if there isn't one. */ -export function findEnclosingPropertyAccess( +/** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ +export function propertyAccessNamedBy( node: tsserver.Node, ts: typeof tsserver, ): tsserver.PropertyAccessExpression | undefined { - let current: tsserver.Node | undefined = node; - while (current) { - if (ts.isPropertyAccessExpression(current)) return current; - current = current.parent; - } - return undefined; + const {parent} = node; + return parent && ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : undefined; +} + +/** + * The property access a member completion at `position` is completing: the + * cursor sits right after its dot (`shipment.|`) or inside its member name + * (`shipment.pro|`). Anywhere else — inside the receiver, or inside an + * argument list further along the chain (`basket.getShipment(|).ID`) — the + * enclosing access's members are not what is being typed, so this returns + * `undefined`. + */ +export function memberCompletionAccess( + sourceFile: tsserver.SourceFile, + ts: typeof tsserver, + position: number, +): tsserver.PropertyAccessExpression | undefined { + const node = getNodeAtPosition(sourceFile, ts, Math.max(position - 1, 0)); + if (!node) return undefined; + // The node is the access itself only when position - 1 falls on a token it + // owns directly (its dot), never inside the receiver or the name. + if (ts.isPropertyAccessExpression(node)) return position >= node.name.pos ? node : undefined; + return propertyAccessNamedBy(node, ts); } /** diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index ad11a0298..0cab9afef 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -65,6 +65,13 @@ export const MAX_SUPERMODULE_HOPS = 8; // that headroom while keeping the worst case at ~12 scans per request. export const MAX_SEARCHES_PER_REQUEST = 12; +// Bounds the editor's per-Program cache of finished hovers and completion +// lists during a long session without edits (hours of hovering around one +// Program). Entries are small plain data, so this is belt-and-braces, and a +// wholesale clear is honest: no LRU bookkeeping for a cache this cheap to +// refill. +export const MAX_DISPLAY_CACHE_ENTRIES = 512; + // Marks the completion entries this plugin synthesizes (as opposed to ones the // TypeScript language service produced itself), so the editor can tell them // apart. Purely a label — it carries no path or other data. diff --git a/packages/b2c-script-types/src/inference/editor-hooks.ts b/packages/b2c-script-types/src/inference/editor-hooks.ts new file mode 100644 index 000000000..944e9d020 --- /dev/null +++ b/packages/b2c-script-types/src/inference/editor-hooks.ts @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Editor integration: decorates the language service's own hover and member +// completion results with what usage inference finds for a value the checker +// left open (`any`, `object`, `{}`, bare `Object`, or a `module.superModule` +// binding). Everything here is additive: the caller computes the underlying +// result, and it comes back untouched whenever inference has nothing to add +// or fails. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {getNodeAtPosition, memberCompletionAccess, propertyAccessNamedBy} from './ast-helpers'; +import {MAX_DISPLAY_CACHE_ENTRIES} from './constants'; +import {createInferenceContext} from './context'; +import type {InferenceContext} from './context'; +import {inferTypeForExpression, inferTypeForNode} from './core'; +import {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './super-module'; +import { + describeTypes, + getMemberOfType, + inferredCompletionEntry, + isOpenForUsageInference, + typesToCompletionEntries, +} from './type-helpers'; + +interface HookEnvironment { + readonly ts: typeof tsserver; + readonly languageService: tsserver.LanguageService; + readonly resolveSuperModulePath?: (containingFile: string) => string | undefined; +} + +interface HookOptions extends HookEnvironment { + readonly log: (message: string) => void; +} + +/** + * What a hover shows once inference resolved a value: the type text, plus the + * doc comment and tags borrowed from the real declaration it resolved to + * (e.g. the `custom` property on `dw.object.ExtensibleObject`). Plain data + * only — see {@link DisplayCache} for why. + */ +interface HoverInference { + readonly description: string; + readonly documentation?: readonly tsserver.SymbolDisplayPart[]; + readonly tags?: readonly tsserver.JSDocTagInfo[]; +} + +/** + * Finished display products (hover results, completion entries) per Program, + * keyed by the request's node. `undefined` ("inference found nothing") is a + * cached answer too: re-deriving nothing costs the same reference searches as + * re-deriving something. + * + * Values are plain data, never checker Types, Symbols or Nodes: those pin + * their checker and, through it, the whole Program they came from. Keying on + * the Program through a WeakMap retires every entry the moment TypeScript + * drops that Program (it builds a new one for any semantic change and reuses + * the same instance otherwise), without holding it alive in the meantime. + */ +interface DisplayCache { + get(key: string, program: tsserver.Program, compute: () => V): V; + clear(): void; +} + +function createDisplayCache(): DisplayCache { + let byProgram = new WeakMap>(); + return { + get(key, program, compute) { + const entries = byProgram.get(program) ?? new Map(); + byProgram.set(program, entries); + if (entries.has(key)) return entries.get(key) as V; + const value = compute(); + if (entries.size >= MAX_DISPLAY_CACHE_ENTRIES) entries.clear(); + entries.set(key, value); + return value; + }, + clear() { + byProgram = new WeakMap(); + }, + }; +} + +/** + * Runs the inference augmentation and degrades to `fallback` (the untouched + * underlying result) if it throws, so a bug here can't take the whole + * tsserver request down. Deliberately wraps only the augmentation, never the + * underlying language-service call: an exception from TypeScript itself must + * reach tsserver's own error reporting exactly as it would without the + * plugin. `OperationCanceledException` always propagates: TS throws it + * cooperatively when the host cancels a request (the user kept typing), and + * tsserver handles a cancellation very differently from an empty response. + */ +function guarded(options: HookOptions, label: string, fallback: T, compute: () => T): T { + try { + return compute(); + } catch (error) { + if (error instanceof options.ts.OperationCanceledException) throw error; + options.log(`usage-inference ${label} failed: ${(error as Error).message}`); + return fallback; + } +} + +interface OpenFile { + readonly program: tsserver.Program; + readonly sourceFile: tsserver.SourceFile; + readonly checker: tsserver.TypeChecker; +} + +function openFile(env: HookEnvironment, fileName: string): OpenFile | undefined { + const program = env.languageService.getProgram(); + const sourceFile = program?.getSourceFile(fileName); + return program && sourceFile ? {program, sourceFile, checker: program.getTypeChecker()} : undefined; +} + +/** + * True when the checker's own type for `expr` leaves room for inference: an + * open type (see isOpenForUsageInference), or a value derived from + * `module.superModule`, whose checker type is never meaningful (sometimes + * `any`, sometimes an opaque circular `typeof base`). + */ +function isInferenceTarget(ts: typeof tsserver, checker: tsserver.TypeChecker, expr: tsserver.Expression): boolean { + return isOpenForUsageInference(ts, checker.getTypeAtLocation(expr)) || !!traceSuperModuleAccess(ts, checker, expr); +} + +function newContext(env: HookEnvironment, triggerPosition?: number): InferenceContext | undefined { + return createInferenceContext(env.ts, env.languageService, env.resolveSuperModulePath, triggerPosition); +} + +/** + * The declaration a member-name hover documents: the member every inferred + * receiver type resolves the name to. Receivers that resolve it to different + * declarations (an override, or unrelated classes) document nothing rather + * than an arbitrary one of them. + */ +function memberSymbol(ctx: InferenceContext, access: tsserver.PropertyAccessExpression): tsserver.Symbol | undefined { + const symbols = new Set( + inferTypeForExpression(ctx, access.expression).map((type) => getMemberOfType(ctx.checker, type, access.name.text)), + ); + const [symbol] = symbols; + return symbols.size === 1 ? symbol : undefined; +} + +/** The class a hover documents when inference resolved exactly one; a union has no single doc comment. */ +function soleTypeSymbol(types: readonly tsserver.Type[]): tsserver.Symbol | undefined { + return types.length === 1 ? types[0].getSymbol() : undefined; +} + +function documentationOf( + checker: tsserver.TypeChecker, + symbol: tsserver.Symbol | undefined, +): Pick { + const documentation = symbol?.getDocumentationComment(checker) ?? []; + const tags = symbol?.getJsDocTags(checker) ?? []; + return { + documentation: documentation.length > 0 ? documentation : undefined, + tags: tags.length > 0 ? tags : undefined, + }; +} + +/** + * Infers what a hover on `node` shows. A member name (`productLineItems` in + * `shipment.productLineItems`) has no declaration of its own until the + * receiver's type is known, so the whole access is resolved instead. + * Documentation comes from the member's declaration, or from the inferred + * class when there is exactly one. + */ +function inferHover(env: HookEnvironment, node: tsserver.Identifier): HoverInference | undefined { + const ctx = newContext(env); + if (!ctx) return undefined; + const access = propertyAccessNamedBy(node, env.ts); + const types = access ? inferTypeForExpression(ctx, access) : inferTypeForNode(ctx, node); + if (types.length === 0) return undefined; + const symbol = access ? memberSymbol(ctx, access) : soleTypeSymbol(types); + return {description: describeTypes(ctx, types), ...documentationOf(ctx.checker, symbol)}; +} + +// Header type texts TypeScript renders for a value it could not type: `any`, +// the `object` keyword, and the bare `Object` interface. +const OPEN_TYPE_TEXTS: ReadonlySet = new Set(['any', 'object', 'Object']); + +/** How many trailing display parts spell an open type: `any` / `object` / `Object`, or the two-part `{}`. */ +function openTypeTailLength(parts: readonly tsserver.SymbolDisplayPart[]): number { + const last = parts[parts.length - 1]; + if (!last) return 0; + if (OPEN_TYPE_TEXTS.has(last.text) && last.kind !== 'text') return 1; + return last.text === '}' && parts[parts.length - 2]?.text === '{' ? 2 : 0; +} + +/** + * Swaps the open type at the end of a hover header (`(parameter) shipment: + * any`) for the inferred description, so the bold header reads + * `(parameter) shipment: Shipment`. Any other header shape is left as it is + * rather than guessed at. + */ +function replaceOpenTypeTail( + parts: tsserver.SymbolDisplayPart[] | undefined, + description: string, +): tsserver.SymbolDisplayPart[] | undefined { + const tail = openTypeTailLength(parts ?? []); + if (!parts || tail === 0) return parts; + return [...parts.slice(0, -tail), {kind: 'text', text: description}]; +} + +function withHoverInference(original: tsserver.QuickInfo, inferred: HoverInference): tsserver.QuickInfo { + const note: tsserver.SymbolDisplayPart = {kind: 'text', text: `\n\nInferred from usage: ${inferred.description}`}; + return { + ...original, + displayParts: replaceOpenTypeTail(original.displayParts, inferred.description), + documentation: [...(inferred.documentation ?? []), ...(original.documentation ?? []), note], + tags: inferred.tags ? [...inferred.tags] : original.tags, + }; +} + +function decorateQuickInfo( + env: HookEnvironment, + cache: DisplayCache, + fileName: string, + position: number, + original: tsserver.QuickInfo, +): tsserver.QuickInfo { + const {ts} = env; + const file = openFile(env, fileName); + const node = file && getNodeAtPosition(file.sourceFile, ts, position); + if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) return original; + const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; + const inferred = cache.get(key, file.program, () => inferHover(env, node)); + return inferred ? withHoverInference(original, inferred) : original; +} + +/** + * Member entries for `receiver`: the members of its inferred types, plus the + * ones pass-through superModule overlay levels add + * (`module.exports = base; module.exports.extra = fn;`), which no candidate + * type can carry. + */ +function inferMemberEntries( + env: HookEnvironment, + receiver: tsserver.Expression, + position: number, +): tsserver.CompletionEntry[] { + const ctx = newContext(env, position); + if (!ctx) return []; + const augmented = collectSuperModuleAugmentedMembers(ctx, receiver).map((member) => + inferredCompletionEntry(env.ts, member.name, member.isMethod), + ); + return [...typesToCompletionEntries(env.ts, ctx.checker, inferTypeForExpression(ctx, receiver)), ...augmented]; +} + +/** + * Appends the inferred entries TypeScript did not already offer (first one + * per name wins), preserving every other field of its result (isIncomplete, + * optionalReplacementSpan, metadata, ...). A fresh member CompletionInfo is + * synthesized only when TypeScript returned nothing at all. + */ +function mergeCompletions( + original: tsserver.CompletionInfo | undefined, + inferred: readonly tsserver.CompletionEntry[], +): tsserver.CompletionInfo | undefined { + const seen = new Set((original?.entries ?? []).map((entry) => entry.name)); + const added: tsserver.CompletionEntry[] = []; + for (const entry of inferred) { + if (seen.has(entry.name)) continue; + seen.add(entry.name); + added.push(entry); + } + if (added.length === 0) return original; + if (original) return {...original, entries: [...original.entries, ...added]}; + return {isGlobalCompletion: false, isMemberCompletion: true, isNewIdentifierLocation: false, entries: added}; +} + +function decorateCompletions( + env: HookEnvironment, + cache: DisplayCache, + fileName: string, + position: number, + original: tsserver.CompletionInfo | undefined, +): tsserver.CompletionInfo | undefined { + const file = openFile(env, fileName); + const access = file && memberCompletionAccess(file.sourceFile, env.ts, position); + if (!file || !access || !isInferenceTarget(env.ts, file.checker, access.expression)) return original; + // The receiver can be any expression (`product.getPriceModel().|`), and + // nested receivers share a start (`a` and `a.b`), so the key spans it. + const receiver = access.expression; + const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; + return mergeCompletions( + original, + cache.get(key, file.program, () => inferMemberEntries(env, receiver, position)), + ); +} + +/** + * Creates the hover and completion decorators for one language service. Each + * request builds one inference context; finished results are cached per + * Program, and `reset()` drops them (the plugin calls it when its + * configuration changes). + */ +export function createUsageInferenceHooks(options: HookOptions) { + const hoverCache = createDisplayCache(); + const completionCache = createDisplayCache(); + return { + decorateQuickInfo(fileName: string, position: number, original: tsserver.QuickInfo | undefined) { + if (!original) return original; + return guarded(options, 'hover', original, () => + decorateQuickInfo(options, hoverCache, fileName, position, original), + ); + }, + decorateCompletions(fileName: string, position: number, original: tsserver.CompletionInfo | undefined) { + return guarded(options, 'completions', original, () => + decorateCompletions(options, completionCache, fileName, position, original), + ); + }, + reset() { + hoverCache.clear(); + completionCache.clear(); + }, + }; +} diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 8bf339486..7d3a4c62f 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -23,20 +23,14 @@ // inference/framework-contracts - parameters the platform itself passes (Page Designer) // inference/policy - turning evidence into the type(s) shown // inference/core - the recursive evidence-gathering engine +// inference/editor-hooks - hover and completion decoration for the plugin export {INFERRED_COMPLETION_SOURCE} from './inference/constants'; export {createInferenceContext} from './inference/context'; -export {getNodeAtPosition, findEnclosingPropertyAccess} from './inference/ast-helpers'; -export { - describeTypes, - getMemberOfType, - inferredCompletionEntry, - isOpenForUsageInference, - typesToCompletionEntries, -} from './inference/type-helpers'; -export {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './inference/super-module'; -export {inferParameterType, inferReturnType, inferTypeForExpression, inferTypeForNode} from './inference/core'; +export {memberCompletionAccess} from './inference/ast-helpers'; +export {describeTypes, typesToCompletionEntries} from './inference/type-helpers'; +export {collectSuperModuleAugmentedMembers} from './inference/super-module'; +export {inferParameterType, inferReturnType, inferTypeForNode} from './inference/core'; export {usageProfileOf} from './inference/usage-profile'; export {matchAmbientTypesByUsage} from './inference/policy'; - -export type {InferenceContext} from './inference/context'; +export {createUsageInferenceHooks} from './inference/editor-hooks'; diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 97faf0f72..f71d83824 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -43,9 +43,9 @@ const path = require('node:path'); const ts = require('typescript'); -const init = require('../../src/index'); const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); const {createInferenceContext, describeTypes, inferReturnType} = require('../../src/usage-inference'); +const {createPluginProxy} = require('../helpers/plugin-proxy'); const CORPUS = process.env.B2C_INFERENCE_CORPUS; const INFERRED_NOTE = 'Inferred from usage: '; @@ -126,14 +126,10 @@ function createCorpusPlugin(cartridges, fileNames) { searches.count++; return getReferencesAtPosition(fileName, position); }; - const proxy = init({typescript: ts}).create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => cartridges[0]?.src ?? '/', - getProjectVersion: () => '1', - }, + currentDirectory: cartridges[0]?.src ?? '/', config: {enabled: true, autoDiscover: false, inferUsage: true, cartridges}, }); return {languageService, proxy, setOverride, searches}; diff --git a/packages/b2c-script-types/test/helpers/assert-inference.js b/packages/b2c-script-types/test/helpers/assert-inference.js index 995723b6d..19a0991fd 100644 --- a/packages/b2c-script-types/test/helpers/assert-inference.js +++ b/packages/b2c-script-types/test/helpers/assert-inference.js @@ -1,3 +1,8 @@ +/* + * 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'); diff --git a/packages/b2c-script-types/test/helpers/cartridge-fixture.js b/packages/b2c-script-types/test/helpers/cartridge-fixture.js index 4ed7162ef..99fafce38 100644 --- a/packages/b2c-script-types/test/helpers/cartridge-fixture.js +++ b/packages/b2c-script-types/test/helpers/cartridge-fixture.js @@ -1,3 +1,8 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ 'use strict'; const {realTypesPrelude} = require('./real-dw-types'); diff --git a/packages/b2c-script-types/test/helpers/plugin-proxy.js b/packages/b2c-script-types/test/helpers/plugin-proxy.js new file mode 100644 index 000000000..efa8e41f3 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/plugin-proxy.js @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const ts = require('typescript'); + +const init = require('../../src/index'); +const {createFixtureHost, sharedDocumentRegistry} = require('./fixture-language-service'); + +/** + * Installs the plugin on a language service the way tsserver does, passing + * only the part of PluginCreateInfo the plugin touches (logger, current + * directory, project version, config, host, language service). + * + * @param {object} options + * @param {object} options.config - the plugin configuration + * @param {Record} [options.files] - in-memory fixture files (when no `host` is given) + * @param {object} [options.compilerOptions] - compiler options for the fixture host + * @param {object} [options.host] - a prepared LanguageServiceHost to use instead of a fixture host + * @param {object} [options.languageService] - the language service to decorate (default: one over `host`) + * @param {() => string} [options.projectVersion] - the project version tsserver reports + * @param {string} [options.currentDirectory] - the project root auto-discovery starts from + * @param {(message: string) => void} [options.log] - receives the plugin's log lines + * @returns {{proxy: object, plugin: {create: Function, onConfigurationChanged: Function}, host: object, languageService: object}} + */ +function createPluginProxy({ + config, + files, + compilerOptions, + host = createFixtureHost(files, compilerOptions), + languageService = ts.createLanguageService(host, sharedDocumentRegistry), + projectVersion = () => '1', + currentDirectory = '/', + log = () => {}, +}) { + const plugin = init({typescript: ts}); + const proxy = plugin.create({ + languageService, + languageServiceHost: host, + project: { + projectService: {logger: {info: log}}, + getCurrentDirectory: () => currentDirectory, + getProjectVersion: projectVersion, + }, + config, + }); + return {proxy, plugin, host, languageService}; +} + +module.exports = {createPluginProxy}; diff --git a/packages/b2c-script-types/test/index.security.test.js b/packages/b2c-script-types/test/index.security.test.js index 2f027d08d..afc180e56 100644 --- a/packages/b2c-script-types/test/index.security.test.js +++ b/packages/b2c-script-types/test/index.security.test.js @@ -22,7 +22,7 @@ const path = require('node:path'); const ts = require('typescript'); -const init = require('../src/index'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); const TYPES_DIR = path.resolve(__dirname, '..', 'types'); @@ -82,15 +82,10 @@ function makeResolver({appRoot, modRoot, containingFile, tmp}) { resolveModuleNameLiterals: (lits) => lits.map(() => ({resolvedModule: undefined})), }; const languageService = ts.createLanguageService(host, ts.createDocumentRegistry()); - const {create} = init({typescript: ts}); - create({ + createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => tmp, - getProjectVersion: () => '1', - }, + currentDirectory: tmp, config: { enabled: true, autoDiscover: false, diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 12c0f2736..a7f020960 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -9,9 +9,9 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); -const init = require('../src/index'); const {INFERRED_COMPLETION_SOURCE} = require('../src/usage-inference'); const {createFixtureHost, sharedDocumentRegistry} = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); const AMBIENT_TYPES = ` @@ -29,24 +29,9 @@ const FIXTURE_FILES = { `, }; -// Builds a plugin instance wired against an in-memory LanguageService, using -// only the subset of tsserver's PluginCreateInfo surface the plugin actually -// touches (logger, project version, config, host, language service). -function createPluginProxy(config) { - const {create} = init({typescript: ts}); - const host = createFixtureHost(FIXTURE_FILES); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const info = { - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, - config, - }; - return create(info); +// The plugin installed over FIXTURE_FILES with the given configuration. +function fixtureProxy(config) { + return createPluginProxy({files: FIXTURE_FILES, config}).proxy; } // Parses the fixture source once to locate exact AST offsets, rather than @@ -78,14 +63,14 @@ describe('create() proxy — usage inference wiring', () => { const {paramPos, dotPos} = fixtureOffsets(); it('leaves hover untouched when inferUsage is off (default)', () => { - const proxy = createPluginProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); const docText = (info?.documentation ?? []).map((p) => p.text).join(''); assert.ok(!docText.includes('Inferred from usage')); }); it('appends an inferred-usage hover note when inferUsage is on', () => { - const proxy = createPluginProxy({ + const proxy = fixtureProxy({ enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, @@ -97,14 +82,14 @@ describe('create() proxy — usage inference wiring', () => { }); it('leaves completions untouched when inferUsage is off (default)', () => { - const proxy = createPluginProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); + const proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); const completions = proxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); const names = (completions?.entries ?? []).map((e) => e.name); assert.ok(!names.includes('ID')); }); it('synthesizes member completions from inferred usage when inferUsage is on', () => { - const proxy = createPluginProxy({ + const proxy = fixtureProxy({ enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, @@ -117,10 +102,10 @@ describe('create() proxy — usage inference wiring', () => { }); 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 plainProxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG}); const original = plainProxy.getCompletionsAtPosition('/helper.js', dotPos, undefined); - const inferProxy = createPluginProxy({ + const inferProxy = fixtureProxy({ enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, @@ -139,7 +124,7 @@ describe('create() proxy — usage inference wiring', () => { // 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 proxy = fixtureProxy({enabled: true, autoDiscover: false, cartridges: [], inferUsage: true}); const info = proxy.getQuickInfoAtPosition('/helper.js', paramPos); const docText = (info?.documentation ?? []).map((p) => p.text).join(''); assert.ok(!docText.includes('Inferred from usage')); @@ -150,7 +135,7 @@ describe('create() proxy — usage inference wiring', () => { }); it('does not run inference when the parent scriptTypes feature is disabled, even when inferUsage is on', () => { - const proxy = createPluginProxy({ + const proxy = fixtureProxy({ enabled: false, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, @@ -173,17 +158,8 @@ describe('create() proxy — usage inference wiring', () => { const files = { '/typed.ts': `function helper(x: {aVeryLongPropertyNameHere: string; anotherVeryLongPropertyName: number; yetAnotherLongOne: boolean}) { return x; }`, }; - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: []}, }); const pos = files['/typed.ts'].indexOf('x:'); @@ -217,15 +193,10 @@ describe('create() proxy — usage inference wiring', () => { const origGetScriptVersion = host.getScriptVersion; host.getScriptVersion = (fileName) => `${versions[fileName] ?? 0}:${origGetScriptVersion(fileName)}`; const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => String(projectVersion), - }, + projectVersion: () => String(projectVersion), config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); const paramPos = files['/helper.js'].indexOf('product)'); // start of the `product` identifier @@ -272,21 +243,10 @@ describe('create() proxy — usage inference wiring', () => { module.exports = {getDisplayName}; `, }; - const proxy = (() => { - const {create} = init({typescript: ts}); - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - return create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, - config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, - }); - })(); + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); const paramPos = files['/priceHelper.js'].indexOf('product)'); const dotPos = files['/priceHelper.js'].indexOf('product.getName()') + 'product.'.length; @@ -319,17 +279,8 @@ describe('create() proxy — usage inference wiring', () => { module.exports = {resolveProductPrice}; `, }; - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); const dotPos = files['/priceHelper.js'].indexOf('.getPrice()') + 1; @@ -363,17 +314,8 @@ describe('create() proxy — usage inference wiring', () => { } `, }; - 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', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); @@ -421,17 +363,8 @@ describe('create() proxy — usage inference wiring', () => { } `, }; - 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', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); @@ -484,17 +417,8 @@ describe('create() proxy — usage inference wiring', () => { 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', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); @@ -517,6 +441,91 @@ describe('create() proxy — usage inference wiring', () => { ); }); + it('rewrites a weak `{Object}` hover header, not only a trailing `any`', () => { + // Under strict checkJs TypeScript renders `@param {Object}` as the + // `Object` interface in the bold hover header rather than as `any`. + const files = { + '/types.d.ts': realTypesPrelude(['Customer', 'Profile'], ''), + '/accountHelpers.js': ` + /** @param {Object} resettingCustomer */ + function sendPasswordResetEmail(resettingCustomer) { + return resettingCustomer.profile.firstName + resettingCustomer.profile.lastName; + } + module.exports = {sendPasswordResetEmail}; + `, + }; + const {proxy} = createPluginProxy({ + files, + compilerOptions: {checkJs: true, strict: true}, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + + const paramPos = files['/accountHelpers.js'].indexOf('resettingCustomer)'); + const hover = proxy.getQuickInfoAtPosition('/accountHelpers.js', paramPos); + const header = (hover?.displayParts ?? []).map((p) => p.text).join(''); + assert.equal(header, '(parameter) resettingCustomer: Customer'); + }); + + it('offers no inferred members inside an argument list further along a chain', () => { + // At `helper(|).name` the cursor completes an argument, not a member of + // `helper(...)`, even though that call is the receiver of the enclosing + // `.name` access. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/helper.js': ` + function helper(product) { + return product; + } + var label = helper(getProduct()).name; + module.exports = {helper}; + `, + }; + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, + }); + const source = files['/helper.js']; + const inferredNames = (position) => + (proxy.getCompletionsAtPosition('/helper.js', position, undefined)?.entries ?? []) + .filter((entry) => entry.source === INFERRED_COMPLETION_SOURCE) + .map((entry) => entry.name); + + assert.deepEqual(inferredNames(source.indexOf('helper(getProduct())') + 'helper('.length), []); + assert.ok(inferredNames(source.indexOf('.name') + 1).includes('ID')); + }); + + it('drops cached hovers when the configuration changes, so a new cartridge path takes effect', () => { + // Same Program before and after: only the cartridge path, and with it + // the module `module.superModule` refers to, changes. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/a/cartridge/scripts/price.js': `module.exports = {fromA: getProduct()};`, + '/b/cartridge/scripts/price.js': `module.exports = {fromB: getProduct()};`, + '/top/cartridge/scripts/price.js': ` + var base = module.superModule; + module.exports = base; + `, + }; + const configOver = (below) => ({ + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'top', src: '/top/'}, + {name: below, src: `/${below}/`}, + ], + inferUsage: true, + }); + const {proxy, plugin} = createPluginProxy({files, config: configOver('a')}); + const topFile = '/top/cartridge/scripts/price.js'; + const basePos = files[topFile].indexOf('base ='); + const hoverText = () => + (proxy.getQuickInfoAtPosition(topFile, basePos)?.documentation ?? []).map((p) => p.text).join(''); + + assert.match(hoverText(), /fromA/); + plugin.onConfigurationChanged(configOver('b')); + assert.match(hoverText(), /fromB/); + }); + it('offers completions for a dangling mid-edit `shipment.` immediately followed by more code on later lines', () => { // Regression test for a real dogfooding find: `.` never gets automatic // semicolon insertion (it always demands a following identifier), so a @@ -544,17 +553,8 @@ describe('create() proxy — usage inference wiring', () => { } `, }; - 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', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); @@ -606,17 +606,8 @@ describe('create() proxy — usage inference wiring', () => { module.exports.getMemberPrice = getMemberPrice; `, }; - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, + const {proxy} = createPluginProxy({ + files, config: { enabled: true, autoDiscover: false, @@ -664,17 +655,8 @@ describe('create() proxy — usage inference wiring', () => { module.exports = server.exports(); `, }; - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, + const {proxy} = createPluginProxy({ + files, config: { enabled: true, autoDiscover: false, @@ -735,17 +717,8 @@ describe('create() proxy — usage inference wiring', () => { module.exports.getPromoPrice = getPromoPrice; `, }; - const host = createFixtureHost(files); - const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const {create} = init({typescript: ts}); - const proxy = create({ - languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, + const {proxy} = createPluginProxy({ + files, config: { enabled: true, autoDiscover: false, @@ -796,15 +769,9 @@ describe('create() proxy — usage inference wiring', () => { return target[prop]; }, }); - const {create} = init({typescript: ts}); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); @@ -840,15 +807,9 @@ describe('create() proxy — usage inference wiring', () => { return target[prop]; }, }); - const {create} = init({typescript: ts}); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, config: {enabled: true, autoDiscover: false, cartridges: CARTRIDGE_CONFIG, inferUsage: true}, }); 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 abe8560de..231db2693 100644 --- a/packages/b2c-script-types/test/usage-inference.hardening.test.js +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -9,7 +9,6 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); -const init = require('../src/index'); const { createInferenceContext, describeTypes, @@ -24,6 +23,7 @@ const { findFunctionDeclaration, sharedDocumentRegistry, } = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); const {realTypesPrelude} = require('./helpers/real-dw-types'); describe('usage-inference hardening', () => { @@ -68,7 +68,6 @@ describe('usage-inference hardening', () => { module.exports = {helper: helper}; `, }; - const {create} = init({typescript: ts}); const host = createFixtureHost(files); const base = ts.createLanguageService(host, sharedDocumentRegistry); let searches = 0; @@ -85,14 +84,9 @@ describe('usage-inference hardening', () => { return typeof value === 'function' ? value.bind(target) : value; }, }); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => '1', - }, config: { enabled: true, inferUsage: true, @@ -235,7 +229,6 @@ describe('usage-inference hardening', () => { ], }); - 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 @@ -246,14 +239,10 @@ describe('usage-inference hardening', () => { const origGetScriptVersion = host.getScriptVersion; host.getScriptVersion = (f) => `${versions[f] ?? 0}:${origGetScriptVersion(f)}`; const languageService = ts.createLanguageService(host, sharedDocumentRegistry); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => String(projectVersion), - }, + projectVersion: () => String(projectVersion), config: { enabled: true, inferUsage: true, @@ -280,17 +269,8 @@ describe('usage-inference hardening', () => { 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', - }, + const {proxy} = createPluginProxy({ + files, config: {enabled: true, inferUsage: false, cartridges: [{name: 'c', src: '/'}]}, }); assertNoInferredHover(proxy, '/helper.js', positionOf(files['/helper.js'], 'product)')); diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 53d189beb..1cb50b7b9 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -10,13 +10,13 @@ const assert = require('node:assert/strict'); const ts = require('typescript'); const {createInferenceContext, describeTypes, inferParameterType, inferReturnType} = require('../src/usage-inference'); -const init = require('../src/index'); const { createFixtureHost, createFixtureLanguageService, findFunctionDeclaration, sharedDocumentRegistry, } = require('./helpers/fixture-language-service'); +const {createPluginProxy} = require('./helpers/plugin-proxy'); const {realTypesPrelude} = require('./helpers/real-dw-types'); // --------------------------------------------------------------------------- @@ -324,15 +324,9 @@ describe('usage-inference — performance baselines', () => { const host = createFixtureHost(files); const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); const counter = withReferenceCounter(baseLs); - const {create} = init({typescript: ts}); - const proxy = create({ + const {proxy} = createPluginProxy({ + host, 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)'); @@ -514,16 +508,11 @@ describe('usage-inference — performance baselines', () => { const host = createFixtureHost(files); const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); const counter = withReferenceCounter(baseLs); - const {create} = init({typescript: ts}); let projectVersion = 1; - const proxy = create({ + const {proxy} = createPluginProxy({ + host, languageService: counter.languageService, - languageServiceHost: host, - project: { - projectService: {logger: {info: () => {}}}, - getCurrentDirectory: () => '/', - getProjectVersion: () => String(projectVersion), - }, + projectVersion: () => String(projectVersion), config: {enabled: true, autoDiscover: false, cartridges: [{name: 'c', src: '/'}], inferUsage: true}, }); const paramPos = files['/helper.js'].indexOf('product)'); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 31794dab3..d9b4bf510 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -12,11 +12,10 @@ const ts = require('typescript'); const { createInferenceContext, describeTypes, - findEnclosingPropertyAccess, - getNodeAtPosition, inferParameterType, inferReturnType, inferTypeForNode, + memberCompletionAccess, typesToCompletionEntries, } = require('../src/usage-inference'); const {createFixtureLanguageService, findFunctionDeclaration} = require('./helpers/fixture-language-service'); @@ -1649,22 +1648,29 @@ describe('usage-inference', () => { }); }); - 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; + describe('memberCompletionAccess', () => { + const source = `var label = helper(product).name; product.ID; basket.getShipment(shipment).ID;`; + const completionAccess = (marker, offset) => { + const sourceFile = ts.createSourceFile('/dotted.js', source, ts.ScriptTarget.ES2020, true); + const access = memberCompletionAccess(sourceFile, ts, source.indexOf(marker) + offset); + return access && access.getText(sourceFile); + }; - const node = getNodeAtPosition(sourceFile, ts, dotPos - 1); - const propAccess = findEnclosingPropertyAccess(node, ts); + it('finds the access whose dot the cursor follows', () => { + assert.equal(completionAccess('product.ID', 'product.'.length), 'product.ID'); + }); + + it('finds the access whose member name is being typed', () => { + assert.equal(completionAccess('product.ID', 'product.I'.length), 'product.ID'); + }); + + it('ignores a cursor inside the receiver', () => { + assert.equal(completionAccess('product.ID', 'prod'.length), undefined); + }); - assert.ok(propAccess); - assert.equal(propAccess.name.text, 'ID'); + it('ignores a cursor inside an argument list further along the chain', () => { + assert.equal(completionAccess('getShipment(shipment)', 'getShipment('.length), undefined); + assert.equal(completionAccess('helper(product)', 'helper(prod'.length), undefined); }); }); }); From 3549eea37c877e8e53de8d1157fe4ad0c081934b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:41:35 +0000 Subject: [PATCH 49/76] refactor(script-types): split complex inference and resolver functions Brings the package to zero findings under fallow's default complexity, duplication and dead-code thresholds by extraction, not suppression: - usage-profile: one recorder per reference role instead of one long if-chain; presence tests, argument overload agreement and branching each get a small named helper. - call-sites: name lookup, callee-position and require() indirection split into ownName/assignedName, directCall/borrowedCall and requireCallOf; one referenceTarget() per reference-search hit. - resolver: cartridge lookup, contained-candidate probing and the package.json main fallback are separate functions with the same containment checks; orderCartridges splits into namedFirst and baseCartridgesLast; reorderForContainingFile is no longer exported. - index.ts: auto-discovery and ambient-declaration injection move out of create(); the two local-cartridge resolution branches merge. A reference-search hit inside a top-level JSDoc comment (SFRA's `@returns {ProductLineItemsModel}` on a constructor with an expando member) resolves to the source file node, which has no parent; it is skipped as before instead of throwing. Adds a regression test for it and unit tests for cartridge ordering. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- packages/b2c-script-types/plugin/index.js | 99 +++---- .../plugin/inference/call-sites.js | 159 ++++++----- .../b2c-script-types/plugin/inference/core.js | 14 +- .../plugin/inference/usage-profile.js | 223 ++++++++++------ .../plugin/resolver/cartridge-discovery.js | 46 ++-- .../plugin/resolver/module-resolution.js | 160 ++++++----- packages/b2c-script-types/src/index.ts | 100 ++++--- .../src/inference/call-sites.ts | 151 ++++++----- .../b2c-script-types/src/inference/core.ts | 18 +- .../src/inference/usage-profile.ts | 250 ++++++++++++------ .../src/resolver/cartridge-discovery.ts | 48 ++-- .../src/resolver/module-resolution.ts | 184 +++++++------ .../test/cartridge-discovery.test.js | 37 +++ .../test/usage-inference.test.js | 35 +++ 14 files changed, 877 insertions(+), 647 deletions(-) create mode 100644 packages/b2c-script-types/test/cartridge-discovery.test.js diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 310a57b90..7c3eaff89 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -118,6 +118,35 @@ function init({ typescript: ts }) { } }; const ownerCartridge = (containingFile) => (0, module_resolution_1.ownerCartridge)(cartridges, normalize, containingFile); + // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. + // Neovim with typescript-language-server). Walks the project root for + // `.project` markers and honors dw.json's `cartridges` field for ordering. + const needsAutoDiscovery = () => enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0; + const autoDiscoverCartridges = (projectRoot, log) => { + if (!projectRoot) + return; + try { + const discovered = (0, cartridge_discovery_1.discoverCartridgesOnDisk)(ts, projectRoot, fileExists); + const configured = (0, cartridge_discovery_1.readDwJsonCartridges)(ts, projectRoot, fileExists); + setCartridges((0, cartridge_discovery_1.orderCartridges)(discovered, configured)); + log(`auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + + (configured ? ` (ordered by dw.json cartridges)` : '')); + } + catch (e) { + log(`auto-discovery failed: ${e.message}`); + } + }; + // The ambient declarations a project with cartridge files needs and its + // file list doesn't already include (dedup by normalized path): + // - global.d.ts: SFCC platform globals (session, request, response, + // customer, empty(), the ambient `dw` namespace). + // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, + // middleware, etc.) — only when a `modules` cartridge is configured. + const missingAmbientDeclarations = (list) => { + const present = new Set(list.map((f) => normalize(f))); + const wanted = cartridges.some((c) => c.name === 'modules') ? [GLOBAL_DTS, SFRA_SERVER_DTS] : [GLOBAL_DTS]; + return wanted.filter((dts) => fileExists(dts) && !present.has(normalize(dts))); + }; // Cached map of byte ranges in types/sfra/server.d.ts to the SFRA module // declared by their enclosing `declare module 'X' { ... }` block. Used to // map go-to-definition results back to the matching modules/.js file. @@ -136,25 +165,8 @@ function init({ typescript: ts }) { function create(info) { const log = (msg) => info.project.projectService.logger.info(`[${constants_1.PLUGIN_NAME}] ${msg}`); applyConfig(info.config); - // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. - // Neovim with typescript-language-server). Walks the project root for - // `.project` markers and honors dw.json's `cartridges` field for ordering. - if (enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0) { - const projectRoot = info.project.getCurrentDirectory(); - if (projectRoot) { - try { - const discovered = (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)` : '')); - } - catch (e) { - log(`auto-discovery failed: ${e.message}`); - } - } - } + if (needsAutoDiscovery()) + autoDiscoverCartridges(info.project.getCurrentDirectory(), log); 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 @@ -203,30 +215,16 @@ function init({ typescript: ts }) { } 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, - // customer, empty(), the ambient `dw` namespace). - // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, - // middleware, etc.) — only injected when a `modules` cartridge is - // configured. Configured projects that already include either via a - // jsconfig include glob are unaffected (dedup by normalized path). + // Inject the ambient declarations into the TS program when the project + // contains at least one cartridge file (isCartridgeFile is false while the + // plugin is disabled or no cartridge is known). Projects that already + // include them via a jsconfig include glob are unaffected. const origGetScriptFileNames = host.getScriptFileNames.bind(host); host.getScriptFileNames = () => { const list = origGetScriptFileNames(); - if (!enabled || cartridges.length === 0) - return list; if (!list.some((f) => isCartridgeFile(f))) return list; - const additions = []; - const present = new Set(list.map((f) => normalize(f))); - if (fileExists(GLOBAL_DTS) && !present.has(normalize(GLOBAL_DTS))) { - additions.push(GLOBAL_DTS); - } - const hasModules = cartridges.some((c) => c.name === 'modules'); - if (hasModules && fileExists(SFRA_SERVER_DTS) && !present.has(normalize(SFRA_SERVER_DTS))) { - additions.push(SFRA_SERVER_DTS); - } + const additions = missingAmbientDeclarations(list); return additions.length > 0 ? [...list, ...additions] : list; }; // Shared by both host resolution hooks below (the modern @@ -241,23 +239,14 @@ function init({ typescript: ts }) { if (dw && fileExists(dw)) { return { resolvedFileName: dw, extension: ts.Extension.Dts, isExternalLibraryImport: true }; } - const cart = resolveCartridgeModuleOnHost(text, containingFile); - if (cart) { - return { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - const mod = resolveModulesCartridgeOnHost(text); - if (mod) { - return { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - return undefined; + const local = resolveCartridgeModuleOnHost(text, containingFile) ?? resolveModulesCartridgeOnHost(text); + if (!local) + return undefined; + return { + resolvedFileName: local.resolved, + extension: local.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; }; // TS derives a file's ScriptKind from its extension and falls back to TS // for anything it doesn't recognize, which would parse legacy .ds scripts diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index 4f2f127fd..19fe086f2 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -31,37 +31,39 @@ function findReferences(ctx, name) { } return references; } +/** The name a function declaration or method declares itself by. */ +function ownName(fn, ts) { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : 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(){}`). + * The name `fn` is assigned to: `exports.foo = function(){}` or + * `module.exports = function(){}`. The `.name` identifier (`foo` or + * `exports`) is what findReferences can actually track; for the bare + * `module.exports` case this resolves to the whole module's value, so callers + * reach it through collectCallSites()'s require() indirection rather than a + * direct property-access call. */ -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) +function assignedName(fn, ts) { + const assignment = fn.parent; + if (!ts.isBinaryExpression(assignment) || assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { 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; + const { left } = assignment; + if (ts.isPropertyAccessExpression(left)) + return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : undefined; +} +/** + * Identifies the name to run findReferences on for a function-like + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). + */ +function getReferenceNameNode(fn, ts) { + const boundName = ts.isExpression(fn) ? bindingNameOf(fn, ts) : undefined; + return ownName(fn, ts) ?? boundName ?? assignedName(fn, ts); } /** The function enclosing `node`, if any. */ function enclosingFunction(node, ts) { @@ -89,6 +91,29 @@ function applyArguments(call, ts) { const forwarding = enclosingFunction(call, ts); return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; } +/** What a reference invokes when called: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function calleeOf(node, ts) { + const { parent } = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee, ts) { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? { node: call, args: call.arguments ?? [] } : undefined; +} +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee, ts) { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) + return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) + return undefined; + if (access.name.text === 'call') + return { node: call, args: call.arguments.slice(1) }; + return access.name.text === 'apply' ? { node: call, args: applyArguments(call, ts) } : undefined; +} /** * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it @@ -96,23 +121,8 @@ function applyArguments(call, ts) { * (`obj.helper`) is treated as the whole access. */ function findCallInCalleePosition(node, ts) { - const callee = node.parent && ts.isPropertyAccessExpression(node.parent) && node.parent.name === node ? node.parent : node; - const parent = callee.parent; - if (!parent) - return undefined; - if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === callee) { - return { node: parent, args: parent.arguments ?? [] }; - } - if (!ts.isPropertyAccessExpression(parent) || parent.expression !== callee) - return undefined; - const call = parent.parent; - if (!call || !ts.isCallExpression(call) || call.expression !== parent) - return undefined; - if (parent.name.text === 'call') - return { node: call, args: call.arguments.slice(1) }; - if (parent.name.text === 'apply') - return { node: call, args: applyArguments(call, ts) }; - return undefined; + const callee = calleeOf(node, ts); + return directCall(callee, ts) ?? borrowedCall(callee, ts); } /** * A `require('specifier')` call, identified structurally (only public @@ -137,6 +147,11 @@ function bindingNameOf(expression, ts) { return undefined; return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; } +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node, ts) { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} /** * 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 @@ -158,20 +173,17 @@ function bindingNameOf(expression, ts) { * immediately-invoked require (`require('./helper')(x)`) — the call site itself. */ function resolveIndirectReferenceTarget(node, ts) { - const parent = node.parent; - if (!parent) - return undefined; + const { parent } = node; 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 }; } - const isRequireSpecifier = ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts); - const value = isRequireSpecifier ? parent : node; - const invocation = value.parent; - if (isRequireSpecifier && invocation && ts.isCallExpression(invocation) && invocation.expression === value) { - return { kind: 'call', call: { node: invocation, args: invocation.arguments } }; // require('./helper')(x) - } + const required = requireCallOf(node, ts); + const invocation = required && directCall(required, ts); + if (invocation) + return { kind: 'call', call: invocation }; // require('./helper')(x) + const value = required ?? node; const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; return name && { kind: 'name', name }; } @@ -219,31 +231,32 @@ function collectCallSites(ctx, nameNode) { * fanning out once it's exhausted. */ function collectCallsFromName(ctx, name, calls, nextFrontier, localBudget) { - const { ts, program } = ctx; ctx.searchBudget--; - for (const ref of findReferences(ctx, name)) { + for (const reference of findReferences(ctx, name)) { 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); + const target = referenceTarget(ctx, reference); + if (target?.kind === 'call') + calls.push(target.call); + else if (target?.kind === 'name') + nextFrontier.push(target.name); } return localBudget; } +/** + * Where one reference search hit leads. Definition sites (the declaration + * itself) never sit in callee position, so they lead nowhere new; neither + * does a hit on a whole module (the source file itself, which has no parent + * — e.g. a `module.exports` reference landing in a file's leading comment). + */ +function referenceTarget(ctx, reference) { + const { ts, program } = ctx; + const file = program.getSourceFile(reference.fileName); + const node = file && (0, ast_helpers_1.getNodeAtPosition)(file, ts, reference.textSpan.start); + if (!node?.parent) + return undefined; + const call = findCallInCalleePosition(node, ts); + return call ? { kind: 'call', call } : resolveIndirectReferenceTarget(node, ts); +} diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 5fbc4ac60..1b0f78efc 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -158,8 +158,11 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const direct = (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(expr)); if (direct.length > 0) return direct; - if (chainHops >= constants_1.MAX_CHAIN_HOPS) - return []; + return chainHops < constants_1.MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; +} +/** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ +function resolveFromParts(ctx, expr, depth, chainHops) { + const { ts } = ctx; if (ts.isCallExpression(expr)) return resolveCallResultTypes(ctx, expr, depth, chainHops); if (ts.isPropertyAccessExpression(expr)) @@ -170,11 +173,10 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) // and `a || b` / `a ?? b` can each evaluate to either side. - if (ts.isConditionalExpression(expr)) + if (ts.isConditionalExpression(expr)) { return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); - if (ts.isBinaryExpression(expr)) - return resolveLogicalTypes(ctx, expr, depth, chainHops); - return []; + } + return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; } function resolveAlternatives(ctx, alternatives, depth, chainHops) { return (0, type_helpers_1.dedupeTypes)(ctx, alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1))); diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js index c43305c4a..8c9e43d2b 100644 --- a/packages/b2c-script-types/plugin/inference/usage-profile.js +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -55,30 +55,43 @@ function primitiveOfTypeofCheck(ctx, typeOf) { function literalText(ctx, node) { return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; } -/** - * The member a presence test on `reference` checks for: `'m' in x`, - * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. - */ -function presenceTestedMember(ctx, reference) { +/** `'m' in x`: the member an `in` test on `reference` names. */ +function inOperatorMember(ctx, reference) { const { ts } = ctx; - const parent = reference.parent; - if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.InKeyword) { - return parent.right === reference ? literalText(ctx, parent.left) : undefined; - } - const isOwnReceiver = ts.isPropertyAccessExpression(parent) && parent.expression === reference && parent.name.text === 'hasOwnProperty'; - const call = isOwnReceiver ? parent.parent : parent; - if (!ts.isCallExpression(call)) + const test = reference.parent; + const isInTest = ts.isBinaryExpression(test) && test.operatorToken.kind === ts.SyntaxKind.InKeyword && test.right === reference; + return isInTest ? literalText(ctx, test.left) : undefined; +} +/** `x.hasOwnProperty('m')`: the member an own-property method call on `reference` names. */ +function ownPropertyMethodMember(ctx, reference) { + const { ts } = ctx; + const access = reference.parent; + const isHasOwn = ts.isPropertyAccessExpression(access) && access.expression === reference && access.name.text === 'hasOwnProperty'; + const call = access.parent; + return isHasOwn && ts.isCallExpression(call) && call.expression === access + ? literalText(ctx, call.arguments[0]) + : undefined; +} +/** `Object[.prototype].hasOwnProperty.call(x, 'm')`: the member a borrowed own-property call on `reference` names. */ +function ownPropertyCallMember(ctx, reference) { + const { ts } = ctx; + const call = reference.parent; + if (!ts.isCallExpression(call) || call.arguments[0] !== reference) return undefined; - if (isOwnReceiver) - return call.expression === parent ? literalText(ctx, call.arguments[0]) : undefined; const callee = call.expression; - const isHasOwnCall = call.arguments[0] === reference && - ts.isPropertyAccessExpression(callee) && + const isHasOwnCall = ts.isPropertyAccessExpression(callee) && callee.name.text === 'call' && ts.isPropertyAccessExpression(callee.expression) && callee.expression.name.text === 'hasOwnProperty'; return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; } +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx, reference) { + return (inOperatorMember(ctx, reference) ?? ownPropertyMethodMember(ctx, reference) ?? ownPropertyCallMember(ctx, reference)); +} /** * True when `access` is the member access the current request's own cursor * sits in (see {@link InferenceContext.triggerPosition}): a member name being @@ -109,29 +122,32 @@ function acceptsArgumentCount(ctx, signature, count) { return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); } /** - * The type `reference` is used as. An argument to an overloaded function - * counts only when every overload taking that many arguments agrees on it + * The type argument `index` of `call` is passed as. For an overloaded callee + * it counts only when every overload taking that many arguments agrees on it * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type * the checker settles on the first such overload, which says nothing about * the one the code means (`variationModel.getAllValues(attribute)` would * make `attribute` an `ObjectAttributeDefinition`). */ -function usedAsType(ctx, reference) { +function argumentType(ctx, call, reference, index) { const { ts, checker } = ctx; - const call = reference.parent; - const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); - const args = (isCall && call.arguments) || []; - const index = args.indexOf(reference); - if (!isCall || index < 0) - return checker.getContextualType(reference); + const argumentCount = call.arguments?.length ?? 0; const callee = checker.getTypeAtLocation(call.expression); const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); - const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, args.length)); + const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, argumentCount)); if (overloads.length <= 1) return checker.getContextualType(reference); const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); return first && rest.every((type) => type === first) ? first : undefined; } +/** The type `reference` is used as: what its context expects, or the argument type of the call it is passed to. */ +function usedAsType(ctx, reference) { + const { ts, checker } = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const index = isCall ? (call.arguments?.indexOf(reference) ?? -1) : -1; + return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); +} function isReferenceTo(ctx, target, node) { return (ctx.ts.isIdentifier(node) && node !== target.declarationName && @@ -159,6 +175,17 @@ function statementsAfterPresenceExit(ctx, target, statements) { testsPresence(ctx, target, statement.expression)); return guard < 0 ? [] : statements.slice(guard + 1); } +/** What a branching `node` tests and the branches it picks between, for `if`, `?:` and `&&`. */ +function branchingOf(ts, node) { + if (ts.isIfStatement(node)) { + const branches = node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + return { condition: node.expression, branches }; + } + if (ts.isConditionalExpression(node)) + return { condition: node.condition, branches: [node.whenTrue, node.whenFalse] }; + const isAnd = ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isAnd ? { condition: node.left, branches: [node.right] } : undefined; +} /** * The branches of `node` that only run for some variants of the value: both * branches of an `if`/`?:` that tests it for a member first (an early @@ -169,19 +196,10 @@ function statementsAfterPresenceExit(ctx, target, statements) { */ function variantBranches(ctx, target, node) { const { ts } = ctx; - if (ts.isIfStatement(node)) { - if (!testsPresence(ctx, target, node.expression)) - return []; - return node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; - } - if (ts.isConditionalExpression(node)) { - return testsPresence(ctx, target, node.condition) ? [node.whenTrue, node.whenFalse] : []; - } - if (ts.isBinaryExpression(node)) { - const isGuardedAnd = node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; - return isGuardedAnd && testsPresence(ctx, target, node.left) ? [node.right] : []; - } - return ts.isBlock(node) || ts.isSourceFile(node) ? statementsAfterPresenceExit(ctx, target, node.statements) : []; + if (ts.isBlock(node) || ts.isSourceFile(node)) + return statementsAfterPresenceExit(ctx, target, node.statements); + const branching = branchingOf(ts, node); + return branching && testsPresence(ctx, target, branching.condition) ? branching.branches : []; } /** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ function recordMember(profile, name, optional) { @@ -189,56 +207,87 @@ function recordMember(profile, name, optional) { if (optional) profile.optionalMemberNames.add(name); } +function pushDefined(list, value) { + if (value !== undefined) + list.push(value); +} +/** The binary expression `reference` is the left operand of, when its operator is `operator`. */ +function leftOperandOf(ctx, reference, operator) { + const parent = reference.parent; + const matches = ctx.ts.isBinaryExpression(parent) && parent.left === reference; + return matches && parent.operatorToken.kind === operator ? parent : undefined; +} +/** The member name a `x.m` / `x['m']` access reads, except the one the cursor is still typing. */ +function accessedMemberName(ctx, access) { + if (!ctx.ts.isPropertyAccessExpression(access)) + return literalText(ctx, access.argumentExpression); + return isTriggerAccess(ctx, access) ? undefined : access.name.text; +} +// Each recorder recognizes one role a reference can play and records it, +// returning true when the reference played that role. +/** `'m' in x`, `x.hasOwnProperty('m')`, ...: a member only some variants of the value have. */ +function recordPresenceTest({ ctx, reference }, profile) { + const member = presenceTestedMember(ctx, reference); + if (member !== undefined) + recordMember(profile, member, true); + return member !== undefined; +} +/** `x.m` / `x['m']`: a member the value is accessed by. */ +function recordMemberAccess({ ctx, reference, inVariantBranch }, profile) { + const { ts } = ctx; + const access = reference.parent; + const isAccess = ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access); + if (!isAccess || access.expression !== reference) + return false; + const member = accessedMemberName(ctx, access); + if (member !== undefined) + recordMember(profile, member, inVariantBranch); + return true; +} +/** `typeof x === 'string'`: a primitive the value is tested against. */ +function recordTypeofTest({ ctx, reference }, profile) { + const typeOf = reference.parent; + if (!ctx.ts.isTypeOfExpression(typeOf)) + return false; + pushDefined(profile.guardTypes, primitiveOfTypeofCheck(ctx, typeOf)); + return true; +} +/** `x instanceof C`: a class the value is tested against. */ +function recordInstanceofTest({ ctx, reference }, profile) { + const test = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.InstanceOfKeyword); + if (test) + pushDefined(profile.guardTypes, instanceTypeOf(ctx, test.right)); + return test !== undefined; +} +/** `x = value`: a value the variable is reassigned. */ +function recordAssignment({ ctx, reference }, profile) { + const assignment = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.EqualsToken); + if (assignment) + profile.assignedValues.push(assignment.right); + return assignment !== undefined; +} /** - * Records what one reference to the profiled value says about it. - * `inVariantBranch` marks a reference inside a branch only some variants of - * the value reach (see {@link variantBranches}). A reference the checker - * narrowed (inside `if (typeof x === 'string')`) is used as what that one - * branch holds, which says nothing about every value. + * Any other use: the type the code uses the value as. A reference the + * checker narrowed (inside `if (typeof x === 'string')`) is used as what that + * one branch holds, which says nothing about every value. */ -function recordReference(ctx, target, reference, inVariantBranch, profile) { - const { ts, checker } = ctx; - const parent = reference.parent; - const tested = presenceTestedMember(ctx, reference); - if (tested !== undefined) { - recordMember(profile, tested, true); - return; - } - if (ts.isPropertyAccessExpression(parent) && parent.expression === reference) { - if (!isTriggerAccess(ctx, parent)) - recordMember(profile, parent.name.text, inVariantBranch); - return; - } - if (ts.isElementAccessExpression(parent) && parent.expression === reference) { - const member = literalText(ctx, parent.argumentExpression); - if (member !== undefined) - recordMember(profile, member, inVariantBranch); - return; +function recordContextualUse({ ctx, target, reference }, profile) { + if (ctx.checker.getTypeAtLocation(reference) === target.declaredType) { + pushDefined(profile.contextualTypes, usedAsType(ctx, reference)); } - if (ts.isTypeOfExpression(parent)) { - const primitive = primitiveOfTypeofCheck(ctx, parent); - if (primitive) - profile.guardTypes.push(primitive); - return; - } - if (ts.isBinaryExpression(parent)) { - const operator = parent.operatorToken.kind; - if (operator === ts.SyntaxKind.InstanceOfKeyword && parent.left === reference) { - const instance = instanceTypeOf(ctx, parent.right); - if (instance) - profile.guardTypes.push(instance); - return; - } - if (operator === ts.SyntaxKind.EqualsToken && parent.left === reference) { - profile.assignedValues.push(parent.right); - return; - } - } - if (checker.getTypeAtLocation(reference) !== target.declaredType) - return; - const used = usedAsType(ctx, reference); - if (used) - profile.contextualTypes.push(used); + return true; +} +const REFERENCE_RECORDERS = [ + recordPresenceTest, + recordMemberAccess, + recordTypeofTest, + recordInstanceofTest, + recordAssignment, + recordContextualUse, +]; +/** Records what one reference to the profiled value says about it: the first role it plays wins. */ +function recordReference(use, profile) { + REFERENCE_RECORDERS.some((record) => record(use, profile)); } function collectProfile(ctx, symbol, declarationName, scope) { const declaredType = ctx.checker.getTypeOfSymbolAtLocation(symbol, declarationName); @@ -246,7 +295,7 @@ function collectProfile(ctx, symbol, declarationName, scope) { const profile = emptyProfile(); const visit = (node, inVariantBranch) => { if (isReferenceTo(ctx, target, node)) - recordReference(ctx, target, node, inVariantBranch, profile); + recordReference({ ctx, target, reference: node, inVariantBranch }, profile); const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); }; diff --git a/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js index cbde0d0a1..bbfc390a4 100644 --- a/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js +++ b/packages/b2c-script-types/plugin/resolver/cartridge-discovery.js @@ -102,42 +102,28 @@ function readDwJsonCartridges(ts, projectRoot, fileExists) { } return undefined; } +/** The configured cartridges that exist, in configured order, then every other discovered one in discovery order. */ +function namedFirst(discovered, configured) { + const byName = new Map(discovered.map((c) => [c.name, c])); + const named = [...new Set(configured)].flatMap((name) => byName.get(name) ?? []); + const namedNames = new Set(named.map((c) => c.name)); + return [...named, ...discovered.filter((c) => !namedNames.has(c.name))]; +} +/** Discovery order with the known base cartridges moved last (the sort is stable). */ +function baseCartridgesLast(discovered) { + // hasOwn guard so a cartridge directory literally named `__proto__` or + // `constructor` can't read an inherited Object.prototype value here (which + // would make the rank a non-number and corrupt the sort comparator). + const rankOf = (name) => Object.prototype.hasOwnProperty.call(constants_1.BASE_CARTRIDGE_RANK, name) ? constants_1.BASE_CARTRIDGE_RANK[name] : 0; + return [...discovered].sort((a, b) => rankOf(a.name) - rankOf(b.name)); +} /** * Applies cartridge ordering: if `configured` is set, named-first then any * remaining discovered cartridges in their original order; otherwise * discovery order with the known base cartridges sorted last. */ function orderCartridges(discovered, configured) { - 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); + return configured && configured.length > 0 ? namedFirst(discovered, configured) : baseCartridgesLast(discovered); } /** * Finds the byte ranges of each `declare module 'X' { ... }` block in a .d.ts diff --git a/packages/b2c-script-types/plugin/resolver/module-resolution.js b/packages/b2c-script-types/plugin/resolver/module-resolution.js index 5a0c52cd8..cd165c2b5 100644 --- a/packages/b2c-script-types/plugin/resolver/module-resolution.js +++ b/packages/b2c-script-types/plugin/resolver/module-resolution.js @@ -10,7 +10,6 @@ var __importDefault = (this && this.__importDefault) || function (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, @@ -80,6 +79,45 @@ function reorderForContainingFile(cartridges, normalize, containingFile) { return cartridges; return [owner, ...cartridges.filter((c) => c !== owner)]; } +function cartridgeLookup(cartridges, moduleName, containingFile, normalize) { + if (moduleName.startsWith('~/')) { + // ~ is the current cartridge — restrict to the cartridge that owns the + // calling file. If the containing file isn't inside any known cartridge, + // there is no current cartridge, so the require can't be resolved. + const owner = ownerCartridge(cartridges, normalize, containingFile); + return owner ? { order: [owner], subpath: moduleName.slice(2) } : undefined; + } + if (moduleName.startsWith('*/')) { + // * walks the cartridge path. Owner-first matches SFRA-style overrides + // (the requesting cartridge wins before falling through to others). + return { order: reorderForContainingFile(cartridges, normalize, containingFile), subpath: moduleName.slice(2) }; + } + // /cartridge/... — only treat as a cartridge require if the + // first segment matches a known cartridge name. Otherwise pass through so + // node_modules and other resolutions still work. + const slash = moduleName.indexOf('/'); + if (slash <= 0) + return undefined; + const head = moduleName.slice(0, slash); + const known = cartridges.find((c) => c.name === head); + return known ? { order: [known], subpath: moduleName.slice(slash + 1) } : undefined; +} +/** + * The first `/` that exists and stays inside the + * cartridge. `subpath` comes straight from the import specifier, so a `..` + * segment (or an absolute/symlinked target) can point outside the cartridge — + * every candidate is resolved and contained before it is accepted. + */ +function firstContainedCandidate(cartridge, subpath, deps) { + const baseAbs = cartridge.rawRoot + subpath; + for (const ext of constants_1.CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, cartridge.root)) { + return { resolved: candidate, source: cartridge.name }; + } + } + return undefined; +} /** * Resolves a SFCC cartridge-style require relative to the configured * cartridge path. Returns the absolute path to the resolved JS file, or @@ -90,56 +128,42 @@ function reorderForContainingFile(cartridges, normalize, containingFile) { * 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) + const lookup = cartridges.length > 0 ? cartridgeLookup(cartridges, moduleName, containingFile, deps.normalize) : undefined; + if (!lookup?.subpath) return undefined; - for (const c of order) { - const baseAbs = c.rawRoot + subpath; - for (const ext of constants_1.CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - // `subpath` comes straight from the import specifier, so a `..` - // segment (or an absolute/symlinked target) can point outside the - // cartridge — resolve and contain before accepting it. - if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, c.root)) { - return { resolved: candidate, source: c.name }; - } - } + for (const cartridge of lookup.order) { + const found = firstContainedCandidate(cartridge, lookup.subpath, deps); + if (found) + return found; } return undefined; } +const NON_MODULES_PREFIXES = ['.', '/', '~/', '*/', 'dw/']; +/** True for a bare specifier (`server`, `server/middleware`) the `modules` cartridge may own. */ +function isModulesCartridgeSpecifier(moduleName) { + if (NON_MODULES_PREFIXES.some((prefix) => moduleName.startsWith(prefix))) + return false; + // Let the bundled SFRA ambient declarations win for these names. If we + // resolved them to the .js file here, TS would infer types from the JS + // (which misses dynamic property assignments in modules/server.js) and + // ignore the ambient `declare module 'server' { ... }` shape. + return !constants_1.SFRA_AMBIENT_MODULES.has(moduleName); +} +/** package.json `main` fallback for a `modules` directory without an index.js. */ +function packageMainCandidate(ts, modulesCart, moduleName, deps) { + const pkgPath = modulesCart.rawRoot + moduleName + '/package.json'; + if (!deps.fileExists(pkgPath) || !deps.isWithinRoot(pkgPath, modulesCart.root)) + return undefined; + const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; + if (typeof main !== 'string' || main.length === 0) + return undefined; + const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + return deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root) + ? { resolved, source: modulesCart.name } + : undefined; +} /** * Resolves a bare `require('server')`-style import against the SFRA `modules` * cartridge. Unlike normal cartridges (which expose files under @@ -147,44 +171,14 @@ function resolveCartridgeModule(cartridges, moduleName, containingFile, deps) { * at the root, so `require('server')` -> `/server[.js|/index.js]` * and `require('server/middleware')` -> `/server/middleware[.js]`. * Falls through unless a cartridge literally named `modules` is in the list. + * `moduleName` may carry `..` after its first segment (it only can't *start* + * with `.`/`/`), so every candidate is contained against the modules root. */ function resolveModulesCartridge(ts, cartridges, moduleName, deps) { - 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'); + const modulesCart = isModulesCartridgeSpecifier(moduleName) + ? cartridges.find((c) => c.name === 'modules') + : undefined; if (!modulesCart) return undefined; - const baseAbs = modulesCart.rawRoot + moduleName; - for (const ext of constants_1.CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - // `moduleName` may carry `..` after its first segment (it only can't - // *start* with `.`/`/`); contain it against the modules root. - if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, modulesCart.root)) { - return { resolved: candidate, source: modulesCart.name }; - } - } - // package.json `main` fallback for directories without an index.js. - const pkgPath = baseAbs + '/package.json'; - if (deps.fileExists(pkgPath) && deps.isWithinRoot(pkgPath, modulesCart.root)) { - const main = (0, cartridge_discovery_1.readJsonFile)(ts, pkgPath)?.main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - // `main` is attacker-controlled JSON content flowing into a path - // join — a `../../..` or absolute value must not escape the root. - if (deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root)) { - return { resolved, source: modulesCart.name }; - } - } - } - return undefined; + return (firstContainedCandidate(modulesCart, moduleName, deps) ?? packageMainCandidate(ts, modulesCart, moduleName, deps)); } diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 400fd17f7..6091d6fb2 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -137,6 +137,38 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { const ownerCartridge = (containingFile: string): NormalizedCartridge | undefined => ownerCartridgeImpl(cartridges, normalize, containingFile); + // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. + // Neovim with typescript-language-server). Walks the project root for + // `.project` markers and honors dw.json's `cartridges` field for ordering. + const needsAutoDiscovery = (): boolean => + enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0; + const autoDiscoverCartridges = (projectRoot: string, log: (msg: string) => void): void => { + if (!projectRoot) return; + try { + const discovered = discoverCartridgesOnDisk(ts, projectRoot, fileExists); + const configured = readDwJsonCartridges(ts, projectRoot, fileExists); + setCartridges(orderCartridges(discovered, configured)); + log( + `auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + + (configured ? ` (ordered by dw.json cartridges)` : ''), + ); + } catch (e) { + log(`auto-discovery failed: ${(e as Error).message}`); + } + }; + + // The ambient declarations a project with cartridge files needs and its + // file list doesn't already include (dedup by normalized path): + // - global.d.ts: SFCC platform globals (session, request, response, + // customer, empty(), the ambient `dw` namespace). + // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, + // middleware, etc.) — only when a `modules` cartridge is configured. + const missingAmbientDeclarations = (list: readonly string[]): string[] => { + const present = new Set(list.map((f) => normalize(f))); + const wanted = cartridges.some((c) => c.name === 'modules') ? [GLOBAL_DTS, SFRA_SERVER_DTS] : [GLOBAL_DTS]; + return wanted.filter((dts) => fileExists(dts) && !present.has(normalize(dts))); + }; + // Cached map of byte ranges in types/sfra/server.d.ts to the SFRA module // declared by their enclosing `declare module 'X' { ... }` block. Used to // map go-to-definition results back to the matching modules/.js file. @@ -157,26 +189,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { applyConfig(info.config); - // Fallback for hosts that don't push cartridges (plain LSP usage, e.g. - // Neovim with typescript-language-server). Walks the project root for - // `.project` markers and honors dw.json's `cartridges` field for ordering. - if (enabled && autoDiscoverEnabled && !cartridgesFromHost && cartridges.length === 0) { - const projectRoot = info.project.getCurrentDirectory(); - if (projectRoot) { - try { - const discovered = discoverCartridgesOnDisk(ts, projectRoot, fileExists); - const configured = readDwJsonCartridges(ts, projectRoot, fileExists); - const ordered = orderCartridges(discovered, configured); - setCartridges(ordered); - log( - `auto-discovered ${cartridges.length} cartridge(s) from ${projectRoot}` + - (configured ? ` (ordered by dw.json cartridges)` : ''), - ); - } catch (e) { - log(`auto-discovery failed: ${(e as Error).message}`); - } - } - } + if (needsAutoDiscovery()) autoDiscoverCartridges(info.project.getCurrentDirectory(), log); const host = info.languageServiceHost; @@ -230,28 +243,15 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { 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, - // customer, empty(), the ambient `dw` namespace). - // - sfra/server.d.ts: SFRA `modules` cartridge typings (server, route, - // middleware, etc.) — only injected when a `modules` cartridge is - // configured. Configured projects that already include either via a - // jsconfig include glob are unaffected (dedup by normalized path). + // Inject the ambient declarations into the TS program when the project + // contains at least one cartridge file (isCartridgeFile is false while the + // plugin is disabled or no cartridge is known). Projects that already + // include them via a jsconfig include glob are unaffected. const origGetScriptFileNames = host.getScriptFileNames.bind(host); host.getScriptFileNames = () => { const list = origGetScriptFileNames(); - if (!enabled || cartridges.length === 0) return list; if (!list.some((f) => isCartridgeFile(f))) return list; - const additions: string[] = []; - const present = new Set(list.map((f) => normalize(f))); - if (fileExists(GLOBAL_DTS) && !present.has(normalize(GLOBAL_DTS))) { - additions.push(GLOBAL_DTS); - } - const hasModules = cartridges.some((c) => c.name === 'modules'); - if (hasModules && fileExists(SFRA_SERVER_DTS) && !present.has(normalize(SFRA_SERVER_DTS))) { - additions.push(SFRA_SERVER_DTS); - } + const additions = missingAmbientDeclarations(list); return additions.length > 0 ? [...list, ...additions] : list; }; @@ -270,23 +270,13 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { if (dw && fileExists(dw)) { return {resolvedFileName: dw, extension: ts.Extension.Dts, isExternalLibraryImport: true}; } - const cart = resolveCartridgeModuleOnHost(text, containingFile); - if (cart) { - return { - resolvedFileName: cart.resolved, - extension: cart.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - const mod = resolveModulesCartridgeOnHost(text); - if (mod) { - return { - resolvedFileName: mod.resolved, - extension: mod.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, - isExternalLibraryImport: false, - }; - } - return undefined; + const local = resolveCartridgeModuleOnHost(text, containingFile) ?? resolveModulesCartridgeOnHost(text); + if (!local) return undefined; + return { + resolvedFileName: local.resolved, + extension: local.resolved.endsWith('.json') ? ts.Extension.Json : ts.Extension.Js, + isExternalLibraryImport: false, + }; }; // TS derives a file's ScriptKind from its extension and falls back to TS diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 0fd1b8cea..4d3fe5b4f 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -59,33 +59,43 @@ function findReferences(ctx: InferenceContext, name: tsserver.Identifier): reado return references; } +/** The name a function declaration or method declares itself by. */ +function ownName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Identifier | undefined { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; +} + +/** + * The name `fn` is assigned to: `exports.foo = function(){}` or + * `module.exports = function(){}`. The `.name` identifier (`foo` or + * `exports`) is what findReferences can actually track; for the bare + * `module.exports` case this resolves to the whole module's value, so callers + * reach it through collectCallSites()'s require() indirection rather than a + * direct property-access call. + */ +function assignedName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Identifier | undefined { + const assignment = fn.parent; + if (!ts.isBinaryExpression(assignment) || assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + const {left} = assignment; + if (ts.isPropertyAccessExpression(left)) return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : 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(){}`). + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). */ 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; + const boundName = ts.isExpression(fn) ? bindingNameOf(fn, ts) : undefined; + return ownName(fn, ts) ?? boundName ?? assignedName(fn, ts); } /** The function enclosing `node`, if any. */ @@ -113,6 +123,29 @@ function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tss return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; } +/** What a reference invokes when called: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function calleeOf(node: tsserver.Node, ts: typeof tsserver): tsserver.Node { + const {parent} = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} + +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? {node: call, args: call.arguments ?? []} : undefined; +} + +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) return undefined; + if (access.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; + return access.name.text === 'apply' ? {node: call, args: applyArguments(call, ts)} : undefined; +} + /** * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it @@ -120,19 +153,8 @@ function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tss * (`obj.helper`) is treated as the whole access. */ function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): CallSite | undefined { - const callee = - node.parent && ts.isPropertyAccessExpression(node.parent) && node.parent.name === node ? node.parent : node; - const parent = callee.parent; - if (!parent) return undefined; - if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.expression === callee) { - return {node: parent, args: parent.arguments ?? []}; - } - if (!ts.isPropertyAccessExpression(parent) || parent.expression !== callee) return undefined; - const call = parent.parent; - if (!call || !ts.isCallExpression(call) || call.expression !== parent) return undefined; - if (parent.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; - if (parent.name.text === 'apply') return {node: call, args: applyArguments(call, ts)}; - return undefined; + const callee = calleeOf(node, ts); + return directCall(callee, ts) ?? borrowedCall(callee, ts); } /** @@ -161,6 +183,15 @@ function bindingNameOf(expression: tsserver.Expression, ts: typeof tsserver): ts return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; } +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} + +/** Where a reference leads: a call site, or a further name whose references lead on. */ +type ReferenceTarget = {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier}; + /** * 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 @@ -181,24 +212,17 @@ function bindingNameOf(expression: tsserver.Expression, ts: typeof tsserver): ts * @returns Either the further name to search references for, or — for an * immediately-invoked require (`require('./helper')(x)`) — the call site itself. */ -function resolveIndirectReferenceTarget( - node: tsserver.Node, - ts: typeof tsserver, -): {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier} | undefined { - const parent = node.parent; - if (!parent) return undefined; +function resolveIndirectReferenceTarget(node: tsserver.Node, ts: typeof tsserver): ReferenceTarget | undefined { + const {parent} = node; 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}; } - const isRequireSpecifier = - ts.isCallExpression(parent) && parent.arguments[0] === node && isRequireCallExpression(parent, ts); - const value = isRequireSpecifier ? parent : node; - const invocation = value.parent; - if (isRequireSpecifier && invocation && ts.isCallExpression(invocation) && invocation.expression === value) { - return {kind: 'call', call: {node: invocation, args: invocation.arguments}}; // require('./helper')(x) - } + const required = requireCallOf(node, ts); + const invocation = required && directCall(required, ts); + if (invocation) return {kind: 'call', call: invocation}; // require('./helper')(x) + const value = required ?? node; const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; return name && {kind: 'name', name}; } @@ -253,26 +277,29 @@ function collectCallsFromName( nextFrontier: tsserver.Identifier[], localBudget: number, ): number { - const {ts, program} = ctx; ctx.searchBudget--; - for (const ref of findReferences(ctx, name)) { + for (const reference of findReferences(ctx, name)) { 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); + const target = referenceTarget(ctx, reference); + if (target?.kind === 'call') calls.push(target.call); + else if (target?.kind === 'name') nextFrontier.push(target.name); } return localBudget; } + +/** + * Where one reference search hit leads. Definition sites (the declaration + * itself) never sit in callee position, so they lead nowhere new; neither + * does a hit on a whole module (the source file itself, which has no parent + * — e.g. a `module.exports` reference landing in a file's leading comment). + */ +function referenceTarget(ctx: InferenceContext, reference: tsserver.ReferenceEntry): ReferenceTarget | undefined { + const {ts, program} = ctx; + const file = program.getSourceFile(reference.fileName); + const node = file && getNodeAtPosition(file, ts, reference.textSpan.start); + if (!node?.parent) return undefined; + const call = findCallInCalleePosition(node, ts); + return call ? {kind: 'call', call} : resolveIndirectReferenceTarget(node, ts); +} diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index cf2b22eeb..65739a073 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -222,17 +222,27 @@ function resolveExpressionTypes( if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, depth, chainHops); const direct = informativeParts(ctx, checker.getTypeAtLocation(expr)); if (direct.length > 0) return direct; - if (chainHops >= MAX_CHAIN_HOPS) return []; + return chainHops < MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; +} + +/** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ +function resolveFromParts( + ctx: InferenceContext, + expr: tsserver.Expression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; if (ts.isCallExpression(expr)) return resolveCallResultTypes(ctx, expr, depth, chainHops); if (ts.isPropertyAccessExpression(expr)) return resolvePropertyTypes(ctx, expr, depth, chainHops); if (ts.isIdentifier(expr)) return resolveIdentifierTypes(ctx, expr, depth, chainHops); if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) // and `a || b` / `a ?? b` can each evaluate to either side. - if (ts.isConditionalExpression(expr)) + if (ts.isConditionalExpression(expr)) { return resolveAlternatives(ctx, [expr.whenTrue, expr.whenFalse], depth, chainHops); - if (ts.isBinaryExpression(expr)) return resolveLogicalTypes(ctx, expr, depth, chainHops); - return []; + } + return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; } function resolveAlternatives( diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts index 3036ae713..9ba76193e 100644 --- a/packages/b2c-script-types/src/inference/usage-profile.ts +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -95,24 +95,34 @@ function literalText(ctx: InferenceContext, node: tsserver.Node | undefined): st return node && ctx.ts.isStringLiteralLike(node) ? node.text : undefined; } -/** - * The member a presence test on `reference` checks for: `'m' in x`, - * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. - */ -function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { +/** `'m' in x`: the member an `in` test on `reference` names. */ +function inOperatorMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { const {ts} = ctx; - const parent = reference.parent; - if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.InKeyword) { - return parent.right === reference ? literalText(ctx, parent.left) : undefined; - } - const isOwnReceiver = - ts.isPropertyAccessExpression(parent) && parent.expression === reference && parent.name.text === 'hasOwnProperty'; - const call = isOwnReceiver ? parent.parent : parent; - if (!ts.isCallExpression(call)) return undefined; - if (isOwnReceiver) return call.expression === parent ? literalText(ctx, call.arguments[0]) : undefined; + const test = reference.parent; + const isInTest = + ts.isBinaryExpression(test) && test.operatorToken.kind === ts.SyntaxKind.InKeyword && test.right === reference; + return isInTest ? literalText(ctx, test.left) : undefined; +} + +/** `x.hasOwnProperty('m')`: the member an own-property method call on `reference` names. */ +function ownPropertyMethodMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { + const {ts} = ctx; + const access = reference.parent; + const isHasOwn = + ts.isPropertyAccessExpression(access) && access.expression === reference && access.name.text === 'hasOwnProperty'; + const call = access.parent; + return isHasOwn && ts.isCallExpression(call) && call.expression === access + ? literalText(ctx, call.arguments[0]) + : undefined; +} + +/** `Object[.prototype].hasOwnProperty.call(x, 'm')`: the member a borrowed own-property call on `reference` names. */ +function ownPropertyCallMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { + const {ts} = ctx; + const call = reference.parent; + if (!ts.isCallExpression(call) || call.arguments[0] !== reference) return undefined; const callee = call.expression; const isHasOwnCall = - call.arguments[0] === reference && ts.isPropertyAccessExpression(callee) && callee.name.text === 'call' && ts.isPropertyAccessExpression(callee.expression) && @@ -120,6 +130,16 @@ function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Identif return isHasOwnCall ? literalText(ctx, call.arguments[1]) : undefined; } +/** + * The member a presence test on `reference` checks for: `'m' in x`, + * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. + */ +function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { + return ( + inOperatorMember(ctx, reference) ?? ownPropertyMethodMember(ctx, reference) ?? ownPropertyCallMember(ctx, reference) + ); +} + /** * True when `access` is the member access the current request's own cursor * sits in (see {@link InferenceContext.triggerPosition}): a member name being @@ -156,28 +176,38 @@ function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver.Signatu } /** - * The type `reference` is used as. An argument to an overloaded function - * counts only when every overload taking that many arguments agrees on it + * The type argument `index` of `call` is passed as. For an overloaded callee + * it counts only when every overload taking that many arguments agrees on it * (`parseInt(s)` / `parseInt(s, radix)`): for an argument of no known type * the checker settles on the first such overload, which says nothing about * the one the code means (`variationModel.getAllValues(attribute)` would * make `attribute` an `ObjectAttributeDefinition`). */ -function usedAsType(ctx: InferenceContext, reference: tsserver.Identifier): tsserver.Type | undefined { +function argumentType( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, + reference: tsserver.Identifier, + index: number, +): tsserver.Type | undefined { const {ts, checker} = ctx; - const call = reference.parent; - const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); - const args: readonly tsserver.Expression[] = (isCall && call.arguments) || []; - const index = args.indexOf(reference); - if (!isCall || index < 0) return checker.getContextualType(reference); + const argumentCount = call.arguments?.length ?? 0; const callee = checker.getTypeAtLocation(call.expression); const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); - const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, args.length)); + const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, argumentCount)); if (overloads.length <= 1) return checker.getContextualType(reference); const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); return first && rest.every((type) => type === first) ? first : undefined; } +/** The type `reference` is used as: what its context expects, or the argument type of the call it is passed to. */ +function usedAsType(ctx: InferenceContext, reference: tsserver.Identifier): tsserver.Type | undefined { + const {ts, checker} = ctx; + const call = reference.parent; + const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); + const index = isCall ? (call.arguments?.indexOf(reference) ?? -1) : -1; + return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); +} + /** The value a profile describes: its symbol, the name it is declared by, and its own (unnarrowed) type. */ interface ProfileTarget { readonly symbol: tsserver.Symbol; @@ -226,6 +256,20 @@ function statementsAfterPresenceExit( return guard < 0 ? [] : statements.slice(guard + 1); } +/** What a branching `node` tests and the branches it picks between, for `if`, `?:` and `&&`. */ +function branchingOf( + ts: typeof tsserver, + node: tsserver.Node, +): {condition: tsserver.Node; branches: tsserver.Node[]} | undefined { + if (ts.isIfStatement(node)) { + const branches = node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; + return {condition: node.expression, branches}; + } + if (ts.isConditionalExpression(node)) return {condition: node.condition, branches: [node.whenTrue, node.whenFalse]}; + const isAnd = ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; + return isAnd ? {condition: node.left, branches: [node.right]} : undefined; +} + /** * The branches of `node` that only run for some variants of the value: both * branches of an `if`/`?:` that tests it for a member first (an early @@ -236,18 +280,9 @@ function statementsAfterPresenceExit( */ function variantBranches(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): readonly tsserver.Node[] { const {ts} = ctx; - if (ts.isIfStatement(node)) { - if (!testsPresence(ctx, target, node.expression)) return []; - return node.elseStatement ? [node.thenStatement, node.elseStatement] : [node.thenStatement]; - } - if (ts.isConditionalExpression(node)) { - return testsPresence(ctx, target, node.condition) ? [node.whenTrue, node.whenFalse] : []; - } - if (ts.isBinaryExpression(node)) { - const isGuardedAnd = node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken; - return isGuardedAnd && testsPresence(ctx, target, node.left) ? [node.right] : []; - } - return ts.isBlock(node) || ts.isSourceFile(node) ? statementsAfterPresenceExit(ctx, target, node.statements) : []; + if (ts.isBlock(node) || ts.isSourceFile(node)) return statementsAfterPresenceExit(ctx, target, node.statements); + const branching = branchingOf(ts, node); + return branching && testsPresence(ctx, target, branching.condition) ? branching.branches : []; } /** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ @@ -256,56 +291,105 @@ function recordMember(profile: ProfileBuilder, name: string, optional: boolean): if (optional) profile.optionalMemberNames.add(name); } -/** - * Records what one reference to the profiled value says about it. - * `inVariantBranch` marks a reference inside a branch only some variants of - * the value reach (see {@link variantBranches}). A reference the checker - * narrowed (inside `if (typeof x === 'string')`) is used as what that one - * branch holds, which says nothing about every value. - */ -function recordReference( +function pushDefined(list: T[], value: T | undefined): void { + if (value !== undefined) list.push(value); +} + +/** One reference to the profiled value, and whether only some variants of the value reach it (see {@link variantBranches}). */ +interface ReferenceUse { + readonly ctx: InferenceContext; + readonly target: ProfileTarget; + readonly reference: tsserver.Identifier; + readonly inVariantBranch: boolean; +} + +/** The binary expression `reference` is the left operand of, when its operator is `operator`. */ +function leftOperandOf( ctx: InferenceContext, - target: ProfileTarget, reference: tsserver.Identifier, - inVariantBranch: boolean, - profile: ProfileBuilder, -): void { - const {ts, checker} = ctx; + operator: tsserver.SyntaxKind, +): tsserver.BinaryExpression | undefined { const parent = reference.parent; - const tested = presenceTestedMember(ctx, reference); - if (tested !== undefined) { - recordMember(profile, tested, true); - return; - } - if (ts.isPropertyAccessExpression(parent) && parent.expression === reference) { - if (!isTriggerAccess(ctx, parent)) recordMember(profile, parent.name.text, inVariantBranch); - return; - } - if (ts.isElementAccessExpression(parent) && parent.expression === reference) { - const member = literalText(ctx, parent.argumentExpression); - if (member !== undefined) recordMember(profile, member, inVariantBranch); - return; - } - if (ts.isTypeOfExpression(parent)) { - const primitive = primitiveOfTypeofCheck(ctx, parent); - if (primitive) profile.guardTypes.push(primitive); - return; - } - if (ts.isBinaryExpression(parent)) { - const operator = parent.operatorToken.kind; - if (operator === ts.SyntaxKind.InstanceOfKeyword && parent.left === reference) { - const instance = instanceTypeOf(ctx, parent.right); - if (instance) profile.guardTypes.push(instance); - return; - } - if (operator === ts.SyntaxKind.EqualsToken && parent.left === reference) { - profile.assignedValues.push(parent.right); - return; - } + const matches = ctx.ts.isBinaryExpression(parent) && parent.left === reference; + return matches && parent.operatorToken.kind === operator ? parent : undefined; +} + +/** The member name a `x.m` / `x['m']` access reads, except the one the cursor is still typing. */ +function accessedMemberName( + ctx: InferenceContext, + access: tsserver.PropertyAccessExpression | tsserver.ElementAccessExpression, +): string | undefined { + if (!ctx.ts.isPropertyAccessExpression(access)) return literalText(ctx, access.argumentExpression); + return isTriggerAccess(ctx, access) ? undefined : access.name.text; +} + +// Each recorder recognizes one role a reference can play and records it, +// returning true when the reference played that role. + +/** `'m' in x`, `x.hasOwnProperty('m')`, ...: a member only some variants of the value have. */ +function recordPresenceTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const member = presenceTestedMember(ctx, reference); + if (member !== undefined) recordMember(profile, member, true); + return member !== undefined; +} + +/** `x.m` / `x['m']`: a member the value is accessed by. */ +function recordMemberAccess({ctx, reference, inVariantBranch}: ReferenceUse, profile: ProfileBuilder): boolean { + const {ts} = ctx; + const access = reference.parent; + const isAccess = ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access); + if (!isAccess || access.expression !== reference) return false; + const member = accessedMemberName(ctx, access); + if (member !== undefined) recordMember(profile, member, inVariantBranch); + return true; +} + +/** `typeof x === 'string'`: a primitive the value is tested against. */ +function recordTypeofTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const typeOf = reference.parent; + if (!ctx.ts.isTypeOfExpression(typeOf)) return false; + pushDefined(profile.guardTypes, primitiveOfTypeofCheck(ctx, typeOf)); + return true; +} + +/** `x instanceof C`: a class the value is tested against. */ +function recordInstanceofTest({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const test = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.InstanceOfKeyword); + if (test) pushDefined(profile.guardTypes, instanceTypeOf(ctx, test.right)); + return test !== undefined; +} + +/** `x = value`: a value the variable is reassigned. */ +function recordAssignment({ctx, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + const assignment = leftOperandOf(ctx, reference, ctx.ts.SyntaxKind.EqualsToken); + if (assignment) profile.assignedValues.push(assignment.right); + return assignment !== undefined; +} + +/** + * Any other use: the type the code uses the value as. A reference the + * checker narrowed (inside `if (typeof x === 'string')`) is used as what that + * one branch holds, which says nothing about every value. + */ +function recordContextualUse({ctx, target, reference}: ReferenceUse, profile: ProfileBuilder): boolean { + if (ctx.checker.getTypeAtLocation(reference) === target.declaredType) { + pushDefined(profile.contextualTypes, usedAsType(ctx, reference)); } - if (checker.getTypeAtLocation(reference) !== target.declaredType) return; - const used = usedAsType(ctx, reference); - if (used) profile.contextualTypes.push(used); + return true; +} + +const REFERENCE_RECORDERS = [ + recordPresenceTest, + recordMemberAccess, + recordTypeofTest, + recordInstanceofTest, + recordAssignment, + recordContextualUse, +]; + +/** Records what one reference to the profiled value says about it: the first role it plays wins. */ +function recordReference(use: ReferenceUse, profile: ProfileBuilder): void { + REFERENCE_RECORDERS.some((record) => record(use, profile)); } function collectProfile( @@ -318,7 +402,7 @@ function collectProfile( const target: ProfileTarget = {symbol, declarationName, declaredType}; const profile = emptyProfile(); const visit = (node: tsserver.Node, inVariantBranch: boolean): void => { - if (isReferenceTo(ctx, target, node)) recordReference(ctx, target, node, inVariantBranch, profile); + if (isReferenceTo(ctx, target, node)) recordReference({ctx, target, reference: node, inVariantBranch}, profile); const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); }; diff --git a/packages/b2c-script-types/src/resolver/cartridge-discovery.ts b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts index e0c0e91f7..badb1abfc 100644 --- a/packages/b2c-script-types/src/resolver/cartridge-discovery.ts +++ b/packages/b2c-script-types/src/resolver/cartridge-discovery.ts @@ -103,6 +103,24 @@ export function readDwJsonCartridges( return undefined; } +/** The configured cartridges that exist, in configured order, then every other discovered one in discovery order. */ +function namedFirst(discovered: ConfiguredCartridge[], configured: string[]): ConfiguredCartridge[] { + const byName = new Map(discovered.map((c) => [c.name, c])); + const named = [...new Set(configured)].flatMap((name) => byName.get(name) ?? []); + const namedNames = new Set(named.map((c) => c.name)); + return [...named, ...discovered.filter((c) => !namedNames.has(c.name))]; +} + +/** Discovery order with the known base cartridges moved last (the sort is stable). */ +function baseCartridgesLast(discovered: ConfiguredCartridge[]): ConfiguredCartridge[] { + // hasOwn guard so a cartridge directory literally named `__proto__` or + // `constructor` can't read an inherited Object.prototype value here (which + // would make the rank a non-number and corrupt the sort comparator). + const rankOf = (name: string): number => + Object.prototype.hasOwnProperty.call(BASE_CARTRIDGE_RANK, name) ? BASE_CARTRIDGE_RANK[name] : 0; + return [...discovered].sort((a, b) => rankOf(a.name) - rankOf(b.name)); +} + /** * Applies cartridge ordering: if `configured` is set, named-first then any * remaining discovered cartridges in their original order; otherwise @@ -112,35 +130,7 @@ 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); + return configured && configured.length > 0 ? namedFirst(discovered, configured) : baseCartridgesLast(discovered); } /** diff --git a/packages/b2c-script-types/src/resolver/module-resolution.ts b/packages/b2c-script-types/src/resolver/module-resolution.ts index 0efa3b420..1ca3571aa 100644 --- a/packages/b2c-script-types/src/resolver/module-resolution.ts +++ b/packages/b2c-script-types/src/resolver/module-resolution.ts @@ -83,7 +83,7 @@ export function ownerCartridge( return cartridges.find((c) => f.startsWith(c.root)); } -export function reorderForContainingFile( +function reorderForContainingFile( cartridges: NormalizedCartridge[], normalize: (p: string) => string, containingFile: string, @@ -99,6 +99,63 @@ export interface ModuleResolutionDeps { fileExists: (p: string) => boolean; } +type ResolvedModule = {resolved: string; source: string}; + +/** Which cartridges a cartridge-style specifier may resolve in (in order), and the path inside them. */ +interface CartridgeLookup { + order: NormalizedCartridge[]; + subpath: string; +} + +function cartridgeLookup( + cartridges: NormalizedCartridge[], + moduleName: string, + containingFile: string, + normalize: (p: string) => string, +): CartridgeLookup | undefined { + if (moduleName.startsWith('~/')) { + // ~ is the current cartridge — restrict to the cartridge that owns the + // calling file. If the containing file isn't inside any known cartridge, + // there is no current cartridge, so the require can't be resolved. + const owner = ownerCartridge(cartridges, normalize, containingFile); + return owner ? {order: [owner], subpath: moduleName.slice(2)} : undefined; + } + if (moduleName.startsWith('*/')) { + // * walks the cartridge path. Owner-first matches SFRA-style overrides + // (the requesting cartridge wins before falling through to others). + return {order: reorderForContainingFile(cartridges, normalize, containingFile), subpath: moduleName.slice(2)}; + } + // /cartridge/... — only treat as a cartridge require if the + // first segment matches a known cartridge name. Otherwise pass through so + // node_modules and other resolutions still work. + const slash = moduleName.indexOf('/'); + if (slash <= 0) return undefined; + const head = moduleName.slice(0, slash); + const known = cartridges.find((c) => c.name === head); + return known ? {order: [known], subpath: moduleName.slice(slash + 1)} : undefined; +} + +/** + * The first `/` that exists and stays inside the + * cartridge. `subpath` comes straight from the import specifier, so a `..` + * segment (or an absolute/symlinked target) can point outside the cartridge — + * every candidate is resolved and contained before it is accepted. + */ +function firstContainedCandidate( + cartridge: NormalizedCartridge, + subpath: string, + deps: Pick, +): ResolvedModule | undefined { + const baseAbs = cartridge.rawRoot + subpath; + for (const ext of CANDIDATE_EXTENSIONS) { + const candidate = baseAbs + ext; + if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, cartridge.root)) { + return {resolved: candidate, source: cartridge.name}; + } + } + return undefined; +} + /** * Resolves a SFCC cartridge-style require relative to the configured * cartridge path. Returns the absolute path to the resolved JS file, or @@ -113,53 +170,46 @@ export function resolveCartridgeModule( moduleName: string, containingFile: string, deps: ModuleResolutionDeps, -): {resolved: string; source: string} | undefined { - if (cartridges.length === 0) return undefined; +): ResolvedModule | undefined { + const lookup = + cartridges.length > 0 ? cartridgeLookup(cartridges, moduleName, containingFile, deps.normalize) : undefined; + if (!lookup?.subpath) return undefined; + for (const cartridge of lookup.order) { + const found = firstContainedCandidate(cartridge, lookup.subpath, deps); + if (found) return found; + } + return undefined; +} - let subpath: string | undefined; - let order: NormalizedCartridge[] | undefined; +const NON_MODULES_PREFIXES = ['.', '/', '~/', '*/', 'dw/']; - 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]; - } +/** True for a bare specifier (`server`, `server/middleware`) the `modules` cartridge may own. */ +function isModulesCartridgeSpecifier(moduleName: string): boolean { + if (NON_MODULES_PREFIXES.some((prefix) => moduleName.startsWith(prefix))) return false; + // Let the bundled SFRA ambient declarations win for these names. If we + // resolved them to the .js file here, TS would infer types from the JS + // (which misses dynamic property assignments in modules/server.js) and + // ignore the ambient `declare module 'server' { ... }` shape. + return !SFRA_AMBIENT_MODULES.has(moduleName); +} - if (!subpath) return undefined; - - for (const c of order) { - const baseAbs = c.rawRoot + subpath; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - // `subpath` comes straight from the import specifier, so a `..` - // segment (or an absolute/symlinked target) can point outside the - // cartridge — resolve and contain before accepting it. - if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, c.root)) { - return {resolved: candidate, source: c.name}; - } - } - } - return undefined; +/** package.json `main` fallback for a `modules` directory without an index.js. */ +function packageMainCandidate( + ts: typeof tsserver, + modulesCart: NormalizedCartridge, + moduleName: string, + deps: Pick, +): ResolvedModule | undefined { + const pkgPath = modulesCart.rawRoot + moduleName + '/package.json'; + if (!deps.fileExists(pkgPath) || !deps.isWithinRoot(pkgPath, modulesCart.root)) return undefined; + const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; + if (typeof main !== 'string' || main.length === 0) return undefined; + const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); + // `main` is attacker-controlled JSON content flowing into a path + // join — a `../../..` or absolute value must not escape the root. + return deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root) + ? {resolved, source: modulesCart.name} + : undefined; } /** @@ -169,46 +219,20 @@ export function resolveCartridgeModule( * at the root, so `require('server')` -> `/server[.js|/index.js]` * and `require('server/middleware')` -> `/server/middleware[.js]`. * Falls through unless a cartridge literally named `modules` is in the list. + * `moduleName` may carry `..` after its first segment (it only can't *start* + * with `.`/`/`), so every candidate is contained against the modules root. */ export function resolveModulesCartridge( ts: typeof tsserver, cartridges: NormalizedCartridge[], moduleName: string, deps: Pick, -): {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'); +): ResolvedModule | undefined { + const modulesCart = isModulesCartridgeSpecifier(moduleName) + ? cartridges.find((c) => c.name === 'modules') + : undefined; if (!modulesCart) return undefined; - - const baseAbs = modulesCart.rawRoot + moduleName; - for (const ext of CANDIDATE_EXTENSIONS) { - const candidate = baseAbs + ext; - // `moduleName` may carry `..` after its first segment (it only can't - // *start* with `.`/`/`); contain it against the modules root. - if (deps.fileExists(candidate) && deps.isWithinRoot(candidate, modulesCart.root)) { - return {resolved: candidate, source: modulesCart.name}; - } - } - - // package.json `main` fallback for directories without an index.js. - const pkgPath = baseAbs + '/package.json'; - if (deps.fileExists(pkgPath) && deps.isWithinRoot(pkgPath, modulesCart.root)) { - const main = (readJsonFile(ts, pkgPath) as {main?: string} | undefined)?.main; - if (typeof main === 'string' && main.length > 0) { - const resolved = (modulesCart.rawRoot + moduleName + '/' + main.replace(/^\.\//, '')).replace(/\\/g, '/'); - // `main` is attacker-controlled JSON content flowing into a path - // join — a `../../..` or absolute value must not escape the root. - if (deps.fileExists(resolved) && deps.isWithinRoot(resolved, modulesCart.root)) { - return {resolved, source: modulesCart.name}; - } - } - } - return undefined; + return ( + firstContainedCandidate(modulesCart, moduleName, deps) ?? packageMainCandidate(ts, modulesCart, moduleName, deps) + ); } diff --git a/packages/b2c-script-types/test/cartridge-discovery.test.js b/packages/b2c-script-types/test/cartridge-discovery.test.js new file mode 100644 index 000000000..8db5a0842 --- /dev/null +++ b/packages/b2c-script-types/test/cartridge-discovery.test.js @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const {orderCartridges} = require('../src/resolver/cartridge-discovery'); + +const cartridge = (name) => ({name, src: `/ws/${name}`}); +const names = (list) => list.map((c) => c.name); + +describe('orderCartridges', () => { + const discovered = ['modules', 'app_storefront_base', 'plugin_a', 'app_custom'].map(cartridge); + + it('follows the configured cartridge path, then appends unnamed discovered cartridges', () => { + const ordered = orderCartridges(discovered, ['app_custom', 'missing', 'app_storefront_base', 'app_custom']); + assert.deepEqual(names(ordered), ['app_custom', 'app_storefront_base', 'modules', 'plugin_a']); + }); + + it('keeps discovery order without a configured path, with SFRA base cartridges last', () => { + assert.deepEqual(names(orderCartridges(discovered, undefined)), [ + 'plugin_a', + 'app_custom', + 'app_storefront_base', + 'modules', + ]); + assert.deepEqual(names(orderCartridges(discovered, [])), names(orderCartridges(discovered, undefined))); + }); + + it('ranks cartridges named like Object.prototype members as ordinary cartridges', () => { + const tricky = ['__proto__', 'modules', 'constructor', 'toString'].map(cartridge); + assert.deepEqual(names(orderCartridges(tricky, undefined)), ['__proto__', 'constructor', 'toString', 'modules']); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index d9b4bf510..ce3561851 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -147,6 +147,41 @@ describe('usage-inference', () => { assert.equal(describeTypes(ctx, types), ''); }); + it('skips a reference that sits in a top-level JSDoc comment (SFRA shipping model shape)', () => { + // A constructor with an expando member can be used as a JSDoc type, so + // its references include `{ProductLineItemsModel}` inside the doc + // comment — a position the AST walk resolves to the source file itself, + // which has no parent to inspect. The `new` call after it must still count. + const files = { + '/types.d.ts': AMBIENT_TYPES, + '/productLineItems.js': ` + function ProductLineItems(items) { + this.id = items.ID; + } + ProductLineItems.count = function () { return 0; }; + module.exports = ProductLineItems; + `, + '/shipping.js': `var ProductLineItemsModel = require('./productLineItems'); + +/** + * @returns {ProductLineItemsModel} the shipment's line items + */ +function getLineItems() { + return new ProductLineItemsModel(getProduct()); +} +module.exports = getLineItems; +`, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/productLineItems.js'); + const fn = findFunctionDeclaration(sourceFile, 'ProductLineItems'); + + const types = inferParameterType(ctx, fn.parameters[0]); + + assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); + }); + it('keeps only the call-site types that have every member the body uses', () => { // One call site passes a look-alike the body can't actually use: the // body reads `.quantity`, which only the inventory shape has. From 0788943b77f94276e72925a9975138a7e4cf3e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:51:35 +0000 Subject: [PATCH 50/76] fix(script-types): infer ES6 class constructor parameters from `new` call sites A constructor declares no name of its own, so call-site search found nothing for `class Model { constructor(product) {} }` and fell back to usage matching. Search through the class (its name, or the variable or export it is bound to) so `new Model(x)` in any file counts as a call site. Adds unit tests for arrow-function exports, arrows in export maps, class constructors, anonymous class-expression exports and class methods. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/call-sites.js | 12 ++-- .../src/inference/call-sites.ts | 19 +++-- .../test/usage-inference.test.js | 69 ++++++++++++++----- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index 19fe086f2..5154450b7 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -31,9 +31,9 @@ function findReferences(ctx, name) { } return references; } -/** The name a function declaration or method declares itself by. */ +/** The name a function declaration, method or class declares itself by. */ function ownName(fn, ts) { - const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn); + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; } /** @@ -59,11 +59,13 @@ function assignedName(fn, ts) { * declaration: its own name, or for one that has none, the name it is bound * to (the common CommonJS shapes: `const foo = function(){}`, * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, - * `module.exports = function(){}`). + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. */ function getReferenceNameNode(fn, ts) { - const boundName = ts.isExpression(fn) ? bindingNameOf(fn, ts) : undefined; - return ownName(fn, ts) ?? boundName ?? assignedName(fn, ts); + const callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + const boundName = ts.isExpression(callable) ? bindingNameOf(callable, ts) : undefined; + return ownName(callable, ts) ?? boundName ?? assignedName(callable, ts); } /** The function enclosing `node`, if any. */ function enclosingFunction(node, ts) { diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 4d3fe5b4f..21736b7d5 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -59,9 +59,12 @@ function findReferences(ctx: InferenceContext, name: tsserver.Identifier): reado return references; } -/** The name a function declaration or method declares itself by. */ -function ownName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Identifier | undefined { - const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn); +/** A function-like declaration, or the class a constructor is invoked through. */ +type Callable = tsserver.SignatureDeclaration | tsserver.ClassLikeDeclaration; + +/** The name a function declaration, method or class declares itself by. */ +function ownName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; } @@ -73,7 +76,7 @@ function ownName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserv * reach it through collectCallSites()'s require() indirection rather than a * direct property-access call. */ -function assignedName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): tsserver.Identifier | undefined { +function assignedName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { const assignment = fn.parent; if (!ts.isBinaryExpression(assignment) || assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { return undefined; @@ -88,14 +91,16 @@ function assignedName(fn: tsserver.SignatureDeclaration, ts: typeof tsserver): t * declaration: its own name, or for one that has none, the name it is bound * to (the common CommonJS shapes: `const foo = function(){}`, * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, - * `module.exports = function(){}`). + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. */ export function getReferenceNameNode( fn: tsserver.SignatureDeclaration, ts: typeof tsserver, ): tsserver.Identifier | undefined { - const boundName = ts.isExpression(fn) ? bindingNameOf(fn, ts) : undefined; - return ownName(fn, ts) ?? boundName ?? assignedName(fn, ts); + const callable: Callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + const boundName = ts.isExpression(callable) ? bindingNameOf(callable, ts) : undefined; + return ownName(callable, ts) ?? boundName ?? assignedName(callable, ts); } /** The function enclosing `node`, if any. */ diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index ce3561851..2d0d440fe 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -280,31 +280,19 @@ module.exports = getLineItems; }); describe('inferParameterType — cross-file export patterns', () => { - function findFunctionExpressionParam(sourceFile) { + /** The first parameter of the first function-like node `isTarget` accepts. */ + function findFirstParam(sourceFile, isTarget) { let param; const visit = (node) => { - if (ts.isFunctionExpression(node)) { - param = node.parameters[0]; - return; - } - ts.forEachChild(node, visit); + if (param) return; + if (isTarget(node)) param = node.parameters[0]; + else 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; - } + const findFunctionExpressionParam = (sourceFile) => findFirstParam(sourceFile, ts.isFunctionExpression); it('resolves a bare `module.exports = function(){}` called via `require(...)` in another file', () => { const files = { @@ -438,12 +426,55 @@ module.exports = getLineItems; }; const languageService = createFixtureLanguageService(files); const ctx = createInferenceContext(ts, languageService); - const param = findMethodDeclarationParam(ctx.program.getSourceFile('/helper.js')); + const param = findFirstParam(ctx.program.getSourceFile('/helper.js'), ts.isMethodDeclaration); const types = inferParameterType(ctx, param); assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); }); + + const ES6_EXPORT_SHAPES = [ + { + shape: 'an arrow-function module export (`module.exports = (product) => ...`)', + helper: `module.exports = (product) => product.ID;`, + consumer: `var helper = require('./helper'); helper(getProduct());`, + isTarget: ts.isArrowFunction, + }, + { + shape: 'an arrow function inside an export map', + helper: `module.exports = { helper: (product) => product.ID };`, + consumer: `var helpers = require('./helper'); helpers.helper(getProduct());`, + isTarget: ts.isArrowFunction, + }, + { + shape: 'an ES6 class constructor reached through `new`', + helper: `class Model { constructor(product) { this.id = product.ID; } } module.exports = Model;`, + consumer: `var Model = require('./helper'); new Model(getProduct());`, + isTarget: ts.isConstructorDeclaration, + }, + { + shape: 'an anonymous class expression export reached through `new`', + helper: `module.exports = class { constructor(product) { this.id = product.ID; } };`, + consumer: `var Model = require('./helper'); new Model(getProduct());`, + isTarget: ts.isConstructorDeclaration, + }, + { + shape: 'an ES6 class method called on an instance', + helper: `class Formatter { format(product) { return product.ID; } } module.exports = Formatter;`, + consumer: `var Formatter = require('./helper'); new Formatter().format(getProduct());`, + isTarget: ts.isMethodDeclaration, + }, + ]; + + for (const {shape, helper, consumer, isTarget} of ES6_EXPORT_SHAPES) { + it(`resolves ${shape} called from another file`, () => { + const files = {'/types.d.ts': AMBIENT_TYPES, '/helper.js': helper, '/consumer.js': consumer}; + const ctx = createInferenceContext(ts, createFixtureLanguageService(files)); + const param = findFirstParam(ctx.program.getSourceFile('/helper.js'), isTarget); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, param)), '{ ID: string; name: string; }'); + }); + } }); describe('inferParameterType — fitting call sites to how the body uses the value', () => { From cc63ab6ddedbcee995e80d5f9ee2bb3cd945a87d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:51:36 +0000 Subject: [PATCH 51/76] test(script-types): cover .call/.apply, contextual, union and callback-by-reference inference Corpus cases for `.call(this, ...)` model inheritance, `.apply(this, arguments)` forwarding, contextual types from typed API calls (primitive and class), `a || {}` initializers, reassigned-variable unions and named element callbacks. The VS Code integration suite gains hovers for `.call()` inheritance, a never-called parameter typed by `ShippingMgr.applyShippingCost`, and an IntelliJ-style union hover. Fixture files are now Prettier-clean; the deliberate mid-edit fixture is listed in .prettierignore. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../b2c-script-types/test/corpus/cases.json | 86 +++++++++++++++++++ packages/b2c-vs-extension/.prettierignore | 4 + .../cartridge/scripts/cartService.js | 4 +- .../scripts/helpers/lineItemHelpers.js | 2 +- .../cartridge/scripts/helpers/modelHelpers.js | 42 +++++++++ .../scripts/helpers/namingHelpers.js | 4 +- .../scripts/helpers/productHelpers.js | 2 +- .../scripts/helpers/variantHelpers.js | 2 +- .../cartridge/scripts/util/collections.js | 2 +- .../script-types-infer-usage.test.ts | 57 ++++++++++++ 10 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index c4ef62dab..1efb8a5d5 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -439,5 +439,91 @@ }, "target": {"file": "/helpers.js", "function": "surcharge", "param": 0}, "expect": "ProductShippingLineItem" + }, + { + "id": "call-this-model-inheritance", + "source": "SFRA product decorators: base.call(this, product, ...)", + "description": "`base.call(this, product)` counts as a call site, with the arguments after thisArg", + "dwTypes": ["Product"], + "files": { + "/base.js": "function BaseModel(product, quantity) {\n this.id = product.ID;\n this.quantity = quantity;\n}\nmodule.exports = BaseModel;\n", + "/full.js": "var BaseModel = require('./base');\nfunction FullModel(product) {\n BaseModel.call(this, product, 1);\n}\nnew FullModel(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": {"file": "/base.js", "function": "BaseModel", "param": 0}, + "expect": "Product" + }, + { + "id": "apply-arguments-forwarding", + "source": "storefront wrappers: helper.apply(this, arguments)", + "description": "`helper.apply(this, arguments)` forwards the wrapper's own call-site arguments", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function helper(product) {\n return product.ID;\n}\nfunction logged(product) {\n return helper.apply(this, arguments);\n}\nlogged(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": {"file": "/helpers.js", "function": "helper", "param": 0}, + "expect": "Product" + }, + { + "id": "contextual-type-without-call-sites", + "source": "storefront productHelpers: ProductMgr.getProduct(pid)", + "description": "A never-called helper's parameter passed on to a typed API takes that API's parameter type", + "dwTypes": ["Product"], + "files": { + "/helpers.js": "function lookup(pid) {\n return findProduct(pid);\n}\nmodule.exports = {lookup: lookup};\n" + }, + "globals": " function findProduct(productID: string): Product;", + "target": {"file": "/helpers.js", "function": "lookup", "param": 0}, + "expect": "string" + }, + { + "id": "logical-or-initializer-ignores-literal-fallback", + "source": "SFRA models: var safeOptions = options || {}", + "description": "`x || {}` initializes to x's type; the literal fallback adds nothing", + "dwTypes": ["Basket"], + "files": { + "/helpers.js": "function totals(lineItemContainer) {\n var container = lineItemContainer || {};\n return container;\n}\ntotals(getBasket());\n" + }, + "globals": " function getBasket(): Basket;", + "target": {"file": "/helpers.js", "kind": "variable", "name": "container"}, + "expect": "Basket" + }, + { + "id": "reassigned-variable-union", + "source": "storefront helpers: var target; if (...) target = a; else target = b;", + "description": "A variable declared without a value takes the union of what it is assigned", + "dwTypes": ["Product", "Category"], + "files": { + "/helpers.js": "function pick(useCategory) {\n var target;\n if (useCategory) {\n target = getCategory();\n } else {\n target = getProduct();\n }\n return target;\n}\n" + }, + "globals": " function getProduct(): Product;\n function getCategory(): Category;", + "target": {"file": "/helpers.js", "kind": "variable", "name": "target"}, + "expect": "Category | Product" + }, + { + "id": "named-element-callback", + "source": "storefront helpers: collections.forEach(variants, handleVariant)", + "description": "A named function passed by reference as an element-first callback takes the element type", + "dwTypes": ["Product", "Collection", "Variant"], + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/helpers.js": "var collections = require('./collections');\nfunction handleVariant(variant) {\n return variant.ID;\n}\nfunction allVariants(product) {\n collections.forEach(product.getVariants(), handleVariant);\n}\nallVariants(getProduct());\n" + }, + "globals": " function getProduct(): Product;", + "target": {"file": "/helpers.js", "function": "handleVariant", "param": 0}, + "expect": "Variant" + }, + { + "id": "contextual-class-type-without-call-sites", + "source": "storefront cartHelpers: ShippingMgr.applyShippingCost(basket)", + "description": "A never-called helper's parameter passed on as a typed dw.* class takes that class", + "dwTypes": ["Basket"], + "files": { + "/helpers.js": "function recalculate(currentBasket) {\n applyShippingCost(currentBasket);\n}\nmodule.exports = {recalculate: recalculate};\n" + }, + "globals": " function applyShippingCost(basket: Basket): void;", + "target": {"file": "/helpers.js", "function": "recalculate", "param": 0}, + "expect": "Basket" } ] diff --git a/packages/b2c-vs-extension/.prettierignore b/packages/b2c-vs-extension/.prettierignore index 69d37e230..0f14c23ed 100644 --- a/packages/b2c-vs-extension/.prettierignore +++ b/packages/b2c-vs-extension/.prettierignore @@ -4,3 +4,7 @@ src/template/ # Intentionally malformed dw.json fixture (trailing comma) used by the activation # regression test; Prettier would "fix" it into valid JSON and defeat the test. src/test/fixtures/malformed-workspace/dw.json + +# Deliberately ends a line on a dangling `shipment.` (a mid-edit shape the +# completion regression test types into); Prettier would join it onto the next line. +src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/shippingHelpers.js diff --git a/packages/b2c-vs-extension/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 453b09de0..c88e0fc5d 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 @@ -17,10 +17,10 @@ function buildLineItemInfo(productId, quantity) { priceValue: productHelpers.getListPriceValue(product), orderable: productHelpers.isOrderable(product, quantity), variantIds: variantHelpers.collectVariantIds(product), - firstVariant: variantHelpers.firstVariantName(product) + firstVariant: variantHelpers.firstVariantName(product), }; } module.exports = { - buildLineItemInfo: buildLineItemInfo + buildLineItemInfo: buildLineItemInfo, }; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/lineItemHelpers.js index 6031ffcfd..a042b1151 100644 --- 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 @@ -28,5 +28,5 @@ function describeCatalogProduct(catalogProduct) { module.exports = { countBonusChoices: countBonusChoices, - describeCatalogProduct: describeCatalogProduct + describeCatalogProduct: describeCatalogProduct, }; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js new file mode 100644 index 000000000..5247a1389 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/modelHelpers.js @@ -0,0 +1,42 @@ +'use strict'; + +// Evidence IntelliJ also reads for undocumented parameters: `.call(this, ...)` +// call sites (SFRA model inheritance), the typed Script API a value is passed +// on to, and call sites that disagree (shown as a union). Integration tests +// locate positions via indexOf — keep shapes stable. + +var ProductMgr = require('dw/catalog/ProductMgr'); +var CatalogMgr = require('dw/catalog/CatalogMgr'); +var ShippingMgr = require('dw/order/ShippingMgr'); + +function BaseModel(source) { + this.id = source.ID; +} + +function FullModel(source) { + BaseModel.call(this, source); + this.online = source.online; +} + +function recalculate(container) { + ShippingMgr.applyShippingCost(container); +} + +function describeItem(item) { + return item.ID; +} + +function buildModels() { + var product = ProductMgr.getProduct('some-id'); + describeItem(product); + describeItem(CatalogMgr.getCategory('some-category')); + return new FullModel(product); +} + +module.exports = { + BaseModel: BaseModel, + FullModel: FullModel, + recalculate: recalculate, + describeItem: describeItem, + buildModels: buildModels, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/namingHelpers.js index a4a60fb49..a2217e425 100644 --- 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 @@ -18,7 +18,7 @@ function sendPasswordResetEmail(email, resettingCustomer, currentLocale) { firstName: resettingCustomer.profile.firstName, lastName: resettingCustomer.profile.lastName, locale: currentLocale.ID, - token: token + token: token, }; } @@ -42,5 +42,5 @@ module.exports = { sendPasswordResetEmail: sendPasswordResetEmail, getLineItemAdjustmentCount: getLineItemAdjustmentCount, isProductLineItem: isProductLineItem, - firstVariantId: firstVariantId + firstVariantId: firstVariantId, }; 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 index 35e065728..fc6c2a63a 100644 --- 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 @@ -29,5 +29,5 @@ function isOrderable(product, quantity) { module.exports = { getSalePrice: getSalePrice, getListPriceValue: getListPriceValue, - isOrderable: isOrderable + isOrderable: isOrderable, }; 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 index f67c73335..874c1c5a1 100644 --- 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 @@ -24,5 +24,5 @@ function firstVariantName(product) { module.exports = { collectVariantIds: collectVariantIds, - firstVariantName: firstVariantName + 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 index 67f6599b7..da77a47b5 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 @@ -20,5 +20,5 @@ function first(collection) { module.exports = { forEach: forEach, - first: first + 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 52354a56b..1185d9b16 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 @@ -795,3 +795,60 @@ suite('scriptTypesInferUsage — *LineItem subclass disambiguation and real-JSDo ); }); }); + +suite('scriptTypesInferUsage — .call() inheritance, typed API uses, and union call sites', () => { + let modelDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + modelDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'modelHelpers.js'), + ), + ); + await vscode.window.showTextDocument(modelDoc); + }); + + test('infers Product for a base model reached only through BaseModel.call(this, source)', async () => { + // `source` is a neutral name and `.ID` alone fits many classes, so only + // the `.call` call site (fed by `new FullModel(product)`) can type it. + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function BaseModel(source', 'function BaseModel('.length), + /Product/, + true, + ); + assert.ok(/Product/.test(text), `expected Product through the .call() call site, got: ${text}`); + }); + + test('infers LineItemCtnr for a never-called parameter passed on to ShippingMgr.applyShippingCost', async () => { + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function recalculate(container', 'function recalculate('.length), + /LineItemCtnr/, + true, + ); + assert.ok(/LineItemCtnr/.test(text), `expected LineItemCtnr from the typed API use, got: ${text}`); + }); + + test('shows the union of conflicting call sites, IntelliJ style (Product | Category)', async () => { + const text = await hoverTextMatching( + modelDoc, + offsetPosition(modelDoc, 'function describeItem(item', 'function describeItem('.length), + /Product \| Category/, + true, + ); + assert.ok(/Product \| Category/.test(text), `expected the Product | Category union, got: ${text}`); + }); +}); From 51a56781161259f1c1060d763096766b6bbbcad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:56:17 +0000 Subject: [PATCH 52/76] docs(script-types): describe IntelliJ-style usage inference and split changesets The IDE guide and package README now list the evidence inference weighs (call sites incl. new/.call/.apply and callbacks, returns and chains, typed Script API uses, the helper's own body), the union-of-call-sites behaviour and names as a tiebreak only. Limitations drop ES6 classes and arrow exports (now supported) and add HookMgr.callHook and ISML pdict. The inference changeset now describes the final feature instead of the branch history and no longer quotes a project-specific custom attribute; the security hardening gets its own changeset. Regenerates the stale tooling index. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-infer-usage.md | 23 +------------------ .changeset/script-types-workspace-trust.md | 6 +++++ docs/guide/ide-integration.md | 17 ++++++++++---- packages/b2c-script-types/README.md | 23 ++++++++++++------- .../b2c-script-types/test/usage-match.test.js | 14 +++++------ .../b2c-tooling-sdk/data/tooling/index.json | 4 ++-- 6 files changed, 43 insertions(+), 44 deletions(-) create mode 100644 .changeset/script-types-workspace-trust.md diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 0af1fa4f5..8df250b9e 100644 --- a/.changeset/script-types-infer-usage.md +++ b/.changeset/script-types-infer-usage.md @@ -3,25 +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. `@salesforce/b2c-cli` picks this up too since `b2c setup ide vscode-types`/`tsserver-plugin` bundle the same plugin. - -Beyond call-site and return-expression inference, the engine also recognizes: -- A parameter or local variable's own member/method accesses (e.g. `shipment.custom`, `shipment.productLineItems`) matched against the Script API's ambient classes, when no call site or usable initializer can resolve its type at all — recovering hover/completions for helpers only reached indirectly (e.g. dispatched from a Controller route), and for collection items pulled out with a manual indexing loop (`var item = items[i]`) instead of `collections.forEach`. -- A single accessed member when it uniquely identifies one Script API class (e.g. `addressBook.addresses` only matches `dw.customer.AddressBook`), instead of only ever guessing from two or more accessed members. A lone member name shared by several classes (e.g. the common `.custom` attribute pattern) is still correctly left unresolved. -- A `'member' in obj` existence check (e.g. `'Subsoort' in apiProduct.custom`) as evidence of that member, not just a direct `obj.member` read — a very common SFCC idiom for guarding an optional custom attribute before reading it. -- `new Helper(x)` constructor calls as a call site, not just plain `helper(x)` calls — SFRA's other very common way to invoke an undocumented "class" model (e.g. `new ProductLineItem(...)`, `new StoreModel(...)`). - -When a member signature still matches more than one Script API class, a parameter or variable conventionally named after the class it holds (`profile` for `dw.customer.Profile`, `shipment` for `dw.order.Shipment`) is now preferred over the previous "fewest total members" tiebreak alone — which could otherwise pick a small, unrelated class purely because it exposed less surface area than the large, correct one (e.g. `dw.customer.ProductListRegistrant` over `dw.customer.Profile` for a variable literally named `profile`, since both happen to share a common `email`/`firstName`/`lastName`/`custom` field subset). - -Also fixes several bugs uncovered while dogfooding this against real projects: -- Hover showed nothing when hovering the member name itself in a chained access (e.g. `productLineItems` in `shipment.productLineItems`) even though hovering the receiver worked. -- Completions were slow/unreliable on large real projects because an internal cache was invalidated on every keystroke instead of once per project session. -- Hover now shows the real declaration's own type name, documentation, and JSDoc tags (not just a bare "Inferred from usage: X" note). -- A class's nested custom-attributes interface (`ICustomAttributes.Shipment`) rendered with the same display name as the unrelated top-level class it's attached to. -- A dangling, mid-edit member access (`shipment.` immediately followed by more code on later lines — `.` never gets automatic semicolon insertion) could get parsed together with the next statement, poisoning usage-based matching with a phantom member name and silently producing no completions for the position being typed. -- The most common placeholder JSDoc of all, `@param {Object}`, silently got no inference in the editor: `checkJs` resolves capital-`Object` to the global `Object` interface (not `any` or the lowercase `object`), which the hover/completion entry gate treated as a real type and skipped — so the headline "undocumented SFRA helper" case produced nothing in a live project even though the engine handled it. Weak `{Object}` now opens the gate like the other placeholders. -- A parameter named after a *specific* `dw.order` line-item subclass (`bonusDiscountLineItem`, `productShippingLineItem`) was mis-resolved to `ProductLineItem` by the generic `LineItem` naming heuristic. These now resolve to their own class when the body's usage fits it, and stay silent otherwise, rather than guessing the wrong sibling. - -Also tightens Preview trust: conflicting call-site argument types stay silent instead of unioning a noisy hover; ambient matches rank by member distinctiveness (so ubiquitous `.custom` / `.UUID` don't dominate); and element-first callbacks cover `collections.map` / `filter` / `every` / `some` / `find` / `first` (not only `forEach`). Cartridge `~/` / `*/` require resolution now consults the language-service host filesystem (not only `ts.sys`), so virtualized hosts and tests resolve the same way as a real project. Call-site types that don't expose every member the parameter body actually uses are dropped (so a duck-typed Store model passed into an address helper can't win the hover); a conventionally named parameter with a single strong member (`customer` + `.profile`) is trusted even when that member is shared by another ambient class; SFRA aliases (`lineItem` / `pli` → `ProductLineItem`, `priceModel` → `ProductPriceModel`, …) and CamelCase suffixes (`resettingCustomer` → `Customer`, `apiProduct` → `Product`, `currentBasket` → `Basket`) get the same short-circuit; generic Script API classes like `Product` are included in ambient matching (shown as `Product`); ternary returns (stock `collections.first`) and `instanceof` class checks feed inference the same way JetBrains' JS evaluator does; and placeholder SFRA JSDoc (`@param {Object}` / `{obj}` / `{*}` / `{}`) no longer blocks usage inference — only deliberate `{any}` and real `dw.*` annotations stay authoritative, matching how IntelliJ helps when authors write real types while still recovering the common undocumented storefront helpers. - -Includes security hardening against malicious repositories: the tsserver plugin now canonicalizes and contains every resolved `require()` path (including a cartridge `package.json` `main`) so a crafted import specifier or symlink in a cloned repo can no longer resolve to a file outside the bundled types directory or the cartridge roots, bounds the size of `dw.json`/`package.json` it parses, and the VS Code extension now declares that Script API IntelliSense requires a trusted workspace (`capabilities.untrustedWorkspaces`) and refuses to forward cartridge paths or run usage inference until the workspace is trusted. +Script API IntelliSense can now infer types for undocumented cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). diff --git a/.changeset/script-types-workspace-trust.md b/.changeset/script-types-workspace-trust.md new file mode 100644 index 000000000..93ccd37e3 --- /dev/null +++ b/.changeset/script-types-workspace-trust.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Hardened Script API IntelliSense against malicious repositories: the tsserver plugin keeps every resolved `require()` path (including a cartridge `package.json` `main`) inside the bundled types and the cartridge roots, and caps the size of the `dw.json`/`package.json` files it reads. The VS Code extension now runs only in trusted workspaces; trust the workspace to use it. diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index cca3eddae..a4fb5a0c7 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -129,15 +129,20 @@ If your editor's LSP client is launched outside the repo root (for example, open JSDoc-documented functions get full hover/completion support when the annotation names a real Script API type (`@param {dw.customer.Customer}` / `@param {Customer}`), because TypeScript reads those directly — the same happy path the IntelliJ SFCC plugin relies on. Plain, undocumented helpers don't, and neither do the placeholder SFRA annotations that show up constantly in real cartridges (`@param {Object}`, `{obj}`, `{*}`, `{}`): those widen to an uninformative type and silence completion for everything downstream. -Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or pass `inferUsage: true` in the plugin config (`init_options.plugins` for other LSP hosts) to have the plugin infer a plausible type for these cases from how the value is actually used elsewhere in the project — call-site arguments for parameters, return statements for return values — chasing through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`), and intermediate local variables (`var priceModel = product.getPriceModel(); return priceModel.getPrice();`) rather than stopping at the first `any` or placeholder `Object`. Deliberate `@param {any}` / `: any` annotations are still respected and never second-guessed; real `dw.*` JSDoc is left alone too. +Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or pass `inferUsage: true` in the plugin config (`init_options.plugins` for other LSP hosts) to have the plugin infer a plausible type for these cases the way IntelliJ does: from how the value is actually used across the project, not from what it is called. Deliberate `@param {any}` / `: any` annotations are still respected and never second-guessed; real `dw.*` JSDoc is left alone too. + +The evidence it weighs: + +- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). +- **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. +- **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. +- **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; with no call site at all, the members are matched against every Script API class. `module.superModule` is understood too: in an overlay cartridge that extends a base module (`var base = module.superModule;`), hover and completions on `base` and on values derived from it resolve against the same-path module in the next cartridge down the cartridge path — including recursing into the base module's own undocumented helpers, and across multi-cartridge plugin stacks where intermediate levels re-export the base and add members (`module.exports = base; module.exports.extra = extra;`). Two more SFRA idioms are covered: - **Iteration callbacks** — `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` (element-first callback when a predicate is passed; `reduce` and unknown callees are skipped), e.g. `collections.forEach(product.getVariants(), function (variant) {...})`. A callback in argument position has no name to search references for, so `variant` is typed from the element type of the collection travelling alongside it (anything with `iterator()`/`next()`, i.e. `dw.util.Collection` and friends). Manual iterator loops (`var iter = coll.iterator(); while (iter.hasNext()) { var item = iter.next(); }`) and ternary returns like stock `collections.first` (`return it.hasNext() ? it.next() : null`) resolve through the same chain machinery. -- **SFRA naming aliases** — parameters conventionally named `lineItem` / `pli`, `priceModel`, `shippingAddress` / `billingAddress`, `paymentInstrument`, etc. short-circuit ambient matching to the Script API class they hold even when the identifier is not the class's own simple name. CamelCase suffixes are recognized too (`resettingCustomer` → `Customer`, `apiProduct` → `Product`, `currentBasket` → `Basket`), matching the naming style SFRA controllers and helpers use constantly. -- **`instanceof` checks** — a single `param instanceof ProductLineItem` (or `dw.order.ProductLineItem`) in the helper body is treated as concrete class evidence when call sites don't resolve. - **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature. Inference deliberately stays out of the way there. Cross-file inference (call sites in other files, `module.superModule`) needs those files in the same TypeScript project. A `jsconfig.json` that includes all cartridge sources — like the one `b2c setup ide vscode-types` generates — provides that; without one, each open file gets its own inferred project and only same-file usage is visible. @@ -146,16 +151,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. +- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. +- Names never add a type of their own; they only break a tie when the usage fits several classes equally well. A `profile` parameter picks `dw.customer.Profile` over `ProductListRegistrant`, `lineItem` / `pli` pick `ProductLineItem`, and qualified names such as `apiProduct`, `currentBasket` or `resettingCustomer` resolve to the class they end in. This won't recover types TypeScript genuinely can't infer — for example, values that are never called with a consistent, well-typed argument anywhere in the project — and it's off by default because it's new and heuristic. **Known limitations** — intentionally deferred patterns: -- 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` +- Hook implementations reached only through `HookMgr.callHook` (the `hooks.json` mapping isn't followed) +- Values ISML templates read from `pdict` - Guessing individual custom attribute names on `.custom` (only `.custom` itself is usage evidence) ### Notes diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index 6f3f6f658..b9fde819e 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -63,11 +63,18 @@ See [src/index.ts](./src/index.ts) for the implementation. An undocumented helper function (no JSDoc) gets its parameters and return value widened to `any` by plain TypeScript inference, and that `any` propagates to every caller. Passing `inferUsage: true` in the plugin config -(off by default) makes the plugin infer a plausible type for these cases from -how the value is actually used elsewhere in the project — see -[src/usage-inference.ts](./src/usage-inference.ts) (barrel) and the engine -modules under [src/inference/](./src/inference/) — and surface it as an -"Inferred from usage" hover note plus synthesized member completions. It's -heuristic and intentionally conservative: it only kicks in where the checker -has already given up with `any`, never overriding a type TypeScript or JSDoc -already resolved. +(off by default) makes the plugin infer a plausible type for these cases the +way IntelliJ does: from call-site arguments (including `new`, `.call`/`.apply` +and callbacks), return values, the typed Script API calls a value is passed +to, and the members and `instanceof`/`typeof` checks in the helper's own body. +The result is surfaced as an "Inferred from usage" hover note plus synthesized +member completions; call sites that disagree show a union of up to three +types. It only kicks in where the checker has already given up (`any`, or a +placeholder SFRA JSDoc such as `@param {Object}`), never overriding a real +type from TypeScript or JSDoc. + +The engine gathers evidence ([src/inference/core.ts](./src/inference/core.ts)) +and applies one decision policy ([src/inference/policy.ts](./src/inference/policy.ts)) +everywhere, so parameter, member and chain hovers and completions always +agree. [src/usage-inference.ts](./src/usage-inference.ts) is the barrel and +lists the modules in reading order. diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 8d09509ee..02728ab61 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -76,8 +76,8 @@ 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. + // identifier-name tiebreak would otherwise rescue Shipment despite the + // weak evidence. const files = { '/types.d.ts': realTypesPrelude(['Shipment', 'ProductLineItem', 'Profile', 'Customer'], ''), '/shippingHelpers.js': ` @@ -113,8 +113,8 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // 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. + // custom+UUID case above: `.profile` is a strong member, so the name may + // break the tie. const files = { '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), '/accountHelpers.js': ` @@ -446,8 +446,8 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage // 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 + // pick ProductListRegistrant; the identifier `profile` breaks the tie in + // favour of Profile. The parameter itself is also recoverable now via the // PascalCase suffix `resettingCustomer` → Customer (even with weak // `@param {obj}`), so `.profile` can resolve through Customer's declared // property as well. @@ -542,7 +542,7 @@ describe('usage-inference — matching ambient dw.* classes from parameter usage 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. + // cannot break the tie in favour of ProductLineItem. const files = { '/types.d.ts': realTypesPrelude(['ProductLineItem', 'ShippingLineItem'], ''), '/priceTotal.js': ` diff --git a/packages/b2c-tooling-sdk/data/tooling/index.json b/packages/b2c-tooling-sdk/data/tooling/index.json index 8e965c128..5727a5d43 100644 --- a/packages/b2c-tooling-sdk/data/tooling/index.json +++ b/packages/b2c-tooling-sdk/data/tooling/index.json @@ -1,6 +1,6 @@ { "version": "2.0.0", - "generatedAt": "2026-10-07T04:56:18.082Z", + "generatedAt": "2026-10-09T07:54:54.221Z", "entries": [ { "id": "cli-account-manager", @@ -359,7 +359,7 @@ "category": "tooling", "url": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/ide-integration.html", "sourceUrl": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/guide/ide-integration.md", - "headings": "Script API IntelliSense • Salesforce B2C Commerce VS Code Extension (recommended) • Legacy `.ds` scripts • Standalone VS Code, WebStorm, or IntelliJ Ultimate • Neovim, Helix, Zed, Sublime, or other LSP-based editors • Notes • IntelliJ SFCC Plugin • Setup", + "headings": "Script API IntelliSense • Salesforce B2C Commerce VS Code Extension (recommended) • Legacy `.ds` scripts • Standalone VS Code, WebStorm, or IntelliJ Ultimate • Neovim, Helix, Zed, Sublime, or other LSP-based editors • Inferring types for undocumented helpers (experimental) • Notes • IntelliJ SFCC Plugin • Setup", "preview": "Configure IDE tooling like the IntelliJ SFCC plugin to consume resolved B2C CLI configuration, plus enable Script API IntelliSense via TypeScript definitions." }, { From d291ece67331ac721546aeaa42ae2e2e8cf01797 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:32:03 +0000 Subject: [PATCH 53/76] test(script-types): triage silent inferences and score downstream variables in ground truth The harness now says why each silent parameter stayed silent (no call sites, budget, dropped by policy, no evidence) and re-infers local variables whose Script API type only came from the blanked JSDoc, the end-to-end effect users see in hovers and completions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../test/eval/ground-truth.test.js | 125 +++++++++++++++--- 1 file changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index f71d83824..0cca6bfe1 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -28,6 +28,11 @@ // silent no inference // // Precision = (exact + union + related) / fired, recall = the same / total. +// Every silent parameter is triaged into why it stayed silent (no call sites +// found, budget exhausted, evidence dropped by the policy, no evidence), so a +// change can target the largest bucket. Local variables whose `dw.*` type +// came only from the blanked JSDoc are re-inferred and scored too: that is +// the end-to-end effect a user sees in hovers and completions on locals. // Parameters that carry no type at all in the original source are hovered // too, to measure how often inference fires on genuinely undocumented code. // Cost is reported as wall time and as reference searches per request; the @@ -44,7 +49,9 @@ const path = require('node:path'); const ts = require('typescript'); const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); -const {createInferenceContext, describeTypes, inferReturnType} = require('../../src/usage-inference'); +const {collectCallSites, getReferenceNameNode} = require('../../src/inference/call-sites'); +const {inferTypeForExpression} = require('../../src/inference/core'); +const {createInferenceContext, describeTypes, inferReturnType, inferTypeForNode} = require('../../src/usage-inference'); const {createPluginProxy} = require('../helpers/plugin-proxy'); const CORPUS = process.env.B2C_INFERENCE_CORPUS; @@ -186,13 +193,35 @@ function collectFunctionTargets(checker, fn, sourceFile, out) { } } +/** True when `type` (or a non-nullable part of it) is declared by the vendored Script API. */ +function isScriptApiType(type) { + const parts = type.isUnion() ? type.types : [type]; + return parts.some((part) => + (part.getSymbol()?.declarations ?? []).some((decl) => decl.getSourceFile().fileName.includes('/types/dw/')), + ); +} + +/** A local variable whose checker type is a Script API class: a candidate for the downstream metric. */ +function collectVariableTarget(checker, decl, sourceFile, out) { + if (!ts.isIdentifier(decl.name) || decl.type) return; + const type = checker.getTypeAtLocation(decl.name); + if (!isScriptApiType(type)) return; + const line = sourceFile.getLineAndCharacterOfPosition(decl.getStart(sourceFile)).line + 1; + out.variables.push({ + label: `${path.basename(sourceFile.fileName)}:${line} var ${decl.name.text}`, + position: decl.name.getStart(sourceFile), + names: documentedNames(checker, type), + }); +} + function collectFileTargets(program, fileName) { const sourceFile = program.getSourceFile(fileName); const checker = program.getTypeChecker(); - const out = {fileName, params: [], returns: [], undocumented: [], blanks: []}; + const out = {fileName, params: [], returns: [], variables: [], undocumented: [], blanks: []}; if (!sourceFile) return out; const visit = (node) => { if (ts.isFunctionLike(node) && 'parameters' in node) collectFunctionTargets(checker, node, sourceFile, out); + if (ts.isVariableDeclaration(node)) collectVariableTarget(checker, node, sourceFile, out); ts.forEachChild(node, visit); }; visit(sourceFile); @@ -260,19 +289,66 @@ function inferredFromHover(proxy, fileName, position) { return index === -1 ? undefined : text.slice(index + INFERRED_NOTE.length).trim(); } -function inferredReturn(languageService, fileName, position) { - const ctx = createInferenceContext(ts, languageService); - const sourceFile = ctx?.program.getSourceFile(fileName); - if (!ctx || !sourceFile) return undefined; - let fn; +/** The node starting exactly at `position` that satisfies `predicate`, outermost first. */ +function findNodeAt(sourceFile, position, predicate) { + let found; const visit = (node) => { - if (fn) return; - if (ts.isFunctionLike(node) && node.getStart(sourceFile) === position) fn = node; + if (found || position < node.getStart(sourceFile) || position >= node.getEnd()) return; + if (predicate(node) && node.getStart(sourceFile) === position) found = node; else ts.forEachChild(node, visit); }; visit(sourceFile); - const types = fn ? inferReturnType(ctx, fn) : []; - return types.length > 0 ? describeTypes(ctx, types) : undefined; + return found; +} + +/** A fresh inference context and the evaluated file in the current (blanked) program. */ +function freshRequest(languageService, fileName) { + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx?.program.getSourceFile(fileName); + return ctx && sourceFile ? {ctx, sourceFile} : undefined; +} + +function inferredReturn(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const fn = request && findNodeAt(request.sourceFile, position, ts.isFunctionLike); + const types = fn ? inferReturnType(request.ctx, fn) : []; + return types.length > 0 ? describeTypes(request.ctx, types) : undefined; +} + +/** + * What inference shows for a local variable, or null when the variable is + * still typed by the checker after blanking (its type never depended on the + * removed JSDoc, so it says nothing about inference). + */ +function inferredVariable(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const name = request && findNodeAt(request.sourceFile, position, ts.isIdentifier); + if (!name || isScriptApiType(request.ctx.checker.getTypeAtLocation(name))) return null; + const types = inferTypeForNode(request.ctx, name); + return types.length > 0 ? describeTypes(request.ctx, types) : undefined; +} + +/** + * Why a parameter stayed silent, re-derived with a fresh request: an + * anonymous callback (nothing to search for), no call sites found, a search + * or reference budget that ran out, call-site evidence the policy dropped + * (fit, union limit), or call sites whose arguments say nothing. + */ +function silentReason(languageService, fileName, position) { + const request = freshRequest(languageService, fileName); + const param = request && findNodeAt(request.sourceFile, position, ts.isIdentifier)?.parent; + if (!param || !ts.isParameter(param)) return 'not-a-parameter'; + const {ctx} = request; + const fn = param.parent; + const nameNode = getReferenceNameNode(fn, ts); + if (!nameNode) return 'anonymous-callback'; + const sites = collectCallSites(ctx, nameNode); + const exhausted = () => ctx.searchBudget <= 0 || ctx.referenceBudget <= 0; + if (sites.length === 0) return exhausted() ? 'budget' : 'no-call-sites'; + const index = fn.parameters.indexOf(param); + const evidence = sites.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); + if (evidence.length > 0) return 'dropped-by-policy'; + return exhausted() ? 'budget' : 'no-evidence'; } const SLOWEST_REPORTED = 20; @@ -292,12 +368,13 @@ function timed(report, label, compute) { return result; } -function scoreTarget(report, ancestry, kind, target, inferred) { +function scoreTarget(report, ancestry, kind, target, inferred, explainSilence) { const verdict = classify(ancestry, target.names, inferred); report[kind][verdict]++; - if (verdict !== 'exact' && verdict !== 'union') { - report.mismatches.push({kind, verdict, target: target.label, documented: target.names.join(' | '), inferred}); - } + if (verdict === 'exact' || verdict === 'union') return; + const reason = verdict === 'silent' && explainSilence ? explainSilence() : undefined; + if (reason) report.silentReasons[reason] = (report.silentReasons[reason] ?? 0) + 1; + report.mismatches.push({kind, verdict, target: target.label, documented: target.names.join(' | '), inferred, reason}); } function emptyScore() { @@ -310,7 +387,9 @@ function evaluateFile(plugin, ancestry, targets, report) { try { for (const target of targets.params) { const inferred = timed(report, target.label, () => inferredFromHover(proxy, targets.fileName, target.position)); - scoreTarget(report, ancestry, 'params', target, inferred); + scoreTarget(report, ancestry, 'params', target, inferred, () => + silentReason(languageService, targets.fileName, target.position), + ); } for (const target of targets.returns) { const inferred = timed(report, target.label, () => @@ -318,6 +397,13 @@ function evaluateFile(plugin, ancestry, targets, report) { ); scoreTarget(report, ancestry, 'returns', target, inferred); } + // Untimed, and after the timed requests: the triage and variable + // requests warm this program's reference cache, which must not lower the + // search counts measured above. + for (const target of targets.variables) { + const inferred = inferredVariable(languageService, targets.fileName, target.position); + if (inferred !== null) scoreTarget(report, ancestry, 'variables', target, inferred); + } } finally { setOverride(targets.fileName, undefined); } @@ -355,9 +441,14 @@ function summarize(report) { const sorted = [...report.timings].sort((a, b) => a - b); const searches = [...report.searches].sort((a, b) => a - b); const u = report.undocumented; + const reasons = Object.entries(report.silentReasons) + .sort((a, b) => b[1] - a[1]) + .map(([reason, count]) => `${reason}=${count}`); return [ line('params'), line('returns'), + line('variables'), + `silent params: ${reasons.join(' ') || 'none'}`, `undocumented params fired on ${u.fired}/${u.total}`, `latency ms p50=${percentile(sorted, 50).toFixed(1)} p95=${percentile(sorted, 95).toFixed(1)} ` + `max=${(sorted.at(-1) ?? 0).toFixed(1)} over ${sorted.length} requests`, @@ -379,6 +470,8 @@ function summarize(report) { const report = { params: emptyScore(), returns: emptyScore(), + variables: emptyScore(), + silentReasons: {}, undocumented: {total: 0, fired: 0, samples: []}, mismatches: [], timings: [], From 12ebe9072c9d56dbc13c7a62921ba5f26d2d2638 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:41:20 +0000 Subject: [PATCH 54/76] fix(script-types): sharper usage-inference policy - Collapse several instantiations of one generic class into the declared class before counting union members, so collections.forEach/map/find helpers called with Collection, Collection, ... show `Collection` instead of staying silent (arrays are never merged). - Never match TopLevel `Module`/`arguments` by usage, and stay silent when a JavaScript built-in (String, Array, ...) also has every used member, so `msg.replace()` is no longer an XML and `x.id` no longer a Module. - When filtering call-site candidates, ignore used members no candidate, ambient class or built-in declares (stale vendored types, expandos); ambient matching without call sites stays strict. - Render generic class instantiations as `Collection` instead of leaking `Product` type arguments. - Ground-truth harness: split the silent-reason classifier. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/ambient-index.js | 10 ++ .../plugin/inference/constants.js | 19 ++- .../plugin/inference/policy.js | 74 +++++++-- .../plugin/inference/type-helpers.js | 17 ++- .../src/inference/ambient-index.ts | 10 ++ .../src/inference/constants.ts | 19 +++ .../b2c-script-types/src/inference/policy.ts | 110 +++++++++++--- .../src/inference/type-helpers.ts | 16 +- .../b2c-script-types/test/corpus/cases.json | 12 ++ .../test/eval/ground-truth.test.js | 17 ++- .../b2c-script-types/test/usage-match.test.js | 141 ++++++++++++++++++ 11 files changed, 398 insertions(+), 47 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/ambient-index.js b/packages/b2c-script-types/plugin/inference/ambient-index.js index 97961c741..9f74963ef 100644 --- a/packages/b2c-script-types/plugin/inference/ambient-index.js +++ b/packages/b2c-script-types/plugin/inference/ambient-index.js @@ -8,6 +8,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.getAmbientClasses = getAmbientClasses; exports.ambientClassType = ambientClassType; exports.findAmbientClassType = findAmbientClassType; +exports.builtinValueTypes = builtinValueTypes; +const constants_1 = require("./constants"); const classesByFile = new WeakMap(); const classesByProgram = new WeakMap(); function indexDeclaration(ctx, statement) { @@ -64,3 +66,11 @@ function findAmbientClassType(ctx, name) { const preferred = named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; return preferred && ambientClassType(ctx, preferred); } +/** The declared types of the JavaScript built-ins in BUILTIN_VALUE_TYPES (`String`, `Array`, ...). */ +function builtinValueTypes(ctx) { + const { ts, checker } = ctx; + return constants_1.BUILTIN_VALUE_TYPES.flatMap((name) => { + const symbol = checker.resolveName(name, undefined, ts.SymbolFlags.Type, false); + return symbol ? [checker.getDeclaredTypeOfSymbol(symbol)] : []; + }); +} diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index aae0169cb..1dc3c0e90 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.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_INFERENCE_DEPTH = void 0; +exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.BUILTIN_VALUE_TYPES = exports.GLOBAL_OBJECT_CLASSES = exports.WEAK_USAGE_MEMBERS = exports.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_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. @@ -87,6 +87,23 @@ exports.UNINFORMATIVE_ANCESTORS = new Set(['Object', 'ExtensibleObject', 'Persis // They still narrow an ambient match, but a signature made only of these // never picks one class out of several. exports.WEAK_USAGE_MEMBERS = new Set(['custom', 'UUID', 'toString', 'valueOf']); +// Script API TopLevel classes that describe one global object (`module`, +// `arguments`) rather than a kind of value code passes around. Usage matching +// skips them: `regionDefinition.id` is not a Module. +exports.GLOBAL_OBJECT_CLASSES = new Set(['Module', 'arguments']); +// JavaScript built-ins whose members ordinary code uses all the time. A usage +// signature one of them also satisfies (`msg.replace(...)`, `x.length`) is +// ambiguous: the value may well be a string or an array, so ambient matching +// stays silent rather than naming the Script API class that happens to fit. +exports.BUILTIN_VALUE_TYPES = [ + 'String', + 'Number', + 'Boolean', + 'Array', + 'Function', + 'Date', + 'RegExp', +]; // Callee names whose callbacks lead with the collection element // (`collections.forEach(coll, function (item) {...})`). Only these get the // sibling-collection element-type heuristic; `reduce` (accumulator first) diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index 0a112f611..2cfafde9a 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -96,12 +96,45 @@ function closestCommonAncestor(ctx, types, memberNames) { restAncestors.every((ancestors) => ancestors.includes(ancestor)) && (0, type_helpers_1.hasAllMembers)(ctx.checker, ancestor, memberNames)); } -/** Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, else the closest shared superclass, else silence. */ +/** + * The generic class `type` instantiates, when its declared type reads as the + * bare class name. Arrays don't: the declared `Array` renders as `T[]`. + */ +function genericClassOf(ctx, type) { + const generic = classOf(ctx, type); + return generic?.typeParameters?.length && !ctx.checker.isArrayType(type) ? generic : undefined; +} +/** + * Reads two or more instantiations of the same generic class as the class + * itself: its declared type, whose unbound type parameters render as nothing + * (`Collection` and `Collection` -> `Collection`). + * A lone instantiation keeps its informative type argument. + */ +function mergeInstantiations(ctx, types) { + const instantiations = new Map(); + for (const type of types) { + const generic = genericClassOf(ctx, type); + if (generic) + instantiations.set(generic, (instantiations.get(generic) ?? 0) + 1); + } + const merged = types.map((type) => { + const generic = genericClassOf(ctx, type); + return generic && (instantiations.get(generic) ?? 0) > 1 ? generic : type; + }); + return (0, type_helpers_1.dedupeTypes)(ctx, merged); +} +/** + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES; wider evidence + * merged by generic class, else the closest shared superclass, else silence. + */ function limitUnion(ctx, types, memberNames = NO_MEMBERS) { const general = mostGeneral(ctx, types); if (general.length <= constants_1.MAX_UNION_TYPES) return general; - const ancestor = closestCommonAncestor(ctx, general, memberNames); + const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); + if (merged.length <= constants_1.MAX_UNION_TYPES) + return merged; + const ancestor = closestCommonAncestor(ctx, merged, memberNames); return ancestor ? [ancestor] : []; } /** @@ -125,12 +158,17 @@ function resolveMatches(ctx, classes) { } return [...bySymbol.values()]; } +/** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ +function fitsBuiltin(ctx, memberNames) { + return (0, ambient_index_1.builtinValueTypes)(ctx).some((builtin) => (0, type_helpers_1.hasAllMembers)(ctx.checker, builtin, memberNames)); +} /** * Step 4b: matches a usage signature against every ambient class (see - * ./ambient-index). Silent when no class has every member; when only - * ubiquitous members (`.custom`, `.UUID`) were used and several classes fit; - * and when a single member fits several classes the identifier name can't - * choose between. A name that denotes a class the usage does *not* fit + * ./ambient-index) except those describing one global object (`Module`). + * Silent when no class has every member; when only ubiquitous members + * (`.custom`, `.UUID`) were used and several classes fit; when a single + * member fits several classes the identifier name can't choose between; and + * when a JavaScript built-in fits as well and the name doesn't pick a class. A name that denotes a class the usage does *not* fit * never falls back to a vaguer reading of the same name * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and * silences a single-member signature outright: `lineItem.preorderable` @@ -143,7 +181,8 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { if (memberNames.size === 0) return []; const classes = (0, ambient_index_1.getAmbientClasses)(ctx); - const fitting = classes.filter((ambientClass) => [...memberNames].every((name) => ambientClass.memberNames.has(name))); + const fitting = classes.filter((ambientClass) => !constants_1.GLOBAL_OBJECT_CLASSES.has(ambientClass.name) && + [...memberNames].every((name) => ambientClass.memberNames.has(name))); const onlyWeakMembers = [...memberNames].every((name) => constants_1.WEAK_USAGE_MEMBERS.has(name)); if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; @@ -155,10 +194,24 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { if (named) return [named.type]; const thinSignature = memberNames.size < constants_1.MIN_USAGE_SIGNATURE_MEMBERS; - if (thinSignature && (denoted || matches.length > 1)) + if ((thinSignature && (denoted || matches.length > 1)) || fitsBuiltin(ctx, memberNames)) return []; return limitUnion(ctx, matches.map((match) => match.type), memberNames); } +/** + * The required members that can tell candidates apart: those some ambient + * class, JavaScript built-in or candidate declares. A member nothing declares + * (`searchHit.discountedPromotionIDs`, newer than the vendored Script API) + * would otherwise drop every candidate, the right one included. + */ +function checkableMembers(ctx, required, candidates) { + const { checker } = ctx; + const declaredBy = (types, name) => types.some((type) => (0, type_helpers_1.getMemberOfType)(checker, type, name) !== undefined); + const declared = (name) => declaredBy(candidates, name) || + (0, ambient_index_1.getAmbientClasses)(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || + declaredBy((0, ambient_index_1.builtinValueTypes)(ctx), name); + return new Set([...required].filter(declared)); +} /** * Decides what a parameter or variable holds from the evidence ./core * gathered for it (call-site arguments, assigned values, framework @@ -173,12 +226,13 @@ function decideType(ctx, evidence, profile, identifierName, fitUsage) { const guardKeys = new Set(guards.map((type) => (0, type_helpers_1.typeDisplayString)(ctx, type))); const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); const required = requiredMembers(profile); + const checkable = checkableMembers(ctx, required, candidates); const uses = constrainingUses(ctx, profile); const fits = (type) => guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type)) || - ((0, type_helpers_1.hasAllMembers)(ctx.checker, type, required) && fitsEveryUse(ctx, type, uses)); + ((0, type_helpers_1.hasAllMembers)(ctx.checker, type, checkable) && fitsEveryUse(ctx, type, uses)); const fitting = fitUsage ? candidates.filter(fits) : candidates; if (fitting.length > 0) - return limitUnion(ctx, fitting, required); + return limitUnion(ctx, fitting, checkable); const used = mostSpecificUse(ctx, profile, required); if (used.length > 0) return used; diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index f2bb2e6e2..1d861e0b0 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -136,16 +136,23 @@ function classDisplayName(checker, symbol) { /** * Renders a candidate type for hover text and dedupe keys. A class or * interface instantiated only with default type arguments reads as the bare - * class name (`Product`, `LineItemCtnr`); everything else — real generic - * arguments (`Collection`), primitives, object literals, lib types — - * is rendered by the checker as TypeScript itself would. + * class name (`Product`, `LineItemCtnr`); one with real type arguments + * renders them the same way (`Collection`, not the checker's + * `Collection>`). Everything else — primitives, object + * literals, arrays, lib types — is rendered by the checker as TypeScript + * itself would. */ function computeTypeDisplayString(ts, checker, type) { const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; - if (isClassOrInterface && hasOnlyDefaultTypeArguments(ts, checker, type)) + if (!isClassOrInterface || checker.isArrayType(type)) + return checker.typeToString(type); + if (hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); - return checker.typeToString(type); + const typeArguments = checker + .getTypeArguments(type) + .map((argument) => computeTypeDisplayString(ts, checker, argument)); + return `${classDisplayName(checker, symbol)}<${typeArguments.join(', ')}>`; } /** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ function typeDisplayString(ctx, type) { diff --git a/packages/b2c-script-types/src/inference/ambient-index.ts b/packages/b2c-script-types/src/inference/ambient-index.ts index 81fa98357..48e62a763 100644 --- a/packages/b2c-script-types/src/inference/ambient-index.ts +++ b/packages/b2c-script-types/src/inference/ambient-index.ts @@ -21,6 +21,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {BUILTIN_VALUE_TYPES} from './constants'; import type {InferenceContext} from './context'; /** One indexed class or interface. */ @@ -87,3 +88,12 @@ export function findAmbientClassType(ctx: InferenceContext, name: string): tsser named.find((ambientClass) => ambientClass.declaration.getSourceFile().fileName.includes('/dw/')) ?? named[0]; return preferred && ambientClassType(ctx, preferred); } + +/** The declared types of the JavaScript built-ins in BUILTIN_VALUE_TYPES (`String`, `Array`, ...). */ +export function builtinValueTypes(ctx: InferenceContext): tsserver.Type[] { + const {ts, checker} = ctx; + return BUILTIN_VALUE_TYPES.flatMap((name) => { + const symbol = checker.resolveName(name, undefined, ts.SymbolFlags.Type, false); + return symbol ? [checker.getDeclaredTypeOfSymbol(symbol)] : []; + }); +} diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 0cab9afef..9f0ae902f 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -99,6 +99,25 @@ export const UNINFORMATIVE_ANCESTORS: ReadonlySet = new Set(['Object', ' // never picks one class out of several. export const WEAK_USAGE_MEMBERS: ReadonlySet = new Set(['custom', 'UUID', 'toString', 'valueOf']); +// Script API TopLevel classes that describe one global object (`module`, +// `arguments`) rather than a kind of value code passes around. Usage matching +// skips them: `regionDefinition.id` is not a Module. +export const GLOBAL_OBJECT_CLASSES: ReadonlySet = new Set(['Module', 'arguments']); + +// JavaScript built-ins whose members ordinary code uses all the time. A usage +// signature one of them also satisfies (`msg.replace(...)`, `x.length`) is +// ambiguous: the value may well be a string or an array, so ambient matching +// stays silent rather than naming the Script API class that happens to fit. +export const BUILTIN_VALUE_TYPES: readonly string[] = [ + 'String', + 'Number', + 'Boolean', + 'Array', + 'Function', + 'Date', + 'RegExp', +]; + // Callee names whose callbacks lead with the collection element // (`collections.forEach(coll, function (item) {...})`). Only these get the // sibling-collection element-type heuristic; `reduce` (accumulator first) diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index f20265ccf..33c309d03 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -12,28 +12,39 @@ // 1. Normalize: split unions, widen literals, drop types that say nothing // (`null`, `any`, `T`, `{}`), dedupe by display. // 2. Fit: a parameter keeps only candidates exposing every member its body -// relies on — members it tests for first (`'ID' in x`) aside — and able +// relies on — members it tests for first (`'ID' in x`) aside, and members +// no declared type has at all (an API newer than the vendored types, +// an expando property), which can't tell candidates apart — and able // to stand wherever the body passes the value to a documented helper, or // a class its body tests for with `instanceof`/`typeof`. A duck-typed view // model, or a wrong argument at one buggy call site, can't masquerade as // the Script API class the body actually needs. // 3. Union like IntelliJ: up to MAX_UNION_TYPES, after dropping any candidate // whose superclass is also a candidate (`Variant | Product` is `Product`). -// Wider evidence collapses to the closest shared superclass that still -// fits, or stays silent. +// Wider evidence first reads instantiations of one generic class as the +// class (`Collection`, `Collection` -> `Collection`), +// then collapses to the closest shared superclass that still fits, or +// stays silent. // 4. No usable evidence: the most specific type the value is used as // (`ProductMgr.getProduct(pid)` makes `pid` a `string`), then matching the // usage against every ambient class, with the identifier name as a -// tiebreak (see ./naming). +// tiebreak (see ./naming). A usage a JavaScript built-in satisfies too +// (`msg.replace(...)`) stays silent. import type tsserver from 'typescript/lib/tsserverlibrary'; -import {ambientClassType, getAmbientClasses} from './ambient-index'; +import {ambientClassType, builtinValueTypes, getAmbientClasses} from './ambient-index'; import type {AmbientClass} from './ambient-index'; -import {MAX_UNION_TYPES, MIN_USAGE_SIGNATURE_MEMBERS, UNINFORMATIVE_ANCESTORS, WEAK_USAGE_MEMBERS} from './constants'; +import { + GLOBAL_OBJECT_CLASSES, + MAX_UNION_TYPES, + MIN_USAGE_SIGNATURE_MEMBERS, + UNINFORMATIVE_ANCESTORS, + WEAK_USAGE_MEMBERS, +} from './constants'; import type {InferenceContext} from './context'; import {pickByName} from './naming'; -import {dedupeTypes, hasAllMembers, informativeParts, typeDisplayString} from './type-helpers'; +import {dedupeTypes, getMemberOfType, hasAllMembers, informativeParts, typeDisplayString} from './type-helpers'; import type {UsageProfile} from './usage-profile'; const NO_MEMBERS: ReadonlySet = new Set(); @@ -141,7 +152,38 @@ function closestCommonAncestor( ); } -/** Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, else the closest shared superclass, else silence. */ +/** + * The generic class `type` instantiates, when its declared type reads as the + * bare class name. Arrays don't: the declared `Array` renders as `T[]`. + */ +function genericClassOf(ctx: InferenceContext, type: tsserver.Type): tsserver.InterfaceType | undefined { + const generic = classOf(ctx, type); + return generic?.typeParameters?.length && !ctx.checker.isArrayType(type) ? generic : undefined; +} + +/** + * Reads two or more instantiations of the same generic class as the class + * itself: its declared type, whose unbound type parameters render as nothing + * (`Collection` and `Collection` -> `Collection`). + * A lone instantiation keeps its informative type argument. + */ +function mergeInstantiations(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const instantiations = new Map(); + for (const type of types) { + const generic = genericClassOf(ctx, type); + if (generic) instantiations.set(generic, (instantiations.get(generic) ?? 0) + 1); + } + const merged = types.map((type) => { + const generic = genericClassOf(ctx, type); + return generic && (instantiations.get(generic) ?? 0) > 1 ? generic : type; + }); + return dedupeTypes(ctx, merged); +} + +/** + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES; wider evidence + * merged by generic class, else the closest shared superclass, else silence. + */ export function limitUnion( ctx: InferenceContext, types: readonly tsserver.Type[], @@ -149,7 +191,9 @@ export function limitUnion( ): tsserver.Type[] { const general = mostGeneral(ctx, types); if (general.length <= MAX_UNION_TYPES) return general; - const ancestor = closestCommonAncestor(ctx, general, memberNames); + const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); + if (merged.length <= MAX_UNION_TYPES) return merged; + const ancestor = closestCommonAncestor(ctx, merged, memberNames); return ancestor ? [ancestor] : []; } @@ -182,12 +226,18 @@ function resolveMatches(ctx: InferenceContext, classes: readonly AmbientClass[]) return [...bySymbol.values()]; } +/** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ +function fitsBuiltin(ctx: InferenceContext, memberNames: ReadonlySet): boolean { + return builtinValueTypes(ctx).some((builtin) => hasAllMembers(ctx.checker, builtin, memberNames)); +} + /** * Step 4b: matches a usage signature against every ambient class (see - * ./ambient-index). Silent when no class has every member; when only - * ubiquitous members (`.custom`, `.UUID`) were used and several classes fit; - * and when a single member fits several classes the identifier name can't - * choose between. A name that denotes a class the usage does *not* fit + * ./ambient-index) except those describing one global object (`Module`). + * Silent when no class has every member; when only ubiquitous members + * (`.custom`, `.UUID`) were used and several classes fit; when a single + * member fits several classes the identifier name can't choose between; and + * when a JavaScript built-in fits as well and the name doesn't pick a class. A name that denotes a class the usage does *not* fit * never falls back to a vaguer reading of the same name * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and * silences a single-member signature outright: `lineItem.preorderable` @@ -203,8 +253,10 @@ export function matchAmbientTypesByUsage( ): tsserver.Type[] { if (memberNames.size === 0) return []; const classes = getAmbientClasses(ctx); - const fitting = classes.filter((ambientClass) => - [...memberNames].every((name) => ambientClass.memberNames.has(name)), + const fitting = classes.filter( + (ambientClass) => + !GLOBAL_OBJECT_CLASSES.has(ambientClass.name) && + [...memberNames].every((name) => ambientClass.memberNames.has(name)), ); const onlyWeakMembers = [...memberNames].every((name) => WEAK_USAGE_MEMBERS.has(name)); if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; @@ -215,7 +267,7 @@ export function matchAmbientTypesByUsage( : identifierName && pickByName(identifierName, matches); if (named) return [named.type]; const thinSignature = memberNames.size < MIN_USAGE_SIGNATURE_MEMBERS; - if (thinSignature && (denoted || matches.length > 1)) return []; + if ((thinSignature && (denoted || matches.length > 1)) || fitsBuiltin(ctx, memberNames)) return []; return limitUnion( ctx, matches.map((match) => match.type), @@ -223,6 +275,27 @@ export function matchAmbientTypesByUsage( ); } +/** + * The required members that can tell candidates apart: those some ambient + * class, JavaScript built-in or candidate declares. A member nothing declares + * (`searchHit.discountedPromotionIDs`, newer than the vendored Script API) + * would otherwise drop every candidate, the right one included. + */ +function checkableMembers( + ctx: InferenceContext, + required: ReadonlySet, + candidates: readonly tsserver.Type[], +): ReadonlySet { + const {checker} = ctx; + const declaredBy = (types: readonly tsserver.Type[], name: string): boolean => + types.some((type) => getMemberOfType(checker, type, name) !== undefined); + const declared = (name: string): boolean => + declaredBy(candidates, name) || + getAmbientClasses(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || + declaredBy(builtinValueTypes(ctx), name); + return new Set([...required].filter(declared)); +} + /** * Decides what a parameter or variable holds from the evidence ./core * gathered for it (call-site arguments, assigned values, framework @@ -243,12 +316,13 @@ export function decideType( const guardKeys = new Set(guards.map((type) => typeDisplayString(ctx, type))); const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); const required = requiredMembers(profile); + const checkable = checkableMembers(ctx, required, candidates); const uses = constrainingUses(ctx, profile); const fits = (type: tsserver.Type): boolean => guardKeys.has(typeDisplayString(ctx, type)) || - (hasAllMembers(ctx.checker, type, required) && fitsEveryUse(ctx, type, uses)); + (hasAllMembers(ctx.checker, type, checkable) && fitsEveryUse(ctx, type, uses)); const fitting = fitUsage ? candidates.filter(fits) : candidates; - if (fitting.length > 0) return limitUnion(ctx, fitting, required); + if (fitting.length > 0) return limitUnion(ctx, fitting, checkable); const used = mostSpecificUse(ctx, profile, required); if (used.length > 0) return used; return matchAmbientTypesByUsage(ctx, profile.memberNames, identifierName); diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index b380ad66f..a70862115 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -139,16 +139,22 @@ function classDisplayName(checker: tsserver.TypeChecker, symbol: tsserver.Symbol /** * Renders a candidate type for hover text and dedupe keys. A class or * interface instantiated only with default type arguments reads as the bare - * class name (`Product`, `LineItemCtnr`); everything else — real generic - * arguments (`Collection`), primitives, object literals, lib types — - * is rendered by the checker as TypeScript itself would. + * class name (`Product`, `LineItemCtnr`); one with real type arguments + * renders them the same way (`Collection`, not the checker's + * `Collection>`). Everything else — primitives, object + * literals, arrays, lib types — is rendered by the checker as TypeScript + * itself would. */ function computeTypeDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): string { const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; - if (isClassOrInterface && hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); - return checker.typeToString(type); + if (!isClassOrInterface || checker.isArrayType(type)) return checker.typeToString(type); + if (hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); + const typeArguments = checker + .getTypeArguments(type as tsserver.TypeReference) + .map((argument) => computeTypeDisplayString(ts, checker, argument)); + return `${classDisplayName(checker, symbol)}<${typeArguments.join(', ')}>`; } /** computeTypeDisplayString() memoized per request — see InferenceContext.typeDisplayStrings. */ diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 1efb8a5d5..505dc53fc 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -525,5 +525,17 @@ "globals": " function applyShippingCost(basket: Basket): void;", "target": {"file": "/helpers.js", "function": "recalculate", "param": 0}, "expect": "Basket" + }, + { + "id": "sfra-collections-foreach-many-element-types", + "source": "SFRA scripts/util/collections.forEach", + "description": "Collections of more element types than a union shows read as Collection instead of silence", + "dwTypes": ["Collection", "Product", "Category", "Shipment", "ProductLineItem"], + "globals": "function getProducts(): Collection;\nfunction getCategories(): Collection;\nfunction getShipments(): Collection;\nfunction getLineItems(): Collection;", + "files": { + "/collections.js": "function forEach(collection, callback, scope) {\n var iterator = Object.hasOwnProperty.call(collection, 'iterator') ? collection.iterator() : collection;\n var index = 0;\n while (iterator.hasNext()) {\n var item = iterator.next();\n if (scope) callback.call(scope, item, index++, collection);\n else callback(item, index++, collection);\n }\n}\nforEach(getProducts(), function () {});\nforEach(getCategories(), function () {});\nforEach(getShipments(), function () {});\nforEach(getLineItems(), function () {});\nmodule.exports = {forEach: forEach};\n" + }, + "target": {"file": "/collections.js", "function": "forEach", "param": 0}, + "expect": "Collection" } ] diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 0cca6bfe1..a389be6a0 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -338,17 +338,18 @@ function silentReason(languageService, fileName, position) { const request = freshRequest(languageService, fileName); const param = request && findNodeAt(request.sourceFile, position, ts.isIdentifier)?.parent; if (!param || !ts.isParameter(param)) return 'not-a-parameter'; - const {ctx} = request; - const fn = param.parent; - const nameNode = getReferenceNameNode(fn, ts); - if (!nameNode) return 'anonymous-callback'; + const nameNode = getReferenceNameNode(param.parent, ts); + return nameNode ? callSiteSilence(request.ctx, nameNode, param) : 'anonymous-callback'; +} + +/** Classifies a silent parameter of a named function by what its call sites gave. */ +function callSiteSilence(ctx, nameNode, param) { const sites = collectCallSites(ctx, nameNode); - const exhausted = () => ctx.searchBudget <= 0 || ctx.referenceBudget <= 0; - if (sites.length === 0) return exhausted() ? 'budget' : 'no-call-sites'; - const index = fn.parameters.indexOf(param); + const index = param.parent.parameters.indexOf(param); const evidence = sites.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); if (evidence.length > 0) return 'dropped-by-policy'; - return exhausted() ? 'budget' : 'no-evidence'; + if (ctx.searchBudget <= 0 || ctx.referenceBudget <= 0) return 'budget'; + return sites.length === 0 ? 'no-call-sites' : 'no-evidence'; } const SLOWEST_REPORTED = 20; diff --git a/packages/b2c-script-types/test/usage-match.test.js b/packages/b2c-script-types/test/usage-match.test.js index 02728ab61..3c99a5501 100644 --- a/packages/b2c-script-types/test/usage-match.test.js +++ b/packages/b2c-script-types/test/usage-match.test.js @@ -768,3 +768,144 @@ describe('usage-inference — ambient-class index', () => { assert.equal(describeTypes(ctx, types), ''); }); }); + +describe('usage-inference — precision guards', () => { + const inferFirstParam = (files, fnName) => { + const {ctx, fn} = setupInference(files, '/helpers.js', fnName); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + }; + + it('never matches a class describing one global object (TopLevel Module for a lone `.id`)', () => { + const files = { + '/types.d.ts': 'declare class Module { cartridge: string; id: string; exports: any; }\n', + '/helpers.js': 'function getRegionId(regionDefinition) {\n return regionDefinition.id;\n}\n', + }; + + assert.equal(inferFirstParam(files, 'getRegionId'), ''); + }); + + it('stays silent when a JavaScript built-in has every used member too (`msg.replace` is a string, not XML)', () => { + const files = { + '/types.d.ts': 'declare class XML { replace(propertyName: any, value: any): XML; copy(): XML; }\n', + '/helpers.js': "function filterLogMessage(msg) {\n return msg.replace(/secret/g, '***');\n}\n", + }; + + assert.equal(inferFirstParam(files, 'filterLogMessage'), ''); + }); + + it('still lets the identifier name pick a Script API class a built-in also fits (calendar.getTime())', () => { + const files = { + '/types.d.ts': 'declare class Calendar { getTime(): any; setTimeZone(zone: string): void; }\n', + '/helpers.js': 'function toMillis(calendar) {\n return calendar.getTime();\n}\n', + }; + + assert.equal(inferFirstParam(files, 'toMillis'), 'Calendar'); + }); + + describe('members no declared type has (vendored API older than the platform, expando properties)', () => { + const SEARCH_HIT_TYPES = + 'declare class SearchHit { productID: string; getProduct(): any; }\ndeclare function getHit(): SearchHit;\n'; + + it('keep the call-site type the body otherwise fits (getPromotions(searchHit) shape)', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': ` + function getPromotions(searchHit) { + return searchHit.discountedPromotionIDs.concat(searchHit.productID); + } + getPromotions(getHit()); + `, + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), 'SearchHit'); + }); + + it('still drop a candidate when another candidate declares the member', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': ` + function getPromotions(searchHit) { + return searchHit.discountedPromotionIDs.concat(searchHit.productID); + } + getPromotions(getHit()); + getPromotions({productID: 'p1', discountedPromotionIDs: []}); + `, + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), '{ productID: string; discountedPromotionIDs: never[]; }'); + }); + + it('keep ambient matching strict when there is no call site', () => { + const files = { + '/types.d.ts': SEARCH_HIT_TYPES, + '/helpers.js': + 'function getPromotions(searchHit) {\n return searchHit.discountedPromotionIDs.concat(searchHit.productID);\n}\n', + }; + + assert.equal(inferFirstParam(files, 'getPromotions'), ''); + }); + }); + + describe('instantiations of one generic class', () => { + const COLLECTION_TYPES = realTypesPrelude( + ['Collection', 'Product', 'Category', 'Shipment', 'Order'], + ` + function getProducts(): Collection; + function getCategories(): Collection; + function getShipments(): Collection; + function getOrders(): Collection; + `, + ); + + it('read as the class itself when they exceed the union limit (SFRA collections.forEach(collection))', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/helpers.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) callback(iterator.next()); + } + forEach(getProducts(), function () {}); + forEach(getCategories(), function () {}); + forEach(getShipments(), function () {}); + forEach(getOrders(), function () {}); + `, + }; + + assert.equal(inferFirstParam(files, 'forEach'), 'Collection'); + }); + + it('never merge arrays: the declared Array would read as `T[]`', () => { + const files = { + '/types.d.ts': '', + '/helpers.js': ` + function wrapList(files) { + return {size: function () { return files.length; }}; + } + wrapList([{a: 1}]); + wrapList([{b: 'x'}]); + wrapList([{c: true}]); + wrapList([{d: null, e: 1}]); + `, + }; + + assert.equal(inferFirstParam(files, 'wrapList'), ''); + }); + + it('keep their element types while the union stays small', () => { + const files = { + '/types.d.ts': COLLECTION_TYPES, + '/helpers.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) callback(iterator.next()); + } + forEach(getProducts(), function () {}); + forEach(getCategories(), function () {}); + `, + }; + + assert.equal(inferFirstParam(files, 'forEach'), 'Collection | Collection'); + }); + }); +}); From 33fdc62e54764d882517a8b10df8a2227987b256 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:02:46 +0000 Subject: [PATCH 55/76] feat(script-types): infer arrays, element access, member values, generic pass-through and downcasts Usage inference now follows values that reach a parameter, return or variable through more than a direct call: - arrays built with push()/unshift()/x[i] = v, read back by index, by native array methods (filter, find, map callbacks typed from the lib's own declarations) or spread; arrays render as `Product[]` - element access on Script API collections without an index signature - untyped members read as the values they were built with: object literal members, shorthand members, this.member in prototype methods, and members written on untyped locals - generic Script API calls whose result is an argument or a callback's return (Transaction.wrap(cb), identity(x)), which JavaScript otherwise instantiates to any - a call-site argument class whose own members do not cover the body's usage narrows to its single fitting subclass (PaymentInstrument -> OrderPaymentInstrument), and stays the base class when several fit Callee resolution and the element-first callback test move into signatures.ts. The ground-truth harness now scores arrays element-wise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../b2c-script-types/plugin/inference/core.js | 160 +++++++--- .../plugin/inference/generic-calls.js | 57 ++++ .../plugin/inference/member-values.js | 57 ++++ .../plugin/inference/policy.js | 29 +- .../plugin/inference/signatures.js | 68 ++++ .../plugin/inference/type-helpers.js | 37 ++- .../plugin/inference/usage-profile.js | 51 ++- .../b2c-script-types/src/inference/core.ts | 210 ++++++++---- .../src/inference/generic-calls.ts | 72 +++++ .../src/inference/member-values.ts | 73 +++++ .../b2c-script-types/src/inference/policy.ts | 40 ++- .../src/inference/signatures.ts | 94 ++++++ .../src/inference/type-helpers.ts | 49 ++- .../src/inference/usage-profile.ts | 69 +++- .../b2c-script-types/test/corpus/cases.json | 17 + .../test/eval/ground-truth.test.js | 11 +- .../test/helpers/real-dw-types.js | 3 + .../test/usage-inference.value-flow.test.js | 300 ++++++++++++++++++ 18 files changed, 1247 insertions(+), 150 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/generic-calls.js create mode 100644 packages/b2c-script-types/plugin/inference/member-values.js create mode 100644 packages/b2c-script-types/plugin/inference/signatures.js create mode 100644 packages/b2c-script-types/src/inference/generic-calls.ts create mode 100644 packages/b2c-script-types/src/inference/member-values.ts create mode 100644 packages/b2c-script-types/src/inference/signatures.ts create mode 100644 packages/b2c-script-types/test/usage-inference.value-flow.test.js diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 1b0f78efc..8185ab7a4 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -14,27 +14,16 @@ const context_1 = require("./context"); const ast_helpers_1 = require("./ast-helpers"); const call_sites_1 = require("./call-sites"); const framework_contracts_1 = require("./framework-contracts"); +const generic_calls_1 = require("./generic-calls"); +const member_values_1 = require("./member-values"); const policy_1 = require("./policy"); +const signatures_1 = require("./signatures"); const super_module_1 = require("./super-module"); const type_helpers_1 = require("./type-helpers"); const usage_profile_1 = require("./usage-profile"); function identifierText(ctx, name) { return ctx.ts.isIdentifier(name) ? name.text : 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); - const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; - if (decl && ts.isFunctionLike(decl)) - return decl; - const sigDecl = checker.getResolvedSignature(call)?.declaration; - return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; -} /** * Everything a local variable can hold: its initializer plus every later * `x = value` assignment (`var result = null; if (a) result = x; else result @@ -52,11 +41,24 @@ function resolveVariableTypes(ctx, decl, depth, chainHops) { return []; return (0, context_1.withCycleGuard)(ctx, decl, [], () => { const profile = (0, usage_profile_1.usageProfileOf)(ctx, decl); - const values = decl.initializer ? [decl.initializer, ...profile.assignedValues] : profile.assignedValues; - const evidence = values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops)); + const initial = decl.initializer + ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) + : []; + const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; return (0, policy_1.decideType)(ctx, evidence, profile, identifierText(ctx, decl.name), false); }); } +/** A variable's initializer; an array literal later filled by `items.push(x)` is an array of what it holds. */ +function resolveInitializerTypes(ctx, initializer, pushedValues, depth, chainHops) { + if (pushedValues.length === 0 || !ctx.ts.isArrayLiteralExpression(initializer)) { + return resolveExpressionTypes(ctx, initializer, depth, chainHops); + } + return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); +} +/** The types of several values the same thing can hold, each one hop further along the expression. */ +function resolveValues(ctx, values, depth, chainHops) { + return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); +} /** * The types one `module.exports = X` assignment of a superModule level * contributes. The checker's type for `module.exports` is used when it is @@ -107,28 +109,27 @@ function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chai return []; } /** - * Infers the type of a callback's first parameter from sibling arguments of - * the call the callback is passed to: `collections.forEach` / `map` / - * `filter` / `every` / `some` / `find` / `first` (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. Unknown callees and - * `reduce` (accumulator first) are left alone — applying the heuristic to an - * arbitrary helper would guess wrong more often than it helps. + * Infers a callback parameter's type from the call the callback is passed + * to. A method on a known receiver declares it (`value: T` of + * `items.filter(function (item) {...})` once `items` is inferred as a + * `ProductLineItem[]`). Otherwise, for an element-first helper + * (`collections.forEach(coll, fn)`, see {@link isElementFirstCallbackCall}), + * the first parameter is an element of the collection travelling alongside + * it. Unknown helpers 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 } = ctx; const call = fn.parent; - if (paramIndex !== 0 || !call || !ts.isCallExpression(call) || !call.arguments.includes(fn)) { - return []; - } - const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; - if (!ts.isIdentifier(callee) || !constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text)) + if (!call || !ctx.ts.isCallExpression(call)) return []; - return call.arguments - .filter((arg) => arg !== fn) - .flatMap((arg) => resolveExpressionTypes(ctx, arg, depth).map((type) => (0, type_helpers_1.collectionElementType)(ctx, type, arg))) - .filter((element) => element !== undefined); + const argIndex = call.arguments.indexOf(fn); + const access = call.expression; + const declared = ctx.ts.isPropertyAccessExpression(access) + ? (0, signatures_1.callbackParameterTypes)(ctx, memberTypesOfReceiver(ctx, access.expression, access.name, depth, 0), call, argIndex, paramIndex) + : []; + if (declared.length > 0 || paramIndex !== 0 || !(0, signatures_1.isElementFirstCallbackCall)(ctx, call)) + return declared; + return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); } /** * Resolves the candidate type(s) of `expr`: the checker's own type when it is @@ -171,6 +172,10 @@ function resolveFromParts(ctx, expr, depth, chainHops) { return resolveIdentifierTypes(ctx, expr, depth, chainHops); if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + if (ts.isElementAccessExpression(expr)) + return resolveElementAccessTypes(ctx, expr, depth, chainHops); + if (ts.isArrayLiteralExpression(expr)) + return resolveArrayTypes(ctx, expr.elements, depth, chainHops); // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) // and `a || b` / `a ?? b` can each evaluate to either side. if (ts.isConditionalExpression(expr)) { @@ -178,6 +183,28 @@ function resolveFromParts(ctx, expr, depth, chainHops) { } return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; } +/** The element types of whatever arrays or collections `collection` holds. */ +function resolveElementTypes(ctx, collection, depth, chainHops) { + return resolveExpressionTypes(ctx, collection, depth, chainHops + 1).flatMap((type) => (0, type_helpers_1.elementTypeOf)(ctx, type, collection) ?? []); +} +/** `x[i]` is an element of `x` (a Script API collection has no index signature to say so); `x['m']` reads member `m`. */ +function resolveElementAccessTypes(ctx, expr, depth, chainHops) { + const index = expr.argumentExpression; + if (ctx.ts.isStringLiteralLike(index)) { + return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + } + return (0, type_helpers_1.dedupeTypes)(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); +} +/** `[a, ...rest]`: an array of the one type its elements share; elements of unrelated types name no array. */ +function resolveArrayTypes(ctx, elements, depth, chainHops) { + const { ts } = ctx; + const elementTypes = elements.flatMap((element) => ts.isSpreadElement(element) + ? resolveElementTypes(ctx, element.expression, depth, chainHops) + : resolveExpressionTypes(ctx, element, depth, chainHops + 1)); + const [element, ...others] = (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, elementTypes)); + const array = element && others.length === 0 ? (0, type_helpers_1.arrayTypeOf)(ctx, element) : undefined; + return array ? [array] : []; +} function resolveAlternatives(ctx, alternatives, depth, chainHops) { return (0, type_helpers_1.dedupeTypes)(ctx, alternatives.flatMap((alternative) => resolveExpressionTypes(ctx, alternative, depth, chainHops + 1))); } @@ -195,23 +222,43 @@ function resolveLogicalTypes(ctx, expr, depth, chainHops) { } } /** - * The declared types of `access`'s member on each candidate type of its - * receiver (`x.getPriceModel` when `x` is undocumented but inferable). When - * no receiver type carries the member and the receiver is (an alias of) + * The types of member `name` on each candidate type of `receiver` + * (`x.getPriceModel` when `x` is undocumented but inferable). When no + * receiver type carries the member and the receiver is (an alias of) * `module.superModule`, the member may be an export augmentation added by a - * pass-through overlay level, which no export type can carry. + * pass-through overlay level, which no export type can carry. A member the + * checker binds on a receiver it types as nothing worth showing (`this` in a + * prototype method) is the member it binds; on an untyped local receiver, it + * is whatever the code writes to it (`productData.apiProduct = ...`). */ -function memberTypesOfReceiver(ctx, access, depth, chainHops) { +function memberTypesOfReceiver(ctx, receiver, name, depth, chainHops) { const { ts, checker } = ctx; - const memberName = access.name.text; - const types = resolveExpressionTypes(ctx, access.expression, depth, chainHops + 1).flatMap((receiverType) => { + const memberName = name.text; + const types = resolveExpressionTypes(ctx, receiver, depth, chainHops + 1).flatMap((receiverType) => { const member = (0, type_helpers_1.getMemberOfType)(checker, receiverType, memberName); - return member ? [checker.getTypeOfSymbolAtLocation(member, access.name)] : []; + return member ? memberTypes(ctx, member, name, depth, chainHops) : []; }); if (types.length > 0) return types; - const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, access.expression); - return superAccess ? resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) : []; + const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, receiver); + if (superAccess) + return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops); + const bound = checker.getSymbolAtLocation(name); + if (bound) + return memberTypes(ctx, bound, name, depth, chainHops); + return resolveValues(ctx, (0, member_values_1.localMemberValues)(ctx, receiver, memberName), depth, chainHops); +} +/** + * A member's declared type, or — when that says nothing, as for most + * undocumented members (`{apiProduct: apiProduct}`, `this.productSearch = + * productSearch`) — the types of the values it is declared with. + */ +function memberTypes(ctx, member, location, depth, chainHops) { + const declared = ctx.checker.getTypeOfSymbolAtLocation(member, location); + const declaration = member.valueDeclaration ?? member.declarations?.[0]; + if ((0, type_helpers_1.informativeParts)(ctx, declared).length > 0 || !declaration) + return [declared]; + return (0, context_1.withCycleGuard)(ctx, declaration, [], () => resolveValues(ctx, (0, member_values_1.memberValueExpressions)(ctx, member), depth, chainHops)); } /** * The return types of `methodType`'s call signatures. A signature returning @@ -235,20 +282,33 @@ function signatureReturnTypes(ctx, methodType, depth) { * the receiver's inferred type(s). */ function resolveCallResultTypes(ctx, expr, depth, chainHops) { - const calleeFn = resolveCalleeDeclaration(ctx, expr); + const calleeFn = (0, signatures_1.resolveCalleeDeclaration)(ctx, expr); const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; - if (inferred.length > 0 || !ctx.ts.isPropertyAccessExpression(expr.expression)) + if (inferred.length > 0) return inferred; - return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, depth, chainHops).flatMap((methodType) => signatureReturnTypes(ctx, methodType, depth))); + const generic = (0, generic_calls_1.genericResultSource)(ctx, expr); + if (generic) { + return generic.kind === 'return' + ? inferReturnType(ctx, generic.fn, depth + 1) + : resolveExpressionTypes(ctx, generic.argument, depth, chainHops + 1); + } + const callee = expr.expression; + if (!ctx.ts.isPropertyAccessExpression(callee)) + return []; + return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, callee.expression, callee.name, depth, chainHops).flatMap((methodType) => signatureReturnTypes(ctx, methodType, depth))); } /** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ function resolvePropertyTypes(ctx, expr, depth, chainHops) { - return (0, type_helpers_1.dedupeTypes)(ctx, memberTypesOfReceiver(ctx, expr, depth, chainHops).filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); + return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); +} +/** The member types worth keeping as a property's value. */ +function resolveMemberValueTypes(ctx, types) { + return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); } /** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { - const { ts, checker } = ctx; - const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + const { ts } = ctx; + const decl = (0, member_values_1.valueDeclarationOf)(ctx, expr); if (decl && ts.isParameter(decl)) return inferParameterType(ctx, decl, depth + 1); if (decl && ts.isVariableDeclaration(decl)) diff --git a/packages/b2c-script-types/plugin/inference/generic-calls.js b/packages/b2c-script-types/plugin/inference/generic-calls.js new file mode 100644 index 000000000..6d0b23ff6 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/generic-calls.js @@ -0,0 +1,57 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.genericResultSource = genericResultSource; +/** True when `node` is a bare reference to the type parameter `name` (`T`). */ +function isTypeParameterReference(ts, node, name) { + return (node !== undefined && + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === name && + node.typeArguments === undefined); +} +/** The function `argument` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +function functionOf(ctx, argument) { + const { ts, checker } = ctx; + if (ts.isFunctionExpression(argument) || ts.isArrowFunction(argument)) + return argument; + const declaration = ts.isIdentifier(argument) ? checker.getSymbolAtLocation(argument)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} +/** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ +function returnedTypeParameter(ts, declaration) { + const result = declaration.type; + const name = result && ts.isTypeReferenceNode(result) && ts.isIdentifier(result.typeName) && result.typeName.text; + return name && declaration.typeParameters?.some((parameter) => parameter.name.text === name) ? name : undefined; +} +/** + * Where `call`'s result comes from when its callee's declaration returns a + * type parameter it takes as an argument (`identity(value: T): T`) or as + * a callback's result (`wrap(callback: () => T): T`). + */ +function genericResultSource(ctx, call) { + const { ts, checker } = ctx; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || ts.isJSDocSignature(declaration)) + return undefined; + const typeParameter = returnedTypeParameter(ts, declaration); + if (!typeParameter) + return undefined; + for (const [index, parameter] of declaration.parameters.entries()) { + const argument = call.arguments[index]; + const type = parameter.type; + if (!argument || !type) + continue; + if (isTypeParameterReference(ts, type, typeParameter)) + return { kind: 'value', argument }; + const fn = ts.isFunctionTypeNode(type) && isTypeParameterReference(ts, type.type, typeParameter); + const callback = fn ? functionOf(ctx, argument) : undefined; + if (callback) + return { kind: 'return', fn: callback }; + } + return undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/member-values.js b/packages/b2c-script-types/plugin/inference/member-values.js new file mode 100644 index 000000000..de88e2397 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/member-values.js @@ -0,0 +1,57 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.memberValueExpressions = memberValueExpressions; +exports.localMemberValues = localMemberValues; +exports.valueDeclarationOf = valueDeclarationOf; +const usage_profile_1 = require("./usage-profile"); +/** The value of one member declaration: `key: value`, `{key}`, `key = value;` in a class, `this.key = value`. */ +function declaredValue(ctx, declaration) { + const { ts } = ctx; + if (ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) + return declaration.initializer; + if (ts.isShorthandPropertyAssignment(declaration)) + return declaration.name; + const assignment = ts.isBinaryExpression(declaration) ? declaration : declaration.parent; + const isAssignment = assignment !== undefined && + ts.isBinaryExpression(assignment) && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} +/** + * The values a member symbol is declared with: an object literal's + * `key: value` and `{key}` shorthand, a class field initializer, and the + * right-hand sides of `this.key = value` / `obj.key = value` assignments a + * JavaScript constructor or expando declares it by. + */ +function memberValueExpressions(ctx, member) { + return (member.declarations ?? []).flatMap((declaration) => declaredValue(ctx, declaration) ?? []); +} +/** + * The values the code writes to `memberName` on an untyped local receiver + * (`productData.apiProduct = ProductMgr.getProduct(id)`), read back later + * through the same parameter or variable (`return productData.apiProduct`). + */ +function localMemberValues(ctx, receiver, memberName) { + const { ts } = ctx; + const declaration = ts.isIdentifier(receiver) ? valueDeclarationOf(ctx, receiver) : undefined; + const isLocal = declaration && (ts.isParameter(declaration) || ts.isVariableDeclaration(declaration)); + return isLocal ? ((0, usage_profile_1.usageProfileOf)(ctx, declaration).memberValues.get(memberName) ?? []) : []; +} +/** + * The declaration of the value `identifier` reads. The name of a `{key}` + * shorthand resolves to the object literal's property, not to the variable + * whose value it copies, so that one asks the checker for the value symbol. + */ +function valueDeclarationOf(ctx, identifier) { + const { ts, checker } = ctx; + const shorthand = ts.isShorthandPropertyAssignment(identifier.parent) ? identifier.parent : undefined; + const symbol = shorthand + ? checker.getShorthandAssignmentValueSymbol(shorthand) + : checker.getSymbolAtLocation(identifier); + return symbol?.valueDeclaration; +} diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index 2cfafde9a..3f8b2aa4e 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -198,6 +198,25 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { return []; return limitUnion(ctx, matches.map((match) => match.type), memberNames); } +/** + * What a candidate lacking some members the body uses can still be: the one + * subclass that has them all (a body relying on `paymentTransaction` is + * handed `OrderPaymentInstrument`s, whatever the argument is documented as), + * or — when several subclasses would do (`getMasterProduct()` on a `Variant` + * or a `VariationGroup`, called once the body knows which) — the candidate + * itself. Root classes (`PersistentObject`, ...) never stand in for a + * subclass this way; nearly every class extends them. + */ +function downcastOf(ctx, type, memberNames) { + const own = classOf(ctx, type); + if (!own || constants_1.UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) + return []; + const fitting = (0, ambient_index_1.getAmbientClasses)(ctx).filter((ambientClass) => [...memberNames].every((name) => ambientClass.memberNames.has(name))); + const subclasses = mostGeneral(ctx, resolveMatches(ctx, fitting) + .map((match) => match.type) + .filter((subclass) => isAncestorOf(ctx, type, subclass))); + return subclasses.length > 1 ? [type] : subclasses; +} /** * The required members that can tell candidates apart: those some ambient * class, JavaScript built-in or candidate declares. A member nothing declares @@ -228,9 +247,13 @@ function decideType(ctx, evidence, profile, identifierName, fitUsage) { const required = requiredMembers(profile); const checkable = checkableMembers(ctx, required, candidates); const uses = constrainingUses(ctx, profile); - const fits = (type) => guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type)) || - ((0, type_helpers_1.hasAllMembers)(ctx.checker, type, checkable) && fitsEveryUse(ctx, type, uses)); - const fitting = fitUsage ? candidates.filter(fits) : candidates; + const fit = (type) => { + if (guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type))) + return [type]; + const fitted = (0, type_helpers_1.hasAllMembers)(ctx.checker, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); + }; + const fitting = fitUsage ? (0, type_helpers_1.dedupeTypes)(ctx, candidates.flatMap(fit)) : candidates; if (fitting.length > 0) return limitUnion(ctx, fitting, checkable); const used = mostSpecificUse(ctx, profile, required); diff --git a/packages/b2c-script-types/plugin/inference/signatures.js b/packages/b2c-script-types/plugin/inference/signatures.js new file mode 100644 index 000000000..40147546b --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/signatures.js @@ -0,0 +1,68 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.resolveCalleeDeclaration = resolveCalleeDeclaration; +exports.isElementFirstCallbackCall = isElementFirstCallbackCall; +exports.acceptsArgumentCount = acceptsArgumentCount; +exports.callbackParameterTypes = callbackParameterTypes; +const constants_1 = require("./constants"); +const type_helpers_1 = require("./type-helpers"); +/** + * Resolves the function-like declaration a call expression's callee refers + * to, via its symbol or — as a fallback for shapes the symbol lookup misses + * — 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 sigDecl = checker.getResolvedSignature(call)?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; +} +/** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ +function isElementFirstCallbackCall(ctx, call) { + const { ts } = ctx; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + return ts.isIdentifier(callee) && constants_1.ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text); +} +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +function acceptsArgumentCount(ctx, signature, count) { + const { ts, checker } = ctx; + const declaration = signature.getDeclaration(); + if (!declaration) + return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} +/** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ +function callbackParameterType(ctx, signature, call, argIndex, paramIndex) { + const { checker } = ctx; + const parameter = signature.getParameters()[argIndex]; + const callbackType = parameter && checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(parameter, call)); + const callbackParameter = callbackType?.getCallSignatures()[0]?.getParameters()[paramIndex]; + return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); +} +/** + * The types `methodTypes`' declared signatures give parameter `paramIndex` + * of the callback `call` passes as argument `argIndex`: `value: T` of + * `filter(predicate: (value: T, ...) => unknown)` on a `ProductLineItem[]`. + * Overloads taking that many arguments must agree, so the accumulator of + * `reduce(callback, initialValue)` (`T` or `U`, by overload) stays unknown. + */ +function callbackParameterTypes(ctx, methodTypes, call, argIndex, paramIndex) { + return methodTypes.flatMap((methodType) => { + const overloads = methodType + .getCallSignatures() + .filter((signature) => acceptsArgumentCount(ctx, signature, call.arguments.length)); + const [first, ...rest] = overloads.map((signature) => callbackParameterType(ctx, signature, call, argIndex, paramIndex)); + const key = first && (0, type_helpers_1.typeDisplayString)(ctx, first); + return first && rest.every((type) => type && (0, type_helpers_1.typeDisplayString)(ctx, type) === key) ? [first] : []; + }); +} diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index 1d861e0b0..1496f88a0 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -12,7 +12,8 @@ exports.typeDisplayString = typeDisplayString; exports.dedupeTypes = dedupeTypes; exports.getMemberOfType = getMemberOfType; exports.hasAllMembers = hasAllMembers; -exports.collectionElementType = collectionElementType; +exports.elementTypeOf = elementTypeOf; +exports.arrayTypeOf = arrayTypeOf; exports.describeTypes = describeTypes; exports.inferredCompletionEntry = inferredCompletionEntry; exports.typesToCompletionEntries = typesToCompletionEntries; @@ -138,14 +139,19 @@ function classDisplayName(checker, symbol) { * interface instantiated only with default type arguments reads as the bare * class name (`Product`, `LineItemCtnr`); one with real type arguments * renders them the same way (`Collection`, not the checker's - * `Collection>`). Everything else — primitives, object - * literals, arrays, lib types — is rendered by the checker as TypeScript - * itself would. + * `Collection>`), and an array reads as its element's + * display (`Product[]`). Everything else — primitives, object literals, lib + * types — is rendered by the checker as TypeScript itself would. */ function computeTypeDisplayString(ts, checker, type) { + if (checker.isArrayType(type)) { + const [element] = checker.getTypeArguments(type); + const display = element ? computeTypeDisplayString(ts, checker, element) : 'any'; + return element?.isUnion() ? `(${display})[]` : `${display}[]`; + } const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; - if (!isClassOrInterface || checker.isArrayType(type)) + if (!isClassOrInterface) return checker.typeToString(type); if (hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); @@ -228,6 +234,27 @@ function collectionElementType(ctx, type, location) { return undefined; return element; } +/** + * The element type of an array (`ProductLineItem[]`) or of a Script API + * collection or iterator (see {@link collectionElementType}): what `x[i]`, + * `x.pop()` or a `forEach` callback over `x` sees. + */ +function elementTypeOf(ctx, type, location) { + const { checker } = ctx; + if (checker.isArrayType(type)) + return checker.getTypeArguments(type)[0]; + return collectionElementType(ctx, type, location); +} +/** + * `element[]`, built by the checker itself so an inferred array reads, and + * resolves members (`filter`, `[0]`, `forEach`), exactly like a declared one. + * The factory is internal to TypeScript, so it is feature-detected: a + * TypeScript without it leaves inferred arrays silent rather than wrong. + */ +function arrayTypeOf(ctx, element) { + const factory = ctx.checker; + return typeof factory.createArrayType === 'function' ? factory.createArrayType(element) : undefined; +} /** Renders candidate types as hover text, e.g. `"Product | Category"`. */ function describeTypes(ctx, types) { return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js index 8c9e43d2b..2ebb75e71 100644 --- a/packages/b2c-script-types/plugin/inference/usage-profile.js +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.usageProfileOf = usageProfileOf; +const signatures_1 = require("./signatures"); const type_helpers_1 = require("./type-helpers"); function emptyProfile() { return { @@ -14,6 +15,8 @@ function emptyProfile() { guardTypes: [], contextualTypes: [], assignedValues: [], + pushedValues: [], + memberValues: new Map(), }; } const EMPTY_PROFILE = emptyProfile(); @@ -111,16 +114,6 @@ function declaredArgumentType(ctx, signature, index, call) { return undefined; return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); } -/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ -function acceptsArgumentCount(ctx, signature, count) { - const { ts, checker } = ctx; - const declaration = signature.getDeclaration(); - if (!declaration) - return true; - const parameters = declaration.parameters; - const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; - return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); -} /** * The type argument `index` of `call` is passed as. For an overloaded callee * it counts only when every overload taking that many arguments agrees on it @@ -134,7 +127,7 @@ function argumentType(ctx, call, reference, index) { const argumentCount = call.arguments?.length ?? 0; const callee = checker.getTypeAtLocation(call.expression); const signatures = ts.isNewExpression(call) ? callee.getConstructSignatures() : callee.getCallSignatures(); - const overloads = signatures.filter((signature) => acceptsArgumentCount(ctx, signature, argumentCount)); + const overloads = signatures.filter((signature) => (0, signatures_1.acceptsArgumentCount)(ctx, signature, argumentCount)); if (overloads.length <= 1) return checker.getContextualType(reference); const [first, ...rest] = overloads.map((signature) => declaredArgumentType(ctx, signature, index, call)); @@ -232,7 +225,40 @@ function recordPresenceTest({ ctx, reference }, profile) { recordMember(profile, member, true); return member !== undefined; } -/** `x.m` / `x['m']`: a member the value is accessed by. */ +// Array methods whose arguments become elements of the receiver. +const ELEMENT_ADDING_METHODS = new Set(['push', 'unshift']); +/** The value `access = value` assigns, if `access` is the target of a plain assignment. */ +function valueAssignedTo(ctx, access) { + const { ts } = ctx; + const assignment = access.parent; + const isAssignment = ts.isBinaryExpression(assignment) && + assignment.left === access && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} +/** The values `x.push(a, b)` / `x.unshift(a)` add as elements, when `access` is such a call's callee. */ +function addedElements(ctx, access, member) { + const { ts } = ctx; + const call = access.parent; + if (member === undefined || !ELEMENT_ADDING_METHODS.has(member)) + return undefined; + if (!ts.isCallExpression(call) || call.expression !== access) + return undefined; + return call.arguments.filter((argument) => !ts.isSpreadElement(argument)); +} +/** What an access writes into the value: elements (`x.push(v)`, `x[i] = v`) or a member (`x.m = v`). */ +function recordWrite(ctx, access, member, profile) { + const added = addedElements(ctx, access, member); + const value = added ? undefined : valueAssignedTo(ctx, access); + if (added) + profile.pushedValues.push(...added); + else if (value && member !== undefined) { + profile.memberValues.set(member, [...(profile.memberValues.get(member) ?? []), value]); + } + else if (value && ctx.ts.isElementAccessExpression(access)) + profile.pushedValues.push(value); +} +/** `x.m` / `x['m']`: a member the value is accessed by, and anything the access writes into it. */ function recordMemberAccess({ ctx, reference, inVariantBranch }, profile) { const { ts } = ctx; const access = reference.parent; @@ -242,6 +268,7 @@ function recordMemberAccess({ ctx, reference, inVariantBranch }, profile) { const member = accessedMemberName(ctx, access); if (member !== undefined) recordMember(profile, member, inVariantBranch); + recordWrite(ctx, access, member, profile); return true; } /** `typeof x === 'string'`: a primitive the value is tested against. */ diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 65739a073..0053dca96 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -16,7 +16,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {ELEMENT_FIRST_CALLBACK_CALLEES, MAX_CHAIN_HOPS} from './constants'; +import {MAX_CHAIN_HOPS} from './constants'; import {withCycleGuard, withInferenceGuards} from './context'; import type {InferenceContext} from './context'; import { @@ -27,7 +27,10 @@ import { } from './ast-helpers'; import {collectCallSites, getReferenceNameNode} from './call-sites'; import {frameworkParameterTypes} from './framework-contracts'; +import {genericResultSource} from './generic-calls'; +import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; import {decideType, limitUnion, normalizeCandidates} from './policy'; +import {callbackParameterTypes, isElementFirstCallbackCall, resolveCalleeDeclaration} from './signatures'; import { collectExportAssignments, findSuperModuleFile, @@ -35,30 +38,13 @@ import { superModuleLevels, traceSuperModuleAccess, } from './super-module'; -import {collectionElementType, dedupeTypes, getMemberOfType, informativeParts, isAnyType} from './type-helpers'; +import {arrayTypeOf, dedupeTypes, elementTypeOf, getMemberOfType, informativeParts, isAnyType} from './type-helpers'; import {usageProfileOf} from './usage-profile'; function identifierText(ctx: InferenceContext, name: tsserver.BindingName): string | undefined { return ctx.ts.isIdentifier(name) ? name.text : 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, -): 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 sigDecl = checker.getResolvedSignature(call)?.declaration; - return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; -} - /** * Everything a local variable can hold: its initializer plus every later * `x = value` assignment (`var result = null; if (a) result = x; else result @@ -80,12 +66,38 @@ function resolveVariableTypes( if (hasExplicitVariableType(decl, ctx.ts)) return []; return withCycleGuard(ctx, decl, [], () => { const profile = usageProfileOf(ctx, decl); - const values = decl.initializer ? [decl.initializer, ...profile.assignedValues] : profile.assignedValues; - const evidence = values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops)); + const initial = decl.initializer + ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) + : []; + const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; return decideType(ctx, evidence, profile, identifierText(ctx, decl.name), false); }); } +/** A variable's initializer; an array literal later filled by `items.push(x)` is an array of what it holds. */ +function resolveInitializerTypes( + ctx: InferenceContext, + initializer: tsserver.Expression, + pushedValues: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + if (pushedValues.length === 0 || !ctx.ts.isArrayLiteralExpression(initializer)) { + return resolveExpressionTypes(ctx, initializer, depth, chainHops); + } + return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); +} + +/** The types of several values the same thing can hold, each one hop further along the expression. */ +function resolveValues( + ctx: InferenceContext, + values: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); +} + /** * The types one `module.exports = X` assignment of a superModule level * contributes. The checker's type for `module.exports` is used when it is @@ -163,14 +175,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` / `map` / - * `filter` / `every` / `some` / `find` / `first` (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. Unknown callees and - * `reduce` (accumulator first) are left alone — applying the heuristic to an - * arbitrary helper would guess wrong more often than it helps. + * Infers a callback parameter's type from the call the callback is passed + * to. A method on a known receiver declares it (`value: T` of + * `items.filter(function (item) {...})` once `items` is inferred as a + * `ProductLineItem[]`). Otherwise, for an element-first helper + * (`collections.forEach(coll, fn)`, see {@link isElementFirstCallbackCall}), + * the first parameter is an element of the collection travelling alongside + * it. Unknown helpers are left alone — applying the heuristic to an arbitrary + * helper would guess wrong more often than it helps. */ function inferCallbackParameterTypes( ctx: InferenceContext, @@ -178,17 +190,21 @@ function inferCallbackParameterTypes( paramIndex: number, depth: number, ): tsserver.Type[] { - const {ts} = ctx; const call = fn.parent; - if (paramIndex !== 0 || !call || !ts.isCallExpression(call) || !call.arguments.includes(fn as tsserver.Expression)) { - return []; - } - const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; - if (!ts.isIdentifier(callee) || !ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text)) return []; - return call.arguments - .filter((arg) => arg !== fn) - .flatMap((arg) => resolveExpressionTypes(ctx, arg, depth).map((type) => collectionElementType(ctx, type, arg))) - .filter((element): element is tsserver.Type => element !== undefined); + if (!call || !ctx.ts.isCallExpression(call)) return []; + const argIndex = call.arguments.indexOf(fn as tsserver.Expression); + const access = call.expression; + const declared = ctx.ts.isPropertyAccessExpression(access) + ? callbackParameterTypes( + ctx, + memberTypesOfReceiver(ctx, access.expression, access.name, depth, 0), + call, + argIndex, + paramIndex, + ) + : []; + if (declared.length > 0 || paramIndex !== 0 || !isElementFirstCallbackCall(ctx, call)) return declared; + return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); } /** @@ -237,6 +253,8 @@ function resolveFromParts( if (ts.isPropertyAccessExpression(expr)) return resolvePropertyTypes(ctx, expr, depth, chainHops); if (ts.isIdentifier(expr)) return resolveIdentifierTypes(ctx, expr, depth, chainHops); if (ts.isParenthesizedExpression(expr)) return resolveExpressionTypes(ctx, expr.expression, depth, chainHops); + if (ts.isElementAccessExpression(expr)) return resolveElementAccessTypes(ctx, expr, depth, chainHops); + if (ts.isArrayLiteralExpression(expr)) return resolveArrayTypes(ctx, expr.elements, depth, chainHops); // `it.hasNext() ? it.next() : null` (the body of SFRA's collections.first) // and `a || b` / `a ?? b` can each evaluate to either side. if (ts.isConditionalExpression(expr)) { @@ -245,6 +263,50 @@ function resolveFromParts( return ts.isBinaryExpression(expr) ? resolveLogicalTypes(ctx, expr, depth, chainHops) : []; } +/** The element types of whatever arrays or collections `collection` holds. */ +function resolveElementTypes( + ctx: InferenceContext, + collection: tsserver.Expression, + depth: number, + chainHops: number, +): tsserver.Type[] { + return resolveExpressionTypes(ctx, collection, depth, chainHops + 1).flatMap( + (type) => elementTypeOf(ctx, type, collection) ?? [], + ); +} + +/** `x[i]` is an element of `x` (a Script API collection has no index signature to say so); `x['m']` reads member `m`. */ +function resolveElementAccessTypes( + ctx: InferenceContext, + expr: tsserver.ElementAccessExpression, + depth: number, + chainHops: number, +): tsserver.Type[] { + const index = expr.argumentExpression; + if (ctx.ts.isStringLiteralLike(index)) { + return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + } + return dedupeTypes(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); +} + +/** `[a, ...rest]`: an array of the one type its elements share; elements of unrelated types name no array. */ +function resolveArrayTypes( + ctx: InferenceContext, + elements: readonly tsserver.Expression[], + depth: number, + chainHops: number, +): tsserver.Type[] { + const {ts} = ctx; + const elementTypes = elements.flatMap((element) => + ts.isSpreadElement(element) + ? resolveElementTypes(ctx, element.expression, depth, chainHops) + : resolveExpressionTypes(ctx, element, depth, chainHops + 1), + ); + const [element, ...others] = limitUnion(ctx, normalizeCandidates(ctx, elementTypes)); + const array = element && others.length === 0 ? arrayTypeOf(ctx, element) : undefined; + return array ? [array] : []; +} + function resolveAlternatives( ctx: InferenceContext, alternatives: readonly tsserver.Expression[], @@ -277,27 +339,54 @@ function resolveLogicalTypes( } /** - * The declared types of `access`'s member on each candidate type of its - * receiver (`x.getPriceModel` when `x` is undocumented but inferable). When - * no receiver type carries the member and the receiver is (an alias of) + * The types of member `name` on each candidate type of `receiver` + * (`x.getPriceModel` when `x` is undocumented but inferable). When no + * receiver type carries the member and the receiver is (an alias of) * `module.superModule`, the member may be an export augmentation added by a - * pass-through overlay level, which no export type can carry. + * pass-through overlay level, which no export type can carry. A member the + * checker binds on a receiver it types as nothing worth showing (`this` in a + * prototype method) is the member it binds; on an untyped local receiver, it + * is whatever the code writes to it (`productData.apiProduct = ...`). */ function memberTypesOfReceiver( ctx: InferenceContext, - access: tsserver.PropertyAccessExpression, + receiver: tsserver.Expression, + name: tsserver.MemberName | tsserver.StringLiteralLike, depth: number, chainHops: number, ): tsserver.Type[] { const {ts, checker} = ctx; - const memberName = access.name.text; - const types = resolveExpressionTypes(ctx, access.expression, depth, chainHops + 1).flatMap((receiverType) => { + const memberName = name.text; + const types = resolveExpressionTypes(ctx, receiver, depth, chainHops + 1).flatMap((receiverType) => { const member = getMemberOfType(checker, receiverType, memberName); - return member ? [checker.getTypeOfSymbolAtLocation(member, access.name)] : []; + return member ? memberTypes(ctx, member, name, depth, chainHops) : []; }); if (types.length > 0) return types; - const superAccess = traceSuperModuleAccess(ts, checker, access.expression); - return superAccess ? resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) : []; + const superAccess = traceSuperModuleAccess(ts, checker, receiver); + if (superAccess) return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops); + const bound = checker.getSymbolAtLocation(name); + if (bound) return memberTypes(ctx, bound, name, depth, chainHops); + return resolveValues(ctx, localMemberValues(ctx, receiver, memberName), depth, chainHops); +} + +/** + * A member's declared type, or — when that says nothing, as for most + * undocumented members (`{apiProduct: apiProduct}`, `this.productSearch = + * productSearch`) — the types of the values it is declared with. + */ +function memberTypes( + ctx: InferenceContext, + member: tsserver.Symbol, + location: tsserver.Node, + depth: number, + chainHops: number, +): tsserver.Type[] { + const declared = ctx.checker.getTypeOfSymbolAtLocation(member, location); + const declaration = member.valueDeclaration ?? member.declarations?.[0]; + if (informativeParts(ctx, declared).length > 0 || !declaration) return [declared]; + return withCycleGuard(ctx, declaration, [], () => + resolveValues(ctx, memberValueExpressions(ctx, member), depth, chainHops), + ); } /** @@ -329,10 +418,18 @@ function resolveCallResultTypes( ): tsserver.Type[] { const calleeFn = resolveCalleeDeclaration(ctx, expr); const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; - if (inferred.length > 0 || !ctx.ts.isPropertyAccessExpression(expr.expression)) return inferred; + if (inferred.length > 0) return inferred; + const generic = genericResultSource(ctx, expr); + if (generic) { + return generic.kind === 'return' + ? inferReturnType(ctx, generic.fn, depth + 1) + : resolveExpressionTypes(ctx, generic.argument, depth, chainHops + 1); + } + const callee = expr.expression; + if (!ctx.ts.isPropertyAccessExpression(callee)) return []; return dedupeTypes( ctx, - memberTypesOfReceiver(ctx, expr.expression, depth, chainHops).flatMap((methodType) => + memberTypesOfReceiver(ctx, callee.expression, callee.name, depth, chainHops).flatMap((methodType) => signatureReturnTypes(ctx, methodType, depth), ), ); @@ -345,9 +442,14 @@ function resolvePropertyTypes( depth: number, chainHops: number, ): tsserver.Type[] { + return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); +} + +/** The member types worth keeping as a property's value. */ +function resolveMemberValueTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { return dedupeTypes( ctx, - memberTypesOfReceiver(ctx, expr, depth, chainHops).filter((type) => !isAnyType(ctx.ts, type)), + types.filter((type) => !isAnyType(ctx.ts, type)), ); } @@ -358,8 +460,8 @@ function resolveIdentifierTypes( depth: number, chainHops: number, ): tsserver.Type[] { - const {ts, checker} = ctx; - const decl = checker.getSymbolAtLocation(expr)?.valueDeclaration; + const {ts} = ctx; + const decl = valueDeclarationOf(ctx, expr); if (decl && ts.isParameter(decl)) return inferParameterType(ctx, decl, depth + 1); if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); return []; diff --git a/packages/b2c-script-types/src/inference/generic-calls.ts b/packages/b2c-script-types/src/inference/generic-calls.ts new file mode 100644 index 000000000..44f0586cb --- /dev/null +++ b/packages/b2c-script-types/src/inference/generic-calls.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Generic Script API functions whose result is one of their arguments: +// `Transaction.wrap(callback: () => T): T` returns what its callback +// returns. A JavaScript callback returning an undocumented value instantiates +// `T` to `any`, so the checker's result says nothing; the declaration still +// says where `T` comes from, and ./core resolves that argument instead. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; + +/** Where a generic call's result comes from: an argument itself, or what a function argument returns. */ +export type GenericResultSource = + | {readonly kind: 'value'; readonly argument: tsserver.Expression} + | {readonly kind: 'return'; readonly fn: tsserver.SignatureDeclaration}; + +/** True when `node` is a bare reference to the type parameter `name` (`T`). */ +function isTypeParameterReference(ts: typeof tsserver, node: tsserver.TypeNode | undefined, name: string): boolean { + return ( + node !== undefined && + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === name && + node.typeArguments === undefined + ); +} + +/** The function `argument` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +function functionOf(ctx: InferenceContext, argument: tsserver.Expression): tsserver.SignatureDeclaration | undefined { + const {ts, checker} = ctx; + if (ts.isFunctionExpression(argument) || ts.isArrowFunction(argument)) return argument; + const declaration = ts.isIdentifier(argument) ? checker.getSymbolAtLocation(argument)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} + +/** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ +function returnedTypeParameter(ts: typeof tsserver, declaration: tsserver.SignatureDeclaration): string | undefined { + const result = declaration.type; + const name = result && ts.isTypeReferenceNode(result) && ts.isIdentifier(result.typeName) && result.typeName.text; + return name && declaration.typeParameters?.some((parameter) => parameter.name.text === name) ? name : undefined; +} + +/** + * Where `call`'s result comes from when its callee's declaration returns a + * type parameter it takes as an argument (`identity(value: T): T`) or as + * a callback's result (`wrap(callback: () => T): T`). + */ +export function genericResultSource( + ctx: InferenceContext, + call: tsserver.CallExpression, +): GenericResultSource | undefined { + const {ts, checker} = ctx; + const declaration = checker.getResolvedSignature(call)?.declaration; + if (!declaration || ts.isJSDocSignature(declaration)) return undefined; + const typeParameter = returnedTypeParameter(ts, declaration); + if (!typeParameter) return undefined; + for (const [index, parameter] of declaration.parameters.entries()) { + const argument = call.arguments[index]; + const type = parameter.type; + if (!argument || !type) continue; + if (isTypeParameterReference(ts, type, typeParameter)) return {kind: 'value', argument}; + const fn = ts.isFunctionTypeNode(type) && isTypeParameterReference(ts, type.type, typeParameter); + const callback = fn ? functionOf(ctx, argument) : undefined; + if (callback) return {kind: 'return', fn: callback}; + } + return undefined; +} diff --git a/packages/b2c-script-types/src/inference/member-values.ts b/packages/b2c-script-types/src/inference/member-values.ts new file mode 100644 index 000000000..19cb069b6 --- /dev/null +++ b/packages/b2c-script-types/src/inference/member-values.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Where an untyped member's value comes from. JavaScript gives a member the +// type of what it is first declared with, which for undocumented code is +// usually `any` (`{apiProduct: apiProduct}`, `this.productSearch = +// productSearch`). The value expressions themselves are still in the source, +// and ./core resolves them like any other expression, IntelliJ style: a member +// is what was put into it. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {usageProfileOf} from './usage-profile'; + +/** The value of one member declaration: `key: value`, `{key}`, `key = value;` in a class, `this.key = value`. */ +function declaredValue(ctx: InferenceContext, declaration: tsserver.Declaration): tsserver.Expression | undefined { + const {ts} = ctx; + if (ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) return declaration.initializer; + if (ts.isShorthandPropertyAssignment(declaration)) return declaration.name; + const assignment = ts.isBinaryExpression(declaration) ? declaration : declaration.parent; + const isAssignment = + assignment !== undefined && + ts.isBinaryExpression(assignment) && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} + +/** + * The values a member symbol is declared with: an object literal's + * `key: value` and `{key}` shorthand, a class field initializer, and the + * right-hand sides of `this.key = value` / `obj.key = value` assignments a + * JavaScript constructor or expando declares it by. + */ +export function memberValueExpressions(ctx: InferenceContext, member: tsserver.Symbol): tsserver.Expression[] { + return (member.declarations ?? []).flatMap((declaration) => declaredValue(ctx, declaration) ?? []); +} + +/** + * The values the code writes to `memberName` on an untyped local receiver + * (`productData.apiProduct = ProductMgr.getProduct(id)`), read back later + * through the same parameter or variable (`return productData.apiProduct`). + */ +export function localMemberValues( + ctx: InferenceContext, + receiver: tsserver.Expression, + memberName: string, +): readonly tsserver.Expression[] { + const {ts} = ctx; + const declaration = ts.isIdentifier(receiver) ? valueDeclarationOf(ctx, receiver) : undefined; + const isLocal = declaration && (ts.isParameter(declaration) || ts.isVariableDeclaration(declaration)); + return isLocal ? (usageProfileOf(ctx, declaration).memberValues.get(memberName) ?? []) : []; +} + +/** + * The declaration of the value `identifier` reads. The name of a `{key}` + * shorthand resolves to the object literal's property, not to the variable + * whose value it copies, so that one asks the checker for the value symbol. + */ +export function valueDeclarationOf( + ctx: InferenceContext, + identifier: tsserver.Identifier, +): tsserver.Declaration | undefined { + const {ts, checker} = ctx; + const shorthand = ts.isShorthandPropertyAssignment(identifier.parent) ? identifier.parent : undefined; + const symbol = shorthand + ? checker.getShorthandAssignmentValueSymbol(shorthand) + : checker.getSymbolAtLocation(identifier); + return symbol?.valueDeclaration; +} diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index 33c309d03..75aa65308 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -12,13 +12,15 @@ // 1. Normalize: split unions, widen literals, drop types that say nothing // (`null`, `any`, `T`, `{}`), dedupe by display. // 2. Fit: a parameter keeps only candidates exposing every member its body -// relies on — members it tests for first (`'ID' in x`) aside, and members +// relies on — members it tests for first (`'ID' in x`) aside, members // no declared type has at all (an API newer than the vendored types, // an expando property), which can't tell candidates apart — and able // to stand wherever the body passes the value to a documented helper, or // a class its body tests for with `instanceof`/`typeof`. A duck-typed view // model, or a wrong argument at one buggy call site, can't masquerade as -// the Script API class the body actually needs. +// the Script API class the body actually needs. A class missing members +// only its subclasses have is a downcast: it narrows to the one subclass +// that has them, or stays itself when several do. // 3. Union like IntelliJ: up to MAX_UNION_TYPES, after dropping any candidate // whose superclass is also a candidate (`Variant | Product` is `Product`). // Wider evidence first reads instantiations of one generic class as the @@ -275,6 +277,30 @@ export function matchAmbientTypesByUsage( ); } +/** + * What a candidate lacking some members the body uses can still be: the one + * subclass that has them all (a body relying on `paymentTransaction` is + * handed `OrderPaymentInstrument`s, whatever the argument is documented as), + * or — when several subclasses would do (`getMasterProduct()` on a `Variant` + * or a `VariationGroup`, called once the body knows which) — the candidate + * itself. Root classes (`PersistentObject`, ...) never stand in for a + * subclass this way; nearly every class extends them. + */ +function downcastOf(ctx: InferenceContext, type: tsserver.Type, memberNames: ReadonlySet): tsserver.Type[] { + const own = classOf(ctx, type); + if (!own || UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) return []; + const fitting = getAmbientClasses(ctx).filter((ambientClass) => + [...memberNames].every((name) => ambientClass.memberNames.has(name)), + ); + const subclasses = mostGeneral( + ctx, + resolveMatches(ctx, fitting) + .map((match) => match.type) + .filter((subclass) => isAncestorOf(ctx, type, subclass)), + ); + return subclasses.length > 1 ? [type] : subclasses; +} + /** * The required members that can tell candidates apart: those some ambient * class, JavaScript built-in or candidate declares. A member nothing declares @@ -318,10 +344,12 @@ export function decideType( const required = requiredMembers(profile); const checkable = checkableMembers(ctx, required, candidates); const uses = constrainingUses(ctx, profile); - const fits = (type: tsserver.Type): boolean => - guardKeys.has(typeDisplayString(ctx, type)) || - (hasAllMembers(ctx.checker, type, checkable) && fitsEveryUse(ctx, type, uses)); - const fitting = fitUsage ? candidates.filter(fits) : candidates; + const fit = (type: tsserver.Type): tsserver.Type[] => { + if (guardKeys.has(typeDisplayString(ctx, type))) return [type]; + const fitted = hasAllMembers(ctx.checker, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); + }; + const fitting = fitUsage ? dedupeTypes(ctx, candidates.flatMap(fit)) : candidates; if (fitting.length > 0) return limitUnion(ctx, fitting, checkable); const used = mostSpecificUse(ctx, profile, required); if (used.length > 0) return used; diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts new file mode 100644 index 000000000..45efc2fab --- /dev/null +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Reading calls and their declared signatures: the function a call invokes, +// which overloads it can mean, and the type a declared callback hands its +// parameters. An inferred receiver +// (`items` built by `items.push(lineItem)`) gets no contextual typing from the +// checker, so `items.filter(function (item) {...})` leaves `item` untyped; +// the method's own declaration on the inferred type still says what `item` +// is, exactly as IntelliJ reads it. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {ELEMENT_FIRST_CALLBACK_CALLEES} from './constants'; +import type {InferenceContext} from './context'; +import {typeDisplayString} 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. + */ +export 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 sigDecl = checker.getResolvedSignature(call)?.declaration; + return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; +} + +/** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ +export function isElementFirstCallbackCall(ctx: InferenceContext, call: tsserver.CallExpression): boolean { + const {ts} = ctx; + const callee = ts.isPropertyAccessExpression(call.expression) ? call.expression.name : call.expression; + return ts.isIdentifier(callee) && ELEMENT_FIRST_CALLBACK_CALLEES.has(callee.text); +} + +/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ +export function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver.Signature, count: number): boolean { + const {ts, checker} = ctx; + const declaration = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; + if (!declaration) return true; + const parameters = declaration.parameters; + const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; + return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); +} + +/** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ +function callbackParameterType( + ctx: InferenceContext, + signature: tsserver.Signature, + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, +): tsserver.Type | undefined { + const {checker} = ctx; + const parameter = signature.getParameters()[argIndex]; + const callbackType = parameter && checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(parameter, call)); + const callbackParameter = callbackType?.getCallSignatures()[0]?.getParameters()[paramIndex]; + return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); +} + +/** + * The types `methodTypes`' declared signatures give parameter `paramIndex` + * of the callback `call` passes as argument `argIndex`: `value: T` of + * `filter(predicate: (value: T, ...) => unknown)` on a `ProductLineItem[]`. + * Overloads taking that many arguments must agree, so the accumulator of + * `reduce(callback, initialValue)` (`T` or `U`, by overload) stays unknown. + */ +export function callbackParameterTypes( + ctx: InferenceContext, + methodTypes: readonly tsserver.Type[], + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, +): tsserver.Type[] { + return methodTypes.flatMap((methodType) => { + const overloads = methodType + .getCallSignatures() + .filter((signature) => acceptsArgumentCount(ctx, signature, call.arguments.length)); + const [first, ...rest] = overloads.map((signature) => + callbackParameterType(ctx, signature, call, argIndex, paramIndex), + ); + const key = first && typeDisplayString(ctx, first); + return first && rest.every((type) => type && typeDisplayString(ctx, type) === key) ? [first] : []; + }); +} diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index a70862115..7bf1c2344 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -8,7 +8,8 @@ // other types that carry no information, rendering a type the way a reader // expects to see it, de-duplicating candidates by that rendering, reaching a // type's real members (stripping nullability), pulling the element type out -// of a collection, and turning candidate types into completion entries. +// of an array or collection (and building an array of one), and turning +// candidate types into completion entries. // These are the "leaf" operations the recursive engine in ./core and the // decision rules in ./policy build on. @@ -141,15 +142,20 @@ function classDisplayName(checker: tsserver.TypeChecker, symbol: tsserver.Symbol * interface instantiated only with default type arguments reads as the bare * class name (`Product`, `LineItemCtnr`); one with real type arguments * renders them the same way (`Collection`, not the checker's - * `Collection>`). Everything else — primitives, object - * literals, arrays, lib types — is rendered by the checker as TypeScript - * itself would. + * `Collection>`), and an array reads as its element's + * display (`Product[]`). Everything else — primitives, object literals, lib + * types — is rendered by the checker as TypeScript itself would. */ function computeTypeDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): string { + if (checker.isArrayType(type)) { + const [element] = checker.getTypeArguments(type as tsserver.TypeReference); + const display = element ? computeTypeDisplayString(ts, checker, element) : 'any'; + return element?.isUnion() ? `(${display})[]` : `${display}[]`; + } const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; - if (!isClassOrInterface || checker.isArrayType(type)) return checker.typeToString(type); + if (!isClassOrInterface) return checker.typeToString(type); if (hasOnlyDefaultTypeArguments(ts, checker, type)) return classDisplayName(checker, symbol); const typeArguments = checker .getTypeArguments(type as tsserver.TypeReference) @@ -224,7 +230,7 @@ export function hasAllMembers( * @param location - any node in the file where the type is being used; * required by getTypeOfSymbolAtLocation to resolve member types. */ -export function collectionElementType( +function collectionElementType( ctx: InferenceContext, type: tsserver.Type, location: tsserver.Node, @@ -243,6 +249,37 @@ export function collectionElementType( return element; } +/** + * The element type of an array (`ProductLineItem[]`) or of a Script API + * collection or iterator (see {@link collectionElementType}): what `x[i]`, + * `x.pop()` or a `forEach` callback over `x` sees. + */ +export function elementTypeOf( + ctx: InferenceContext, + type: tsserver.Type, + location: tsserver.Node, +): tsserver.Type | undefined { + const {checker} = ctx; + if (checker.isArrayType(type)) return checker.getTypeArguments(type as tsserver.TypeReference)[0]; + return collectionElementType(ctx, type, location); +} + +/** The checker's own array factory, which TypeScript keeps off its public typings. */ +interface ArrayTypeFactory { + createArrayType?(elementType: tsserver.Type): tsserver.Type; +} + +/** + * `element[]`, built by the checker itself so an inferred array reads, and + * resolves members (`filter`, `[0]`, `forEach`), exactly like a declared one. + * The factory is internal to TypeScript, so it is feature-detected: a + * TypeScript without it leaves inferred arrays silent rather than wrong. + */ +export function arrayTypeOf(ctx: InferenceContext, element: tsserver.Type): tsserver.Type | undefined { + const factory = ctx.checker as tsserver.TypeChecker & ArrayTypeFactory; + return typeof factory.createArrayType === 'function' ? factory.createArrayType(element) : undefined; +} + /** Renders candidate types as hover text, e.g. `"Product | Category"`. */ export function describeTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): string { return [...new Set(types.map((type) => typeDisplayString(ctx, type)))].join(' | '); diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts index 9ba76193e..55b5c5b7b 100644 --- a/packages/b2c-script-types/src/inference/usage-profile.ts +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -8,12 +8,14 @@ // walk: the members it is accessed by (duck typing), the classes it is // tested against (`instanceof`, `typeof`), the types the code passes it as // (a typed call argument or assignment target), and the values assigned to -// it. Every rule in ./policy reads this same profile, so a hover, a member -// hover and a completion all see the same evidence. +// it, pushed into it and written to its members. Every rule in ./policy +// reads this same profile, so a hover, a member hover and a completion all +// see the same evidence. import type tsserver from 'typescript/lib/tsserverlibrary'; import type {InferenceContext} from './context'; +import {acceptsArgumentCount} from './signatures'; import {isOpenForUsageInference} from './type-helpers'; export interface UsageProfile { @@ -37,6 +39,10 @@ export interface UsageProfile { readonly contextualTypes: readonly tsserver.Type[]; /** Right-hand sides of `x = value` assignments to the value after its declaration. */ readonly assignedValues: readonly tsserver.Expression[]; + /** Values the code adds to it as elements: `x.push(v)`, `x.unshift(v)`, `x[i] = v`. */ + readonly pushedValues: readonly tsserver.Expression[]; + /** Values the code writes to its members, by member name: `x.m = v`, `x['m'] = v`. */ + readonly memberValues: ReadonlyMap; } interface ProfileBuilder { @@ -45,6 +51,8 @@ interface ProfileBuilder { readonly guardTypes: tsserver.Type[]; readonly contextualTypes: tsserver.Type[]; readonly assignedValues: tsserver.Expression[]; + readonly pushedValues: tsserver.Expression[]; + readonly memberValues: Map; } function emptyProfile(): ProfileBuilder { @@ -54,6 +62,8 @@ function emptyProfile(): ProfileBuilder { guardTypes: [], contextualTypes: [], assignedValues: [], + pushedValues: [], + memberValues: new Map(), }; } @@ -165,16 +175,6 @@ function declaredArgumentType( return ctx.checker.getTypeOfSymbolAtLocation(parameter, call); } -/** True when `signature` takes `count` arguments (synthetic signatures without a declaration always do). */ -function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver.Signature, count: number): boolean { - const {ts, checker} = ctx; - const declaration = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; - if (!declaration) return true; - const parameters = declaration.parameters; - const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; - return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); -} - /** * The type argument `index` of `call` is passed as. For an overloaded callee * it counts only when every overload taking that many arguments agrees on it @@ -333,7 +333,49 @@ function recordPresenceTest({ctx, reference}: ReferenceUse, profile: ProfileBuil return member !== undefined; } -/** `x.m` / `x['m']`: a member the value is accessed by. */ +// Array methods whose arguments become elements of the receiver. +const ELEMENT_ADDING_METHODS: ReadonlySet = new Set(['push', 'unshift']); + +/** The value `access = value` assigns, if `access` is the target of a plain assignment. */ +function valueAssignedTo(ctx: InferenceContext, access: tsserver.Expression): tsserver.Expression | undefined { + const {ts} = ctx; + const assignment = access.parent; + const isAssignment = + ts.isBinaryExpression(assignment) && + assignment.left === access && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken; + return isAssignment ? assignment.right : undefined; +} + +/** The values `x.push(a, b)` / `x.unshift(a)` add as elements, when `access` is such a call's callee. */ +function addedElements( + ctx: InferenceContext, + access: tsserver.Expression, + member: string | undefined, +): tsserver.Expression[] | undefined { + const {ts} = ctx; + const call = access.parent; + if (member === undefined || !ELEMENT_ADDING_METHODS.has(member)) return undefined; + if (!ts.isCallExpression(call) || call.expression !== access) return undefined; + return call.arguments.filter((argument) => !ts.isSpreadElement(argument)); +} + +/** What an access writes into the value: elements (`x.push(v)`, `x[i] = v`) or a member (`x.m = v`). */ +function recordWrite( + ctx: InferenceContext, + access: tsserver.PropertyAccessExpression | tsserver.ElementAccessExpression, + member: string | undefined, + profile: ProfileBuilder, +): void { + const added = addedElements(ctx, access, member); + const value = added ? undefined : valueAssignedTo(ctx, access); + if (added) profile.pushedValues.push(...added); + else if (value && member !== undefined) { + profile.memberValues.set(member, [...(profile.memberValues.get(member) ?? []), value]); + } else if (value && ctx.ts.isElementAccessExpression(access)) profile.pushedValues.push(value); +} + +/** `x.m` / `x['m']`: a member the value is accessed by, and anything the access writes into it. */ function recordMemberAccess({ctx, reference, inVariantBranch}: ReferenceUse, profile: ProfileBuilder): boolean { const {ts} = ctx; const access = reference.parent; @@ -341,6 +383,7 @@ function recordMemberAccess({ctx, reference, inVariantBranch}: ReferenceUse, pro if (!isAccess || access.expression !== reference) return false; const member = accessedMemberName(ctx, access); if (member !== undefined) recordMember(profile, member, inVariantBranch); + recordWrite(ctx, access, member, profile); return true; } diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 505dc53fc..bc5c03319 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -537,5 +537,22 @@ }, "target": {"file": "/collections.js", "function": "forEach", "param": 0}, "expect": "Collection" + }, + { + "id": "cart-matching-line-items-push", + "source": "SFRA cartHelpers.getMatchingProducts shape", + "description": "An array filled by push() inside collections.forEach and returned in an object reads back as an array of the collection's elements", + "dwTypes": ["Basket", "Collection", "ProductLineItem"], + "globals": "function getBasket(): Basket;", + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/cartHelpers.js": "var collections = require('./collections');\n\nfunction getMatchingProducts(productId, productLineItems) {\n var matchingProducts = [];\n var uuid;\n collections.forEach(productLineItems, function (item) {\n if (item.productID === productId) {\n matchingProducts.push(item);\n uuid = item.UUID;\n }\n });\n return {matchingProducts: matchingProducts, uuid: uuid};\n}\n\nfunction getExistingProductLineItemInCart(productId) {\n var matching = getMatchingProducts(productId, getBasket().productLineItems).matchingProducts;\n return matching[0];\n}\n\nmodule.exports = {getExistingProductLineItemInCart: getExistingProductLineItemInCart};\n" + }, + "target": { + "file": "/cartHelpers.js", + "kind": "variable", + "name": "matching" + }, + "expect": "ProductLineItem[]" } ] diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index a389be6a0..cdb6c4feb 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -154,12 +154,19 @@ function dwTypeSpan(tag) { return {start: expression.getStart(), end: expression.getEnd()}; } +/** A documented type's simple class name; an array reads as its element's (`dw.order.Basket[]` -> 'Basket[]'). */ +function documentedName(checker, type) { + if (!checker.isArrayType(type)) return (type.aliasSymbol ?? type.getSymbol())?.getName(); + const element = documentedName(checker, checker.getTypeArguments(type)[0]); + return element && `${element}[]`; +} + /** Simple class names of a documented type's non-nullable constituents (`dw.order.Basket|null` -> ['Basket']). */ function documentedNames(checker, type) { const parts = type.isUnion() ? type.types : [type]; return parts .filter((t) => !(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined))) - .map((t) => (t.aliasSymbol ?? t.getSymbol())?.getName()) + .map((t) => documentedName(checker, t)) .filter(Boolean); } @@ -270,6 +277,8 @@ function parseInferredNames(description) { } function isRelated(ancestry, a, b) { + const arrays = a.endsWith('[]') && b.endsWith('[]'); + if (arrays) return isRelated(ancestry, a.slice(0, -2), b.slice(0, -2)); return a === b || ancestry.get(a)?.has(b) === true || ancestry.get(b)?.has(a) === true; } 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 e47d9291a..b4fdaa3fe 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -46,6 +46,9 @@ const REAL_DW_TYPES = { ShippingMethod: dtsPath('dw', 'order', 'ShippingMethod'), ShippingLineItem: dtsPath('dw', 'order', 'ShippingLineItem'), PriceAdjustment: dtsPath('dw', 'order', 'PriceAdjustment'), + Transaction: dtsPath('dw', 'system', 'Transaction'), + PaymentInstrument: dtsPath('dw', 'order', 'PaymentInstrument'), + CustomerPaymentInstrument: dtsPath('dw', 'customer', 'CustomerPaymentInstrument'), }; /** diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js new file mode 100644 index 000000000..58f18c718 --- /dev/null +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +// Values that reach a parameter, variable or return through something other +// than a direct call: arrays built with push() and read back by index or +// array methods, members written into objects and read later, generic +// Script API calls such as Transaction.wrap, and downcasts the body makes +// after testing what it holds. + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const { + createInferenceContext, + describeTypes, + inferParameterType, + inferReturnType, + inferTypeForNode, +} = require('../src/usage-inference'); +const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); + +const CART_TYPES = realTypesPrelude( + ['Basket', 'Collection', 'Product', 'ProductLineItem'], + ` + function getBasket(): Basket; + function getProduct(): Product; + function identity(value: T): T; +`, +); + +// Shared helpers the cases below call: an array filled by push() from a +// Script API iterator, the way SFRA's cart helpers collect line items. +const LINE_ITEM_HELPERS = ` + function lineItemsOf() { + var items = []; + var iterator = getBasket().getProductLineItems().iterator(); + while (iterator.hasNext()) { + items.push(iterator.next()); + } + return items; + } +`; + +/** The function a case targets: a declaration, or a function expression assigned to `x.name` / `name:`. */ +function isFunctionNamed(node, name) { + if (ts.isFunctionDeclaration(node)) return node.name?.text === name; + if (!ts.isFunctionExpression(node)) return false; + const parent = node.parent; + if (ts.isBinaryExpression(parent) && ts.isPropertyAccessExpression(parent.left)) + return parent.left.name.text === name; + return ts.isPropertyAssignment(parent) && parent.name.getText() === name; +} + +const FINDERS = { + return: (node, name) => isFunctionNamed(node, name), + param: (node, name) => ts.isParameter(node) && ts.isIdentifier(node.name) && node.name.text === name, + var: (node, name) => ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name, +}; + +const INFER = { + return: (ctx, node) => inferReturnType(ctx, node), + param: (ctx, node) => inferParameterType(ctx, node), + var: (ctx, node) => inferTypeForNode(ctx, node.name), +}; + +/** Infers `kind` (`return`, `param`, `var`) for the first node called `name` in `/helper.js`. */ +function infer(types, source, kind, name) { + const languageService = createFixtureLanguageService({'/types.d.ts': types, '/helper.js': source}); + const ctx = createInferenceContext(ts, languageService); + let target; + const visit = (node) => { + if (!target && FINDERS[kind](node, name)) target = node; + if (!target) ts.forEachChild(node, visit); + }; + visit(ctx.program.getSourceFile('/helper.js')); + assert.ok(target, `no ${kind} target named ${name}`); + return describeTypes(ctx, INFER[kind](ctx, target)); +} + +function runCases(cases) { + for (const {title, types = CART_TYPES, source, kind, name, expected} of cases) { + it(title, () => { + assert.equal(infer(types, source, kind, name), expected); + }); + } +} + +describe('usage-inference — arrays and element access', () => { + runCases([ + { + title: 'an array literal filled by push() is an array of what was pushed', + source: LINE_ITEM_HELPERS, + kind: 'return', + name: 'lineItemsOf', + expected: 'ProductLineItem[]', + }, + { + title: 'the variable holding a push()-built array reads as that array', + source: LINE_ITEM_HELPERS, + kind: 'var', + name: 'items', + expected: 'ProductLineItem[]', + }, + { + title: 'indexing an inferred array yields its element', + source: `${LINE_ITEM_HELPERS} function firstLineItem() { return lineItemsOf()[0]; }`, + kind: 'return', + name: 'firstLineItem', + expected: 'ProductLineItem', + }, + { + title: 'indexing a Script API collection (no index signature) yields its element', + source: 'function firstOf(basket) { return basket.productLineItems[0]; } firstOf(getBasket());', + kind: 'return', + name: 'firstOf', + expected: 'ProductLineItem', + }, + { + title: 'filter() on an inferred array keeps the element type, as the lib declares it', + source: `${LINE_ITEM_HELPERS} function giftItems() { return lineItemsOf().filter(function (item) { return item.gift; }); }`, + kind: 'return', + name: 'giftItems', + expected: 'ProductLineItem[]', + }, + { + title: 'find() on an inferred array yields an element', + source: `${LINE_ITEM_HELPERS} function giftItem() { return lineItemsOf().find(function (item) { return item.gift; }); }`, + kind: 'return', + name: 'giftItem', + expected: 'ProductLineItem', + }, + { + title: "a native array method's callback parameter is typed from the inferred receiver", + source: `${LINE_ITEM_HELPERS} function names() { return lineItemsOf().map(function (lineItem) { return lineItem.productName; }); }`, + kind: 'param', + name: 'lineItem', + expected: 'ProductLineItem', + }, + { + title: 'reduce() types the current element but not an accumulator its overloads disagree on', + source: `${LINE_ITEM_HELPERS} function count() { return lineItemsOf().reduce(function (total, line) { return total + line.quantityValue; }, 0); }`, + kind: 'param', + name: 'line', + expected: 'ProductLineItem', + }, + { + title: 'the reduce() accumulator stays silent', + source: `${LINE_ITEM_HELPERS} function count() { return lineItemsOf().reduce(function (total, line) { return total + line.quantityValue; }, 0); }`, + kind: 'param', + name: 'total', + expected: '', + }, + { + title: 'an array literal of one inferred type is an array of it', + source: 'function wrap(product) { return [product]; } wrap(getProduct());', + kind: 'return', + name: 'wrap', + expected: 'Product[]', + }, + { + title: 'a spread copies the element type', + source: `${LINE_ITEM_HELPERS} function copy() { return [...lineItemsOf()]; }`, + kind: 'return', + name: 'copy', + expected: 'ProductLineItem[]', + }, + { + title: 'an array literal mixing unrelated types names no array', + source: 'function pair(product, basket) { return [product, basket]; } pair(getProduct(), getBasket());', + kind: 'return', + name: 'pair', + expected: '', + }, + ]); +}); + +describe('usage-inference — member values', () => { + runCases([ + { + title: "an object literal member reads as the value it was built with (SFRA getConfig's options.apiProduct)", + source: ` + function getConfig(apiProduct) { return {apiProduct: apiProduct, quantity: 1}; } + function show() { var options = getConfig(getProduct()); return options.apiProduct; } + `, + kind: 'return', + name: 'show', + expected: 'Product', + }, + { + title: 'a shorthand member reads as the variable it copies', + source: ` + function getMatching() { var basket = getBasket(); var lineItems = basket.productLineItems; return {lineItems}; } + function matching() { return getMatching().lineItems; } + `, + kind: 'return', + name: 'matching', + expected: 'Collection', + }, + { + title: "a constructor's this.member reads as the argument stored in it", + source: ` + function Model(product) { this.product = product; } + Model.prototype.current = function () { return this.product; }; + new Model(getProduct()); + `, + kind: 'return', + name: 'current', + expected: 'Product', + }, + { + title: 'a member written on an untyped local reads back as the value written', + source: ` + function cachedProduct(viewData) { + var data = viewData && viewData.product; + if (data.apiProduct === undefined) { + data.apiProduct = getProduct() || null; + } + return data.apiProduct; + } + `, + kind: 'return', + name: 'cachedProduct', + expected: 'Product', + }, + ]); +}); + +describe('usage-inference — generic calls', () => { + const TRANSACTION_TYPES = ` + import TransactionClass = require('${REAL_DW_TYPES.Transaction}'); + import Basket = require('${REAL_DW_TYPES.Basket}'); + declare global { + var Transaction: typeof TransactionClass; + function getBasket(): Basket; + } + `; + + runCases([ + { + title: 'Transaction.wrap(callback) returns what its callback returns', + types: TRANSACTION_TYPES, + source: ` + function save(basket) { return Transaction.wrap(function () { return basket; }); } + save(getBasket()); + `, + kind: 'return', + name: 'save', + expected: 'Basket', + }, + { + title: 'a generic identity returns its argument', + source: 'function same(product) { return identity(product); } same(getProduct());', + kind: 'return', + name: 'same', + expected: 'Product', + }, + ]); +}); + +describe('usage-inference — downcasts in the body', () => { + runCases([ + { + title: 'a body calling a member only subclasses have keeps the argument class when several subclasses do', + types: realTypesPrelude(['Product', 'Variant', 'VariationGroup'], 'function getProduct(): Product;'), + source: ` + function describe(apiProduct, productType) { + var category = apiProduct.getPrimaryCategory(); + if (productType === 'variant') { + category = apiProduct.getMasterProduct().getPrimaryCategory(); + } + return category; + } + describe(getProduct(), 'variant'); + `, + kind: 'param', + name: 'apiProduct', + expected: 'Product', + }, + { + title: 'a body relying on members only one subclass has narrows the argument to it', + types: realTypesPrelude( + ['PaymentInstrument', 'OrderPaymentInstrument', 'CustomerPaymentInstrument'], + 'function getPaymentInstrument(): PaymentInstrument;', + ), + source: ` + function amountOf(paymentInstrument) { return paymentInstrument.paymentTransaction.amount; } + amountOf(getPaymentInstrument()); + `, + kind: 'param', + name: 'paymentInstrument', + expected: 'OrderPaymentInstrument', + }, + ]); +}); From 951cb90798a7b50202d204b664b43e41a6f30ada Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:26:22 +0000 Subject: [PATCH 56/76] feat(script-types): follow constructors through exports, factories and arguments; read this-stored and forwarded usage Usage inference now finds the call sites of a function that travels as a value before it is called, the way SFRA's search refinement models do: - `module.exports = Name` and `exports.x = Name` lead to the require() calls and member accesses of the module - an unbound `return require('...')` makes the enclosing function a factory, whose call results are followed like the function itself - a function passed as an argument is followed into the receiving parameter, where `new Model(...)` / `Model(...)` finally runs Parameter usage also covers what IntelliJ reads: - a constructor argument kept as `this.x = x` is used wherever the file reads `this.x` (prototype methods included) - a parameter passed unchanged to an undocumented project function (`helper(x)`, `new Model(x)`, `Base.call(this, x)`) inherits that parameter's members and contextual types, up to three functions away Searches for a function-local name (a parameter or local variable) scan one file and no longer spend the project-wide search budget; the reference-hop limit goes from 2 to 4 to cover the refinement chain. On the real-cartridge ground truth, parameter recall rises from 84.2% to 94.1% at 97.2% precision; at most 12 project-wide searches per request, p95 latency 42.7 ms. Two undocumented `address` callbacks that used to show OrderAddress are now silent: the array they iterate mostly holds plain-object copies whose `states`/`country` members OrderAddress lacks. The ground-truth harness reports project-wide and single-file searches apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/call-sites.js | 246 ++++----------- .../plugin/inference/constants.js | 19 +- .../b2c-script-types/plugin/inference/core.js | 3 +- .../plugin/inference/framework-contracts.js | 4 +- .../plugin/inference/this-properties.js | 55 ++++ .../plugin/inference/usage-profile.js | 107 +++++-- .../plugin/inference/value-flow.js | 213 +++++++++++++ .../plugin/usage-inference.js | 5 + .../src/inference/call-sites.ts | 287 ++++-------------- .../src/inference/constants.ts | 18 +- .../b2c-script-types/src/inference/context.ts | 7 +- .../b2c-script-types/src/inference/core.ts | 3 +- .../src/inference/framework-contracts.ts | 2 +- .../src/inference/signatures.ts | 2 +- .../src/inference/this-properties.ts | 75 +++++ .../src/inference/usage-profile.ts | 170 ++++++++--- .../src/inference/value-flow.ts | 269 ++++++++++++++++ .../b2c-script-types/src/usage-inference.ts | 5 + .../b2c-script-types/test/corpus/cases.json | 21 ++ .../test/eval/ground-truth.test.js | 55 +++- .../test/helpers/real-dw-types.js | 1 + .../test/usage-inference.value-flow.test.js | 238 ++++++++++++++- 22 files changed, 1296 insertions(+), 509 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/this-properties.js create mode 100644 packages/b2c-script-types/plugin/inference/value-flow.js create mode 100644 packages/b2c-script-types/src/inference/this-properties.ts create mode 100644 packages/b2c-script-types/src/inference/value-flow.ts diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index 5154450b7..9833a0020 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -5,10 +5,10 @@ * 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"); +const value_flow_1 = require("./value-flow"); // Reference searches are what an inference request spends its time on, and // a Program never changes, so their results are kept for as long as the // Program lives: hovering one parameter after another in an unchanged @@ -31,173 +31,34 @@ function findReferences(ctx, name) { } return references; } -/** The name a function declaration, method or class declares itself by. */ -function ownName(fn, ts) { - const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); - return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; -} -/** - * The name `fn` is assigned to: `exports.foo = function(){}` or - * `module.exports = function(){}`. The `.name` identifier (`foo` or - * `exports`) is what findReferences can actually track; for the bare - * `module.exports` case this resolves to the whole module's value, so callers - * reach it through collectCallSites()'s require() indirection rather than a - * direct property-access call. - */ -function assignedName(fn, ts) { - const assignment = fn.parent; - if (!ts.isBinaryExpression(assignment) || assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { - return undefined; - } - const { left } = assignment; - if (ts.isPropertyAccessExpression(left)) - return ts.isIdentifier(left.name) ? left.name : undefined; - return ts.isIdentifier(left) ? left : undefined; -} -/** - * Identifies the name to run findReferences on for a function-like - * declaration: its own name, or for one that has none, the name it is bound - * to (the common CommonJS shapes: `const foo = function(){}`, - * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, - * `module.exports = function(){}`). A class constructor is searched through - * its class, since that is what `new Model(x)` names. - */ -function getReferenceNameNode(fn, ts) { - const callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; - const boundName = ts.isExpression(callable) ? bindingNameOf(callable, ts) : undefined; - return ownName(callable, ts) ?? boundName ?? assignedName(callable, ts); -} -/** The function enclosing `node`, if any. */ -function enclosingFunction(node, ts) { - for (let current = node.parent; current; current = current.parent) { - if (ts.isFunctionLike(current)) - return current; - } - return undefined; -} /** - * The arguments `fn.apply(thisArg, list)` passes: the elements of an array - * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the - * enclosing function's own parameters, which inference then resolves from - * that function's call sites in turn. + * True when `name` declares a parameter or a variable inside a function. + * TypeScript scopes a reference search on such a local to its function, so + * it reads one file instead of scanning the program: it is not charged to + * ctx.searchBudget, though each of its hits still spends ctx.referenceBudget. */ -function applyArguments(call, ts) { - const list = call.arguments[1]; - if (!list) - return []; - if (ts.isArrayLiteralExpression(list)) { - return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); - } - if (!ts.isIdentifier(list) || list.text !== 'arguments') - return []; - const forwarding = enclosingFunction(call, ts); - return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; -} -/** What a reference invokes when called: the reference itself, or for a member name (`obj.helper`), the whole access. */ -function calleeOf(node, ts) { - const { parent } = node; - return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; -} -/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ -function directCall(callee, ts) { - const call = callee.parent; - const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; - return isCall ? { node: call, args: call.arguments ?? [] } : undefined; -} -/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ -function borrowedCall(callee, ts) { - const access = callee.parent; - if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) - return undefined; - const call = access.parent; - if (!ts.isCallExpression(call) || call.expression !== access) - return undefined; - if (access.name.text === 'call') - return { node: call, args: call.arguments.slice(1) }; - return access.name.text === 'apply' ? { node: call, args: applyArguments(call, ts) } : undefined; +function isFunctionLocal(ts, name) { + const declaration = name.parent; + if (ts.isParameter(declaration)) + return true; + const isVariable = ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration); + return isVariable && (0, value_flow_1.enclosingFunction)(declaration, ts) !== undefined; } -/** - * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, - * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it - * invokes, if it sits in callee position. The name of a property access - * (`obj.helper`) is treated as the whole access. - */ -function findCallInCalleePosition(node, ts) { - const callee = calleeOf(node, ts); - return directCall(callee, ts) ?? borrowedCall(callee, ts); -} -/** - * A `require('specifier')` call, identified structurally (only public - * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part - * of TypeScript's public API surface, so isn't safe to depend on here). - */ -function isRequireCallExpression(node, ts) { - return (ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'require' && - node.arguments.length > 0 && - ts.isStringLiteralLike(node.arguments[0])); -} -/** - * The name a value is bound to when `expression` initializes a variable - * (`var helper = …`) or an object property (`{helper: …}`) — the next name - * to search references for when the value's own references dead-end there. - */ -function bindingNameOf(expression, ts) { - const parent = expression.parent; - if (!parent || !(ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))) - return undefined; - return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; -} -/** The `require('…')` call whose module specifier `node` is. */ -function requireCallOf(node, ts) { - const call = node.parent; - return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; -} -/** - * 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(...)` whose result is bound to a - * variable (`var helper = require('./helper')`) or to a property of an - * export map (SFRA's `decorators/index.js`: - * `{images: require('./images')}`, called as `decorators.images(...)`); - * - a destructuring binding element (`const {helper} = require(...)` or - * `const {helper: local} = someObject`); - * - an alias of the function itself (`var run = helper`, or SFRA's canonical - * `module.exports = {getSalePrice: getSalePrice}` export shape): a search - * on the function name dead-ends at the alias, while the consumers - * (`productHelpers.getSalePrice(x)` in another file) are references of the - * alias name. - * - * @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; - 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 }; - } - const required = requireCallOf(node, ts); - const invocation = required && directCall(required, ts); - if (invocation) - return { kind: 'call', call: invocation }; // require('./helper')(x) - const value = required ?? node; - const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; - return name && { kind: 'name', name }; +function frontierKey({ name, role }) { + const sourceFile = name.getSourceFile(); + return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } /** * 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. + * MAX_REFERENCE_HOPS names the function value flows into (see ./value-flow: + * require() bindings, exports, aliases, factories returning it, parameters + * it is passed to) when a reference doesn't sit directly in callee + * position. Stops early once ctx.referenceBudget (result count) or + * ctx.searchBudget (project scans) runs out, returning whatever call sites + * were already found rather than continuing to fan out — an under-inferred + * (but still heuristic, clearly-labeled) result beats hanging on a + * widely-referenced helper. Results are memoized per name node for the + * duration of the request. */ function collectCallSites(ctx, nameNode) { const memoized = ctx.callSiteMemo.get(nameNode); @@ -205,19 +66,19 @@ function collectCallSites(ctx, nameNode) { return memoized; const calls = []; const seenNameKeys = new Set(); - let frontier = [nameNode]; + let frontier = [{ name: nameNode, role: 'value' }]; 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) + for (const next of frontier) { + if (localBudget <= 0) break; - const sourceFile = name.getSourceFile(); - const key = `${sourceFile.fileName}:${name.getStart(sourceFile)}`; - if (seenNameKeys.has(key)) + const key = frontierKey(next); + const searchable = ctx.searchBudget > 0 || isFunctionLocal(ctx.ts, next.name); + if (seenNameKeys.has(key) || !searchable) continue; seenNameKeys.add(key); - localBudget = collectCallsFromName(ctx, name, calls, nextFrontier, localBudget); + localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); } frontier = nextFrontier; } @@ -225,40 +86,37 @@ function collectCallSites(ctx, nameNode) { 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. + * Runs one reference search for `next.name` and sorts each hit into either a + * resolved call site (pushed to `calls`, once per call) or a further name to + * chase on the next hop (pushed to `nextFrontier`). Consumes one unit of the + * shared search budget unless the name is function-local, 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) { - ctx.searchBudget--; - for (const reference of findReferences(ctx, name)) { +function collectCallsFromName(ctx, next, calls, nextFrontier, localBudget) { + if (!isFunctionLocal(ctx.ts, next.name)) + ctx.searchBudget--; + for (const reference of findReferences(ctx, next.name)) { if (localBudget <= 0) break; localBudget--; ctx.referenceBudget--; - const target = referenceTarget(ctx, reference); - if (target?.kind === 'call') + const target = referenceTarget(ctx, reference, next.role); + if (target?.kind === 'name') + nextFrontier.push(target); + else if (target && !calls.some((site) => site.node === target.call.node)) calls.push(target.call); - else if (target?.kind === 'name') - nextFrontier.push(target.name); } return localBudget; } /** - * Where one reference search hit leads. Definition sites (the declaration - * itself) never sit in callee position, so they lead nowhere new; neither - * does a hit on a whole module (the source file itself, which has no parent - * — e.g. a `module.exports` reference landing in a file's leading comment). + * Where one reference search hit leads (see valueTarget()). Definition sites + * (the declaration itself) lead nowhere new; neither does a hit on a whole + * module (the source file itself, which has no parent — e.g. a + * `module.exports` reference landing in a file's leading comment). */ -function referenceTarget(ctx, reference) { - const { ts, program } = ctx; - const file = program.getSourceFile(reference.fileName); - const node = file && (0, ast_helpers_1.getNodeAtPosition)(file, ts, reference.textSpan.start); - if (!node?.parent) - return undefined; - const call = findCallInCalleePosition(node, ts); - return call ? { kind: 'call', call } : resolveIndirectReferenceTarget(node, ts); +function referenceTarget(ctx, reference, role) { + const file = ctx.program.getSourceFile(reference.fileName); + const node = file && (0, ast_helpers_1.getNodeAtPosition)(file, ctx.ts, reference.textSpan.start); + return node?.parent ? (0, value_flow_1.valueTarget)(ctx, node, role) : undefined; } diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 1dc3c0e90..06c0e4fdc 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.BUILTIN_VALUE_TYPES = exports.GLOBAL_OBJECT_CLASSES = exports.WEAK_USAGE_MEMBERS = exports.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_INFERENCE_DEPTH = void 0; +exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.BUILTIN_VALUE_TYPES = exports.GLOBAL_OBJECT_CLASSES = exports.WEAK_USAGE_MEMBERS = exports.UNINFORMATIVE_ANCESTORS = exports.MAX_UNION_TYPES = exports.MIN_USAGE_SIGNATURE_MEMBERS = exports.INFERRED_COMPLETION_SOURCE = exports.MAX_DISPLAY_CACHE_ENTRIES = exports.MAX_SEARCHES_PER_REQUEST = exports.MAX_SUPERMODULE_HOPS = exports.MAX_CHAIN_HOPS = exports.MAX_REFERENCES_PER_CALL = exports.MAX_REFERENCES_PER_REQUEST = exports.MAX_REFERENCE_HOPS = exports.MAX_USAGE_FORWARDING_HOPS = exports.MAX_INFERENCE_DEPTH = void 0; // Tunable limits for the usage-inference engine. They exist so a crafted (or // merely huge) cartridge can't make a single hover/completion do unbounded // work — every recursive walk and reference search is capped by one of these. @@ -14,10 +14,19 @@ exports.ELEMENT_FIRST_CALLBACK_CALLEES = exports.BUILTIN_VALUE_TYPES = exports.G // 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; +// How many functions a parameter's usage is followed into when the body +// passes the parameter on unchanged (`new BooleanAttributeValue(search, +// definition, refinementValue)`): what the receiving parameter's function +// does with it is usage of this one too. Each hop is one walk of a function +// (or of its file, for a value stored on `this`) and no reference search. +exports.MAX_USAGE_FORWARDING_HOPS = 3; +// How many names a function value is followed through (see ./value-flow) +// before collectCallSites() gives up on finding an actual call site. SFRA's +// refinement models need four: the model -> `module.exports` (its require() +// calls) -> the factory returning it -> `var Model = factory(...)` -> the +// `Model` parameter it is passed on to, where `new Model(...)` finally runs. +// Hops into function-local names cost no project-wide search. +exports.MAX_REFERENCE_HOPS = 4; // Hard cap on how many reference-search hits collectCallSites() will process // across a single top-level inference request (not just one call site) — // bounds worst-case cost for a helper referenced from dozens of places, diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 8185ab7a4..7b5c43792 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -13,6 +13,7 @@ const constants_1 = require("./constants"); const context_1 = require("./context"); const ast_helpers_1 = require("./ast-helpers"); const call_sites_1 = require("./call-sites"); +const value_flow_1 = require("./value-flow"); const framework_contracts_1 = require("./framework-contracts"); const generic_calls_1 = require("./generic-calls"); const member_values_1 = require("./member-values"); @@ -322,7 +323,7 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { * search for — from the collection it iterates. */ function parameterEvidence(ctx, fn, paramIndex, depth) { - const nameNode = (0, call_sites_1.getReferenceNameNode)(fn, ctx.ts); + const nameNode = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts); if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); return (0, call_sites_1.collectCallSites)(ctx, nameNode).flatMap((site) => { diff --git a/packages/b2c-script-types/plugin/inference/framework-contracts.js b/packages/b2c-script-types/plugin/inference/framework-contracts.js index 60789cff4..c92138b84 100644 --- a/packages/b2c-script-types/plugin/inference/framework-contracts.js +++ b/packages/b2c-script-types/plugin/inference/framework-contracts.js @@ -7,7 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.frameworkParameterTypes = frameworkParameterTypes; const ambient_index_1 = require("./ambient-index"); -const call_sites_1 = require("./call-sites"); +const value_flow_1 = require("./value-flow"); const FRAMEWORK_CONTRACTS = [ { directory: '/cartridge/experience/components/', @@ -22,7 +22,7 @@ const FRAMEWORK_CONTRACTS = [ ]; /** The contract-defined type of `fn`'s parameter at `paramIndex`, if `fn` is a platform entry point. */ function frameworkParameterTypes(ctx, fn, paramIndex) { - const functionName = (0, call_sites_1.getReferenceNameNode)(fn, ctx.ts)?.text; + const functionName = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts)?.text; if (!functionName) return []; const fileName = fn.getSourceFile().fileName.replace(/\\/g, '/'); diff --git a/packages/b2c-script-types/plugin/inference/this-properties.js b/packages/b2c-script-types/plugin/inference/this-properties.js new file mode 100644 index 000000000..ba65d92fc --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/this-properties.js @@ -0,0 +1,55 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.thisMembersStoring = thisMembersStoring; +exports.isStoredMemberRead = isStoredMemberRead; +/** `this.x = value`: a plain assignment to a member of `this`. */ +function isThisMemberStore(ts, node) { + return (ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.expression.kind === ts.SyntaxKind.ThisKeyword); +} +/** True when `node` gives its body a `this` of its own (a function, method or class; arrow functions share it). */ +function hasOwnThis(ts, node) { + return (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLike(node); +} +/** + * The members of `this` the function declaring `param` stores it in + * (`this.refinementValue = refinementValue`). Stores inside nested functions + * are skipped: their `this` is another value. + */ +function thisMembersStoring(ctx, param) { + const { ts, checker } = ctx; + const members = new Set(); + const body = param.parent.body; + const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; + if (!body || !paramSymbol) + return members; + const visit = (node) => { + const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; + const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; + if (member) + members.add(member); + if (!hasOwnThis(ts, node)) + ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return members; +} +/** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ +function isStoredMemberRead(ctx, members, node) { + const { ts, checker } = ctx; + if (members.size === 0 || !ts.isPropertyAccessExpression(node)) + return false; + const store = node.parent; + const written = isThisMemberStore(ts, store) && store.left === node; + if (node.expression.kind !== ts.SyntaxKind.ThisKeyword || written) + return false; + const member = checker.getSymbolAtLocation(node.name); + return member !== undefined && members.has(member); +} diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js index 2ebb75e71..0402a85ee 100644 --- a/packages/b2c-script-types/plugin/inference/usage-profile.js +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -6,8 +6,12 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.usageProfileOf = usageProfileOf; +const ast_helpers_1 = require("./ast-helpers"); +const constants_1 = require("./constants"); const signatures_1 = require("./signatures"); +const this_properties_1 = require("./this-properties"); const type_helpers_1 = require("./type-helpers"); +const value_flow_1 = require("./value-flow"); function emptyProfile() { return { memberNames: new Set(), @@ -17,6 +21,7 @@ function emptyProfile() { assignedValues: [], pushedValues: [], memberValues: new Map(), + forwardedTo: [], }; } const EMPTY_PROFILE = emptyProfile(); @@ -142,10 +147,11 @@ function usedAsType(ctx, reference) { return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); } function isReferenceTo(ctx, target, node) { - return (ctx.ts.isIdentifier(node) && + const isName = ctx.ts.isIdentifier(node) && node !== target.declarationName && node.text === target.declarationName.text && - ctx.checker.getSymbolAtLocation(node) === target.symbol); + ctx.checker.getSymbolAtLocation(node) === target.symbol; + return isName || (0, this_properties_1.isStoredMemberRead)(ctx, target.storedIn, node); } /** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ function testsPresence(ctx, target, condition) { @@ -293,6 +299,19 @@ function recordAssignment({ ctx, reference }, profile) { profile.assignedValues.push(assignment.right); return assignment !== undefined; } +/** + * `helper(x)`, `new Model(x)`, `Base.call(this, x)` with an undocumented + * project function: the value is used however that function uses its + * parameter. Never the only role a reference plays — the contextual use is + * recorded too. + */ +function recordForwarding({ ctx, reference, inVariantBranch }, profile) { + const parameter = (0, value_flow_1.receivingParameter)(ctx, reference); + if (parameter && !(0, ast_helpers_1.hasExplicitParameterType)(parameter, ctx.ts)) { + profile.forwardedTo.push({ parameter, optional: inVariantBranch }); + } + return false; +} /** * Any other use: the type the code uses the value as. A reference the * checker narrowed (inside `if (typeof x === 'string')`) is used as what that @@ -310,15 +329,14 @@ const REFERENCE_RECORDERS = [ recordTypeofTest, recordInstanceofTest, recordAssignment, + recordForwarding, recordContextualUse, ]; /** Records what one reference to the profiled value says about it: the first role it plays wins. */ function recordReference(use, profile) { REFERENCE_RECORDERS.some((record) => record(use, profile)); } -function collectProfile(ctx, symbol, declarationName, scope) { - const declaredType = ctx.checker.getTypeOfSymbolAtLocation(symbol, declarationName); - const target = { symbol, declarationName, declaredType }; +function collectProfile(ctx, target, scope) { const profile = emptyProfile(); const visit = (node, inVariantBranch) => { if (isReferenceTo(ctx, target, node)) @@ -338,25 +356,76 @@ function enclosingFunctionBody(ctx, node) { return undefined; } /** - * The usage profile of a parameter (scoped to its function body, nested - * closures included) or a variable (scoped to its enclosing function, or the - * whole file for a top-level variable). Destructured declarations have no + * Where a value's uses can be: a parameter's function body (nested closures + * included), a variable's enclosing function or file, or — once a parameter + * is stored on `this` — the whole file its prototype methods read it in. + */ +function profileScope(ctx, declaration, storedIn) { + if (storedIn.size > 0) + return declaration.getSourceFile(); + return ctx.ts.isParameter(declaration) + ? declaration.parent.body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); +} +/** + * What the value's own scope says about it, gathered over its + * {@link profileScope}; a parameter stored on `this` is also read through + * that member (see ./this-properties). Destructured declarations have no * single value to profile and get an empty profile. Memoized per request. */ -function usageProfileOf(ctx, declaration) { +function ownProfileOf(ctx, declaration) { const { ts, checker } = ctx; - if (!ts.isIdentifier(declaration.name)) + const declarationName = declaration.name; + if (!ts.isIdentifier(declarationName)) return EMPTY_PROFILE; - const symbol = checker.getSymbolAtLocation(declaration.name); - const scope = ts.isParameter(declaration) - ? declaration.parent.body - : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); - if (!symbol || !scope) + const symbol = checker.getSymbolAtLocation(declarationName); + const cached = symbol && ctx.profiles.get(symbol); + if (!symbol || cached) + return cached ?? EMPTY_PROFILE; + const storedIn = ts.isParameter(declaration) ? (0, this_properties_1.thisMembersStoring)(ctx, declaration) : new Set(); + const scope = profileScope(ctx, declaration, storedIn); + if (!scope) return EMPTY_PROFILE; - const cached = ctx.profiles.get(symbol); - if (cached) - return cached; - const profile = collectProfile(ctx, symbol, declaration.name, scope); + const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const profile = collectProfile(ctx, { symbol, declarationName, declaredType, storedIn }, scope); ctx.profiles.set(symbol, profile); return profile; } +/** + * The own profiles of the parameters `profile`'s value is passed on to, + * breadth first, up to {@link MAX_USAGE_FORWARDING_HOPS} functions away; a + * profile reached through a variant branch anywhere along the way is + * optional. + */ +function forwardedProfiles(ctx, declaration, profile) { + const reached = []; + const seen = new Set([declaration]); + let frontier = profile.forwardedTo; + for (let hop = 0; hop < constants_1.MAX_USAGE_FORWARDING_HOPS && frontier.length > 0; hop++) { + const unseen = frontier.filter(({ parameter }) => !seen.has(parameter) && seen.add(parameter)); + const profiles = unseen.map(({ parameter, optional }) => ({ profile: ownProfileOf(ctx, parameter), optional })); + reached.push(...profiles); + frontier = profiles.flatMap(({ profile: next, optional }) => next.forwardedTo.map((use) => ({ parameter: use.parameter, optional: optional || use.optional }))); + } + return reached; +} +/** + * The usage profile of a parameter or a variable: what its own scope says + * about it (see {@link ownProfileOf}), plus the members and contextual types + * of the parameters it is passed on to unchanged — a wrapper's `x` must + * support whatever the `new Model(x)` it forwards to does with it. + */ +function usageProfileOf(ctx, declaration) { + const own = ownProfileOf(ctx, declaration); + if (own.forwardedTo.length === 0) + return own; + const members = { memberNames: new Set(own.memberNames), optionalMemberNames: new Set(own.optionalMemberNames) }; + const contextualTypes = [...own.contextualTypes]; + for (const { profile, optional } of forwardedProfiles(ctx, declaration, own)) { + for (const name of profile.memberNames) { + recordMember(members, name, optional || profile.optionalMemberNames.has(name)); + } + contextualTypes.push(...profile.contextualTypes); + } + return { ...own, ...members, contextualTypes }; +} diff --git a/packages/b2c-script-types/plugin/inference/value-flow.js b/packages/b2c-script-types/plugin/inference/value-flow.js new file mode 100644 index 000000000..c915ea61c --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/value-flow.js @@ -0,0 +1,213 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getReferenceNameNode = getReferenceNameNode; +exports.enclosingFunction = enclosingFunction; +exports.receivingParameter = receivingParameter; +exports.valueTarget = valueTarget; +const signatures_1 = require("./signatures"); +/** The name a function declaration, method or class declares itself by. */ +function ownName(fn, ts) { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; +} +/** The name `left = value` binds: `x`, `obj.x`, `exports.x`, or `exports` for `module.exports`. */ +function assignedName(ts, left) { + if (ts.isPropertyAccessExpression(left)) + return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : undefined; +} +/** The variable or property `value` initializes: `var helper = …`, `{helper: …}`, `{helper}`, `const {helper} = …`. */ +function declaredName(ts, value) { + const parent = value.parent; + if (ts.isShorthandPropertyAssignment(parent)) + return parent.name; + const initializes = ts.isBindingElement(parent) || + ((ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) && parent.initializer === value); + return initializes && ts.isIdentifier(parent.name) ? parent.name : undefined; +} +/** + * The name `value` is bound to: the variable or property it initializes + * (see {@link declaredName}), or the target it is assigned to + * (`exports.helper = …`). For `module.exports = …` that is the `exports` + * name, whose references are the `require()` calls of the module. + */ +function bindingNameOf(ts, value) { + const parent = value.parent; + const isAssignment = ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === value; + return isAssignment ? assignedName(ts, parent.left) : declaredName(ts, value); +} +/** + * Identifies the name to run findReferences on for a function-like + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. + */ +function getReferenceNameNode(fn, ts) { + const callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + return ownName(callable, ts) ?? (ts.isExpression(callable) ? bindingNameOf(ts, callable) : undefined); +} +/** The function enclosing `node`, if any. */ +function enclosingFunction(node, ts) { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) + return current; + } + return undefined; +} +/** + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call, ts) { + const list = call.arguments[1]; + if (!list) + return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') + return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} +/** What a reference stands for as a value: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function accessOf(node, ts) { + const { parent } = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee, ts) { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? { node: call, args: call.arguments ?? [] } : undefined; +} +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee, ts) { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) + return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) + return undefined; + if (access.name.text === 'call') + return { node: call, args: call.arguments.slice(1) }; + return access.name.text === 'apply' ? { node: call, args: applyArguments(call, ts) } : undefined; +} +/** + * A `require('specifier')` call, identified structurally (only public + * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part + * of TypeScript's public API surface, so isn't safe to depend on here). + */ +function isRequireCallExpression(node, ts) { + return (ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length > 0 && + ts.isStringLiteralLike(node.arguments[0])); +} +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node, ts) { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} +/** True when `parent` can evaluate to `child` unchanged: `(x)`, `c ? x : y`, `a || x`, `a ?? x`, `a && x`. */ +function passesThrough(ts, parent, child) { + if (ts.isParenthesizedExpression(parent)) + return true; + if (ts.isConditionalExpression(parent)) + return parent.condition !== child; + if (!ts.isBinaryExpression(parent)) + return false; + const operator = parent.operatorToken.kind; + const { SyntaxKind } = ts; + if (operator === SyntaxKind.AmpersandAmpersandToken) + return parent.right === child; + return operator === SyntaxKind.BarBarToken || operator === SyntaxKind.QuestionQuestionToken; +} +/** The outermost expression that still evaluates to `node` (`getModel(a) || Fallback` for `Fallback`). */ +function outermostValue(ts, node) { + let value = node; + while (value.parent && passesThrough(ts, value.parent, value)) + value = value.parent; + return value; +} +/** + * The project function `call` passes its arguments to, and the argument its + * first parameter receives: `fn(a)`, `new Fn(a)`, and `Fn.call(this, a)`, the + * SFRA inheritance idiom. Only a function whose body is in the project (not + * one a declaration file describes) does anything worth following. + */ +function argumentReceiver(ctx, call) { + const { ts, checker } = ctx; + const callee = call.expression; + const borrowed = ts.isCallExpression(call) && ts.isPropertyAccessExpression(callee) && callee.name.text === 'call'; + const fn = borrowed + ? checker.getTypeAtLocation(callee.expression).getCallSignatures()[0]?.getDeclaration() + : (0, signatures_1.resolveCalleeDeclaration)(ctx, call); + const hasBody = fn !== undefined && fn.body !== undefined; + return hasBody ? { fn: fn, firstArgument: borrowed ? 1 : 0 } : undefined; +} +/** + * The parameter of a project function that receives `value` as an argument: + * `Model` of `createRefinement(search, definition, Model)`, or + * `refinementValue` of `new BooleanAttributeValue(search, definition, + * refinementValue)`. + */ +function receivingParameter(ctx, value) { + const { ts } = ctx; + const call = value.parent; + if (!ts.isCallExpression(call) && !ts.isNewExpression(call)) + return undefined; + const index = call.arguments?.indexOf(value) ?? -1; + const receiver = index >= 0 ? argumentReceiver(ctx, call) : undefined; + const parameter = receiver?.fn.parameters[index - receiver.firstArgument]; + return parameter && !parameter.dotDotDotToken && ts.isIdentifier(parameter.name) ? parameter : undefined; +} +/** The function `value` is the result of: `return value;`, or an arrow function's expression body. */ +function returningFunction(ts, value) { + const parent = value.parent; + if (ts.isArrowFunction(parent)) + return parent.body === value ? parent : undefined; + return ts.isReturnStatement(parent) ? enclosingFunction(parent, ts) : undefined; +} +/** The next name holding `value` in `role`: a binding, an assignment target, or the parameter it is passed to. */ +function heldBy(ctx, value, role) { + const name = bindingNameOf(ctx.ts, value) ?? receivingParameter(ctx, value)?.name; + return name && { kind: 'name', name, role }; +} +/** A function returning `value` is a factory of it: its calls evaluate to `value`. */ +function returnedBy(ctx, value) { + const factory = returningFunction(ctx.ts, value); + const name = factory && getReferenceNameNode(factory, ctx.ts); + return name && { kind: 'name', name, role: 'factory' }; +} +/** + * Where one reference to the function leads. In the `value` role the + * reference is the function itself: a call invoking it + * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, + * `require('./helper')(x)`) is a call site; a binding, an assignment, an + * argument or a `return` leads on to the next name holding it. In the + * `factory` role the reference names a function returning it, so a call of + * that reference evaluates to the function and is followed as a value in + * turn. + */ +function valueTarget(ctx, reference, role) { + const { ts } = ctx; + const value = outermostValue(ts, requireCallOf(reference, ts) ?? accessOf(reference, ts)); + if (role === 'factory') { + const call = value.parent; + const invoked = ts.isCallExpression(call) && call.expression === value; + return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); + } + const call = directCall(value, ts) ?? borrowedCall(value, ts); + return call ? { kind: 'call', call } : (heldBy(ctx, value, 'value') ?? returnedBy(ctx, value)); +} diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index beca9bb20..327b8846f 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -17,8 +17,13 @@ exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.u // inference/context - the per-request scratchpad (program, budgets, memo, guards) // inference/ast-helpers - pure AST navigation (find node, return exprs, ...) // inference/type-helpers - Type utilities + hover text / completion entries +// inference/value-flow - where a function value goes: calls, exports, factories, parameters // inference/call-sites - find where a function is called across the project +// inference/signatures - the function a call invokes and its declared callback types +// inference/generic-calls - generic calls whose result is an argument or a callback's return +// inference/member-values - the values an untyped member is built with // inference/super-module - module.superModule detection and export scanning +// inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 21736b7d5..8726aa765 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -7,30 +7,19 @@ // Finds where an undocumented function is actually *called* across the whole // project — the raw material for inferring a parameter's type from the // arguments it receives. A reference search can land on a name that isn't a -// direct call (a require() binding, a destructured import, an alias map), so -// this layer follows a bounded number of those indirection hops to reach the -// real call expressions. "Call" includes `new Helper(x)` and -// `Helper.call(this, x)` — the shapes SFRA's constructor-function "class" -// models are invoked and inherited with. +// direct call (a require() binding, an export, a factory returning the +// function, a parameter it is passed to — see ./value-flow), so this layer +// follows a bounded number of those hops to reach the real call expressions. +// "Call" includes `new Helper(x)` and `Helper.call(this, x)` — the shapes +// SFRA's constructor-function "class" models are invoked and inherited with. 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'; - -/** - * One invocation of a function and the arguments its parameters receive, by - * parameter index. Besides an ordinary call (`helper(x)`) this covers a - * constructor invocation (`new Helper(x)`, SFRA's "class" models; a bare - * `new Helper` passes nothing), `helper.call(thisArg, x)` and - * `helper.apply(thisArg, [x])` — the shapes SFRA model inheritance uses - * (`BaseAttributeValue.call(this, productSearch, ...)`). - */ -export interface CallSite { - readonly node: tsserver.CallExpression | tsserver.NewExpression; - readonly args: readonly tsserver.Expression[]; -} +import {enclosingFunction, valueTarget} from './value-flow'; +import type {CallSite, ReferenceTarget, ValueRole} from './value-flow'; // Reference searches are what an inference request spends its time on, and // a Program never changes, so their results are kept for as long as the @@ -59,206 +48,59 @@ function findReferences(ctx: InferenceContext, name: tsserver.Identifier): reado return references; } -/** A function-like declaration, or the class a constructor is invoked through. */ -type Callable = tsserver.SignatureDeclaration | tsserver.ClassLikeDeclaration; - -/** The name a function declaration, method or class declares itself by. */ -function ownName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { - const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); - return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; -} - -/** - * The name `fn` is assigned to: `exports.foo = function(){}` or - * `module.exports = function(){}`. The `.name` identifier (`foo` or - * `exports`) is what findReferences can actually track; for the bare - * `module.exports` case this resolves to the whole module's value, so callers - * reach it through collectCallSites()'s require() indirection rather than a - * direct property-access call. - */ -function assignedName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { - const assignment = fn.parent; - if (!ts.isBinaryExpression(assignment) || assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { - return undefined; - } - const {left} = assignment; - if (ts.isPropertyAccessExpression(left)) return ts.isIdentifier(left.name) ? left.name : undefined; - return ts.isIdentifier(left) ? left : undefined; -} - -/** - * Identifies the name to run findReferences on for a function-like - * declaration: its own name, or for one that has none, the name it is bound - * to (the common CommonJS shapes: `const foo = function(){}`, - * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, - * `module.exports = function(){}`). A class constructor is searched through - * its class, since that is what `new Model(x)` names. - */ -export function getReferenceNameNode( - fn: tsserver.SignatureDeclaration, - ts: typeof tsserver, -): tsserver.Identifier | undefined { - const callable: Callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; - const boundName = ts.isExpression(callable) ? bindingNameOf(callable, ts) : undefined; - return ownName(callable, ts) ?? boundName ?? assignedName(callable, ts); -} - -/** The function enclosing `node`, if any. */ -function enclosingFunction(node: tsserver.Node, ts: typeof tsserver): tsserver.SignatureDeclaration | undefined { - for (let current = node.parent; current; current = current.parent) { - if (ts.isFunctionLike(current)) return current; - } - return undefined; +/** A name whose references are searched next, and how it holds the function. */ +interface FrontierName { + readonly name: tsserver.Identifier; + readonly role: ValueRole; } /** - * The arguments `fn.apply(thisArg, list)` passes: the elements of an array - * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the - * enclosing function's own parameters, which inference then resolves from - * that function's call sites in turn. + * True when `name` declares a parameter or a variable inside a function. + * TypeScript scopes a reference search on such a local to its function, so + * it reads one file instead of scanning the program: it is not charged to + * ctx.searchBudget, though each of its hits still spends ctx.referenceBudget. */ -function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tsserver.Expression[] { - const list = call.arguments[1]; - if (!list) return []; - if (ts.isArrayLiteralExpression(list)) { - return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); - } - if (!ts.isIdentifier(list) || list.text !== 'arguments') return []; - const forwarding = enclosingFunction(call, ts); - return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; -} - -/** What a reference invokes when called: the reference itself, or for a member name (`obj.helper`), the whole access. */ -function calleeOf(node: tsserver.Node, ts: typeof tsserver): tsserver.Node { - const {parent} = node; - return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; -} - -/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ -function directCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { - const call = callee.parent; - const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; - return isCall ? {node: call, args: call.arguments ?? []} : undefined; +function isFunctionLocal(ts: typeof tsserver, name: tsserver.Identifier): boolean { + const declaration = name.parent; + if (ts.isParameter(declaration)) return true; + const isVariable = ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration); + return isVariable && enclosingFunction(declaration, ts) !== undefined; } -/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ -function borrowedCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { - const access = callee.parent; - if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) return undefined; - const call = access.parent; - if (!ts.isCallExpression(call) || call.expression !== access) return undefined; - if (access.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; - return access.name.text === 'apply' ? {node: call, args: applyArguments(call, ts)} : undefined; -} - -/** - * Given a reference identifier (`helper` in `helper(x)`, `new Helper(x)`, - * `exports.helper(x)`, `helper.call(this, x)`), finds the call site it - * invokes, if it sits in callee position. The name of a property access - * (`obj.helper`) is treated as the whole access. - */ -function findCallInCalleePosition(node: tsserver.Node, ts: typeof tsserver): CallSite | undefined { - const callee = calleeOf(node, ts); - return directCall(callee, ts) ?? borrowedCall(callee, ts); -} - -/** - * A `require('specifier')` call, identified structurally (only public - * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part - * of TypeScript's public API surface, so isn't safe to depend on here). - */ -function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node is tsserver.CallExpression { - return ( - ts.isCallExpression(node) && - ts.isIdentifier(node.expression) && - node.expression.text === 'require' && - node.arguments.length > 0 && - ts.isStringLiteralLike(node.arguments[0]) - ); -} - -/** - * The name a value is bound to when `expression` initializes a variable - * (`var helper = …`) or an object property (`{helper: …}`) — the next name - * to search references for when the value's own references dead-end there. - */ -function bindingNameOf(expression: tsserver.Expression, ts: typeof tsserver): tsserver.Identifier | undefined { - const parent = expression.parent; - if (!parent || !(ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent))) return undefined; - return parent.initializer === expression && ts.isIdentifier(parent.name) ? parent.name : undefined; -} - -/** The `require('…')` call whose module specifier `node` is. */ -function requireCallOf(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { - const call = node.parent; - return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; -} - -/** Where a reference leads: a call site, or a further name whose references lead on. */ -type ReferenceTarget = {kind: 'call'; call: CallSite} | {kind: 'name'; name: tsserver.Identifier}; - -/** - * 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(...)` whose result is bound to a - * variable (`var helper = require('./helper')`) or to a property of an - * export map (SFRA's `decorators/index.js`: - * `{images: require('./images')}`, called as `decorators.images(...)`); - * - a destructuring binding element (`const {helper} = require(...)` or - * `const {helper: local} = someObject`); - * - an alias of the function itself (`var run = helper`, or SFRA's canonical - * `module.exports = {getSalePrice: getSalePrice}` export shape): a search - * on the function name dead-ends at the alias, while the consumers - * (`productHelpers.getSalePrice(x)` in another file) are references of the - * alias name. - * - * @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): ReferenceTarget | undefined { - const {parent} = node; - 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}; - } - const required = requireCallOf(node, ts); - const invocation = required && directCall(required, ts); - if (invocation) return {kind: 'call', call: invocation}; // require('./helper')(x) - const value = required ?? node; - const name = ts.isExpression(value) ? bindingNameOf(value, ts) : undefined; - return name && {kind: 'name', name}; +function frontierKey({name, role}: FrontierName): string { + const sourceFile = name.getSourceFile(); + return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } /** * 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. + * MAX_REFERENCE_HOPS names the function value flows into (see ./value-flow: + * require() bindings, exports, aliases, factories returning it, parameters + * it is passed to) when a reference doesn't sit directly in callee + * position. Stops early once ctx.referenceBudget (result count) or + * ctx.searchBudget (project scans) runs out, returning whatever call sites + * were already found rather than continuing to fan out — an under-inferred + * (but still heuristic, clearly-labeled) result beats hanging on a + * widely-referenced helper. Results are memoized per name node for the + * duration of the request. */ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Identifier): CallSite[] { const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; const calls: CallSite[] = []; const seenNameKeys = new Set(); - let frontier: tsserver.Identifier[] = [nameNode]; + let frontier: FrontierName[] = [{name: nameNode, role: 'value'}]; 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; + const nextFrontier: FrontierName[] = []; + for (const next of frontier) { + if (localBudget <= 0) break; + const key = frontierKey(next); + const searchable = ctx.searchBudget > 0 || isFunctionLocal(ctx.ts, next.name); + if (seenNameKeys.has(key) || !searchable) continue; seenNameKeys.add(key); - localBudget = collectCallsFromName(ctx, name, calls, nextFrontier, localBudget); + localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); } frontier = nextFrontier; } @@ -268,43 +110,44 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident } /** - * 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. + * Runs one reference search for `next.name` and sorts each hit into either a + * resolved call site (pushed to `calls`, once per call) or a further name to + * chase on the next hop (pushed to `nextFrontier`). Consumes one unit of the + * shared search budget unless the name is function-local, 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, + next: FrontierName, calls: CallSite[], - nextFrontier: tsserver.Identifier[], + nextFrontier: FrontierName[], localBudget: number, ): number { - ctx.searchBudget--; - for (const reference of findReferences(ctx, name)) { + if (!isFunctionLocal(ctx.ts, next.name)) ctx.searchBudget--; + for (const reference of findReferences(ctx, next.name)) { if (localBudget <= 0) break; localBudget--; ctx.referenceBudget--; - const target = referenceTarget(ctx, reference); - if (target?.kind === 'call') calls.push(target.call); - else if (target?.kind === 'name') nextFrontier.push(target.name); + const target = referenceTarget(ctx, reference, next.role); + if (target?.kind === 'name') nextFrontier.push(target); + else if (target && !calls.some((site) => site.node === target.call.node)) calls.push(target.call); } return localBudget; } /** - * Where one reference search hit leads. Definition sites (the declaration - * itself) never sit in callee position, so they lead nowhere new; neither - * does a hit on a whole module (the source file itself, which has no parent - * — e.g. a `module.exports` reference landing in a file's leading comment). + * Where one reference search hit leads (see valueTarget()). Definition sites + * (the declaration itself) lead nowhere new; neither does a hit on a whole + * module (the source file itself, which has no parent — e.g. a + * `module.exports` reference landing in a file's leading comment). */ -function referenceTarget(ctx: InferenceContext, reference: tsserver.ReferenceEntry): ReferenceTarget | undefined { - const {ts, program} = ctx; - const file = program.getSourceFile(reference.fileName); - const node = file && getNodeAtPosition(file, ts, reference.textSpan.start); - if (!node?.parent) return undefined; - const call = findCallInCalleePosition(node, ts); - return call ? {kind: 'call', call} : resolveIndirectReferenceTarget(node, ts); +function referenceTarget( + ctx: InferenceContext, + reference: tsserver.ReferenceEntry, + role: ValueRole, +): ReferenceTarget | undefined { + const file = ctx.program.getSourceFile(reference.fileName); + const node = file && getNodeAtPosition(file, ctx.ts, reference.textSpan.start); + return node?.parent ? valueTarget(ctx, node, role) : undefined; } diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 9f0ae902f..f47b61752 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -14,10 +14,20 @@ // 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; +// How many functions a parameter's usage is followed into when the body +// passes the parameter on unchanged (`new BooleanAttributeValue(search, +// definition, refinementValue)`): what the receiving parameter's function +// does with it is usage of this one too. Each hop is one walk of a function +// (or of its file, for a value stored on `this`) and no reference search. +export const MAX_USAGE_FORWARDING_HOPS = 3; + +// How many names a function value is followed through (see ./value-flow) +// before collectCallSites() gives up on finding an actual call site. SFRA's +// refinement models need four: the model -> `module.exports` (its require() +// calls) -> the factory returning it -> `var Model = factory(...)` -> the +// `Model` parameter it is passed on to, where `new Model(...)` finally runs. +// Hops into function-local names cost no project-wide search. +export const MAX_REFERENCE_HOPS = 4; // Hard cap on how many reference-search hits collectCallSites() will process // across a single top-level inference request (not just one call site) — diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 4cf0a8bf9..7d53fbc66 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -12,7 +12,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import type {CallSite} from './call-sites'; +import type {CallSite} from './value-flow'; import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; import type {UsageProfile} from './usage-profile'; @@ -81,8 +81,9 @@ export interface InferenceContext { */ readonly typeDisplayStrings: Map; /** - * Request-scoped memo of usage profiles (see ./usage-profile), keyed by the - * parameter's or variable's symbol. Every consumer of a profile (the + * Request-scoped memo of own usage profiles (see ./usage-profile; the + * usage of the parameters a value is passed on to is merged in on top), + * keyed by the parameter's or variable's symbol. Every consumer of a profile (the * body-usage filter, contextual constraints, ambient matching, reassigned * values) reads the same single walk of the declaring scope. */ diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 0053dca96..a88b8eebc 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -25,7 +25,8 @@ import { hasExplicitReturnType, hasExplicitVariableType, } from './ast-helpers'; -import {collectCallSites, getReferenceNameNode} from './call-sites'; +import {collectCallSites} from './call-sites'; +import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; import {genericResultSource} from './generic-calls'; import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; diff --git a/packages/b2c-script-types/src/inference/framework-contracts.ts b/packages/b2c-script-types/src/inference/framework-contracts.ts index 8f0f2324a..513d06fc3 100644 --- a/packages/b2c-script-types/src/inference/framework-contracts.ts +++ b/packages/b2c-script-types/src/inference/framework-contracts.ts @@ -13,7 +13,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {findAmbientClassType} from './ambient-index'; -import {getReferenceNameNode} from './call-sites'; +import {getReferenceNameNode} from './value-flow'; import type {InferenceContext} from './context'; interface FrameworkContract { diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts index 45efc2fab..e103061cb 100644 --- a/packages/b2c-script-types/src/inference/signatures.ts +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -25,7 +25,7 @@ import {typeDisplayString} from './type-helpers'; */ export function resolveCalleeDeclaration( ctx: InferenceContext, - call: tsserver.CallExpression, + call: tsserver.CallExpression | tsserver.NewExpression, ): tsserver.SignatureDeclaration | undefined { const {checker, ts} = ctx; const sym = checker.getSymbolAtLocation(call.expression); diff --git a/packages/b2c-script-types/src/inference/this-properties.ts b/packages/b2c-script-types/src/inference/this-properties.ts new file mode 100644 index 000000000..4e4738c97 --- /dev/null +++ b/packages/b2c-script-types/src/inference/this-properties.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Constructor functions keep their arguments on `this` +// (`this.refinementValue = refinementValue`) and use them in prototype +// methods (`this.refinementValue.hitCount`), rarely through the parameter +// itself. IntelliJ reads those uses as uses of the parameter, and so does +// ./usage-profile: the checker binds every `this.refinementValue` of the +// constructor's prototype methods to the member the constructor declares, so +// a read of that member anywhere in the file is a use of the value stored in +// it. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; + +/** `this.x = value`: a plain assignment to a member of `this`. */ +function isThisMemberStore( + ts: typeof tsserver, + node: tsserver.Node, +): node is tsserver.BinaryExpression & {readonly left: tsserver.PropertyAccessExpression} { + return ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + node.left.expression.kind === ts.SyntaxKind.ThisKeyword + ); +} + +/** True when `node` gives its body a `this` of its own (a function, method or class; arrow functions share it). */ +function hasOwnThis(ts: typeof tsserver, node: tsserver.Node): boolean { + return (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLike(node); +} + +/** + * The members of `this` the function declaring `param` stores it in + * (`this.refinementValue = refinementValue`). Stores inside nested functions + * are skipped: their `this` is another value. + */ +export function thisMembersStoring( + ctx: InferenceContext, + param: tsserver.ParameterDeclaration, +): ReadonlySet { + const {ts, checker} = ctx; + const members = new Set(); + const body = (param.parent as tsserver.FunctionLikeDeclaration).body; + const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; + if (!body || !paramSymbol) return members; + const visit = (node: tsserver.Node): void => { + const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; + const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; + if (member) members.add(member); + if (!hasOwnThis(ts, node)) ts.forEachChild(node, visit); + }; + ts.forEachChild(body, visit); + return members; +} + +/** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ +export function isStoredMemberRead( + ctx: InferenceContext, + members: ReadonlySet, + node: tsserver.Node, +): node is tsserver.PropertyAccessExpression { + const {ts, checker} = ctx; + if (members.size === 0 || !ts.isPropertyAccessExpression(node)) return false; + const store = node.parent; + const written = isThisMemberStore(ts, store) && store.left === node; + if (node.expression.kind !== ts.SyntaxKind.ThisKeyword || written) return false; + const member = checker.getSymbolAtLocation(node.name); + return member !== undefined && members.has(member); +} diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts index 55b5c5b7b..97ddd3c82 100644 --- a/packages/b2c-script-types/src/inference/usage-profile.ts +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -14,9 +14,23 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {hasExplicitParameterType} from './ast-helpers'; +import {MAX_USAGE_FORWARDING_HOPS} from './constants'; import type {InferenceContext} from './context'; import {acceptsArgumentCount} from './signatures'; +import {isStoredMemberRead, thisMembersStoring} from './this-properties'; import {isOpenForUsageInference} from './type-helpers'; +import {receivingParameter} from './value-flow'; + +/** + * An undocumented parameter of a project function the value is passed to + * unchanged; `optional` when only some variants of the value are (see + * {@link variantBranches}). + */ +export interface ForwardedUse { + readonly parameter: tsserver.ParameterDeclaration; + readonly optional: boolean; +} export interface UsageProfile { /** @@ -43,6 +57,8 @@ export interface UsageProfile { readonly pushedValues: readonly tsserver.Expression[]; /** Values the code writes to its members, by member name: `x.m = v`, `x['m'] = v`. */ readonly memberValues: ReadonlyMap; + /** Parameters the value is passed on to (`helper(x)`, `new Model(x)`), whose usage is usage of it too. */ + readonly forwardedTo: readonly ForwardedUse[]; } interface ProfileBuilder { @@ -53,6 +69,7 @@ interface ProfileBuilder { readonly assignedValues: tsserver.Expression[]; readonly pushedValues: tsserver.Expression[]; readonly memberValues: Map; + readonly forwardedTo: ForwardedUse[]; } function emptyProfile(): ProfileBuilder { @@ -64,6 +81,7 @@ function emptyProfile(): ProfileBuilder { assignedValues: [], pushedValues: [], memberValues: new Map(), + forwardedTo: [], }; } @@ -106,7 +124,7 @@ function literalText(ctx: InferenceContext, node: tsserver.Node | undefined): st } /** `'m' in x`: the member an `in` test on `reference` names. */ -function inOperatorMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { +function inOperatorMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { const {ts} = ctx; const test = reference.parent; const isInTest = @@ -115,7 +133,7 @@ function inOperatorMember(ctx: InferenceContext, reference: tsserver.Identifier) } /** `x.hasOwnProperty('m')`: the member an own-property method call on `reference` names. */ -function ownPropertyMethodMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { +function ownPropertyMethodMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { const {ts} = ctx; const access = reference.parent; const isHasOwn = @@ -127,7 +145,7 @@ function ownPropertyMethodMember(ctx: InferenceContext, reference: tsserver.Iden } /** `Object[.prototype].hasOwnProperty.call(x, 'm')`: the member a borrowed own-property call on `reference` names. */ -function ownPropertyCallMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { +function ownPropertyCallMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { const {ts} = ctx; const call = reference.parent; if (!ts.isCallExpression(call) || call.arguments[0] !== reference) return undefined; @@ -144,7 +162,7 @@ function ownPropertyCallMember(ctx: InferenceContext, reference: tsserver.Identi * The member a presence test on `reference` checks for: `'m' in x`, * `x.hasOwnProperty('m')`, or `Object[.prototype].hasOwnProperty.call(x, 'm')`. */ -function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Identifier): string | undefined { +function presenceTestedMember(ctx: InferenceContext, reference: tsserver.Expression): string | undefined { return ( inOperatorMember(ctx, reference) ?? ownPropertyMethodMember(ctx, reference) ?? ownPropertyCallMember(ctx, reference) ); @@ -186,7 +204,7 @@ function declaredArgumentType( function argumentType( ctx: InferenceContext, call: tsserver.CallExpression | tsserver.NewExpression, - reference: tsserver.Identifier, + reference: tsserver.Expression, index: number, ): tsserver.Type | undefined { const {ts, checker} = ctx; @@ -200,7 +218,7 @@ function argumentType( } /** The type `reference` is used as: what its context expects, or the argument type of the call it is passed to. */ -function usedAsType(ctx: InferenceContext, reference: tsserver.Identifier): tsserver.Type | undefined { +function usedAsType(ctx: InferenceContext, reference: tsserver.Expression): tsserver.Type | undefined { const {ts, checker} = ctx; const call = reference.parent; const isCall = ts.isCallExpression(call) || ts.isNewExpression(call); @@ -208,20 +226,25 @@ function usedAsType(ctx: InferenceContext, reference: tsserver.Identifier): tsse return isCall && index >= 0 ? argumentType(ctx, call, reference, index) : checker.getContextualType(reference); } -/** The value a profile describes: its symbol, the name it is declared by, and its own (unnarrowed) type. */ +/** + * The value a profile describes: its symbol, the name it is declared by, its + * own (unnarrowed) type, and the members of `this` it is stored in (see + * ./this-properties), whose reads are reads of the value. + */ interface ProfileTarget { readonly symbol: tsserver.Symbol; readonly declarationName: tsserver.Identifier; readonly declaredType: tsserver.Type; + readonly storedIn: ReadonlySet; } -function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): node is tsserver.Identifier { - return ( +function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): node is tsserver.Expression { + const isName = ctx.ts.isIdentifier(node) && node !== target.declarationName && node.text === target.declarationName.text && - ctx.checker.getSymbolAtLocation(node) === target.symbol - ); + ctx.checker.getSymbolAtLocation(node) === target.symbol; + return isName || isStoredMemberRead(ctx, target.storedIn, node); } /** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ @@ -286,7 +309,11 @@ function variantBranches(ctx: InferenceContext, target: ProfileTarget, node: tss } /** Records one member the value is accessed by; `optional` when only some variants of the value need it. */ -function recordMember(profile: ProfileBuilder, name: string, optional: boolean): void { +function recordMember( + profile: Pick, + name: string, + optional: boolean, +): void { profile.memberNames.add(name); if (optional) profile.optionalMemberNames.add(name); } @@ -299,14 +326,14 @@ function pushDefined(list: T[], value: T | undefined): void { interface ReferenceUse { readonly ctx: InferenceContext; readonly target: ProfileTarget; - readonly reference: tsserver.Identifier; + readonly reference: tsserver.Expression; readonly inVariantBranch: boolean; } /** The binary expression `reference` is the left operand of, when its operator is `operator`. */ function leftOperandOf( ctx: InferenceContext, - reference: tsserver.Identifier, + reference: tsserver.Expression, operator: tsserver.SyntaxKind, ): tsserver.BinaryExpression | undefined { const parent = reference.parent; @@ -409,6 +436,20 @@ function recordAssignment({ctx, reference}: ReferenceUse, profile: ProfileBuilde return assignment !== undefined; } +/** + * `helper(x)`, `new Model(x)`, `Base.call(this, x)` with an undocumented + * project function: the value is used however that function uses its + * parameter. Never the only role a reference plays — the contextual use is + * recorded too. + */ +function recordForwarding({ctx, reference, inVariantBranch}: ReferenceUse, profile: ProfileBuilder): boolean { + const parameter = receivingParameter(ctx, reference); + if (parameter && !hasExplicitParameterType(parameter, ctx.ts)) { + profile.forwardedTo.push({parameter, optional: inVariantBranch}); + } + return false; +} + /** * Any other use: the type the code uses the value as. A reference the * checker narrowed (inside `if (typeof x === 'string')`) is used as what that @@ -427,6 +468,7 @@ const REFERENCE_RECORDERS = [ recordTypeofTest, recordInstanceofTest, recordAssignment, + recordForwarding, recordContextualUse, ]; @@ -435,14 +477,7 @@ function recordReference(use: ReferenceUse, profile: ProfileBuilder): void { REFERENCE_RECORDERS.some((record) => record(use, profile)); } -function collectProfile( - ctx: InferenceContext, - symbol: tsserver.Symbol, - declarationName: tsserver.Identifier, - scope: tsserver.Node, -): UsageProfile { - const declaredType = ctx.checker.getTypeOfSymbolAtLocation(symbol, declarationName); - const target: ProfileTarget = {symbol, declarationName, declaredType}; +function collectProfile(ctx: InferenceContext, target: ProfileTarget, scope: tsserver.Node): UsageProfile { const profile = emptyProfile(); const visit = (node: tsserver.Node, inVariantBranch: boolean): void => { if (isReferenceTo(ctx, target, node)) recordReference({ctx, target, reference: node, inVariantBranch}, profile); @@ -462,25 +497,90 @@ function enclosingFunctionBody(ctx: InferenceContext, node: tsserver.Node): tsse } /** - * The usage profile of a parameter (scoped to its function body, nested - * closures included) or a variable (scoped to its enclosing function, or the - * whole file for a top-level variable). Destructured declarations have no + * Where a value's uses can be: a parameter's function body (nested closures + * included), a variable's enclosing function or file, or — once a parameter + * is stored on `this` — the whole file its prototype methods read it in. + */ +function profileScope( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, + storedIn: ReadonlySet, +): tsserver.Node | undefined { + if (storedIn.size > 0) return declaration.getSourceFile(); + return ctx.ts.isParameter(declaration) + ? (declaration.parent as tsserver.FunctionLikeDeclaration).body + : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); +} + +/** + * What the value's own scope says about it, gathered over its + * {@link profileScope}; a parameter stored on `this` is also read through + * that member (see ./this-properties). Destructured declarations have no * single value to profile and get an empty profile. Memoized per request. */ -export function usageProfileOf( +function ownProfileOf( ctx: InferenceContext, declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, ): UsageProfile { const {ts, checker} = ctx; - if (!ts.isIdentifier(declaration.name)) return EMPTY_PROFILE; - const symbol = checker.getSymbolAtLocation(declaration.name); - const scope = ts.isParameter(declaration) - ? (declaration.parent as tsserver.FunctionLikeDeclaration).body - : (enclosingFunctionBody(ctx, declaration) ?? declaration.getSourceFile()); - if (!symbol || !scope) return EMPTY_PROFILE; - const cached = ctx.profiles.get(symbol); - if (cached) return cached; - const profile = collectProfile(ctx, symbol, declaration.name, scope); + const declarationName = declaration.name; + if (!ts.isIdentifier(declarationName)) return EMPTY_PROFILE; + const symbol = checker.getSymbolAtLocation(declarationName); + const cached = symbol && ctx.profiles.get(symbol); + if (!symbol || cached) return cached ?? EMPTY_PROFILE; + const storedIn = ts.isParameter(declaration) ? thisMembersStoring(ctx, declaration) : new Set(); + const scope = profileScope(ctx, declaration, storedIn); + if (!scope) return EMPTY_PROFILE; + const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); + const profile = collectProfile(ctx, {symbol, declarationName, declaredType, storedIn}, scope); ctx.profiles.set(symbol, profile); return profile; } + +/** + * The own profiles of the parameters `profile`'s value is passed on to, + * breadth first, up to {@link MAX_USAGE_FORWARDING_HOPS} functions away; a + * profile reached through a variant branch anywhere along the way is + * optional. + */ +function forwardedProfiles( + ctx: InferenceContext, + declaration: tsserver.Declaration, + profile: UsageProfile, +): {profile: UsageProfile; optional: boolean}[] { + const reached: {profile: UsageProfile; optional: boolean}[] = []; + const seen = new Set([declaration]); + let frontier = profile.forwardedTo; + for (let hop = 0; hop < MAX_USAGE_FORWARDING_HOPS && frontier.length > 0; hop++) { + const unseen = frontier.filter(({parameter}) => !seen.has(parameter) && seen.add(parameter)); + const profiles = unseen.map(({parameter, optional}) => ({profile: ownProfileOf(ctx, parameter), optional})); + reached.push(...profiles); + frontier = profiles.flatMap(({profile: next, optional}) => + next.forwardedTo.map((use) => ({parameter: use.parameter, optional: optional || use.optional})), + ); + } + return reached; +} + +/** + * The usage profile of a parameter or a variable: what its own scope says + * about it (see {@link ownProfileOf}), plus the members and contextual types + * of the parameters it is passed on to unchanged — a wrapper's `x` must + * support whatever the `new Model(x)` it forwards to does with it. + */ +export function usageProfileOf( + ctx: InferenceContext, + declaration: tsserver.ParameterDeclaration | tsserver.VariableDeclaration, +): UsageProfile { + const own = ownProfileOf(ctx, declaration); + if (own.forwardedTo.length === 0) return own; + const members = {memberNames: new Set(own.memberNames), optionalMemberNames: new Set(own.optionalMemberNames)}; + const contextualTypes = [...own.contextualTypes]; + for (const {profile, optional} of forwardedProfiles(ctx, declaration, own)) { + for (const name of profile.memberNames) { + recordMember(members, name, optional || profile.optionalMemberNames.has(name)); + } + contextualTypes.push(...profile.contextualTypes); + } + return {...own, ...members, contextualTypes}; +} diff --git a/packages/b2c-script-types/src/inference/value-flow.ts b/packages/b2c-script-types/src/inference/value-flow.ts new file mode 100644 index 000000000..cc4dbaee4 --- /dev/null +++ b/packages/b2c-script-types/src/inference/value-flow.ts @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Where a function value travels once it has a name. A reference search on a +// function's name finds every place the function *value* sits: most are calls, +// but SFCC code also hands functions and constructor "classes" around — +// `module.exports = Model` (consumers reach it through `require()`), a +// factory that picks a model (`return require('…/boolean')`, then +// `var Model = getModel(definition); new Model(...)`), and a model passed on +// as an argument (`createRefinement(search, definition, Model)`). +// valueTarget() says where one such position leads: to a call that invokes +// the value, or to the next name that holds it. ./call-sites runs the +// reference searches and follows those names, a bounded number of hops. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {resolveCalleeDeclaration} from './signatures'; + +/** + * One invocation of a function and the arguments its parameters receive, by + * parameter index. Besides an ordinary call (`helper(x)`) this covers a + * constructor invocation (`new Helper(x)`, SFRA's "class" models; a bare + * `new Helper` passes nothing), `helper.call(thisArg, x)` and + * `helper.apply(thisArg, [x])` — the shapes SFRA model inheritance uses + * (`BaseAttributeValue.call(this, productSearch, ...)`). + */ +export interface CallSite { + readonly node: tsserver.CallExpression | tsserver.NewExpression; + readonly args: readonly tsserver.Expression[]; +} + +/** How a searched name holds the function: as the function itself, or as a factory whose calls return it. */ +export type ValueRole = 'factory' | 'value'; + +/** Where a reference leads: a call site, or a further name whose references lead on. */ +export type ReferenceTarget = + | {readonly kind: 'call'; readonly call: CallSite} + | {readonly kind: 'name'; readonly name: tsserver.Identifier; readonly role: ValueRole}; + +/** A function-like declaration, or the class a constructor is invoked through. */ +type Callable = tsserver.SignatureDeclaration | tsserver.ClassLikeDeclaration; + +/** The name a function declaration, method or class declares itself by. */ +function ownName(fn: Callable, ts: typeof tsserver): tsserver.Identifier | undefined { + const declaresName = ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn) || ts.isClassDeclaration(fn); + return declaresName && fn.name && ts.isIdentifier(fn.name) ? fn.name : undefined; +} + +/** The name `left = value` binds: `x`, `obj.x`, `exports.x`, or `exports` for `module.exports`. */ +function assignedName(ts: typeof tsserver, left: tsserver.Expression): tsserver.Identifier | undefined { + if (ts.isPropertyAccessExpression(left)) return ts.isIdentifier(left.name) ? left.name : undefined; + return ts.isIdentifier(left) ? left : undefined; +} + +/** The variable or property `value` initializes: `var helper = …`, `{helper: …}`, `{helper}`, `const {helper} = …`. */ +function declaredName(ts: typeof tsserver, value: tsserver.Node): tsserver.Identifier | undefined { + const parent = value.parent; + if (ts.isShorthandPropertyAssignment(parent)) return parent.name; + const initializes = + ts.isBindingElement(parent) || + ((ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent)) && parent.initializer === value); + return initializes && ts.isIdentifier(parent.name) ? parent.name : undefined; +} + +/** + * The name `value` is bound to: the variable or property it initializes + * (see {@link declaredName}), or the target it is assigned to + * (`exports.helper = …`). For `module.exports = …` that is the `exports` + * name, whose references are the `require()` calls of the module. + */ +function bindingNameOf(ts: typeof tsserver, value: tsserver.Node): tsserver.Identifier | undefined { + const parent = value.parent; + const isAssignment = + ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && parent.right === value; + return isAssignment ? assignedName(ts, parent.left) : declaredName(ts, value); +} + +/** + * Identifies the name to run findReferences on for a function-like + * declaration: its own name, or for one that has none, the name it is bound + * to (the common CommonJS shapes: `const foo = function(){}`, + * `{foo: function(){}}`, `{foo(){}}`, `exports.foo = function(){}`, + * `module.exports = function(){}`). A class constructor is searched through + * its class, since that is what `new Model(x)` names. + */ +export function getReferenceNameNode( + fn: tsserver.SignatureDeclaration, + ts: typeof tsserver, +): tsserver.Identifier | undefined { + const callable: Callable = ts.isConstructorDeclaration(fn) ? fn.parent : fn; + return ownName(callable, ts) ?? (ts.isExpression(callable) ? bindingNameOf(ts, callable) : undefined); +} + +/** The function enclosing `node`, if any. */ +export function enclosingFunction(node: tsserver.Node, ts: typeof tsserver): tsserver.SignatureDeclaration | undefined { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) return current; + } + return undefined; +} + +/** + * The arguments `fn.apply(thisArg, list)` passes: the elements of an array + * literal, or — for the forwarding idiom `fn.apply(this, arguments)` — the + * enclosing function's own parameters, which inference then resolves from + * that function's call sites in turn. + */ +function applyArguments(call: tsserver.CallExpression, ts: typeof tsserver): tsserver.Expression[] { + const list = call.arguments[1]; + if (!list) return []; + if (ts.isArrayLiteralExpression(list)) { + return list.elements.filter((element) => !ts.isSpreadElement(element) && !ts.isOmittedExpression(element)); + } + if (!ts.isIdentifier(list) || list.text !== 'arguments') return []; + const forwarding = enclosingFunction(call, ts); + return forwarding ? forwarding.parameters.map((parameter) => parameter.name).filter(ts.isIdentifier) : []; +} + +/** What a reference stands for as a value: the reference itself, or for a member name (`obj.helper`), the whole access. */ +function accessOf(node: tsserver.Node, ts: typeof tsserver): tsserver.Node { + const {parent} = node; + return ts.isPropertyAccessExpression(parent) && parent.name === node ? parent : node; +} + +/** `callee(x)` / `new Callee(x)`: the call `callee` is invoked by directly. */ +function directCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const call = callee.parent; + const isCall = (ts.isCallExpression(call) || ts.isNewExpression(call)) && call.expression === callee; + return isCall ? {node: call, args: call.arguments ?? []} : undefined; +} + +/** `callee.call(thisArg, x)` / `callee.apply(thisArg, [x])`: the call `callee` is invoked through. */ +function borrowedCall(callee: tsserver.Node, ts: typeof tsserver): CallSite | undefined { + const access = callee.parent; + if (!ts.isPropertyAccessExpression(access) || access.expression !== callee) return undefined; + const call = access.parent; + if (!ts.isCallExpression(call) || call.expression !== access) return undefined; + if (access.name.text === 'call') return {node: call, args: call.arguments.slice(1)}; + return access.name.text === 'apply' ? {node: call, args: applyArguments(call, ts)} : undefined; +} + +/** + * A `require('specifier')` call, identified structurally (only public + * AST-node-kind checks — `ts.isRequireCall` exists at runtime but isn't part + * of TypeScript's public API surface, so isn't safe to depend on here). + */ +function isRequireCallExpression(node: tsserver.Node, ts: typeof tsserver): node is tsserver.CallExpression { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'require' && + node.arguments.length > 0 && + ts.isStringLiteralLike(node.arguments[0]) + ); +} + +/** The `require('…')` call whose module specifier `node` is. */ +function requireCallOf(node: tsserver.Node, ts: typeof tsserver): tsserver.CallExpression | undefined { + const call = node.parent; + return isRequireCallExpression(call, ts) && call.arguments[0] === node ? call : undefined; +} + +/** True when `parent` can evaluate to `child` unchanged: `(x)`, `c ? x : y`, `a || x`, `a ?? x`, `a && x`. */ +function passesThrough(ts: typeof tsserver, parent: tsserver.Node, child: tsserver.Node): boolean { + if (ts.isParenthesizedExpression(parent)) return true; + if (ts.isConditionalExpression(parent)) return parent.condition !== child; + if (!ts.isBinaryExpression(parent)) return false; + const operator = parent.operatorToken.kind; + const {SyntaxKind} = ts; + if (operator === SyntaxKind.AmpersandAmpersandToken) return parent.right === child; + return operator === SyntaxKind.BarBarToken || operator === SyntaxKind.QuestionQuestionToken; +} + +/** The outermost expression that still evaluates to `node` (`getModel(a) || Fallback` for `Fallback`). */ +function outermostValue(ts: typeof tsserver, node: tsserver.Node): tsserver.Node { + let value = node; + while (value.parent && passesThrough(ts, value.parent, value)) value = value.parent; + return value; +} + +/** + * The project function `call` passes its arguments to, and the argument its + * first parameter receives: `fn(a)`, `new Fn(a)`, and `Fn.call(this, a)`, the + * SFRA inheritance idiom. Only a function whose body is in the project (not + * one a declaration file describes) does anything worth following. + */ +function argumentReceiver( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, +): {readonly fn: tsserver.FunctionLikeDeclaration; readonly firstArgument: number} | undefined { + const {ts, checker} = ctx; + const callee = call.expression; + const borrowed = ts.isCallExpression(call) && ts.isPropertyAccessExpression(callee) && callee.name.text === 'call'; + const fn = borrowed + ? checker.getTypeAtLocation(callee.expression).getCallSignatures()[0]?.getDeclaration() + : resolveCalleeDeclaration(ctx, call); + const hasBody = fn !== undefined && (fn as tsserver.FunctionLikeDeclaration).body !== undefined; + return hasBody ? {fn: fn as tsserver.FunctionLikeDeclaration, firstArgument: borrowed ? 1 : 0} : undefined; +} + +/** + * The parameter of a project function that receives `value` as an argument: + * `Model` of `createRefinement(search, definition, Model)`, or + * `refinementValue` of `new BooleanAttributeValue(search, definition, + * refinementValue)`. + */ +export function receivingParameter( + ctx: InferenceContext, + value: tsserver.Node, +): tsserver.ParameterDeclaration | undefined { + const {ts} = ctx; + const call = value.parent; + if (!ts.isCallExpression(call) && !ts.isNewExpression(call)) return undefined; + const index = call.arguments?.indexOf(value as tsserver.Expression) ?? -1; + const receiver = index >= 0 ? argumentReceiver(ctx, call) : undefined; + const parameter = receiver?.fn.parameters[index - receiver.firstArgument]; + return parameter && !parameter.dotDotDotToken && ts.isIdentifier(parameter.name) ? parameter : undefined; +} + +/** The function `value` is the result of: `return value;`, or an arrow function's expression body. */ +function returningFunction(ts: typeof tsserver, value: tsserver.Node): tsserver.SignatureDeclaration | undefined { + const parent = value.parent; + if (ts.isArrowFunction(parent)) return parent.body === value ? parent : undefined; + return ts.isReturnStatement(parent) ? enclosingFunction(parent, ts) : undefined; +} + +/** The next name holding `value` in `role`: a binding, an assignment target, or the parameter it is passed to. */ +function heldBy(ctx: InferenceContext, value: tsserver.Node, role: ValueRole): ReferenceTarget | undefined { + const name = + bindingNameOf(ctx.ts, value) ?? (receivingParameter(ctx, value)?.name as tsserver.Identifier | undefined); + return name && {kind: 'name', name, role}; +} + +/** A function returning `value` is a factory of it: its calls evaluate to `value`. */ +function returnedBy(ctx: InferenceContext, value: tsserver.Node): ReferenceTarget | undefined { + const factory = returningFunction(ctx.ts, value); + const name = factory && getReferenceNameNode(factory, ctx.ts); + return name && {kind: 'name', name, role: 'factory'}; +} + +/** + * Where one reference to the function leads. In the `value` role the + * reference is the function itself: a call invoking it + * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, + * `require('./helper')(x)`) is a call site; a binding, an assignment, an + * argument or a `return` leads on to the next name holding it. In the + * `factory` role the reference names a function returning it, so a call of + * that reference evaluates to the function and is followed as a value in + * turn. + */ +export function valueTarget( + ctx: InferenceContext, + reference: tsserver.Node, + role: ValueRole, +): ReferenceTarget | undefined { + const {ts} = ctx; + const value = outermostValue(ts, requireCallOf(reference, ts) ?? accessOf(reference, ts)); + if (role === 'factory') { + const call = value.parent; + const invoked = ts.isCallExpression(call) && call.expression === value; + return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); + } + const call = directCall(value, ts) ?? borrowedCall(value, ts); + return call ? {kind: 'call', call} : (heldBy(ctx, value, 'value') ?? returnedBy(ctx, value)); +} diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 7d3a4c62f..650f05503 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -15,8 +15,13 @@ // inference/context - the per-request scratchpad (program, budgets, memo, guards) // inference/ast-helpers - pure AST navigation (find node, return exprs, ...) // inference/type-helpers - Type utilities + hover text / completion entries +// inference/value-flow - where a function value goes: calls, exports, factories, parameters // inference/call-sites - find where a function is called across the project +// inference/signatures - the function a call invokes and its declared callback types +// inference/generic-calls - generic calls whose result is an argument or a callback's return +// inference/member-values - the values an untyped member is built with // inference/super-module - module.superModule detection and export scanning +// inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index bc5c03319..d16c801a5 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -554,5 +554,26 @@ "name": "matching" }, "expect": "ProductLineItem[]" + }, + { + "id": "search-refinement-factory-model", + "source": "SFRA scripts/factories/searchRefinements + models/search/attributeRefinementValue shape", + "description": "A refinement model's wrapper is exported, picked by a factory's unbound `return require(...)`, held in a local `Model`, passed on as an argument and finally called with `new Model(...)`; the model keeps the value on `this` and reads it in a prototype method, which rules out the categories the other branch passes (the API declares the values as SearchRefinementValue)", + "dwTypes": ["Category", "Collection", "ProductSearchModel", "ProductSearchRefinementValue"], + "globals": "function getSearch(): ProductSearchModel;", + "files": { + "/collections.js": "function forEach(collection, callback) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n callback(iterator.next());\n }\n}\nmodule.exports = {forEach: forEach};\n", + "/models/boolean.js": "function BooleanAttributeValue(productSearch, refinementDefinition, refinementValue) {\n this.productSearch = productSearch;\n this.refinementDefinition = refinementDefinition;\n this.refinementValue = refinementValue;\n this.initialize();\n}\n\nBooleanAttributeValue.prototype.initialize = function () {\n this.hitCount = this.refinementValue.hitCount;\n this.displayValue = this.refinementValue.displayValue;\n this.selected = this.productSearch.isRefinedByAttributeValue(\n this.refinementDefinition.attributeID,\n this.refinementValue.value\n );\n};\n\nfunction BooleanRefinementValueWrapper(productSearch, refinementDefinition, refinementValue) {\n var value = new BooleanAttributeValue(productSearch, refinementDefinition, refinementValue);\n return {hitCount: value.hitCount, displayValue: value.displayValue, selected: value.selected};\n}\n\nmodule.exports = BooleanRefinementValueWrapper;\n", + "/models/category.js": "function CategoryAttributeValue(productSearch, refinementDefinition, category, selected) {\n this.id = category.ID;\n this.online = category.online;\n this.selected = selected;\n this.subCategories = [];\n}\n\nmodule.exports = CategoryAttributeValue;\n", + "/searchRefinements.js": "var collections = require('./collections');\n\nfunction getAttributeRefinementValueModel(refinementDefinition) {\n if (refinementDefinition.categoryRefinement) {\n return require('./models/category');\n }\n return require('./models/boolean');\n}\n\nfunction createCategorySearchRefinement(productSearch, refinementDefinition, Model) {\n var currentCategory = productSearch.category;\n var topCategory = new Model(productSearch, refinementDefinition, currentCategory, true);\n collections.forEach(currentCategory.subCategories, function (category) {\n topCategory.subCategories.push(new Model(productSearch, refinementDefinition, category, false));\n });\n return [topCategory];\n}\n\nfunction get(productSearch, refinementDefinition, refinementValues) {\n var Model = getAttributeRefinementValueModel(refinementDefinition);\n if (refinementDefinition.categoryRefinement) {\n return createCategorySearchRefinement(productSearch, refinementDefinition, Model);\n }\n var tree = [];\n collections.forEach(refinementValues, function (value) {\n tree.push(new Model(productSearch, refinementDefinition, value));\n });\n return tree;\n}\n\nmodule.exports = {get: get};\n", + "/productSearch.js": "var searchRefinementsFactory = require('./searchRefinements');\n\nfunction getRefinements(productSearch, refinements, refinementDefinitions) {\n return refinementDefinitions.toArray().map(function (definition) {\n var refinementValues = refinements.getAllRefinementValues(definition);\n var values = searchRefinementsFactory.get(productSearch, definition, refinementValues);\n return {displayName: definition.displayName, values: values};\n });\n}\n\nfunction ProductSearch(productSearch) {\n this.refinements = getRefinements(\n productSearch,\n productSearch.refinements,\n productSearch.refinements.refinementDefinitions\n );\n}\n\nmodule.exports = ProductSearch;\n", + "/search.js": "var ProductSearch = require('./productSearch');\n\nnew ProductSearch(getSearch());\n" + }, + "target": { + "file": "/models/boolean.js", + "function": "BooleanRefinementValueWrapper", + "param": 2 + }, + "expect": "SearchRefinementValue" } ] diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index cdb6c4feb..d9d82f85c 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -35,8 +35,10 @@ // the end-to-end effect a user sees in hovers and completions on locals. // Parameters that carry no type at all in the original source are hovered // too, to measure how often inference fires on genuinely undocumented code. -// Cost is reported as wall time and as reference searches per request; the -// search count is the same on every machine and run, so compare that one. +// Cost is reported as wall time and as reference searches per request, +// project-wide (budgeted by MAX_SEARCHES_PER_REQUEST) and single-file ones +// apart; the search count is the same on every machine and run, so compare +// that one. // // Optional: B2C_INFERENCE_CARTRIDGE_PATH (colon-separated cartridge names) // sets the cartridge order, B2C_INFERENCE_REPORT writes the full JSON report @@ -49,7 +51,8 @@ const path = require('node:path'); const ts = require('typescript'); const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); -const {collectCallSites, getReferenceNameNode} = require('../../src/inference/call-sites'); +const {collectCallSites} = require('../../src/inference/call-sites'); +const {getReferenceNameNode} = require('../../src/inference/value-flow'); const {inferTypeForExpression} = require('../../src/inference/core'); const {createInferenceContext, describeTypes, inferReturnType, inferTypeForNode} = require('../../src/usage-inference'); const {createPluginProxy} = require('../helpers/plugin-proxy'); @@ -121,16 +124,40 @@ function createCorpusHost(fileNames, currentDirectory) { return {host, setOverride}; } +/** The innermost node of `sourceFile` spanning `position`. */ +function nodeAt(sourceFile, position) { + let found = sourceFile; + const visit = (node) => { + if (node.getStart(sourceFile) <= position && position < node.getEnd()) { + found = node; + ts.forEachChild(node, visit); + } + }; + ts.forEachChild(sourceFile, visit); + return found; +} + +/** True when the name searched at `position` is a parameter or a variable declared inside a function (the plugin's own rule). */ +function isFunctionLocalAt(sourceFile, position) { + const declaration = sourceFile ? nodeAt(sourceFile, position).parent : undefined; + if (!declaration) return false; + if (ts.isParameter(declaration)) return true; + if (!ts.isVariableDeclaration(declaration) && !ts.isBindingElement(declaration)) return false; + return ts.findAncestor(declaration, ts.isFunctionLike) !== undefined; +} + function createCorpusPlugin(cartridges, fileNames) { const {host, setOverride} = createCorpusHost(fileNames, cartridges[0]?.src ?? '/'); const languageService = ts.createLanguageService(host); // Reference searches are what an inference request spends its time on; // counting them gives a cost measure that, unlike wall time, is the same - // on every machine and run. - const searches = {count: 0}; + // on every machine and run. Searches for a name declared inside a function + // scan one file only and spend no search budget, so they are counted apart. + const searches = {count: 0, local: 0}; const getReferencesAtPosition = languageService.getReferencesAtPosition.bind(languageService); languageService.getReferencesAtPosition = (fileName, position) => { - searches.count++; + if (isFunctionLocalAt(languageService.getProgram().getSourceFile(fileName), position)) searches.local++; + else searches.count++; return getReferencesAtPosition(fileName, position); }; const {proxy} = createPluginProxy({ @@ -366,14 +393,18 @@ const SLOWEST_REPORTED = 20; const SEARCH_COUNTER = Symbol('searchCounter'); function timed(report, label, compute) { - const searchesBefore = report[SEARCH_COUNTER].count; + const counter = report[SEARCH_COUNTER]; + const searchesBefore = counter.count; + const localBefore = counter.local; const start = process.hrtime.bigint(); const result = compute(); const ms = Number(process.hrtime.bigint() - start) / 1e6; - const searches = report[SEARCH_COUNTER].count - searchesBefore; + const searches = counter.count - searchesBefore; + const localSearches = counter.local - localBefore; report.timings.push(ms); report.searches.push(searches); - report.slowest.push({target: label, ms, searches}); + report.localSearches.push(localSearches); + report.slowest.push({target: label, ms, searches, localSearches}); report.slowest.sort((a, b) => b.ms - a.ms).splice(SLOWEST_REPORTED); return result; } @@ -450,6 +481,7 @@ function summarize(report) { }; const sorted = [...report.timings].sort((a, b) => a - b); const searches = [...report.searches].sort((a, b) => a - b); + const localSearches = [...report.localSearches].sort((a, b) => a - b); const u = report.undocumented; const reasons = Object.entries(report.silentReasons) .sort((a, b) => b[1] - a[1]) @@ -462,8 +494,10 @@ function summarize(report) { `undocumented params fired on ${u.fired}/${u.total}`, `latency ms p50=${percentile(sorted, 50).toFixed(1)} p95=${percentile(sorted, 95).toFixed(1)} ` + `max=${(sorted.at(-1) ?? 0).toFixed(1)} over ${sorted.length} requests`, - `reference searches p50=${percentile(searches, 50)} p95=${percentile(searches, 95)} ` + + `project-wide searches p50=${percentile(searches, 50)} p95=${percentile(searches, 95)} ` + `max=${searches.at(-1) ?? 0} total=${searches.reduce((sum, n) => sum + n, 0)}`, + `single-file searches p95=${percentile(localSearches, 95)} max=${localSearches.at(-1) ?? 0} ` + + `total=${localSearches.reduce((sum, n) => sum + n, 0)}`, ].join('\n'); } @@ -486,6 +520,7 @@ function summarize(report) { mismatches: [], timings: [], searches: [], + localSearches: [], slowest: [], [SEARCH_COUNTER]: plugin.searches, }; 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 b4fdaa3fe..ecd3cf5ed 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -41,6 +41,7 @@ const REAL_DW_TYPES = { Basket: dtsPath('dw', 'order', 'Basket'), OrderAddress: dtsPath('dw', 'order', 'OrderAddress'), ProductSearchModel: dtsPath('dw', 'catalog', 'ProductSearchModel'), + ProductSearchRefinementValue: dtsPath('dw', 'catalog', 'ProductSearchRefinementValue'), ProductAvailabilityModel: dtsPath('dw', 'catalog', 'ProductAvailabilityModel'), OrderPaymentInstrument: dtsPath('dw', 'order', 'OrderPaymentInstrument'), ShippingMethod: dtsPath('dw', 'order', 'ShippingMethod'), diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 58f18c718..9d5bb6902 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -8,8 +8,9 @@ // Values that reach a parameter, variable or return through something other // than a direct call: arrays built with push() and read back by index or // array methods, members written into objects and read later, generic -// Script API calls such as Transaction.wrap, and downcasts the body makes -// after testing what it holds. +// Script API calls such as Transaction.wrap, downcasts the body makes after +// testing what it holds, and constructors that travel as values (exported, +// returned by a factory, passed as an argument) before they are called. const assert = require('node:assert/strict'); @@ -69,24 +70,34 @@ const INFER = { var: (ctx, node) => inferTypeForNode(ctx, node.name), }; -/** Infers `kind` (`return`, `param`, `var`) for the first node called `name` in `/helper.js`. */ -function infer(types, source, kind, name) { - const languageService = createFixtureLanguageService({'/types.d.ts': types, '/helper.js': source}); - const ctx = createInferenceContext(ts, languageService); +/** The first node in `fileName` that `kind`'s finder accepts for `name`. */ +function findTarget(ctx, fileName, kind, name) { let target; const visit = (node) => { if (!target && FINDERS[kind](node, name)) target = node; if (!target) ts.forEachChild(node, visit); }; - visit(ctx.program.getSourceFile('/helper.js')); - assert.ok(target, `no ${kind} target named ${name}`); - return describeTypes(ctx, INFER[kind](ctx, target)); + visit(ctx.program.getSourceFile(fileName)); + assert.ok(target, `no ${kind} target named ${name} in ${fileName}`); + return target; +} + +/** + * Infers `kind` (`return`, `param`, `var`) for the first node called `name` + * in `file` (default `/helper.js`), with `source` as `/helper.js` and any + * further `files` beside it. + */ +function infer({types = CART_TYPES, source, files = {}, file = '/helper.js', kind, name}) { + const sources = source === undefined ? files : {'/helper.js': source, ...files}; + const languageService = createFixtureLanguageService({'/types.d.ts': types, ...sources}); + const ctx = createInferenceContext(ts, languageService); + return describeTypes(ctx, INFER[kind](ctx, findTarget(ctx, file, kind, name))); } function runCases(cases) { - for (const {title, types = CART_TYPES, source, kind, name, expected} of cases) { + for (const {title, expected, ...target} of cases) { it(title, () => { - assert.equal(infer(types, source, kind, name), expected); + assert.equal(infer(target), expected); }); } } @@ -298,3 +309,208 @@ describe('usage-inference — downcasts in the body', () => { }, ]); }); + +describe('usage-inference — constructors as values', () => { + const REFINEMENT_TYPES = realTypesPrelude( + ['Category', 'ProductSearchModel', 'ProductSearchRefinementValue'], + ` + function getSearch(): ProductSearchModel; + function getCategory(): Category; + function getRefinementValue(): ProductSearchRefinementValue; + `, + ); + + // The shape of SFRA's search refinements: a model keeps its constructor + // arguments on `this` and reads them in prototype methods, a wrapper builds + // it with `new`, the module exports the wrapper, a factory picks the module + // with an unbound `return require(...)`, and the caller passes the chosen + // constructor on to the function that finally calls `new Model(...)`. + const REFINEMENT_FILES = { + '/models/boolean.js': ` + function BooleanValue(productSearch, refinementValue) { + this.productSearch = productSearch; + this.refinementValue = refinementValue; + this.initialize(); + } + BooleanValue.prototype.initialize = function () { + this.hitCount = this.refinementValue.hitCount; + this.displayValue = this.refinementValue.displayValue; + }; + function BooleanValueWrapper(productSearch, rawValue) { + var value = new BooleanValue(productSearch, rawValue); + return {hitCount: value.hitCount, displayValue: value.displayValue}; + } + module.exports = BooleanValueWrapper; + `, + '/models/category.js': ` + function CategoryValue(productSearch, category) { this.id = category.ID; } + module.exports = CategoryValue; + `, + '/factory.js': ` + function getModel(isCategory) { + if (isCategory) { + return require('./models/category'); + } + return require('./models/boolean'); + } + function createCategoryRefinements(productSearch, Model) { + return [new Model(productSearch, productSearch.category)]; + } + function createRefinements(productSearch, isCategory) { + var Model = getModel(isCategory); + if (isCategory) { + return createCategoryRefinements(productSearch, Model); + } + return [new Model(productSearch, getRefinementValue())]; + } + module.exports = {createRefinements: createRefinements}; + `, + '/search.js': ` + var factory = require('./factory'); + factory.createRefinements(getSearch(), false); + `, + }; + + runCases([ + { + title: 'a constructor returned by a factory as an unbound require() is called where the factory result is', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'productSearch', + expected: 'ProductSearchModel', + }, + { + // Category only reaches it through the parameter; the factory may + // return either module, so the direct call adds the refinement value. + title: 'a constructor passed as an argument is called where the receiving parameter is', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/category.js', + kind: 'param', + name: 'category', + expected: 'ProductSearchRefinementValue | Category', + }, + { + title: "members read off this. in prototype methods narrow the model's own parameter", + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'refinementValue', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'a wrapper parameter passed on to new Model(x) is narrowed by what Model does with it', + types: REFINEMENT_TYPES, + files: REFINEMENT_FILES, + file: '/models/boolean.js', + kind: 'param', + name: 'rawValue', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'exports.name = fn is called through the requiring module', + files: { + '/helper.js': 'function describeItem(product) { return product.name; } exports.describeItem = describeItem;', + '/consumer.js': "var helper = require('./helper'); helper.describeItem(getProduct());", + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + { + title: 'module.exports = Name is called through every name a require() binds it to', + files: { + '/helper.js': 'function Item(product) { this.name = product.name; } module.exports = Item;', + '/consumer.js': "var LineItemModel = require('./helper'); new LineItemModel(getProduct());", + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + ]); + + // Two call sites pass unrelated classes; only what the value is used for, + // possibly several functions away, tells them apart. + const forwarded = (chain) => ` + ${chain} + first(getRefinementValue()); + first(getCategory()); + `; + + runCases([ + { + title: 'without usage, unrelated call-site classes stay a union', + types: REFINEMENT_TYPES, + source: forwarded('function first(value) { return value; }'), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue | Category', + }, + { + title: 'usage of the parameter it is passed to through Base.call(this, x) narrows a subclass constructor', + types: REFINEMENT_TYPES, + source: forwarded(` + function Base(value) { this.value = value; } + Base.prototype.count = function () { return this.value.hitCount; }; + function first(value) { Base.call(this, value); } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'usage three functions away still narrows the parameter', + types: REFINEMENT_TYPES, + source: forwarded(` + function first(value) { return second(value); } + function second(value) { return third(value); } + function third(value) { return fourth(value); } + function fourth(value) { return value.hitCount; } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue', + }, + { + title: 'usage four functions away is beyond MAX_USAGE_FORWARDING_HOPS', + types: REFINEMENT_TYPES, + source: forwarded(` + function first(value) { return second(value); } + function second(value) { return third(value); } + function third(value) { return fourth(value); } + function fourth(value) { return fifth(value); } + function fifth(value) { return value.hitCount; } + `), + kind: 'param', + name: 'value', + expected: 'ProductSearchRefinementValue | Category', + }, + ]); + + it('follows a function-local name without spending the project-wide search budget', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': CART_TYPES, + '/helper.js': ` + function show(product) { return product.name; } + function run() { var render = show; render(getProduct()); } + `, + }); + let searches = 0; + const getReferencesAtPosition = languageService.getReferencesAtPosition.bind(languageService); + languageService.getReferencesAtPosition = (fileName, position) => { + searches++; + return getReferencesAtPosition(fileName, position); + }; + const ctx = createInferenceContext(ts, languageService); + ctx.searchBudget = 1; + + const types = inferParameterType(ctx, findTarget(ctx, '/helper.js', 'param', 'product')); + + assert.equal(describeTypes(ctx, types), 'Product'); + assert.equal(searches, 2, 'one search for show, one for the local render'); + assert.equal(ctx.searchBudget, 0, 'only the search for show is charged'); + }); +}); From 9b7065f167dc1b3541897fb685e0807e38424dce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:48:38 +0000 Subject: [PATCH 57/76] feat(script-types): infer call-specific returns and read nullable placeholders as weak A helper whose return depends on what one call passes it (SFRA's collections.find / collections.first over basket.shipments) is now inferred again for that call with its parameters bound to the call's arguments, when its return across all callers says nothing or leaves a union. A bound argument also narrows a declared any-instantiation such as a bare {dw.util.Collection}, so the element type flows through iterator().next(). Results under bindings never enter the request memo. {Object|null}, {?Object}, {Object=} and similar nullable or optional placeholders are now weak like {Object}, so they no longer block inference. superModule resolution moves from core into super-module behind a resolver callback, keeping core within its size budget. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/ast-helpers.js | 47 +++-- .../plugin/inference/bindings.js | 83 ++++++++ .../plugin/inference/context.js | 23 ++- .../b2c-script-types/plugin/inference/core.js | 107 ++++++----- .../plugin/inference/super-module.js | 85 ++++++-- .../plugin/usage-inference.js | 3 +- .../src/inference/ast-helpers.ts | 49 +++-- .../src/inference/bindings.ts | 107 +++++++++++ .../b2c-script-types/src/inference/context.ts | 30 ++- .../b2c-script-types/src/inference/core.ts | 156 +++++++-------- .../src/inference/super-module.ts | 126 ++++++++++-- .../b2c-script-types/src/usage-inference.ts | 3 +- .../b2c-script-types/test/corpus/cases.json | 18 ++ .../test/usage-inference.test.js | 23 ++- .../test/usage-inference.value-flow.test.js | 181 ++++++++++++++++++ 15 files changed, 830 insertions(+), 211 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/bindings.js create mode 100644 packages/b2c-script-types/src/inference/bindings.ts diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index c746b1436..09dc0b289 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -64,7 +64,8 @@ function memberCompletionAccess(sourceFile, ts, position) { } /** * SFRA helpers are often "documented" with a placeholder type that carries no - * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`. Those + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`). 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. @@ -73,9 +74,12 @@ function memberCompletionAccess(sourceFile, ts, position) { * pretend you know this type", and we still respect it. */ function isWeakTypeNode(typeNode, ts) { - let node = typeNode; - while (ts.isParenthesizedTypeNode(node)) - node = node.type; + const node = unwrapTypeNode(typeNode, ts); + // `{Object|null}`, `{Object|undefined}`: a placeholder that may be missing is still a placeholder. + if (ts.isUnionTypeNode(node)) { + const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); + return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); + } // JSDoc `{*}` — "any value", not a real shape. if (node.kind === ts.SyntaxKind.JSDocAllType) return true; @@ -85,20 +89,33 @@ function isWeakTypeNode(typeNode, ts) { // 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(); + const lower = typeReferenceName(node, ts)?.toLowerCase(); // `Object` / `object` / the SFRA-conventional misspelling `obj`. return lower === 'object' || lower === 'obj'; } +/** The type `(T)`, `?T`, `!T` and `T=` wrap: parentheses and JSDoc nullability or optionality change no shape. */ +function unwrapTypeNode(typeNode, ts) { + let node = typeNode; + while (ts.isParenthesizedTypeNode(node) || + ts.isJSDocNullableType(node) || + ts.isJSDocNonNullableType(node) || + ts.isJSDocOptionalType(node)) { + node = node.type; + } + return node; +} +/** `null` or `undefined` as a type. */ +function isNullishTypeNode(node, ts) { + if (node.kind === ts.SyntaxKind.UndefinedKeyword) + return true; + return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword; +} +/** The name a type reference names: `Object` in `{Object}` or `extends Object`. */ +function typeReferenceName(node, ts) { + if (ts.isTypeReferenceNode(node)) + return node.typeName.getText(); + return ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression) ? node.expression.text : undefined; +} /** True when `typeNode` is a real annotation we must not second-guess (including deliberate `any`). */ function isStrongTypeNode(typeNode, ts) { return typeNode !== undefined && !isWeakTypeNode(typeNode, ts); diff --git a/packages/b2c-script-types/plugin/inference/bindings.js b/packages/b2c-script-types/plugin/inference/bindings.js new file mode 100644 index 000000000..e75f836ff --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/bindings.js @@ -0,0 +1,83 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NO_BINDINGS = void 0; +exports.withBindings = withBindings; +exports.argumentBindings = argumentBindings; +exports.boundArgument = boundArgument; +exports.hasAnyTypeArgument = hasAnyTypeArgument; +exports.narrows = narrows; +const member_values_1 = require("./member-values"); +const type_helpers_1 = require("./type-helpers"); +/** No parameter bound: what every inference outside a call-specific retry runs with. */ +exports.NO_BINDINGS = new Map(); +/** Runs `compute` with `bindings` in force, restoring the previous ones afterwards. */ +function withBindings(ctx, bindings, compute) { + const previous = ctx.bindings; + ctx.bindings = bindings; + try { + return compute(); + } + finally { + ctx.bindings = previous; + } +} +/** + * `fn`'s plain parameters bound to the arguments `call` passes them, on top of + * the bindings already in force (a helper nested in a bound function still + * sees its parameters). Arguments after a spread, rest parameters and + * destructured parameters are left unbound; `undefined` when nothing binds. + */ +function argumentBindings(ctx, fn, call, depth) { + const { ts } = ctx; + const outer = ctx.bindings; + const bindings = new Map(outer); + for (const [index, parameter] of fn.parameters.entries()) { + const argument = call.arguments[index]; + if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) + break; + if (ts.isIdentifier(parameter.name)) + bindings.set(parameter, { argument, depth, outer }); + } + return bindings.size > outer.size ? bindings : undefined; +} +/** The binding the parameter `expr` names has in the bindings in force. */ +function boundArgument(ctx, expr) { + const { ts } = ctx; + if (ctx.bindings.size === 0 || !ts.isIdentifier(expr)) + return undefined; + const declaration = (0, member_values_1.valueDeclarationOf)(ctx, expr); + return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; +} +/** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ +function hasAnyTypeArgument(ctx, type) { + const { ts, checker } = ctx; + if (!(type.flags & ts.TypeFlags.Object)) + return false; + if (!(type.objectFlags & ts.ObjectFlags.Reference)) + return false; + return checker.getTypeArguments(type).some((argument) => (0, type_helpers_1.isAnyType)(ts, argument)); +} +/** + * True when `types`, recovered for one call, may stand in for the `general` + * types the same expression has without it: when those say nothing, or when + * every recovered type is assignable to one of them (`Collection` + * for a parameter declared `{dw.util.Collection}`; `Shipment` out of a + * helper returning `Shipment | ProductLineItem` across its callers). + * Anything else is left alone, and TypeScript builds without the public + * `isTypeAssignableTo` never narrow a type that says something. + */ +function narrows(ctx, general, types) { + const { checker } = ctx; + if (types.length === 0) + return false; + if (general.length === 0) + return true; + if (typeof checker.isTypeAssignableTo !== 'function') + return false; + return types.every((type) => general.some((part) => checker.isTypeAssignableTo(type, part))); +} diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js index d876e1701..dcf8ae89f 100644 --- a/packages/b2c-script-types/plugin/inference/context.js +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createInferenceContext = createInferenceContext; exports.withCycleGuard = withCycleGuard; exports.withInferenceGuards = withInferenceGuards; +const bindings_1 = require("./bindings"); const constants_1 = require("./constants"); /** * Builds a fresh inference context for one top-level hover/completion @@ -30,6 +31,7 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath, tri typeDisplayStrings: new Map(), profiles: new Map(), cycleHits: 0, + bindings: bindings_1.NO_BINDINGS, resolveSuperModulePath, triggerPosition, }; @@ -62,10 +64,15 @@ function withCycleGuard(ctx, node, fallback, compute) { * reusable however deep the current path is. A result whose computation hit * a cycle guard is not memoized: it was cut short by what happened to be on * the current call stack, and the same node reached later from outside the - * cycle could resolve more. + * cycle could resolve more. While call-specific bindings are in force + * nothing is memoized, since a result for one call's arguments is not the + * node's general result; and inside a bound function the memo is not read + * either, since its general result is what the bindings are there to + * improve on. Anywhere else the general result still holds. */ function withInferenceGuards(ctx, node, depth, compute) { - const cached = ctx.memo.get(node); + const unbound = ctx.bindings.size === 0; + const cached = unbound || !isInBoundFunction(ctx, node) ? ctx.memo.get(node) : undefined; if (cached && cached.atDepth <= depth) return cached.types; if (depth > constants_1.MAX_INFERENCE_DEPTH) @@ -73,8 +80,18 @@ function withInferenceGuards(ctx, node, depth, compute) { return withCycleGuard(ctx, node, [], () => { const cycleHitsBefore = ctx.cycleHits; const types = compute(); - if (ctx.cycleHits === cycleHitsBefore) + if (unbound && ctx.cycleHits === cycleHitsBefore) ctx.memo.set(node, { atDepth: depth, types }); return types; }); } +/** True when `node` lies inside a function whose parameters are bound to one call's arguments. */ +function isInBoundFunction(ctx, node) { + const file = node.getSourceFile(); + for (const parameter of ctx.bindings.keys()) { + const fn = parameter.parent; + if (fn.getSourceFile() === file && node.pos >= fn.pos && node.end <= fn.end) + return true; + } + return false; +} diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 7b5c43792..ea97fd4fc 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -12,6 +12,7 @@ exports.inferTypeForExpression = inferTypeForExpression; const constants_1 = require("./constants"); const context_1 = require("./context"); const ast_helpers_1 = require("./ast-helpers"); +const bindings_1 = require("./bindings"); const call_sites_1 = require("./call-sites"); const value_flow_1 = require("./value-flow"); const framework_contracts_1 = require("./framework-contracts"); @@ -56,59 +57,14 @@ function resolveInitializerTypes(ctx, initializer, pushedValues, depth, chainHop } return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); } +/** Resolves values one hop further along the expression `depth` and `chainHops` describe. */ +function nextHop(ctx, depth, chainHops) { + return (value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1); +} /** The types of several values the same thing can hold, each one hop further along the expression. */ function resolveValues(ctx, values, depth, chainHops) { return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); } -/** - * The types one `module.exports = X` assignment of a superModule level - * contributes. The checker's type for `module.exports` is used when it is - * concrete — it merges the assigned object with later - * `module.exports.name = fn` augmentations. A pass-through overlay - * (`module.exports = base`, base itself a superModule) is resolved by - * recursing into the right-hand side instead, another cartridge down; the - * checker sometimes merges such a level into an opaque `typeof base` that - * still carries none of the deeper cartridges' members, so a pass-through - * right-hand side is recursed into even when the left side looked concrete. - */ -function superModuleExportTypes(ctx, assignment, depth, chainHops) { - const { ts, checker } = ctx; - const concrete = (0, super_module_1.isConcreteExportAssignment)(ctx, assignment); - const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; - if (!concrete || (0, super_module_1.traceSuperModuleAccess)(ts, checker, assignment.right)) { - types.push(...resolveExpressionTypes(ctx, assignment.right, depth, chainHops + 1)); - } - return types; -} -/** - * Resolves what `module.superModule` evaluates to: the export type(s) of the - * same-subpath module in the next cartridge down the path. Members a - * pass-through level *adds* can't be merged into these types; they are - * resolved by name in {@link resolveSuperModuleMemberTypes}. - */ -function resolveSuperModuleTypes(ctx, expr, depth, chainHops) { - const superFile = (0, super_module_1.findSuperModuleFile)(ctx, expr.getSourceFile().fileName); - if (!superFile) - return []; - // The guard catches overlay cycles from a misconfigured cartridge path. - return (0, context_1.withCycleGuard)(ctx, superFile, [], () => (0, type_helpers_1.dedupeTypes)(ctx, (0, super_module_1.collectExportAssignments)(superFile, ctx.ts).full.flatMap((assignment) => superModuleExportTypes(ctx, assignment, depth, chainHops)))); -} -/** - * Resolves `memberName` from the first superModule level (walking down the - * cartridge path) that adds it as an export augmentation - * (`module.exports.name = fn`) — the complement to - * {@link resolveSuperModuleTypes} for members no export type carries. - */ -function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops) { - for (const { members } of (0, super_module_1.superModuleLevels)(ctx, superAccess)) { - const matches = members.filter((member) => member.name === memberName); - if (matches.length === 0) - continue; - const types = matches.flatMap((member) => resolveExpressionTypes(ctx, member.expr, depth, chainHops + 1)); - return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); - } - return []; -} /** * Infers a callback parameter's type from the call the callback is passed * to. A method on a known receiver declares it (`value: T` of @@ -156,12 +112,42 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { // circular `typeof base` that would pass for informative. const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); if (superAccess) - return resolveSuperModuleTypes(ctx, superAccess, depth, chainHops); + return (0, super_module_1.resolveSuperModuleTypes)(ctx, superAccess, nextHop(ctx, depth, chainHops)); const direct = (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(expr)); + const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; + if (bound) + return bound; if (direct.length > 0) return direct; return chainHops < constants_1.MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; } +/** + * While a call's arguments are bound (see ./bindings): a bound parameter is + * what its argument is, and an expression the checker types as an `any` + * instantiation (`collection.iterator()` on a bare `{dw.util.Collection}`) + * is recovered through its parts, which may reach a bound parameter. Either + * stands in for the checker's type only where it narrows it. A bound + * parameter whose argument says nothing keeps its general type, resolved + * without the bindings (and so served from the memo). + */ +function resolveBoundTypes(ctx, expr, declared, depth, chainHops) { + const binding = (0, bindings_1.boundArgument)(ctx, expr); + if (binding) { + const types = argumentTypes(ctx, binding); + if ((0, bindings_1.narrows)(ctx, declared, types)) + return types; + return (0, bindings_1.withBindings)(ctx, bindings_1.NO_BINDINGS, () => resolveExpressionTypes(ctx, expr, depth, chainHops)); + } + if (chainHops >= constants_1.MAX_CHAIN_HOPS || !declared.some((type) => (0, bindings_1.hasAnyTypeArgument)(ctx, type))) + return undefined; + const narrowed = resolveFromParts(ctx, expr, depth, chainHops); + return (0, bindings_1.narrows)(ctx, declared, narrowed) ? narrowed : undefined; +} +/** What a bound argument evaluates to where its call is written, resolved once per binding. */ +function argumentTypes(ctx, binding) { + binding.resolved ?? (binding.resolved = (0, bindings_1.withBindings)(ctx, binding.outer, () => (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, resolveExpressionTypes(ctx, binding.argument, binding.depth))))); + return binding.resolved; +} /** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ function resolveFromParts(ctx, expr, depth, chainHops) { const { ts } = ctx; @@ -242,8 +228,9 @@ function memberTypesOfReceiver(ctx, receiver, name, depth, chainHops) { if (types.length > 0) return types; const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, receiver); - if (superAccess) - return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops); + if (superAccess) { + return (0, super_module_1.resolveSuperModuleMemberTypes)(ctx, superAccess, memberName, nextHop(ctx, depth, chainHops)); + } const bound = checker.getSymbolAtLocation(name); if (bound) return memberTypes(ctx, bound, name, depth, chainHops); @@ -276,6 +263,20 @@ function signatureReturnTypes(ctx, methodType, depth) { return declaration && ts.isFunctionLike(declaration) ? inferReturnType(ctx, declaration, depth + 1) : []; }); } +/** + * What `fn` returns for this `call`. Its return across all callers, unless + * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a + * helper handed either collection): then what it returns for the arguments + * this call passes it (see ./bindings), wherever that is narrower. + */ +function calleeReturnTypes(ctx, fn, call, depth) { + const general = inferReturnType(ctx, fn, depth + 1); + if (general.length === 1 || (0, ast_helpers_1.hasExplicitReturnType)(fn, ctx.ts)) + return general; + const bindings = (0, bindings_1.argumentBindings)(ctx, fn, call, depth); + const specific = bindings ? (0, bindings_1.withBindings)(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; + return (0, bindings_1.narrows)(ctx, general, specific) ? specific : general; +} /** * Resolves an `any` call expression: first by inferring the callee's own * return type, then — for a method call on an undocumented receiver @@ -284,7 +285,7 @@ function signatureReturnTypes(ctx, methodType, depth) { */ function resolveCallResultTypes(ctx, expr, depth, chainHops) { const calleeFn = (0, signatures_1.resolveCalleeDeclaration)(ctx, expr); - const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; + const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) return inferred; const generic = (0, generic_calls_1.genericResultSource)(ctx, expr); diff --git a/packages/b2c-script-types/plugin/inference/super-module.js b/packages/b2c-script-types/plugin/inference/super-module.js index 9f49513a1..e9f639ae6 100644 --- a/packages/b2c-script-types/plugin/inference/super-module.js +++ b/packages/b2c-script-types/plugin/inference/super-module.js @@ -5,13 +5,12 @@ * 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.superModuleLevels = superModuleLevels; +exports.resolveSuperModuleTypes = resolveSuperModuleTypes; +exports.resolveSuperModuleMemberTypes = resolveSuperModuleMemberTypes; exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; const constants_1 = require("./constants"); +const context_1 = require("./context"); const type_helpers_1 = require("./type-helpers"); /** * The SFCC `module.superModule` expression — the runtime handle to the @@ -56,21 +55,29 @@ function collectExportAssignments(sf, ts) { const full = []; const members = []; for (const stmt of sf.statements) { - if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) + const assignment = memberAssignmentOf(stmt, ts); + if (!assignment) continue; - const bin = stmt.expression; - if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(bin.left)) - continue; - const base = bin.left.expression; - if (isModuleExports(base, ts)) - members.push({ name: bin.left.name.text, expr: bin.right }); - else if (ts.isIdentifier(base) && base.text === 'exports') - members.push({ name: bin.left.name.text, expr: bin.right }); - else if (isModuleExports(bin.left, ts)) - full.push(bin); + const { binary, target } = assignment; + if (isExportsObject(target.expression, ts)) + members.push({ name: target.name.text, expr: binary.right }); + else if (isModuleExports(target, ts)) + full.push(binary); } return { full, members }; } +/** The `x.name = value` assignment a statement is, if any. */ +function memberAssignmentOf(stmt, ts) { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? { binary, target: binary.left } : undefined; +} +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +function isExportsObject(expr, ts) { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} /** `module.exports`, identified structurally. */ function isModuleExports(expr, ts) { return (ts.isPropertyAccessExpression(expr) && @@ -144,6 +151,54 @@ function* superModuleLevels(ctx, superAccess) { fromFileName = superFile.fileName; } } +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes(ctx, assignment, resolve) { + const { ts, checker } = ctx; + const concrete = isConcreteExportAssignment(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) + types.push(...resolve(assignment.right)); + return types; +} +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. + */ +function resolveSuperModuleTypes(ctx, expr, resolve) { + const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); + if (!superFile) + return []; + // The guard catches overlay cycles from a misconfigured cartridge path. + return (0, context_1.withCycleGuard)(ctx, superFile, [], () => (0, type_helpers_1.dedupeTypes)(ctx, collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => superModuleExportTypes(ctx, assignment, resolve)))); +} +/** + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. + */ +function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, resolve) { + for (const { members } of superModuleLevels(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) + continue; + const types = matches.flatMap((member) => resolve(member.expr)); + return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); + } + return []; +} /** * Collects every member the superModule chain reachable from `expr` * contributes through export augmentations (`module.exports.name = fn`) at diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 327b8846f..6e5f3b8d4 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -22,9 +22,10 @@ exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.u // inference/signatures - the function a call invokes and its declared callback types // inference/generic-calls - generic calls whose result is an argument or a callback's return // inference/member-values - the values an untyped member is built with -// inference/super-module - module.superModule detection and export scanning +// inference/super-module - module.superModule detection and resolution // inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/bindings - a helper's parameters bound to one call's arguments // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches // inference/framework-contracts - parameters the platform itself passes (Page Designer) diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index 7d6130b76..ab4b8eab1 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -76,7 +76,8 @@ export function memberCompletionAccess( /** * SFRA helpers are often "documented" with a placeholder type that carries no - * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`. Those + * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`). 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. @@ -85,29 +86,49 @@ export function memberCompletionAccess( * 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; - + const node = unwrapTypeNode(typeNode, ts); + // `{Object|null}`, `{Object|undefined}`: a placeholder that may be missing is still a placeholder. + if (ts.isUnionTypeNode(node)) { + const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); + return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); + } // 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(); + const lower = typeReferenceName(node, ts)?.toLowerCase(); // `Object` / `object` / the SFRA-conventional misspelling `obj`. return lower === 'object' || lower === 'obj'; } +/** The type `(T)`, `?T`, `!T` and `T=` wrap: parentheses and JSDoc nullability or optionality change no shape. */ +function unwrapTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): tsserver.TypeNode { + let node = typeNode; + while ( + ts.isParenthesizedTypeNode(node) || + ts.isJSDocNullableType(node) || + ts.isJSDocNonNullableType(node) || + ts.isJSDocOptionalType(node) + ) { + node = node.type; + } + return node; +} + +/** `null` or `undefined` as a type. */ +function isNullishTypeNode(node: tsserver.TypeNode, ts: typeof tsserver): boolean { + if (node.kind === ts.SyntaxKind.UndefinedKeyword) return true; + return ts.isLiteralTypeNode(node) && node.literal.kind === ts.SyntaxKind.NullKeyword; +} + +/** The name a type reference names: `Object` in `{Object}` or `extends Object`. */ +function typeReferenceName(node: tsserver.TypeNode, ts: typeof tsserver): string | undefined { + if (ts.isTypeReferenceNode(node)) return node.typeName.getText(); + return ts.isExpressionWithTypeArguments(node) && ts.isIdentifier(node.expression) ? node.expression.text : undefined; +} + /** True when `typeNode` is a real annotation we must not second-guess (including deliberate `any`). */ function isStrongTypeNode(typeNode: tsserver.TypeNode | undefined, ts: typeof tsserver): boolean { return typeNode !== undefined && !isWeakTypeNode(typeNode, ts); diff --git a/packages/b2c-script-types/src/inference/bindings.ts b/packages/b2c-script-types/src/inference/bindings.ts new file mode 100644 index 000000000..099392839 --- /dev/null +++ b/packages/b2c-script-types/src/inference/bindings.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Call-specific bindings. What a generic helper returns can depend on what one +// call passes it: SFRA's `collections.find(collection, match)` returns an +// element of `collection`, a Shipment for `basket.shipments` and a +// ProductLineItem for `basket.productLineItems`. Across all of its callers +// the helper's return says nothing, so ./core infers it once more for the call +// being resolved, with the helper's parameters bound to that call's +// arguments. Neither IntelliJ nor the checker substitutes arguments into an +// undocumented JavaScript function this way. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {valueDeclarationOf} from './member-values'; +import {isAnyType} from './type-helpers'; + +/** One parameter bound to the argument a single call passes it. */ +export interface ArgumentBinding { + readonly argument: tsserver.Expression; + /** The depth of the call, which the argument is resolved at. */ + readonly depth: number; + /** The bindings in force where the call is written, which the argument is resolved under. */ + readonly outer: Bindings; + /** The argument's types, resolved on first use. */ + resolved?: tsserver.Type[]; +} + +export type Bindings = ReadonlyMap; + +/** No parameter bound: what every inference outside a call-specific retry runs with. */ +export const NO_BINDINGS: Bindings = new Map(); + +/** Runs `compute` with `bindings` in force, restoring the previous ones afterwards. */ +export function withBindings(ctx: InferenceContext, bindings: Bindings, compute: () => T): T { + const previous = ctx.bindings; + ctx.bindings = bindings; + try { + return compute(); + } finally { + ctx.bindings = previous; + } +} + +/** + * `fn`'s plain parameters bound to the arguments `call` passes them, on top of + * the bindings already in force (a helper nested in a bound function still + * sees its parameters). Arguments after a spread, rest parameters and + * destructured parameters are left unbound; `undefined` when nothing binds. + */ +export function argumentBindings( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + call: tsserver.CallExpression, + depth: number, +): Bindings | undefined { + const {ts} = ctx; + const outer = ctx.bindings; + const bindings = new Map(outer); + for (const [index, parameter] of fn.parameters.entries()) { + const argument = call.arguments[index]; + if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) break; + if (ts.isIdentifier(parameter.name)) bindings.set(parameter, {argument, depth, outer}); + } + return bindings.size > outer.size ? bindings : undefined; +} + +/** The binding the parameter `expr` names has in the bindings in force. */ +export function boundArgument(ctx: InferenceContext, expr: tsserver.Expression): ArgumentBinding | undefined { + const {ts} = ctx; + if (ctx.bindings.size === 0 || !ts.isIdentifier(expr)) return undefined; + const declaration = valueDeclarationOf(ctx, expr); + return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; +} + +/** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ +export function hasAnyTypeArgument(ctx: InferenceContext, type: tsserver.Type): boolean { + const {ts, checker} = ctx; + if (!(type.flags & ts.TypeFlags.Object)) return false; + if (!((type as tsserver.ObjectType).objectFlags & ts.ObjectFlags.Reference)) return false; + return checker.getTypeArguments(type as tsserver.TypeReference).some((argument) => isAnyType(ts, argument)); +} + +/** + * True when `types`, recovered for one call, may stand in for the `general` + * types the same expression has without it: when those say nothing, or when + * every recovered type is assignable to one of them (`Collection` + * for a parameter declared `{dw.util.Collection}`; `Shipment` out of a + * helper returning `Shipment | ProductLineItem` across its callers). + * Anything else is left alone, and TypeScript builds without the public + * `isTypeAssignableTo` never narrow a type that says something. + */ +export function narrows( + ctx: InferenceContext, + general: readonly tsserver.Type[], + types: readonly tsserver.Type[], +): boolean { + const {checker} = ctx; + if (types.length === 0) return false; + if (general.length === 0) return true; + if (typeof checker.isTypeAssignableTo !== 'function') return false; + return types.every((type) => general.some((part) => checker.isTypeAssignableTo(type, part))); +} diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 7d53fbc66..be722557a 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -12,6 +12,8 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {NO_BINDINGS} from './bindings'; +import type {Bindings} from './bindings'; import type {CallSite} from './value-flow'; import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; import type {UsageProfile} from './usage-profile'; @@ -96,6 +98,12 @@ export interface InferenceContext { * request), so such results must not be memoized — see withInferenceGuards. */ cycleHits: number; + /** + * The parameters bound to one call's arguments while that call's + * result is inferred (see ./bindings); empty otherwise. Swapped in and out + * as calls nest, never shared between them. + */ + bindings: Bindings; /** * Maps a cartridge file to the same-subpath file in the next cartridge * down the cartridge path — the module `module.superModule` refers to at @@ -145,6 +153,7 @@ export function createInferenceContext( typeDisplayStrings: new Map(), profiles: new Map(), cycleHits: 0, + bindings: NO_BINDINGS, resolveSuperModulePath, triggerPosition, }; @@ -178,7 +187,11 @@ export function withCycleGuard(ctx: InferenceContext, node: tsserver.Node, fa * reusable however deep the current path is. A result whose computation hit * a cycle guard is not memoized: it was cut short by what happened to be on * the current call stack, and the same node reached later from outside the - * cycle could resolve more. + * cycle could resolve more. While call-specific bindings are in force + * nothing is memoized, since a result for one call's arguments is not the + * node's general result; and inside a bound function the memo is not read + * either, since its general result is what the bindings are there to + * improve on. Anywhere else the general result still holds. */ export function withInferenceGuards( ctx: InferenceContext, @@ -186,13 +199,24 @@ export function withInferenceGuards( depth: number, compute: () => tsserver.Type[], ): tsserver.Type[] { - const cached = ctx.memo.get(node); + const unbound = ctx.bindings.size === 0; + const cached = unbound || !isInBoundFunction(ctx, node) ? ctx.memo.get(node) : undefined; if (cached && cached.atDepth <= depth) return cached.types; if (depth > MAX_INFERENCE_DEPTH) return []; return withCycleGuard(ctx, node, [], () => { const cycleHitsBefore = ctx.cycleHits; const types = compute(); - if (ctx.cycleHits === cycleHitsBefore) ctx.memo.set(node, {atDepth: depth, types}); + if (unbound && ctx.cycleHits === cycleHitsBefore) ctx.memo.set(node, {atDepth: depth, types}); return types; }); } + +/** True when `node` lies inside a function whose parameters are bound to one call's arguments. */ +function isInBoundFunction(ctx: InferenceContext, node: tsserver.Node): boolean { + const file = node.getSourceFile(); + for (const parameter of ctx.bindings.keys()) { + const fn = parameter.parent; + if (fn.getSourceFile() === file && node.pos >= fn.pos && node.end <= fn.end) return true; + } + return false; +} diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index a88b8eebc..75d752490 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -25,6 +25,8 @@ import { hasExplicitReturnType, hasExplicitVariableType, } from './ast-helpers'; +import {NO_BINDINGS, argumentBindings, boundArgument, hasAnyTypeArgument, narrows, withBindings} from './bindings'; +import type {ArgumentBinding} from './bindings'; import {collectCallSites} from './call-sites'; import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; @@ -32,13 +34,8 @@ import {genericResultSource} from './generic-calls'; import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; import {decideType, limitUnion, normalizeCandidates} from './policy'; import {callbackParameterTypes, isElementFirstCallbackCall, resolveCalleeDeclaration} from './signatures'; -import { - collectExportAssignments, - findSuperModuleFile, - isConcreteExportAssignment, - superModuleLevels, - traceSuperModuleAccess, -} from './super-module'; +import type {ExpressionResolver} from './super-module'; +import {resolveSuperModuleMemberTypes, resolveSuperModuleTypes, traceSuperModuleAccess} from './super-module'; import {arrayTypeOf, dedupeTypes, elementTypeOf, getMemberOfType, informativeParts, isAnyType} from './type-helpers'; import {usageProfileOf} from './usage-profile'; @@ -89,6 +86,11 @@ function resolveInitializerTypes( return resolveArrayTypes(ctx, [...initializer.elements, ...pushedValues], depth, chainHops); } +/** Resolves values one hop further along the expression `depth` and `chainHops` describe. */ +function nextHop(ctx: InferenceContext, depth: number, chainHops: number): ExpressionResolver { + return (value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1); +} + /** The types of several values the same thing can hold, each one hop further along the expression. */ function resolveValues( ctx: InferenceContext, @@ -99,82 +101,6 @@ function resolveValues( return values.flatMap((value) => resolveExpressionTypes(ctx, value, depth, chainHops + 1)); } -/** - * The types one `module.exports = X` assignment of a superModule level - * contributes. The checker's type for `module.exports` is used when it is - * concrete — it merges the assigned object with later - * `module.exports.name = fn` augmentations. A pass-through overlay - * (`module.exports = base`, base itself a superModule) is resolved by - * recursing into the right-hand side instead, another cartridge down; the - * checker sometimes merges such a level into an opaque `typeof base` that - * still carries none of the deeper cartridges' members, so a pass-through - * right-hand side is recursed into even when the left side looked concrete. - */ -function superModuleExportTypes( - ctx: InferenceContext, - assignment: tsserver.BinaryExpression, - depth: number, - chainHops: number, -): tsserver.Type[] { - const {ts, checker} = ctx; - const concrete = isConcreteExportAssignment(ctx, assignment); - const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; - if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) { - types.push(...resolveExpressionTypes(ctx, assignment.right, depth, chainHops + 1)); - } - return types; -} - -/** - * Resolves what `module.superModule` evaluates to: the export type(s) of the - * same-subpath module in the next cartridge down the path. Members a - * pass-through level *adds* can't be merged into these types; they are - * resolved by name in {@link resolveSuperModuleMemberTypes}. - */ -function resolveSuperModuleTypes( - ctx: InferenceContext, - expr: tsserver.PropertyAccessExpression, - depth: number, - chainHops: number, -): tsserver.Type[] { - const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); - if (!superFile) return []; - // The guard catches overlay cycles from a misconfigured cartridge path. - return withCycleGuard(ctx, superFile, [], () => - dedupeTypes( - ctx, - collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => - superModuleExportTypes(ctx, assignment, depth, chainHops), - ), - ), - ); -} - -/** - * Resolves `memberName` from the first superModule level (walking down the - * cartridge path) that adds it as an export augmentation - * (`module.exports.name = fn`) — the complement to - * {@link resolveSuperModuleTypes} for members no export type carries. - */ -function resolveSuperModuleMemberTypes( - ctx: InferenceContext, - superAccess: tsserver.PropertyAccessExpression, - memberName: string, - depth: number, - chainHops: number, -): tsserver.Type[] { - for (const {members} of superModuleLevels(ctx, superAccess)) { - const matches = members.filter((member) => member.name === memberName); - if (matches.length === 0) continue; - const types = matches.flatMap((member) => resolveExpressionTypes(ctx, member.expr, depth, chainHops + 1)); - return dedupeTypes( - ctx, - types.filter((type) => !isAnyType(ctx.ts, type)), - ); - } - return []; -} - /** * Infers a callback parameter's type from the call the callback is passed * to. A method on a known receiver declares it (`value: T` of @@ -236,12 +162,49 @@ function resolveExpressionTypes( // expressions is never meaningful — sometimes `any`, sometimes an opaque // circular `typeof base` that would pass for informative. const superAccess = traceSuperModuleAccess(ts, checker, expr); - if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, depth, chainHops); + if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, nextHop(ctx, depth, chainHops)); const direct = informativeParts(ctx, checker.getTypeAtLocation(expr)); + const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; + if (bound) return bound; if (direct.length > 0) return direct; return chainHops < MAX_CHAIN_HOPS ? resolveFromParts(ctx, expr, depth, chainHops) : []; } +/** + * While a call's arguments are bound (see ./bindings): a bound parameter is + * what its argument is, and an expression the checker types as an `any` + * instantiation (`collection.iterator()` on a bare `{dw.util.Collection}`) + * is recovered through its parts, which may reach a bound parameter. Either + * stands in for the checker's type only where it narrows it. A bound + * parameter whose argument says nothing keeps its general type, resolved + * without the bindings (and so served from the memo). + */ +function resolveBoundTypes( + ctx: InferenceContext, + expr: tsserver.Expression, + declared: readonly tsserver.Type[], + depth: number, + chainHops: number, +): tsserver.Type[] | undefined { + const binding = boundArgument(ctx, expr); + if (binding) { + const types = argumentTypes(ctx, binding); + if (narrows(ctx, declared, types)) return types; + return withBindings(ctx, NO_BINDINGS, () => resolveExpressionTypes(ctx, expr, depth, chainHops)); + } + if (chainHops >= MAX_CHAIN_HOPS || !declared.some((type) => hasAnyTypeArgument(ctx, type))) return undefined; + const narrowed = resolveFromParts(ctx, expr, depth, chainHops); + return narrows(ctx, declared, narrowed) ? narrowed : undefined; +} + +/** What a bound argument evaluates to where its call is written, resolved once per binding. */ +function argumentTypes(ctx: InferenceContext, binding: ArgumentBinding): tsserver.Type[] { + binding.resolved ??= withBindings(ctx, binding.outer, () => + limitUnion(ctx, normalizeCandidates(ctx, resolveExpressionTypes(ctx, binding.argument, binding.depth))), + ); + return binding.resolved; +} + /** Recovers `expr`'s type(s) through what it is built from, once the checker's own type said nothing. */ function resolveFromParts( ctx: InferenceContext, @@ -364,7 +327,9 @@ function memberTypesOfReceiver( }); if (types.length > 0) return types; const superAccess = traceSuperModuleAccess(ts, checker, receiver); - if (superAccess) return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, depth, chainHops); + if (superAccess) { + return resolveSuperModuleMemberTypes(ctx, superAccess, memberName, nextHop(ctx, depth, chainHops)); + } const bound = checker.getSymbolAtLocation(name); if (bound) return memberTypes(ctx, bound, name, depth, chainHops); return resolveValues(ctx, localMemberValues(ctx, receiver, memberName), depth, chainHops); @@ -405,6 +370,25 @@ function signatureReturnTypes(ctx: InferenceContext, methodType: tsserver.Type, }); } +/** + * What `fn` returns for this `call`. Its return across all callers, unless + * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a + * helper handed either collection): then what it returns for the arguments + * this call passes it (see ./bindings), wherever that is narrower. + */ +function calleeReturnTypes( + ctx: InferenceContext, + fn: tsserver.SignatureDeclaration, + call: tsserver.CallExpression, + depth: number, +): tsserver.Type[] { + const general = inferReturnType(ctx, fn, depth + 1); + if (general.length === 1 || hasExplicitReturnType(fn, ctx.ts)) return general; + const bindings = argumentBindings(ctx, fn, call, depth); + const specific = bindings ? withBindings(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; + return narrows(ctx, general, specific) ? specific : general; +} + /** * Resolves an `any` call expression: first by inferring the callee's own * return type, then — for a method call on an undocumented receiver @@ -418,7 +402,7 @@ function resolveCallResultTypes( chainHops: number, ): tsserver.Type[] { const calleeFn = resolveCalleeDeclaration(ctx, expr); - const inferred = calleeFn ? inferReturnType(ctx, calleeFn, depth + 1) : []; + const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) return inferred; const generic = genericResultSource(ctx, expr); if (generic) { diff --git a/packages/b2c-script-types/src/inference/super-module.ts b/packages/b2c-script-types/src/inference/super-module.ts index 1589f8ba1..dd72a5a4c 100644 --- a/packages/b2c-script-types/src/inference/super-module.ts +++ b/packages/b2c-script-types/src/inference/super-module.ts @@ -7,16 +7,20 @@ // 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. +// module's `module.exports = ...` / `module.exports.x = ...` assignments, and +// resolve what a superModule access evaluates to. Resolving the values an +// overlay exports needs the recursive engine (./core), which hands its +// resolver in as an {@link ExpressionResolver} so this module never imports it. import type tsserver from 'typescript/lib/tsserverlibrary'; import {MAX_SUPERMODULE_HOPS} from './constants'; +import {withCycleGuard} from './context'; import type {InferenceContext} from './context'; -import {isAnyType} from './type-helpers'; +import {dedupeTypes, isAnyType} from './type-helpers'; + +/** The recursive engine's resolver for a value one hop further along the expression being resolved. */ +export type ExpressionResolver = (value: tsserver.Expression) => tsserver.Type[]; /** * The SFCC `module.superModule` expression — the runtime handle to the @@ -36,7 +40,7 @@ function isSuperModuleAccess(expr: tsserver.PropertyAccessExpression, ts: typeof * 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 { +function findSuperModuleFile(ctx: InferenceContext, fromFileName: string): tsserver.SourceFile | undefined { const {program} = ctx; if (!ctx.resolveSuperModulePath) return undefined; const superPath = ctx.resolveSuperModulePath(fromFileName); @@ -50,7 +54,7 @@ export function findSuperModuleFile(ctx: InferenceContext, fromFileName: string) } /** A module's top-level export assignments — see {@link collectExportAssignments}. */ -export interface ExportAssignments { +interface ExportAssignments { readonly full: readonly tsserver.BinaryExpression[]; readonly members: ReadonlyArray<{readonly name: string; readonly expr: tsserver.Expression}>; } @@ -62,22 +66,36 @@ export interface ExportAssignments { * 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): ExportAssignments { +function collectExportAssignments(sf: tsserver.SourceFile, ts: typeof tsserver): ExportAssignments { const full: tsserver.BinaryExpression[] = []; const members: Array<{name: string; expr: tsserver.Expression}> = []; for (const stmt of sf.statements) { - if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; - const bin = stmt.expression; - if (bin.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !ts.isPropertyAccessExpression(bin.left)) continue; - const base = bin.left.expression; - if (isModuleExports(base, ts)) members.push({name: bin.left.name.text, expr: bin.right}); - else if (ts.isIdentifier(base) && base.text === 'exports') - members.push({name: bin.left.name.text, expr: bin.right}); - else if (isModuleExports(bin.left, ts)) full.push(bin); + const assignment = memberAssignmentOf(stmt, ts); + if (!assignment) continue; + const {binary, target} = assignment; + if (isExportsObject(target.expression, ts)) members.push({name: target.name.text, expr: binary.right}); + else if (isModuleExports(target, ts)) full.push(binary); } return {full, members}; } +/** The `x.name = value` assignment a statement is, if any. */ +function memberAssignmentOf( + stmt: tsserver.Statement, + ts: typeof tsserver, +): {binary: tsserver.BinaryExpression; target: tsserver.PropertyAccessExpression} | undefined { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? {binary, target: binary.left} : undefined; +} + +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +function isExportsObject(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} + /** `module.exports`, identified structurally. */ function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolean { return ( @@ -87,6 +105,7 @@ function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolea expr.name.text === 'exports' ); } + /** * True when a `module.exports = X` assignment gives the checker a genuinely * usable exports type: not `any`, and actually exposing members. A @@ -95,7 +114,7 @@ function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolea * 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 { +function isConcreteExportAssignment(ctx: InferenceContext, bin: tsserver.BinaryExpression): boolean { const {ts, checker} = ctx; const exportsType = checker.getTypeAtLocation(bin.left); if (isAnyType(ts, exportsType)) return false; @@ -147,7 +166,7 @@ function passesThrough(ctx: InferenceContext, exports: ExportAssignments): boole * (`module.exports = base`): a level with a concrete `module.exports` * replaces everything below it at runtime, unless it carries the base along. */ -export function* superModuleLevels( +function* superModuleLevels( ctx: InferenceContext, superAccess: tsserver.PropertyAccessExpression, ): Generator { @@ -164,6 +183,77 @@ export function* superModuleLevels( } } +/** + * The types one `module.exports = X` assignment of a superModule level + * contributes. The checker's type for `module.exports` is used when it is + * concrete — it merges the assigned object with later + * `module.exports.name = fn` augmentations. A pass-through overlay + * (`module.exports = base`, base itself a superModule) is resolved by + * recursing into the right-hand side instead, another cartridge down; the + * checker sometimes merges such a level into an opaque `typeof base` that + * still carries none of the deeper cartridges' members, so a pass-through + * right-hand side is recursed into even when the left side looked concrete. + */ +function superModuleExportTypes( + ctx: InferenceContext, + assignment: tsserver.BinaryExpression, + resolve: ExpressionResolver, +): tsserver.Type[] { + const {ts, checker} = ctx; + const concrete = isConcreteExportAssignment(ctx, assignment); + const types = concrete ? [checker.getTypeAtLocation(assignment.left)] : []; + if (!concrete || traceSuperModuleAccess(ts, checker, assignment.right)) types.push(...resolve(assignment.right)); + return types; +} + +/** + * Resolves what `module.superModule` evaluates to: the export type(s) of the + * same-subpath module in the next cartridge down the path. Members a + * pass-through level *adds* can't be merged into these types; they are + * resolved by name in {@link resolveSuperModuleMemberTypes}. + */ +export function resolveSuperModuleTypes( + ctx: InferenceContext, + expr: tsserver.PropertyAccessExpression, + resolve: ExpressionResolver, +): tsserver.Type[] { + const superFile = findSuperModuleFile(ctx, expr.getSourceFile().fileName); + if (!superFile) return []; + // The guard catches overlay cycles from a misconfigured cartridge path. + return withCycleGuard(ctx, superFile, [], () => + dedupeTypes( + ctx, + collectExportAssignments(superFile, ctx.ts).full.flatMap((assignment) => + superModuleExportTypes(ctx, assignment, resolve), + ), + ), + ); +} + +/** + * Resolves `memberName` from the first superModule level (walking down the + * cartridge path) that adds it as an export augmentation + * (`module.exports.name = fn`) — the complement to + * {@link resolveSuperModuleTypes} for members no export type carries. + */ +export function resolveSuperModuleMemberTypes( + ctx: InferenceContext, + superAccess: tsserver.PropertyAccessExpression, + memberName: string, + resolve: ExpressionResolver, +): tsserver.Type[] { + for (const {members} of superModuleLevels(ctx, superAccess)) { + const matches = members.filter((member) => member.name === memberName); + if (matches.length === 0) continue; + const types = matches.flatMap((member) => resolve(member.expr)); + return dedupeTypes( + ctx, + types.filter((type) => !isAnyType(ctx.ts, type)), + ); + } + return []; +} + /** * Collects every member the superModule chain reachable from `expr` * contributes through export augmentations (`module.exports.name = fn`) at diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 650f05503..2820fb174 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -20,9 +20,10 @@ // inference/signatures - the function a call invokes and its declared callback types // inference/generic-calls - generic calls whose result is an argument or a callback's return // inference/member-values - the values an untyped member is built with -// inference/super-module - module.superModule detection and export scanning +// inference/super-module - module.superModule detection and resolution // inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/bindings - a helper's parameters bound to one call's arguments // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches // inference/framework-contracts - parameters the platform itself passes (Page Designer) diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index d16c801a5..73d29972a 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -575,5 +575,23 @@ "param": 2 }, "expect": "SearchRefinementValue" + }, + { + "id": "shipping-collections-find-call-specific", + "source": "SFRA scripts/util/collections.find + scripts/checkout/shippingHelpers.getShipmentByUUID shape", + "description": "collections.find documents its result as the `{Object|null}` placeholder and its collection as a bare `{dw.util.Collection}` whose elements are `any`; across its callers it returns nothing in particular, but for the call getShipmentByUUID makes with `basket.shipments` it returns a Shipment, so the controller's variable holding that result reads as one", + "dwTypes": ["Basket", "Collection"], + "globals": "type DwUtilCollection = Collection;\nnamespace dw.util { type Collection = DwUtilCollection; }\nfunction getCurrentBasket(): Basket;", + "files": { + "/collections.js": "/**\n * Find method for dw.util.Collection subclass instance\n * @param {dw.util.Collection} collection - Collection subclass instance to find value in\n * @param {Function} match - Match function\n * @param {Object} [scope] - Optional execution scope to pass to the match function\n * @returns {Object|null} Single item from the collection\n */\nfunction find(collection, match, scope) {\n var result = null;\n\n if (collection) {\n var iterator = collection.iterator();\n while (iterator.hasNext()) {\n var item = iterator.next();\n if (scope ? match.call(scope, item) : match(item)) {\n result = item;\n break;\n }\n }\n }\n\n return result;\n}\n\nmodule.exports = {find: find};\n", + "/shippingHelpers.js": "var collections = require('./collections');\n\nfunction getShipmentByUUID(basket, uuid) {\n return collections.find(basket.shipments, function (shipment) {\n return shipment.UUID === uuid;\n });\n}\n\nfunction getProductLineItemByUUID(basket, uuid) {\n return collections.find(basket.productLineItems, function (item) {\n return item.UUID === uuid;\n });\n}\n\nmodule.exports = {getShipmentByUUID: getShipmentByUUID, getProductLineItemByUUID: getProductLineItemByUUID};\n", + "/CheckoutShippingServices.js": "var shippingHelpers = require('./shippingHelpers');\n\nfunction selectShippingMethod(req) {\n var currentBasket = getCurrentBasket();\n var shipment = shippingHelpers.getShipmentByUUID(currentBasket, req.form.shipmentUUID);\n return shipment;\n}\n\nmodule.exports = {selectShippingMethod: selectShippingMethod};\n" + }, + "target": { + "file": "/CheckoutShippingServices.js", + "kind": "variable", + "name": "shipment" + }, + "expect": "Shipment" } ] diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 2d0d440fe..3d7eb69d3 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -663,8 +663,9 @@ module.exports = getLineItems; 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', '*', '{}']) { + // not permanently silence usage inference the way a deliberate `{any}` does, + // also when they are written nullable or optional. + for (const annotation of ['Object', 'obj', '*', '{}', 'Object|null', '?Object', 'Object=', '(obj|undefined)']) { it(`infers through @param {${annotation}} on a named customer parameter`, () => { const files = { '/types.d.ts': realTypesPrelude(['Customer', 'ServiceConfig'], ''), @@ -686,6 +687,24 @@ module.exports = getLineItems; }); } + it('still respects a nullable real type mixed with a placeholder (`{Customer|Object|null}`)', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['Customer'], ''), + '/helper.js': ` + /** + * @param {Customer|Object|null} customer + */ + function getPasswordResetToken(customer) { + return customer.profile.credentials.createResetPasswordToken(); + } + `, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'getPasswordResetToken'); + + assert.deepEqual(inferParameterType(ctx, fn.parameters[0]), []); + }); + it('still respects a real @param {Customer} annotation (does not second-guess dw.* JSDoc)', () => { // If we ignored the Customer annotation we'd chase the Product call site. // Respecting dw.* JSDoc matches IntelliJ and leaves the author's type alone. diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 9d5bb6902..b24ab7cdc 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -514,3 +514,184 @@ describe('usage-inference — constructors as values', () => { assert.equal(ctx.searchBudget, 0, 'only the search for show is charged'); }); }); + +describe('usage-inference — call-specific returns', () => { + // `{dw.util.Collection}` the way SFRA's JSDoc writes it: no type argument, + // so the checker reads every element as `any`. + const COLLECTION_TYPES = ` + import Basket = require('${REAL_DW_TYPES.Basket}'); + import DwCollection = require('${REAL_DW_TYPES.Collection}'); + declare global { + namespace dw.util { type Collection = DwCollection; } + function getBasket(): Basket; + } + `; + + // SFRA's collections.find and collections.first, verbatim but for comments. + const COLLECTIONS = ` + /** + * @param {dw.util.Collection} collection - Collection subclass instance to find value in + * @param {Function} match - Match function + * @returns {Object|null} Single item from the collection + */ + function find(collection, match) { + var result = null; + if (collection) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + var item = iterator.next(); + if (match(item)) { + result = item; + break; + } + } + } + return result; + } + /** + * @param {dw.util.Collection} collection - Collection subclass instance to work with + * @return {Object|null} First element from the collection + */ + function first(collection) { + var iterator = collection.iterator(); + return iterator.hasNext() ? iterator.next() : null; + } + module.exports = {find: find, first: first}; + `; + + const callers = (source) => ({ + '/collections.js': COLLECTIONS, + '/helper.js': ` + var collections = require('./collections'); + ${source} + shipmentByUUID(getBasket(), 'uuid'); + lineItemByUUID(getBasket(), 'uuid'); + `, + }); + + runCases([ + { + title: 'collections.find over basket.shipments returns a Shipment', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (shipment) { return shipment.UUID === uuid; }); + } + function lineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (item) { return item.UUID === uuid; }); + } + `), + kind: 'return', + name: 'shipmentByUUID', + expected: 'Shipment', + }, + { + title: 'the same helper over basket.productLineItems returns a ProductLineItem', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (shipment) { return shipment.UUID === uuid; }); + } + function lineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (item) { return item.UUID === uuid; }); + } + `), + kind: 'return', + name: 'lineItemByUUID', + expected: 'ProductLineItem', + }, + { + title: 'collections.first returns an element of the collection it is given', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + kind: 'return', + name: 'shipmentByUUID', + expected: 'Shipment', + }, + { + title: 'the helper itself, across all of its callers, stays silent', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + file: '/collections.js', + kind: 'return', + name: 'first', + expected: '', + }, + { + title: 'a wrapper passing its own parameter on binds through both calls', + types: COLLECTION_TYPES, + files: callers(` + function findIn(items, uuid) { + return collections.find(items, function (item) { return item.UUID === uuid; }); + } + function shipmentByUUID(basket, uuid) { return findIn(basket.shipments, uuid); } + function lineItemByUUID(basket, uuid) { return findIn(basket.productLineItems, uuid); } + `), + kind: 'return', + name: 'lineItemByUUID', + expected: 'ProductLineItem', + }, + { + title: 'the wrapper across its callers is the union of what they pass', + types: COLLECTION_TYPES, + files: callers(` + function findIn(items, uuid) { + return collections.find(items, function (item) { return item.UUID === uuid; }); + } + function shipmentByUUID(basket, uuid) { return findIn(basket.shipments, uuid); } + function lineItemByUUID(basket, uuid) { return findIn(basket.productLineItems, uuid); } + `), + kind: 'return', + name: 'findIn', + expected: 'Shipment | ProductLineItem', + }, + { + title: 'a call-specific variable reads as the element it is assigned', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket, uuid) { + var shipment = collections.find(basket.shipments, function (s) { return s.UUID === uuid; }); + return shipment; + } + function lineItemByUUID() {} + `), + kind: 'var', + name: 'shipment', + expected: 'Shipment', + }, + { + title: 'an argument the declared parameter type rules out binds nothing', + types: COLLECTION_TYPES, + files: callers(` + function shipmentByUUID(basket) { return collections.first(basket); } + function lineItemByUUID() {} + `), + kind: 'return', + name: 'shipmentByUUID', + expected: '', + }, + ]); + + it('keeps call-specific results out of the request memo', () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': COLLECTION_TYPES, + ...callers(` + function shipmentByUUID(basket) { return collections.first(basket.shipments); } + function lineItemByUUID(basket) { return collections.first(basket.productLineItems); } + `), + }); + const ctx = createInferenceContext(ts, languageService); + const returnOf = (file, name) => describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, file, 'return', name))); + + assert.equal(returnOf('/helper.js', 'shipmentByUUID'), 'Shipment'); + assert.equal(returnOf('/collections.js', 'first'), '', 'the general answer is not the first call’s'); + assert.equal(returnOf('/helper.js', 'lineItemByUUID'), 'ProductLineItem'); + assert.equal(ctx.bindings.size, 0, 'bindings are released after each call'); + }); +}); From 5d5423a04c81c5d6334382e26585a2588f571df0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:53:28 +0000 Subject: [PATCH 58/76] feat(script-types): follow values handed down helper chains without spending depth A caller passing its own untyped parameter on as is (SFRA's addProductToCart -> getExistingProductLineItemInCart -> getExistingProductLineItemsInCart -> getMatchingProducts) is the same value, so its call sites are now searched at the same inference depth; an anonymous callback's parameter, read off the call it is passed to in the same body, costs no depth either. Such chains are bounded by the per-request search budget instead of the depth cap, and the forwarding-chain perf baseline records that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/call-sites.js | 22 +++++ .../b2c-script-types/plugin/inference/core.js | 21 ++++- .../src/inference/call-sites.ts | 27 +++++- .../b2c-script-types/src/inference/core.ts | 23 +++++- .../test/usage-inference.perf.test.js | 15 ++-- .../test/usage-inference.value-flow.test.js | 82 +++++++++++++++++++ 6 files changed, 177 insertions(+), 13 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index 9833a0020..ab7c53354 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -6,8 +6,12 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.collectCallSites = collectCallSites; +exports.forwardedParameter = forwardedParameter; const constants_1 = require("./constants"); const ast_helpers_1 = require("./ast-helpers"); +const bindings_1 = require("./bindings"); +const member_values_1 = require("./member-values"); +const type_helpers_1 = require("./type-helpers"); const value_flow_1 = require("./value-flow"); // Reference searches are what an inference request spends its time on, and // a Program never changes, so their results are kept for as long as the @@ -120,3 +124,21 @@ function referenceTarget(ctx, reference, role) { const node = file && (0, ast_helpers_1.getNodeAtPosition)(file, ctx.ts, reference.textSpan.start); return node?.parent ? (0, value_flow_1.valueTarget)(ctx, node, role) : undefined; } +/** + * The caller's own parameter an argument passes on as is (`items` in + * `getMatchingProducts(productId, items)` inside a function taking `items`), + * when only that parameter's call sites can say what it holds: the checker + * has no type for it, it has no default value and no type of its own, and it + * is not bound to a call being resolved (see ./bindings). + */ +function forwardedParameter(ctx, argument) { + const { ts, checker } = ctx; + if (!ts.isIdentifier(argument) || (0, bindings_1.boundArgument)(ctx, argument)) + return undefined; + const declaration = (0, member_values_1.valueDeclarationOf)(ctx, argument); + if (!declaration || !ts.isParameter(declaration) || declaration.initializer) + return undefined; + if ((0, ast_helpers_1.hasExplicitParameterType)(declaration, ts)) + return undefined; + return (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(argument)).length === 0 ? declaration : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index ea97fd4fc..64b37974b 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -311,8 +311,11 @@ function resolveMemberValueTypes(ctx, types) { function resolveIdentifierTypes(ctx, expr, depth, chainHops) { const { ts } = ctx; const decl = (0, member_values_1.valueDeclarationOf)(ctx, expr); - if (decl && ts.isParameter(decl)) - return inferParameterType(ctx, decl, depth + 1); + // Only a search of another function's call sites costs depth: an anonymous + // callback's parameter is read off the call it is passed to, in this body. + if (decl && ts.isParameter(decl)) { + return inferParameterType(ctx, decl, (0, value_flow_1.getReferenceNameNode)(decl.parent, ts) ? depth + 1 : depth); + } if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); return []; @@ -329,9 +332,21 @@ function parameterEvidence(ctx, fn, paramIndex, depth) { return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); return (0, call_sites_1.collectCallSites)(ctx, nameNode).flatMap((site) => { const arg = site.args[paramIndex]; - return arg ? resolveExpressionTypes(ctx, arg, depth) : []; + return arg ? argumentEvidence(ctx, arg, depth) : []; }); } +/** + * The types one call-site argument passes. A caller's own untyped parameter + * passed on as is (see {@link forwardedParameter}) is the same value, so its + * call sites are searched at the same depth: a chain of helpers handing a + * value down (`addProductToCart` -> `getExistingProductLineItemInCart` -> + * `getExistingProductLineItemsInCart` -> `getMatchingProducts`) costs one + * level, not one per helper. The search budget still bounds the chain. + */ +function argumentEvidence(ctx, arg, depth) { + const forwarded = (0, call_sites_1.forwardedParameter)(ctx, arg); + return forwarded ? inferParameterType(ctx, forwarded, depth) : resolveExpressionTypes(ctx, arg, depth); +} /** * Infers a parameter's type(s): from a platform contract when the function * is one the platform calls (see ./framework-contracts), otherwise from the diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 8726aa765..b64159518 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -12,12 +12,18 @@ // follows a bounded number of those hops to reach the real call expressions. // "Call" includes `new Helper(x)` and `Helper.call(this, x)` — the shapes // SFRA's constructor-function "class" models are invoked and inherited with. +// An argument that is the caller's own untyped parameter is recognized here +// too, so ./core can follow a value handed down a chain of helpers to where +// it really comes from. import type tsserver from 'typescript/lib/tsserverlibrary'; import {MAX_REFERENCE_HOPS, MAX_REFERENCES_PER_CALL} from './constants'; import type {InferenceContext} from './context'; -import {getNodeAtPosition} from './ast-helpers'; +import {getNodeAtPosition, hasExplicitParameterType} from './ast-helpers'; +import {boundArgument} from './bindings'; +import {valueDeclarationOf} from './member-values'; +import {informativeParts} from './type-helpers'; import {enclosingFunction, valueTarget} from './value-flow'; import type {CallSite, ReferenceTarget, ValueRole} from './value-flow'; @@ -151,3 +157,22 @@ function referenceTarget( const node = file && getNodeAtPosition(file, ctx.ts, reference.textSpan.start); return node?.parent ? valueTarget(ctx, node, role) : undefined; } + +/** + * The caller's own parameter an argument passes on as is (`items` in + * `getMatchingProducts(productId, items)` inside a function taking `items`), + * when only that parameter's call sites can say what it holds: the checker + * has no type for it, it has no default value and no type of its own, and it + * is not bound to a call being resolved (see ./bindings). + */ +export function forwardedParameter( + ctx: InferenceContext, + argument: tsserver.Expression, +): tsserver.ParameterDeclaration | undefined { + const {ts, checker} = ctx; + if (!ts.isIdentifier(argument) || boundArgument(ctx, argument)) return undefined; + const declaration = valueDeclarationOf(ctx, argument); + if (!declaration || !ts.isParameter(declaration) || declaration.initializer) return undefined; + if (hasExplicitParameterType(declaration, ts)) return undefined; + return informativeParts(ctx, checker.getTypeAtLocation(argument)).length === 0 ? declaration : undefined; +} diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 75d752490..8f7391833 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -27,7 +27,7 @@ import { } from './ast-helpers'; import {NO_BINDINGS, argumentBindings, boundArgument, hasAnyTypeArgument, narrows, withBindings} from './bindings'; import type {ArgumentBinding} from './bindings'; -import {collectCallSites} from './call-sites'; +import {collectCallSites, forwardedParameter} from './call-sites'; import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; import {genericResultSource} from './generic-calls'; @@ -447,7 +447,11 @@ function resolveIdentifierTypes( ): tsserver.Type[] { const {ts} = ctx; const decl = valueDeclarationOf(ctx, expr); - if (decl && ts.isParameter(decl)) return inferParameterType(ctx, decl, depth + 1); + // Only a search of another function's call sites costs depth: an anonymous + // callback's parameter is read off the call it is passed to, in this body. + if (decl && ts.isParameter(decl)) { + return inferParameterType(ctx, decl, getReferenceNameNode(decl.parent, ts) ? depth + 1 : depth); + } if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); return []; } @@ -468,10 +472,23 @@ function parameterEvidence( if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); return collectCallSites(ctx, nameNode).flatMap((site) => { const arg = site.args[paramIndex]; - return arg ? resolveExpressionTypes(ctx, arg, depth) : []; + return arg ? argumentEvidence(ctx, arg, depth) : []; }); } +/** + * The types one call-site argument passes. A caller's own untyped parameter + * passed on as is (see {@link forwardedParameter}) is the same value, so its + * call sites are searched at the same depth: a chain of helpers handing a + * value down (`addProductToCart` -> `getExistingProductLineItemInCart` -> + * `getExistingProductLineItemsInCart` -> `getMatchingProducts`) costs one + * level, not one per helper. The search budget still bounds the chain. + */ +function argumentEvidence(ctx: InferenceContext, arg: tsserver.Expression, depth: number): tsserver.Type[] { + const forwarded = forwardedParameter(ctx, arg); + return forwarded ? inferParameterType(ctx, forwarded, depth) : resolveExpressionTypes(ctx, arg, depth); +} + /** * Infers a parameter's type(s): from a platform contract when the function * is one the platform calls (see ./framework-contracts), otherwise from the 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 1cb50b7b9..3d84f89de 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -52,10 +52,12 @@ const BASELINE = { // 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. + // the depth cap without any search. Parameter-direction chasing follows a + // parameter passed on as is at no depth cost (it is the same value all the + // way down), one search per level, so the request's search budget + // (MAX_SEARCHES_PER_REQUEST) is what stops it. deepForwardingChainReturns: 0, - deepForwardingChainParam: 6, + deepForwardingChainParam: 12, // Twenty sibling branches through the same sub-helper must share ONE // memoized search set, not repeat it per branch. wideFanOutMemoized: 4, @@ -235,7 +237,7 @@ describe('usage-inference — performance baselines', () => { 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', () => { + it('bounds a 12-level parameter-forwarding chain by the depth cap and the search budget', () => { // f1(x) -> f2(x) -> ... -> f12(x) -> x: parameters are implicit any, so // TypeScript can't resolve this natively — the engine's own caps are all // that bounds the cost, and it must stay flat no matter how deep the @@ -263,8 +265,9 @@ describe('usage-inference — performance baselines', () => { 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. + // Parameter direction (hover on f12's x): each level passes `x` on as is, + // which costs no depth, so one search per level until the search budget + // stops the climb. counter.reset(); const ctx2 = createInferenceContext(ts, counter.languageService); const {elapsedMs: paramMs} = timed(() => diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index b24ab7cdc..f9a75209b 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -695,3 +695,85 @@ describe('usage-inference — call-specific returns', () => { assert.equal(ctx.bindings.size, 0, 'bindings are released after each call'); }); }); + +describe('usage-inference — values handed down a chain of helpers', () => { + // SFRA's cart helpers pass the basket's line items down four functions + // unchanged before one iterates them with an inline callback. Neither hop + // adds uncertainty, so neither costs inference depth. + const CART_CHAIN = { + '/collections.js': ` + function forEach(collection, callback) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + callback(iterator.next()); + } + } + module.exports = {forEach: forEach}; + `, + '/cartHelpers.js': ` + var collections = require('./collections'); + function getMatchingProducts(productId, productLineItems) { + var matchingProducts = []; + collections.forEach(productLineItems, function (item) { + if (item.productID === productId) matchingProducts.push(item); + }); + return {matchingProducts: matchingProducts}; + } + function getExistingProductLineItemsInCart(product, productId, productLineItems) { + var matchingProducts = getMatchingProducts(productId, productLineItems).matchingProducts; + return matchingProducts.filter(function (matchingProduct) { + return product.bundle ? matchingProduct.bundledProductLineItems.length > 0 : true; + }); + } + function getExistingProductLineItemInCart(product, productId, productLineItems) { + return getExistingProductLineItemsInCart(product, productId, productLineItems)[0]; + } + function addProductToCart(currentBasket, productId) { + var productLineItems = currentBasket.productLineItems; + return getExistingProductLineItemInCart(getProduct(), productId, productLineItems); + } + addProductToCart(getBasket(), 'id'); + `, + }; + + runCases([ + { + title: 'the filtered line items of a parameter forwarded three times', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'return', + name: 'getExistingProductLineItemsInCart', + expected: 'ProductLineItem[]', + }, + { + title: 'the first of them, one more helper up', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'return', + name: 'getExistingProductLineItemInCart', + expected: 'ProductLineItem', + }, + { + title: 'the inline callback at the far end of the chain', + files: CART_CHAIN, + file: '/cartHelpers.js', + kind: 'param', + name: 'item', + expected: 'ProductLineItem', + }, + { + title: 'a forwarded parameter documented with a real type keeps that type', + files: { + '/helper.js': ` + function show(product) { return product.name; } + /** @param {Product} product */ + function render(product) { return show(product); } + render(getProduct()); + `, + }, + kind: 'param', + name: 'product', + expected: 'Product', + }, + ]); +}); From ad430eed6da41db3118dbabcfe85394116959e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:16:32 +0000 Subject: [PATCH 59/76] feat(script-types): follow HookMgr.callHook into hooks.json-registered hook scripts Hook scripts are never called by name: the platform dispatches HookMgr.callHook(extensionPoint, functionName, ...args) to the scripts a cartridge's hooks.json registers. Usage inference now reads those registrations (package.json `hooks` -> hooks.json, parsed as JSON only, paths contained to the cartridge) and follows callHook in both directions: a hook export's parameters take the arguments of the callHook calls that reach it, and a callHook call returns what the matching exports return. Extension points built at runtime ('app.payment.processor.' + id, template literals) match by their literal prefix. One project-wide search per request finds every callHook call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- packages/b2c-script-types/plugin/index.js | 12 + .../plugin/inference/ast-helpers.js | 32 ++ .../plugin/inference/call-sites.js | 2 + .../plugin/inference/context.js | 4 +- .../b2c-script-types/plugin/inference/core.js | 26 +- .../plugin/inference/editor-hooks.js | 15 +- .../plugin/inference/hook-calls.js | 173 ++++++++++ .../plugin/inference/policy.js | 4 +- .../plugin/inference/super-module.js | 35 +- .../plugin/inference/type-helpers.js | 5 + .../plugin/resolver/hook-registry.js | 74 +++++ packages/b2c-script-types/src/index.ts | 14 + .../src/inference/ast-helpers.ts | 35 ++ .../src/inference/call-sites.ts | 3 +- .../b2c-script-types/src/inference/context.ts | 34 +- .../b2c-script-types/src/inference/core.ts | 40 +-- .../src/inference/editor-hooks.ts | 22 +- .../src/inference/hook-calls.ts | 212 ++++++++++++ .../b2c-script-types/src/inference/policy.ts | 4 +- .../src/inference/super-module.ts | 40 +-- .../src/inference/type-helpers.ts | 8 + .../src/resolver/hook-registry.ts | 103 ++++++ .../b2c-script-types/test/corpus/cases.json | 306 +++++++++++++++--- .../test/corpus/corpus.test.js | 4 +- .../test/helpers/real-dw-types.js | 2 + .../test/hook-registry.test.js | 149 +++++++++ packages/b2c-script-types/test/index.test.js | 50 +++ .../test/usage-inference.perf.test.js | 49 +++ .../test/usage-inference.real-types.test.js | 2 +- .../test/usage-inference.test.js | 16 +- .../test/usage-inference.value-flow.test.js | 114 ++++++- 31 files changed, 1408 insertions(+), 181 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/hook-calls.js create mode 100644 packages/b2c-script-types/plugin/resolver/hook-registry.js create mode 100644 packages/b2c-script-types/src/inference/hook-calls.ts create mode 100644 packages/b2c-script-types/src/resolver/hook-registry.ts create mode 100644 packages/b2c-script-types/test/hook-registry.test.js diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 7c3eaff89..f18eaedd2 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 hook_registry_1 = require("./resolver/hook-registry"); const module_resolution_1 = require("./resolver/module-resolution"); const TYPES_DIR = node_path_1.default.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); // Ambient declarations for SFCC globals (`session`, `request`, `response`, @@ -215,6 +216,16 @@ function init({ typescript: ts }) { } return undefined; }; + // Which scripts the cartridges' hooks.json files register for which + // extension points, so usage inference can follow + // `HookMgr.callHook(extensionPoint, functionName, ...args)` into them. + // Read through the language-service host, like require() resolution; a + // read that throws declares nothing. + const hookRegistrations = () => (0, hook_registry_1.readHookRegistrations)(cartridges, { + readFile: (p) => (host.readFile ? host.readFile(p) : ts.sys.readFile(p)), + fileExists: hostFileExists, + isWithinRoot, + }); // Inject the ambient declarations into the TS program when the project // contains at least one cartridge file (isCartridgeFile is false while the // plugin is disabled or no cartridge is known). Projects that already @@ -348,6 +359,7 @@ function init({ typescript: ts }) { ts, languageService: info.languageService, resolveSuperModulePath, + hookRegistrations, log, }); usageInferenceResets.add(usageInference.reset); diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index 09dc0b289..73304351e 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -11,6 +11,10 @@ exports.memberCompletionAccess = memberCompletionAccess; exports.hasExplicitParameterType = hasExplicitParameterType; exports.hasExplicitReturnType = hasExplicitReturnType; exports.hasExplicitVariableType = hasExplicitVariableType; +exports.memberAssignmentOf = memberAssignmentOf; +exports.isExportsObject = isExportsObject; +exports.isModuleExports = isModuleExports; +exports.exportsObjectWrittenBy = exportsObjectWrittenBy; exports.collectReturnExpressions = collectReturnExpressions; /** * Finds the most specific node whose span contains `pos`. Standard technique @@ -139,6 +143,34 @@ function hasExplicitReturnType(fn, ts) { function hasExplicitVariableType(decl, ts) { return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); } +/** The `x.name = value` assignment a statement is, if any. */ +function memberAssignmentOf(stmt, ts) { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? { binary, target: binary.left } : undefined; +} +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +function isExportsObject(expr, ts) { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} +/** `module.exports`, identified structurally. */ +function isModuleExports(expr, ts) { + return (ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports'); +} +/** The `exports` / `module.exports` object a top-level statement assigns to or adds a member to. */ +function exportsObjectWrittenBy(stmt, ts) { + const target = memberAssignmentOf(stmt, ts)?.target; + if (!target) + return undefined; + if (isExportsObject(target.expression, ts)) + return target.expression; + return isModuleExports(target, ts) ? target : undefined; +} /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index ab7c53354..a2792c1fc 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -5,6 +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.findReferences = findReferences; exports.collectCallSites = collectCallSites; exports.forwardedParameter = forwardedParameter; const constants_1 = require("./constants"); @@ -21,6 +22,7 @@ const value_flow_1 = require("./value-flow"); // search budget, so a request reaches the same call sites, and the same // answer, whether or not an earlier request warmed the cache. const referencesByProgram = new WeakMap(); +/** Every reference to `name` across the project, from the per-Program cache. Spends no budget: callers do. */ function findReferences(ctx, name) { let cache = referencesByProgram.get(ctx.program); if (!cache) { diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js index dcf8ae89f..f6dbfd76e 100644 --- a/packages/b2c-script-types/plugin/inference/context.js +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -14,7 +14,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, triggerPosition) { +function createInferenceContext(ts, languageService, host = {}, triggerPosition) { const program = languageService.getProgram(); if (!program) return undefined; @@ -32,7 +32,7 @@ function createInferenceContext(ts, languageService, resolveSuperModulePath, tri profiles: new Map(), cycleHits: 0, bindings: bindings_1.NO_BINDINGS, - resolveSuperModulePath, + host, triggerPosition, }; } diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 64b37974b..6c1dc31d2 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -17,15 +17,13 @@ const call_sites_1 = require("./call-sites"); const value_flow_1 = require("./value-flow"); const framework_contracts_1 = require("./framework-contracts"); const generic_calls_1 = require("./generic-calls"); +const hook_calls_1 = require("./hook-calls"); const member_values_1 = require("./member-values"); const policy_1 = require("./policy"); const signatures_1 = require("./signatures"); const super_module_1 = require("./super-module"); const type_helpers_1 = require("./type-helpers"); const usage_profile_1 = require("./usage-profile"); -function identifierText(ctx, name) { - return ctx.ts.isIdentifier(name) ? name.text : undefined; -} /** * Everything a local variable can hold: its initializer plus every later * `x = value` assignment (`var result = null; if (a) result = x; else result @@ -47,7 +45,7 @@ function resolveVariableTypes(ctx, decl, depth, chainHops) { ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) : []; const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; - return (0, policy_1.decideType)(ctx, evidence, profile, identifierText(ctx, decl.name), false); + return (0, policy_1.decideType)(ctx, evidence, profile, decl.name, false); }); } /** A variable's initializer; an array literal later filled by `items.push(x)` is an array of what it holds. */ @@ -178,7 +176,7 @@ function resolveElementTypes(ctx, collection, depth, chainHops) { function resolveElementAccessTypes(ctx, expr, depth, chainHops) { const index = expr.argumentExpression; if (ctx.ts.isStringLiteralLike(index)) { - return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + return (0, type_helpers_1.dedupeKnownTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); } return (0, type_helpers_1.dedupeTypes)(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); } @@ -288,6 +286,9 @@ function resolveCallResultTypes(ctx, expr, depth, chainHops) { const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) return inferred; + const hookResults = (0, hook_calls_1.hookImplementations)(ctx, expr).flatMap((hook) => inferReturnType(ctx, hook, depth + 1)); + if (hookResults.length > 0) + return (0, type_helpers_1.dedupeTypes)(ctx, hookResults); const generic = (0, generic_calls_1.genericResultSource)(ctx, expr); if (generic) { return generic.kind === 'return' @@ -301,11 +302,7 @@ function resolveCallResultTypes(ctx, expr, depth, chainHops) { } /** Resolves an `any` property access (`x.ID`) on an undocumented receiver from the receiver's inferred type(s). */ function resolvePropertyTypes(ctx, expr, depth, chainHops) { - return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); -} -/** The member types worth keeping as a property's value. */ -function resolveMemberValueTypes(ctx, types) { - return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); + return (0, type_helpers_1.dedupeKnownTypes)(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); } /** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { @@ -323,14 +320,15 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { /** * The argument types a parameter receives: at every call site of its * function across the project (`helper(x)`, `new Helper(x)`, - * `Helper.call(this, x)`), or — for an anonymous callback with no name to - * search for — from the collection it iterates. + * `Helper.call(this, x)`, and for a hook script's export, the + * `HookMgr.callHook(...)` calls dispatched to it), or — for an anonymous + * callback with no name to search for — from the collection it iterates. */ function parameterEvidence(ctx, fn, paramIndex, depth) { const nameNode = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts); if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - return (0, call_sites_1.collectCallSites)(ctx, nameNode).flatMap((site) => { + return [...(0, call_sites_1.collectCallSites)(ctx, nameNode), ...(0, hook_calls_1.hookCallSites)(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; }); @@ -368,7 +366,7 @@ function inferParameterType(ctx, param, depth = 0) { if (contract.length > 0) return contract; const evidence = parameterEvidence(ctx, fn, paramIndex, depth); - return (0, policy_1.decideType)(ctx, evidence, (0, usage_profile_1.usageProfileOf)(ctx, param), identifierText(ctx, param.name), true); + return (0, policy_1.decideType)(ctx, evidence, (0, usage_profile_1.usageProfileOf)(ctx, param), param.name, true); }); } /** diff --git a/packages/b2c-script-types/plugin/inference/editor-hooks.js b/packages/b2c-script-types/plugin/inference/editor-hooks.js index 91b0ce74c..3e7842171 100644 --- a/packages/b2c-script-types/plugin/inference/editor-hooks.js +++ b/packages/b2c-script-types/plugin/inference/editor-hooks.js @@ -67,7 +67,7 @@ function isInferenceTarget(ts, checker, expr) { return (0, type_helpers_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(expr)) || !!(0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); } function newContext(env, triggerPosition) { - return (0, context_1.createInferenceContext)(env.ts, env.languageService, env.resolveSuperModulePath, triggerPosition); + return (0, context_1.createInferenceContext)(env.ts, env.languageService, env, triggerPosition); } /** * The declaration a member-name hover documents: the member every inferred @@ -202,11 +202,19 @@ function decorateCompletions(env, cache, fileName, position, original) { * Creates the hover and completion decorators for one language service. Each * request builds one inference context; finished results are cached per * Program, and `reset()` drops them (the plugin calls it when its - * configuration changes). + * configuration changes). The host's hook registrations are read once per + * Program too: hooks.json is no part of the Program, so the next edit is + * what picks up a change to it. */ -function createUsageInferenceHooks(options) { +function createUsageInferenceHooks(host) { const hoverCache = createDisplayCache(); const completionCache = createDisplayCache(); + const registrationCache = createDisplayCache(); + const readRegistrations = host.hookRegistrations; + const options = { + ...host, + hookRegistrations: readRegistrations && ((program) => registrationCache.get('hooks', program, () => readRegistrations(program))), + }; return { decorateQuickInfo(fileName, position, original) { if (!original) @@ -219,6 +227,7 @@ function createUsageInferenceHooks(options) { reset() { hoverCache.clear(); completionCache.clear(); + registrationCache.clear(); }, }; } diff --git a/packages/b2c-script-types/plugin/inference/hook-calls.js b/packages/b2c-script-types/plugin/inference/hook-calls.js new file mode 100644 index 000000000..5b612191c --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/hook-calls.js @@ -0,0 +1,173 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.hookCallSites = hookCallSites; +exports.hookImplementations = hookImplementations; +const constants_1 = require("./constants"); +const ast_helpers_1 = require("./ast-helpers"); +const call_sites_1 = require("./call-sites"); +// Where the Script API declares `HookMgr.callHook`, whichever copy of the dw +// types a project loads. +const HOOK_MANAGER_FILE = '/dw/system/HookMgr.d.ts'; +const CALL_HOOK = 'callHook'; +// Request-scoped: a request asks the host for its registrations once, and +// searches for callHook calls once. +const scriptsByRequest = new WeakMap(); +const hookCallsByRequest = new WeakMap(); +/** The extension points each script of the program is registered for. */ +function registeredScripts(ctx) { + let scripts = scriptsByRequest.get(ctx); + if (!scripts) { + const index = new Map(); + for (const { extensionPoint, script } of ctx.host.hookRegistrations?.(ctx.program) ?? []) { + const file = ctx.program.getSourceFile(script); + if (file) + index.set(file, [...(index.get(file) ?? []), extensionPoint]); + } + scripts = index; + scriptsByRequest.set(ctx, scripts); + } + return scripts; +} +function reaches(pattern, extensionPoint) { + return pattern.isPrefix ? extensionPoint.startsWith(pattern.text) : extensionPoint === pattern.text; +} +/** The string `expr` always evaluates to: a literal, or a value the checker types as one (a `const`). */ +function stringValue(ctx, expr) { + if (ctx.ts.isStringLiteralLike(expr)) + return expr.text; + const type = ctx.checker.getTypeAtLocation(expr); + return type.isStringLiteral() ? type.value : undefined; +} +/** The literal text a string built at runtime starts with: `'app.payment.processor.' + id`, `` `app.${id}` ``. */ +function literalPrefix(ctx, expr) { + const { ts } = ctx; + if (ts.isTemplateExpression(expr)) + return expr.head.text || undefined; + if (ts.isParenthesizedExpression(expr)) + return literalPrefix(ctx, expr.expression); + if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.PlusToken) + return undefined; + return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); +} +function extensionPointPattern(ctx, expr) { + const name = stringValue(ctx, expr); + if (name !== undefined) + return { text: name, isPrefix: false }; + const prefix = literalPrefix(ctx, expr); + return prefix ? { text: prefix, isPrefix: true } : undefined; +} +/** True when `call` invokes the Script API's `HookMgr.callHook`, through whatever name it is required as. */ +function isCallHookCall(ctx, call) { + const { ts, checker } = ctx; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== CALL_HOOK) + return false; + const declaration = checker.getSymbolAtLocation(callee.name)?.valueDeclaration; + return declaration?.getSourceFile().fileName.endsWith(HOOK_MANAGER_FILE) === true; +} +/** `call` read as a callHook call, when it is one and its extension point and function name are known. */ +function hookCallOf(ctx, call) { + const [extensionPointArgument, functionNameArgument] = call.arguments; + if (!functionNameArgument || !isCallHookCall(ctx, call)) + return undefined; + const extensionPoint = extensionPointPattern(ctx, extensionPointArgument); + const functionName = stringValue(ctx, functionNameArgument); + return extensionPoint && functionName ? { call, extensionPoint, functionName } : undefined; +} +/** The name `HookMgr.callHook` is declared by, when the program loads the Script API's HookMgr. */ +function callHookName(ctx) { + const { ts, program } = ctx; + const file = program + .getSourceFiles() + .find((sourceFile) => sourceFile.isDeclarationFile && sourceFile.fileName.endsWith(HOOK_MANAGER_FILE)); + for (const statement of file?.statements ?? []) { + if (!ts.isClassDeclaration(statement)) + continue; + const member = statement.members.find((m) => m.name && ts.isIdentifier(m.name) && m.name.text === CALL_HOOK); + if (member) + return member.name; + } + return undefined; +} +/** The callHook call a reference to `callHook` is the callee of. */ +function hookCallAt(ctx, reference) { + const { ts } = ctx; + const file = ctx.program.getSourceFile(reference.fileName); + const node = file && !file.isDeclarationFile ? (0, ast_helpers_1.getNodeAtPosition)(file, ts, reference.textSpan.start) : undefined; + const access = node && (0, ast_helpers_1.propertyAccessNamedBy)(node, ts); + const call = access?.parent; + return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; +} +/** Every callHook call in the project: one project-wide search, made once per request. */ +function hookCalls(ctx) { + let calls = hookCallsByRequest.get(ctx); + if (!calls) { + const name = callHookName(ctx); + const searchable = name !== undefined && ctx.searchBudget > 0; + if (searchable) + ctx.searchBudget--; + calls = searchable ? (0, call_sites_1.findReferences)(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + hookCallsByRequest.set(ctx, calls); + } + return calls; +} +/** + * What a CommonJS module exports, as the checker types the object its first + * top-level `exports.x = ...` / `module.exports = ...` statement writes to + * (the public API gives no module symbol for a CommonJS file). + */ +function moduleExportsType(ctx, file) { + for (const statement of file.statements) { + const exportsObject = (0, ast_helpers_1.exportsObjectWrittenBy)(statement, ctx.ts); + if (exportsObject) + return ctx.checker.getTypeAtLocation(exportsObject); + } + return undefined; +} +/** The functions `file` exports, with the names it exports them under; only those named `name` when given. */ +function exportedFunctions(ctx, file, name) { + const { ts, checker } = ctx; + const exportsType = moduleExportsType(ctx, file); + const members = exportsType ? checker.getPropertiesOfType(exportsType) : []; + return members + .filter((member) => name === undefined || member.name === name) + .flatMap((member) => checker + .getTypeOfSymbolAtLocation(member, file) + .getCallSignatures() + .flatMap((signature) => { + const fn = signature.getDeclaration(); + return fn && ts.isFunctionLike(fn) ? [{ name: member.name, fn }] : []; + })); +} +/** + * The callHook calls that invoke `fn`, an export of a registered hook script, + * with the hook's own arguments by parameter index. Each call spends one + * reference from the request's budget. + */ +function hookCallSites(ctx, fn) { + const extensionPoints = registeredScripts(ctx).get(fn.getSourceFile()); + if (!extensionPoints) + return []; + const exported = exportedFunctions(ctx, fn.getSourceFile()).filter((entry) => entry.fn === fn); + const names = new Set(exported.map((entry) => entry.name)); + if (names.size === 0) + return []; + const calls = hookCalls(ctx).filter((hook) => names.has(hook.functionName) && extensionPoints.some((point) => reaches(hook.extensionPoint, point))); + const sites = calls.slice(0, Math.max(0, Math.min(constants_1.MAX_REFERENCES_PER_CALL, ctx.referenceBudget))); + ctx.referenceBudget -= sites.length; + return sites.map(({ call }) => ({ node: call, args: call.arguments.slice(2) })); +} +/** The hook functions a callHook call dispatches to: the matching export of every script registered for it. */ +function hookImplementations(ctx, call) { + const hook = hookCallOf(ctx, call); + if (!hook) + return []; + return [...registeredScripts(ctx)].flatMap(([file, extensionPoints]) => extensionPoints.some((point) => reaches(hook.extensionPoint, point)) + ? exportedFunctions(ctx, file, hook.functionName).map((entry) => entry.fn) + : []); +} diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index 3f8b2aa4e..0e15480a1 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -240,7 +240,7 @@ function checkableMembers(ctx, required, candidates) { * use. On for parameters, whose call sites are independent samples that may * pass a look-alike; off for a variable's own assigned values. */ -function decideType(ctx, evidence, profile, identifierName, fitUsage) { +function decideType(ctx, evidence, profile, name, fitUsage) { const guards = normalizeCandidates(ctx, profile.guardTypes); const guardKeys = new Set(guards.map((type) => (0, type_helpers_1.typeDisplayString)(ctx, type))); const candidates = normalizeCandidates(ctx, [...evidence, ...guards]); @@ -259,5 +259,5 @@ function decideType(ctx, evidence, profile, identifierName, fitUsage) { const used = mostSpecificUse(ctx, profile, required); if (used.length > 0) return used; - return matchAmbientTypesByUsage(ctx, profile.memberNames, identifierName); + return matchAmbientTypesByUsage(ctx, profile.memberNames, ctx.ts.isIdentifier(name) ? name.text : undefined); } diff --git a/packages/b2c-script-types/plugin/inference/super-module.js b/packages/b2c-script-types/plugin/inference/super-module.js index e9f639ae6..ddb552e19 100644 --- a/packages/b2c-script-types/plugin/inference/super-module.js +++ b/packages/b2c-script-types/plugin/inference/super-module.js @@ -9,6 +9,7 @@ exports.traceSuperModuleAccess = traceSuperModuleAccess; exports.resolveSuperModuleTypes = resolveSuperModuleTypes; exports.resolveSuperModuleMemberTypes = resolveSuperModuleMemberTypes; exports.collectSuperModuleAugmentedMembers = collectSuperModuleAugmentedMembers; +const ast_helpers_1 = require("./ast-helpers"); const constants_1 = require("./constants"); const context_1 = require("./context"); const type_helpers_1 = require("./type-helpers"); @@ -24,16 +25,14 @@ function isSuperModuleAccess(expr, ts) { /** * 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 + * host-supplied ctx.host.resolveSuperModulePath. Only works when that file is * part of the current program (true under the recommended jsconfig setup * that includes all cartridge files, but not in a bare inferred project * where nothing require()s the base file). */ function findSuperModuleFile(ctx, fromFileName) { const { program } = ctx; - if (!ctx.resolveSuperModulePath) - return undefined; - const superPath = ctx.resolveSuperModulePath(fromFileName); + const superPath = ctx.host.resolveSuperModulePath?.(fromFileName); if (!superPath) return undefined; // The resolver returns host-normalized (possibly case-folded) paths; @@ -55,36 +54,17 @@ function collectExportAssignments(sf, ts) { const full = []; const members = []; for (const stmt of sf.statements) { - const assignment = memberAssignmentOf(stmt, ts); + const assignment = (0, ast_helpers_1.memberAssignmentOf)(stmt, ts); if (!assignment) continue; const { binary, target } = assignment; - if (isExportsObject(target.expression, ts)) + if ((0, ast_helpers_1.isExportsObject)(target.expression, ts)) members.push({ name: target.name.text, expr: binary.right }); - else if (isModuleExports(target, ts)) + else if ((0, ast_helpers_1.isModuleExports)(target, ts)) full.push(binary); } return { full, members }; } -/** The `x.name = value` assignment a statement is, if any. */ -function memberAssignmentOf(stmt, ts) { - const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; - if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { - return undefined; - } - return ts.isPropertyAccessExpression(binary.left) ? { binary, target: binary.left } : undefined; -} -/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ -function isExportsObject(expr, ts) { - return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); -} -/** `module.exports`, identified structurally. */ -function isModuleExports(expr, ts) { - return (ts.isPropertyAccessExpression(expr) && - ts.isIdentifier(expr.expression) && - expr.expression.text === 'module' && - expr.name.text === 'exports'); -} /** * True when a `module.exports = X` assignment gives the checker a genuinely * usable exports type: not `any`, and actually exposing members. A @@ -194,8 +174,7 @@ function resolveSuperModuleMemberTypes(ctx, superAccess, memberName, resolve) { const matches = members.filter((member) => member.name === memberName); if (matches.length === 0) continue; - const types = matches.flatMap((member) => resolve(member.expr)); - return (0, type_helpers_1.dedupeTypes)(ctx, types.filter((type) => !(0, type_helpers_1.isAnyType)(ctx.ts, type))); + return (0, type_helpers_1.dedupeKnownTypes)(ctx, matches.flatMap((member) => resolve(member.expr))); } return []; } diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index 1496f88a0..9ab790007 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -10,6 +10,7 @@ exports.isOpenForUsageInference = isOpenForUsageInference; exports.informativeParts = informativeParts; exports.typeDisplayString = typeDisplayString; exports.dedupeTypes = dedupeTypes; +exports.dedupeKnownTypes = dedupeKnownTypes; exports.getMemberOfType = getMemberOfType; exports.hasAllMembers = hasAllMembers; exports.elementTypeOf = elementTypeOf; @@ -185,6 +186,10 @@ function dedupeTypes(ctx, types) { return true; }); } +/** The distinct types among `types` that say something: {@link dedupeTypes} without `any`. */ +function dedupeKnownTypes(ctx, types) { + return dedupeTypes(ctx, types.filter((type) => !isAnyType(ctx.ts, type))); +} /** * Strips any nullable part from `type` and computes its apparent type before * a member lookup. `getPropertyOfType` on a union only returns members common diff --git a/packages/b2c-script-types/plugin/resolver/hook-registry.js b/packages/b2c-script-types/plugin/resolver/hook-registry.js new file mode 100644 index 000000000..abee4d60e --- /dev/null +++ b/packages/b2c-script-types/plugin/resolver/hook-registry.js @@ -0,0 +1,74 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.readHookRegistrations = readHookRegistrations; +// Hook registrations: which script implements which extension point. The +// platform reads the `hooks` entry of a cartridge's package.json, which names +// a hooks.json file listing `{name, script}` pairs; each script path is +// relative to that hooks.json and, like a require(), may leave out its +// extension. Usage inference reads the registrations to treat +// `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` as a call of +// the `calculate` export of every script registered for `dw.order.calculate`. +// +// Both files are project content: they are only ever parsed as JSON, and every +// path they name is contained to the cartridge that declares it. +const node_path_1 = __importDefault(require("node:path")); +// A registered script resolves like a require(): as written, then with the +// extensions the platform tries for a script. +const SCRIPT_EXTENSIONS = ['', '.js', '.ds']; +function readJson(deps, fileName) { + try { + const text = deps.readFile(fileName); + return text === undefined ? undefined : JSON.parse(text); + } + catch { + return undefined; + } +} +/** `value[key]` when `value` is an object and that field holds a non-empty string. */ +function stringField(value, key) { + const field = value !== null && typeof value === 'object' ? value[key] : undefined; + return typeof field === 'string' && field.length > 0 ? field : undefined; +} +/** `relative` resolved against the directory `dir`, when that stays inside `cartridge`. */ +function containedPath(deps, cartridge, dir, relative) { + const candidate = node_path_1.default.posix.join(dir, relative.replace(/\\/g, '/')); + return deps.isWithinRoot(candidate, cartridge.root) ? candidate : undefined; +} +function resolveScript(deps, cartridge, dir, script) { + const base = containedPath(deps, cartridge, dir, script); + if (base === undefined) + return undefined; + return SCRIPT_EXTENSIONS.map((ext) => base + ext).find((candidate) => deps.fileExists(candidate)); +} +/** The hooks.json file a cartridge's package.json names, if any. */ +function hooksFileOf(deps, cartridge) { + const hooksPath = stringField(readJson(deps, cartridge.rawRoot + 'package.json'), 'hooks'); + return hooksPath === undefined ? undefined : containedPath(deps, cartridge, cartridge.rawRoot, hooksPath); +} +/** The registrations one cartridge's package.json and hooks.json declare. */ +function cartridgeRegistrations(deps, cartridge) { + const hooksFile = hooksFileOf(deps, cartridge); + const hooks = hooksFile === undefined ? undefined : readJson(deps, hooksFile); + const entries = hooks !== null && typeof hooks === 'object' ? hooks.hooks : undefined; + if (hooksFile === undefined || !Array.isArray(entries)) + return []; + const hooksDir = node_path_1.default.posix.dirname(hooksFile); + return entries.flatMap((entry) => { + const extensionPoint = stringField(entry, 'name'); + const script = stringField(entry, 'script'); + const resolved = extensionPoint && script ? resolveScript(deps, cartridge, hooksDir, script) : undefined; + return extensionPoint && resolved ? [{ extensionPoint, script: resolved }] : []; + }); +} +/** Every hook registration the configured cartridges declare. Unreadable or malformed files declare none. */ +function readHookRegistrations(cartridges, deps) { + return cartridges.flatMap((cartridge) => cartridgeRegistrations(deps, cartridge)); +} diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 6091d6fb2..7e426d2a1 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -16,6 +16,7 @@ import { parseDeclareModuleRanges, readDwJsonCartridges, } from './resolver/cartridge-discovery'; +import {readHookRegistrations} from './resolver/hook-registry'; import { createPathContainment, ownerCartridge as ownerCartridgeImpl, @@ -243,6 +244,18 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { return undefined; }; + // Which scripts the cartridges' hooks.json files register for which + // extension points, so usage inference can follow + // `HookMgr.callHook(extensionPoint, functionName, ...args)` into them. + // Read through the language-service host, like require() resolution; a + // read that throws declares nothing. + const hookRegistrations = () => + readHookRegistrations(cartridges, { + readFile: (p) => (host.readFile ? host.readFile(p) : ts.sys.readFile(p)), + fileExists: hostFileExists, + isWithinRoot, + }); + // Inject the ambient declarations into the TS program when the project // contains at least one cartridge file (isCartridgeFile is false while the // plugin is disabled or no cartridge is known). Projects that already @@ -403,6 +416,7 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { ts, languageService: info.languageService, resolveSuperModulePath, + hookRegistrations, log, }); usageInferenceResets.add(usageInference.reset); diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index ab4b8eab1..6571862b9 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -156,6 +156,41 @@ export function hasExplicitVariableType(decl: tsserver.VariableDeclaration, ts: return isStrongTypeNode(decl.type, ts) || isStrongTypeNode(ts.getJSDocType(decl), ts); } +/** The `x.name = value` assignment a statement is, if any. */ +export function memberAssignmentOf( + stmt: tsserver.Statement, + ts: typeof tsserver, +): {binary: tsserver.BinaryExpression; target: tsserver.PropertyAccessExpression} | undefined { + const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; + if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { + return undefined; + } + return ts.isPropertyAccessExpression(binary.left) ? {binary, target: binary.left} : undefined; +} + +/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ +export function isExportsObject(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); +} + +/** `module.exports`, identified structurally. */ +export function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolean { + return ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === 'module' && + expr.name.text === 'exports' + ); +} + +/** The `exports` / `module.exports` object a top-level statement assigns to or adds a member to. */ +export function exportsObjectWrittenBy(stmt: tsserver.Statement, ts: typeof tsserver): tsserver.Expression | undefined { + const target = memberAssignmentOf(stmt, ts)?.target; + if (!target) return undefined; + if (isExportsObject(target.expression, ts)) return target.expression; + return isModuleExports(target, ts) ? target : undefined; +} + /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index b64159518..848016a3d 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -39,7 +39,8 @@ const referencesByProgram = new WeakMap< Map >(); -function findReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly tsserver.ReferenceEntry[] { +/** Every reference to `name` across the project, from the per-Program cache. Spends no budget: callers do. */ +export function findReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly tsserver.ReferenceEntry[] { let cache = referencesByProgram.get(ctx.program); if (!cache) { cache = new Map(); diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index be722557a..e91c10b8e 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -17,6 +17,27 @@ import type {Bindings} from './bindings'; import type {CallSite} from './value-flow'; import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; import type {UsageProfile} from './usage-profile'; +import type {HookRegistration} from '../resolver/hook-registry'; + +/** + * What the plugin host knows about the project beyond its Program: the + * cartridge path and the cartridges' hook registrations. Without it, the + * evidence that depends on them is simply not gathered. + */ +export interface InferenceHost { + /** + * Maps a cartridge file to the same-subpath file in the next cartridge + * down the cartridge path — the module `module.superModule` refers to at + * runtime. Without it, `module.superModule` expressions stay uninferred. + */ + readonly resolveSuperModulePath?: (containingFile: string) => string | undefined; + /** + * The extension points the cartridges' hooks.json files register scripts + * for (see ./hook-calls). Asked at most once per request; a host may cache + * the answer per Program. + */ + readonly hookRegistrations?: (program: tsserver.Program) => readonly HookRegistration[]; +} interface MemoEntry { /** @@ -104,13 +125,8 @@ export interface InferenceContext { * as calls nest, never shared between them. */ bindings: Bindings; - /** - * 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; + /** What the plugin host supplies about the project (cartridge path, hook registrations). */ + readonly host: InferenceHost; /** * A completion request's cursor position. Exists so usage profiling (see * ./usage-profile) can exclude the property access the completion is @@ -135,7 +151,7 @@ export interface InferenceContext { export function createInferenceContext( ts: typeof tsserver, languageService: tsserver.LanguageService, - resolveSuperModulePath?: (containingFile: string) => string | undefined, + host: InferenceHost = {}, triggerPosition?: number, ): InferenceContext | undefined { const program = languageService.getProgram(); @@ -154,7 +170,7 @@ export function createInferenceContext( profiles: new Map(), cycleHits: 0, bindings: NO_BINDINGS, - resolveSuperModulePath, + host, triggerPosition, }; } diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 8f7391833..68fac2612 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -31,18 +31,23 @@ import {collectCallSites, forwardedParameter} from './call-sites'; import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; import {genericResultSource} from './generic-calls'; +import {hookCallSites, hookImplementations} from './hook-calls'; import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; import {decideType, limitUnion, normalizeCandidates} from './policy'; import {callbackParameterTypes, isElementFirstCallbackCall, resolveCalleeDeclaration} from './signatures'; import type {ExpressionResolver} from './super-module'; import {resolveSuperModuleMemberTypes, resolveSuperModuleTypes, traceSuperModuleAccess} from './super-module'; -import {arrayTypeOf, dedupeTypes, elementTypeOf, getMemberOfType, informativeParts, isAnyType} from './type-helpers'; +import { + arrayTypeOf, + dedupeKnownTypes, + dedupeTypes, + elementTypeOf, + getMemberOfType, + informativeParts, + isAnyType, +} from './type-helpers'; import {usageProfileOf} from './usage-profile'; -function identifierText(ctx: InferenceContext, name: tsserver.BindingName): string | undefined { - return ctx.ts.isIdentifier(name) ? name.text : undefined; -} - /** * Everything a local variable can hold: its initializer plus every later * `x = value` assignment (`var result = null; if (a) result = x; else result @@ -68,7 +73,7 @@ function resolveVariableTypes( ? resolveInitializerTypes(ctx, decl.initializer, profile.pushedValues, depth, chainHops) : []; const evidence = [...initial, ...resolveValues(ctx, profile.assignedValues, depth, chainHops)]; - return decideType(ctx, evidence, profile, identifierText(ctx, decl.name), false); + return decideType(ctx, evidence, profile, decl.name, false); }); } @@ -248,7 +253,7 @@ function resolveElementAccessTypes( ): tsserver.Type[] { const index = expr.argumentExpression; if (ctx.ts.isStringLiteralLike(index)) { - return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); + return dedupeKnownTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, index, depth, chainHops)); } return dedupeTypes(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); } @@ -404,6 +409,8 @@ function resolveCallResultTypes( const calleeFn = resolveCalleeDeclaration(ctx, expr); const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) return inferred; + const hookResults = hookImplementations(ctx, expr).flatMap((hook) => inferReturnType(ctx, hook, depth + 1)); + if (hookResults.length > 0) return dedupeTypes(ctx, hookResults); const generic = genericResultSource(ctx, expr); if (generic) { return generic.kind === 'return' @@ -427,15 +434,7 @@ function resolvePropertyTypes( depth: number, chainHops: number, ): tsserver.Type[] { - return resolveMemberValueTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); -} - -/** The member types worth keeping as a property's value. */ -function resolveMemberValueTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { - return dedupeTypes( - ctx, - types.filter((type) => !isAnyType(ctx.ts, type)), - ); + return dedupeKnownTypes(ctx, memberTypesOfReceiver(ctx, expr.expression, expr.name, depth, chainHops)); } /** Resolves an `any` identifier through what it names: a parameter's call sites or a variable's values. */ @@ -459,8 +458,9 @@ function resolveIdentifierTypes( /** * The argument types a parameter receives: at every call site of its * function across the project (`helper(x)`, `new Helper(x)`, - * `Helper.call(this, x)`), or — for an anonymous callback with no name to - * search for — from the collection it iterates. + * `Helper.call(this, x)`, and for a hook script's export, the + * `HookMgr.callHook(...)` calls dispatched to it), or — for an anonymous + * callback with no name to search for — from the collection it iterates. */ function parameterEvidence( ctx: InferenceContext, @@ -470,7 +470,7 @@ function parameterEvidence( ): tsserver.Type[] { const nameNode = getReferenceNameNode(fn, ctx.ts); if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - return collectCallSites(ctx, nameNode).flatMap((site) => { + return [...collectCallSites(ctx, nameNode), ...hookCallSites(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; }); @@ -512,7 +512,7 @@ export function inferParameterType( const contract = frameworkParameterTypes(ctx, fn, paramIndex); if (contract.length > 0) return contract; const evidence = parameterEvidence(ctx, fn, paramIndex, depth); - return decideType(ctx, evidence, usageProfileOf(ctx, param), identifierText(ctx, param.name), true); + return decideType(ctx, evidence, usageProfileOf(ctx, param), param.name, true); }); } diff --git a/packages/b2c-script-types/src/inference/editor-hooks.ts b/packages/b2c-script-types/src/inference/editor-hooks.ts index 944e9d020..9254c983d 100644 --- a/packages/b2c-script-types/src/inference/editor-hooks.ts +++ b/packages/b2c-script-types/src/inference/editor-hooks.ts @@ -15,8 +15,9 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {getNodeAtPosition, memberCompletionAccess, propertyAccessNamedBy} from './ast-helpers'; import {MAX_DISPLAY_CACHE_ENTRIES} from './constants'; +import type {HookRegistration} from '../resolver/hook-registry'; import {createInferenceContext} from './context'; -import type {InferenceContext} from './context'; +import type {InferenceContext, InferenceHost} from './context'; import {inferTypeForExpression, inferTypeForNode} from './core'; import {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './super-module'; import { @@ -27,10 +28,9 @@ import { typesToCompletionEntries, } from './type-helpers'; -interface HookEnvironment { +interface HookEnvironment extends InferenceHost { readonly ts: typeof tsserver; readonly languageService: tsserver.LanguageService; - readonly resolveSuperModulePath?: (containingFile: string) => string | undefined; } interface HookOptions extends HookEnvironment { @@ -127,7 +127,7 @@ function isInferenceTarget(ts: typeof tsserver, checker: tsserver.TypeChecker, e } function newContext(env: HookEnvironment, triggerPosition?: number): InferenceContext | undefined { - return createInferenceContext(env.ts, env.languageService, env.resolveSuperModulePath, triggerPosition); + return createInferenceContext(env.ts, env.languageService, env, triggerPosition); } /** @@ -296,11 +296,20 @@ function decorateCompletions( * Creates the hover and completion decorators for one language service. Each * request builds one inference context; finished results are cached per * Program, and `reset()` drops them (the plugin calls it when its - * configuration changes). + * configuration changes). The host's hook registrations are read once per + * Program too: hooks.json is no part of the Program, so the next edit is + * what picks up a change to it. */ -export function createUsageInferenceHooks(options: HookOptions) { +export function createUsageInferenceHooks(host: HookOptions) { const hoverCache = createDisplayCache(); const completionCache = createDisplayCache(); + const registrationCache = createDisplayCache(); + const readRegistrations = host.hookRegistrations; + const options: HookOptions = { + ...host, + hookRegistrations: + readRegistrations && ((program) => registrationCache.get('hooks', program, () => readRegistrations(program))), + }; return { decorateQuickInfo(fileName: string, position: number, original: tsserver.QuickInfo | undefined) { if (!original) return original; @@ -316,6 +325,7 @@ export function createUsageInferenceHooks(options: HookOptions) { reset() { hoverCache.clear(); completionCache.clear(); + registrationCache.clear(); }, }; } diff --git a/packages/b2c-script-types/src/inference/hook-calls.ts b/packages/b2c-script-types/src/inference/hook-calls.ts new file mode 100644 index 000000000..5e5a20265 --- /dev/null +++ b/packages/b2c-script-types/src/inference/hook-calls.ts @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Hooks. Nothing in a project calls a hook script's exports by name: the +// platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', +// basket)` to the `calculate` export of every script a cartridge's hooks.json +// registers for `dw.order.calculate` (see ../resolver/hook-registry). So the +// call sites of such an export are the callHook calls naming one of its +// extension points and its export name, passing the hook's arguments from the +// third argument on; and a callHook call returns what those exports return. +// An extension point built at runtime (`'app.payment.processor.' + +// processor.ID.toLowerCase()`) reaches every registered one its literal +// prefix starts. IntelliJ follows hooks in neither direction. + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {MAX_REFERENCES_PER_CALL} from './constants'; +import type {InferenceContext} from './context'; +import {exportsObjectWrittenBy, getNodeAtPosition, propertyAccessNamedBy} from './ast-helpers'; +import {findReferences} from './call-sites'; +import type {CallSite} from './value-flow'; + +// Where the Script API declares `HookMgr.callHook`, whichever copy of the dw +// types a project loads. +const HOOK_MANAGER_FILE = '/dw/system/HookMgr.d.ts'; +const CALL_HOOK = 'callHook'; + +/** The extension points a callHook call reaches: one name, or every name starting with a literal prefix. */ +interface ExtensionPointPattern { + readonly text: string; + readonly isPrefix: boolean; +} + +/** One `HookMgr.callHook(extensionPoint, functionName, ...args)` call whose two names are known. */ +interface HookCall { + readonly call: tsserver.CallExpression; + readonly extensionPoint: ExtensionPointPattern; + readonly functionName: string; +} + +// Request-scoped: a request asks the host for its registrations once, and +// searches for callHook calls once. +const scriptsByRequest = new WeakMap>(); +const hookCallsByRequest = new WeakMap(); + +/** The extension points each script of the program is registered for. */ +function registeredScripts(ctx: InferenceContext): ReadonlyMap { + let scripts = scriptsByRequest.get(ctx); + if (!scripts) { + const index = new Map(); + for (const {extensionPoint, script} of ctx.host.hookRegistrations?.(ctx.program) ?? []) { + const file = ctx.program.getSourceFile(script); + if (file) index.set(file, [...(index.get(file) ?? []), extensionPoint]); + } + scripts = index; + scriptsByRequest.set(ctx, scripts); + } + return scripts; +} + +function reaches(pattern: ExtensionPointPattern, extensionPoint: string): boolean { + return pattern.isPrefix ? extensionPoint.startsWith(pattern.text) : extensionPoint === pattern.text; +} + +/** The string `expr` always evaluates to: a literal, or a value the checker types as one (a `const`). */ +function stringValue(ctx: InferenceContext, expr: tsserver.Expression): string | undefined { + if (ctx.ts.isStringLiteralLike(expr)) return expr.text; + const type = ctx.checker.getTypeAtLocation(expr); + return type.isStringLiteral() ? type.value : undefined; +} + +/** The literal text a string built at runtime starts with: `'app.payment.processor.' + id`, `` `app.${id}` ``. */ +function literalPrefix(ctx: InferenceContext, expr: tsserver.Expression): string | undefined { + const {ts} = ctx; + if (ts.isTemplateExpression(expr)) return expr.head.text || undefined; + if (ts.isParenthesizedExpression(expr)) return literalPrefix(ctx, expr.expression); + if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.PlusToken) return undefined; + return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); +} + +function extensionPointPattern(ctx: InferenceContext, expr: tsserver.Expression): ExtensionPointPattern | undefined { + const name = stringValue(ctx, expr); + if (name !== undefined) return {text: name, isPrefix: false}; + const prefix = literalPrefix(ctx, expr); + return prefix ? {text: prefix, isPrefix: true} : undefined; +} + +/** True when `call` invokes the Script API's `HookMgr.callHook`, through whatever name it is required as. */ +function isCallHookCall(ctx: InferenceContext, call: tsserver.CallExpression): boolean { + const {ts, checker} = ctx; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== CALL_HOOK) return false; + const declaration = checker.getSymbolAtLocation(callee.name)?.valueDeclaration; + return declaration?.getSourceFile().fileName.endsWith(HOOK_MANAGER_FILE) === true; +} + +/** `call` read as a callHook call, when it is one and its extension point and function name are known. */ +function hookCallOf(ctx: InferenceContext, call: tsserver.CallExpression): HookCall | undefined { + const [extensionPointArgument, functionNameArgument] = call.arguments; + if (!functionNameArgument || !isCallHookCall(ctx, call)) return undefined; + const extensionPoint = extensionPointPattern(ctx, extensionPointArgument); + const functionName = stringValue(ctx, functionNameArgument); + return extensionPoint && functionName ? {call, extensionPoint, functionName} : undefined; +} + +/** The name `HookMgr.callHook` is declared by, when the program loads the Script API's HookMgr. */ +function callHookName(ctx: InferenceContext): tsserver.Identifier | undefined { + const {ts, program} = ctx; + const file = program + .getSourceFiles() + .find((sourceFile) => sourceFile.isDeclarationFile && sourceFile.fileName.endsWith(HOOK_MANAGER_FILE)); + for (const statement of file?.statements ?? []) { + if (!ts.isClassDeclaration(statement)) continue; + const member = statement.members.find((m) => m.name && ts.isIdentifier(m.name) && m.name.text === CALL_HOOK); + if (member) return member.name as tsserver.Identifier; + } + return undefined; +} + +/** The callHook call a reference to `callHook` is the callee of. */ +function hookCallAt(ctx: InferenceContext, reference: tsserver.ReferenceEntry): HookCall | undefined { + const {ts} = ctx; + const file = ctx.program.getSourceFile(reference.fileName); + const node = file && !file.isDeclarationFile ? getNodeAtPosition(file, ts, reference.textSpan.start) : undefined; + const access = node && propertyAccessNamedBy(node, ts); + const call = access?.parent; + return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; +} + +/** Every callHook call in the project: one project-wide search, made once per request. */ +function hookCalls(ctx: InferenceContext): readonly HookCall[] { + let calls = hookCallsByRequest.get(ctx); + if (!calls) { + const name = callHookName(ctx); + const searchable = name !== undefined && ctx.searchBudget > 0; + if (searchable) ctx.searchBudget--; + calls = searchable ? findReferences(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + hookCallsByRequest.set(ctx, calls); + } + return calls; +} + +/** + * What a CommonJS module exports, as the checker types the object its first + * top-level `exports.x = ...` / `module.exports = ...` statement writes to + * (the public API gives no module symbol for a CommonJS file). + */ +function moduleExportsType(ctx: InferenceContext, file: tsserver.SourceFile): tsserver.Type | undefined { + for (const statement of file.statements) { + const exportsObject = exportsObjectWrittenBy(statement, ctx.ts); + if (exportsObject) return ctx.checker.getTypeAtLocation(exportsObject); + } + return undefined; +} + +/** The functions `file` exports, with the names it exports them under; only those named `name` when given. */ +function exportedFunctions( + ctx: InferenceContext, + file: tsserver.SourceFile, + name?: string, +): Array<{readonly name: string; readonly fn: tsserver.SignatureDeclaration}> { + const {ts, checker} = ctx; + const exportsType = moduleExportsType(ctx, file); + const members = exportsType ? checker.getPropertiesOfType(exportsType) : []; + return members + .filter((member) => name === undefined || member.name === name) + .flatMap((member) => + checker + .getTypeOfSymbolAtLocation(member, file) + .getCallSignatures() + .flatMap((signature) => { + const fn = signature.getDeclaration() as tsserver.SignatureDeclaration | undefined; + return fn && ts.isFunctionLike(fn) ? [{name: member.name, fn}] : []; + }), + ); +} + +/** + * The callHook calls that invoke `fn`, an export of a registered hook script, + * with the hook's own arguments by parameter index. Each call spends one + * reference from the request's budget. + */ +export function hookCallSites(ctx: InferenceContext, fn: tsserver.SignatureDeclaration): CallSite[] { + const extensionPoints = registeredScripts(ctx).get(fn.getSourceFile()); + if (!extensionPoints) return []; + const exported = exportedFunctions(ctx, fn.getSourceFile()).filter((entry) => entry.fn === fn); + const names = new Set(exported.map((entry) => entry.name)); + if (names.size === 0) return []; + const calls = hookCalls(ctx).filter( + (hook) => names.has(hook.functionName) && extensionPoints.some((point) => reaches(hook.extensionPoint, point)), + ); + const sites = calls.slice(0, Math.max(0, Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget))); + ctx.referenceBudget -= sites.length; + return sites.map(({call}) => ({node: call, args: call.arguments.slice(2)})); +} + +/** The hook functions a callHook call dispatches to: the matching export of every script registered for it. */ +export function hookImplementations( + ctx: InferenceContext, + call: tsserver.CallExpression, +): tsserver.SignatureDeclaration[] { + const hook = hookCallOf(ctx, call); + if (!hook) return []; + return [...registeredScripts(ctx)].flatMap(([file, extensionPoints]) => + extensionPoints.some((point) => reaches(hook.extensionPoint, point)) + ? exportedFunctions(ctx, file, hook.functionName).map((entry) => entry.fn) + : [], + ); +} diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index 75aa65308..95c898ff8 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -335,7 +335,7 @@ export function decideType( ctx: InferenceContext, evidence: readonly tsserver.Type[], profile: UsageProfile, - identifierName: string | undefined, + name: tsserver.BindingName, fitUsage: boolean, ): tsserver.Type[] { const guards = normalizeCandidates(ctx, profile.guardTypes); @@ -353,5 +353,5 @@ export function decideType( if (fitting.length > 0) return limitUnion(ctx, fitting, checkable); const used = mostSpecificUse(ctx, profile, required); if (used.length > 0) return used; - return matchAmbientTypesByUsage(ctx, profile.memberNames, identifierName); + return matchAmbientTypesByUsage(ctx, profile.memberNames, ctx.ts.isIdentifier(name) ? name.text : undefined); } diff --git a/packages/b2c-script-types/src/inference/super-module.ts b/packages/b2c-script-types/src/inference/super-module.ts index dd72a5a4c..d8d3ea98c 100644 --- a/packages/b2c-script-types/src/inference/super-module.ts +++ b/packages/b2c-script-types/src/inference/super-module.ts @@ -14,10 +14,11 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {isExportsObject, isModuleExports, memberAssignmentOf} from './ast-helpers'; import {MAX_SUPERMODULE_HOPS} from './constants'; import {withCycleGuard} from './context'; import type {InferenceContext} from './context'; -import {dedupeTypes, isAnyType} from './type-helpers'; +import {dedupeKnownTypes, dedupeTypes, isAnyType} from './type-helpers'; /** The recursive engine's resolver for a value one hop further along the expression being resolved. */ export type ExpressionResolver = (value: tsserver.Expression) => tsserver.Type[]; @@ -35,15 +36,14 @@ function isSuperModuleAccess(expr: tsserver.PropertyAccessExpression, ts: typeof /** * 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 + * host-supplied ctx.host.resolveSuperModulePath. Only works when that file is * part of the current program (true under the recommended jsconfig setup * that includes all cartridge files, but not in a bare inferred project * where nothing require()s the base file). */ function findSuperModuleFile(ctx: InferenceContext, fromFileName: string): tsserver.SourceFile | undefined { const {program} = ctx; - if (!ctx.resolveSuperModulePath) return undefined; - const superPath = ctx.resolveSuperModulePath(fromFileName); + const superPath = ctx.host.resolveSuperModulePath?.(fromFileName); if (!superPath) return undefined; // The resolver returns host-normalized (possibly case-folded) paths; // program keys may differ in case on case-insensitive filesystems. @@ -79,33 +79,6 @@ function collectExportAssignments(sf: tsserver.SourceFile, ts: typeof tsserver): return {full, members}; } -/** The `x.name = value` assignment a statement is, if any. */ -function memberAssignmentOf( - stmt: tsserver.Statement, - ts: typeof tsserver, -): {binary: tsserver.BinaryExpression; target: tsserver.PropertyAccessExpression} | undefined { - const binary = ts.isExpressionStatement(stmt) ? stmt.expression : undefined; - if (!binary || !ts.isBinaryExpression(binary) || binary.operatorToken.kind !== ts.SyntaxKind.EqualsToken) { - return undefined; - } - return ts.isPropertyAccessExpression(binary.left) ? {binary, target: binary.left} : undefined; -} - -/** `module.exports` or the `exports` shorthand: the object a module's members are added to. */ -function isExportsObject(expr: tsserver.Expression, ts: typeof tsserver): boolean { - return isModuleExports(expr, ts) || (ts.isIdentifier(expr) && expr.text === 'exports'); -} - -/** `module.exports`, identified structurally. */ -function isModuleExports(expr: tsserver.Expression, ts: typeof tsserver): boolean { - return ( - ts.isPropertyAccessExpression(expr) && - ts.isIdentifier(expr.expression) && - expr.expression.text === 'module' && - expr.name.text === 'exports' - ); -} - /** * True when a `module.exports = X` assignment gives the checker a genuinely * usable exports type: not `any`, and actually exposing members. A @@ -245,10 +218,9 @@ export function resolveSuperModuleMemberTypes( for (const {members} of superModuleLevels(ctx, superAccess)) { const matches = members.filter((member) => member.name === memberName); if (matches.length === 0) continue; - const types = matches.flatMap((member) => resolve(member.expr)); - return dedupeTypes( + return dedupeKnownTypes( ctx, - types.filter((type) => !isAnyType(ctx.ts, type)), + matches.flatMap((member) => resolve(member.expr)), ); } return []; diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 7bf1c2344..967217179 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -188,6 +188,14 @@ export function dedupeTypes(ctx: InferenceContext, types: readonly tsserver.Type }); } +/** The distinct types among `types` that say something: {@link dedupeTypes} without `any`. */ +export function dedupeKnownTypes(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + return dedupeTypes( + ctx, + types.filter((type) => !isAnyType(ctx.ts, type)), + ); +} + /** * Strips any nullable part from `type` and computes its apparent type before * a member lookup. `getPropertyOfType` on a union only returns members common diff --git a/packages/b2c-script-types/src/resolver/hook-registry.ts b/packages/b2c-script-types/src/resolver/hook-registry.ts new file mode 100644 index 000000000..47d82786f --- /dev/null +++ b/packages/b2c-script-types/src/resolver/hook-registry.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Hook registrations: which script implements which extension point. The +// platform reads the `hooks` entry of a cartridge's package.json, which names +// a hooks.json file listing `{name, script}` pairs; each script path is +// relative to that hooks.json and, like a require(), may leave out its +// extension. Usage inference reads the registrations to treat +// `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` as a call of +// the `calculate` export of every script registered for `dw.order.calculate`. +// +// Both files are project content: they are only ever parsed as JSON, and every +// path they name is contained to the cartridge that declares it. + +import path from 'node:path'; + +import type {NormalizedCartridge} from './constants'; + +export interface HookRegistration { + /** The extension point (`dw.order.calculate`, `app.payment.processor.basic_credit`). */ + readonly extensionPoint: string; + /** The script registered for it, resolved to an existing file. */ + readonly script: string; +} + +export interface HookRegistryDeps { + readFile(fileName: string): string | undefined; + fileExists(fileName: string): boolean; + isWithinRoot(candidate: string, root: string): boolean; +} + +// A registered script resolves like a require(): as written, then with the +// extensions the platform tries for a script. +const SCRIPT_EXTENSIONS = ['', '.js', '.ds']; + +function readJson(deps: HookRegistryDeps, fileName: string): unknown { + try { + const text = deps.readFile(fileName); + return text === undefined ? undefined : JSON.parse(text); + } catch { + return undefined; + } +} + +/** `value[key]` when `value` is an object and that field holds a non-empty string. */ +function stringField(value: unknown, key: string): string | undefined { + const field = value !== null && typeof value === 'object' ? (value as Record)[key] : undefined; + return typeof field === 'string' && field.length > 0 ? field : undefined; +} + +/** `relative` resolved against the directory `dir`, when that stays inside `cartridge`. */ +function containedPath( + deps: HookRegistryDeps, + cartridge: NormalizedCartridge, + dir: string, + relative: string, +): string | undefined { + const candidate = path.posix.join(dir, relative.replace(/\\/g, '/')); + return deps.isWithinRoot(candidate, cartridge.root) ? candidate : undefined; +} + +function resolveScript( + deps: HookRegistryDeps, + cartridge: NormalizedCartridge, + dir: string, + script: string, +): string | undefined { + const base = containedPath(deps, cartridge, dir, script); + if (base === undefined) return undefined; + return SCRIPT_EXTENSIONS.map((ext) => base + ext).find((candidate) => deps.fileExists(candidate)); +} + +/** The hooks.json file a cartridge's package.json names, if any. */ +function hooksFileOf(deps: HookRegistryDeps, cartridge: NormalizedCartridge): string | undefined { + const hooksPath = stringField(readJson(deps, cartridge.rawRoot + 'package.json'), 'hooks'); + return hooksPath === undefined ? undefined : containedPath(deps, cartridge, cartridge.rawRoot, hooksPath); +} + +/** The registrations one cartridge's package.json and hooks.json declare. */ +function cartridgeRegistrations(deps: HookRegistryDeps, cartridge: NormalizedCartridge): HookRegistration[] { + const hooksFile = hooksFileOf(deps, cartridge); + const hooks = hooksFile === undefined ? undefined : readJson(deps, hooksFile); + const entries = hooks !== null && typeof hooks === 'object' ? (hooks as {hooks?: unknown}).hooks : undefined; + if (hooksFile === undefined || !Array.isArray(entries)) return []; + const hooksDir = path.posix.dirname(hooksFile); + return entries.flatMap((entry: unknown) => { + const extensionPoint = stringField(entry, 'name'); + const script = stringField(entry, 'script'); + const resolved = extensionPoint && script ? resolveScript(deps, cartridge, hooksDir, script) : undefined; + return extensionPoint && resolved ? [{extensionPoint, script: resolved}] : []; + }); +} + +/** Every hook registration the configured cartridges declare. Unreadable or malformed files declare none. */ +export function readHookRegistrations( + cartridges: readonly NormalizedCartridge[], + deps: HookRegistryDeps, +): HookRegistration[] { + return cartridges.flatMap((cartridge) => cartridgeRegistrations(deps, cartridge)); +} diff --git a/packages/b2c-script-types/test/corpus/cases.json b/packages/b2c-script-types/test/corpus/cases.json index 73d29972a..a2537b70d 100644 --- a/packages/b2c-script-types/test/corpus/cases.json +++ b/packages/b2c-script-types/test/corpus/cases.json @@ -7,7 +7,11 @@ "files": { "/helpers.js": "function getAddressBookAddressByForm(addressBook, form) {\n return addressBook.addresses;\n}\n" }, - "target": {"file": "/helpers.js", "function": "getAddressBookAddressByForm", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "getAddressBookAddressByForm", + "param": 0 + }, "expect": "AddressBook" }, { @@ -18,7 +22,11 @@ "files": { "/helpers.js": "function getPasswordResetToken(customer) {\n return customer.profile.credentials.createResetPasswordToken();\n}\n" }, - "target": {"file": "/helpers.js", "function": "getPasswordResetToken", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "getPasswordResetToken", + "param": 0 + }, "expect": "Customer" }, { @@ -29,7 +37,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "sendPasswordResetEmail", + "param": 1 + }, "expect": "Customer" }, { @@ -40,7 +52,11 @@ "files": { "/helpers.js": "function priceOf(apiProduct) {\n return apiProduct.getPriceModel();\n}\n" }, - "target": {"file": "/helpers.js", "function": "priceOf", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "priceOf", + "param": 0 + }, "expect": "Product" }, { @@ -51,7 +67,11 @@ "files": { "/helpers.js": "function billingOf(currentBasket) {\n return currentBasket.billingAddress;\n}\n" }, - "target": {"file": "/helpers.js", "function": "billingOf", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "billingOf", + "param": 0 + }, "expect": "Basket" }, { @@ -62,7 +82,11 @@ "files": { "/helpers.js": "function amountOf(paymentInstrument) {\n return paymentInstrument.capturedAmount;\n}\n" }, - "target": {"file": "/helpers.js", "function": "amountOf", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "amountOf", + "param": 0 + }, "expect": "OrderPaymentInstrument" }, { @@ -73,7 +97,11 @@ "files": { "/helpers.js": "function greet(registeredCustomerProfile) {\n return registeredCustomerProfile.firstName + registeredCustomerProfile.email;\n}\n" }, - "target": {"file": "/helpers.js", "function": "greet", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "greet", + "param": 0 + }, "expect": "Profile" }, { @@ -84,7 +112,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "getPasswordResetToken", + "param": 0 + }, "expect": "Customer" }, { @@ -95,7 +127,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "markShipmentForShipping", + "param": 0 + }, "expect": "Shipment" }, { @@ -106,7 +142,11 @@ "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"}, + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "profile" + }, "expect": "Profile" }, { @@ -117,7 +157,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "copyBillingAddressToBasket", + "param": 1 + }, "expect": "Basket" }, { @@ -128,7 +172,11 @@ "files": { "/helpers.js": "function collectLineItems(order) {\n return order.productLineItems;\n}\n" }, - "target": {"file": "/helpers.js", "function": "collectLineItems", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "collectLineItems", + "param": 0 + }, "expect": "Order" }, { @@ -140,7 +188,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "function": "checkPrice", + "param": 0 + }, "expect": "Product" }, { @@ -152,7 +204,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, "expect": "Product" }, { @@ -163,7 +219,11 @@ "files": { "/helpers.js": "function isSpecialCategoryProduct(product) {\n return product.getCategories();\n}\n" }, - "target": {"file": "/helpers.js", "function": "isSpecialCategoryProduct", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, "expect": "Product" }, { @@ -174,7 +234,11 @@ "files": { "/helpers.js": "function isSpecialCategoryProduct(obj) {\n return obj.getCategories();\n}\n" }, - "target": {"file": "/helpers.js", "function": "isSpecialCategoryProduct", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "isSpecialCategoryProduct", + "param": 0 + }, "expect": null }, { @@ -185,7 +249,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "findSegmentCategory", + "param": 0 + }, "expect": "Category" }, { @@ -196,7 +264,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "escapeAddress", + "param": 0 + }, "expect": "OrderAddress | CustomerAddress" }, { @@ -207,7 +279,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "getInfo", + "param": 0 + }, "expect": "Profile" }, { @@ -218,7 +294,11 @@ "files": { "/helpers.js": "function syncAddressesIfRequired(customer) {\n var book = customer.addressBook;\n return book;\n}\n" }, - "target": {"file": "/helpers.js", "function": "syncAddressesIfRequired", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "syncAddressesIfRequired", + "param": 0 + }, "expect": "Customer" }, { @@ -229,7 +309,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "kind": "callbackParam", + "param": 0 + }, "expect": "{ optionID: string; optionValueID: string; }" }, { @@ -241,7 +325,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "function": "LineItemModel", + "param": 0 + }, "expect": "Product" }, { @@ -252,7 +340,12 @@ "files": { "/helpers.js": "function describeShipment(shipment) {\n return 'ATTR' in shipment.custom;\n}\n" }, - "target": {"file": "/helpers.js", "function": "describeShipment", "param": 0, "collectMembers": true}, + "target": { + "file": "/helpers.js", + "function": "describeShipment", + "param": 0, + "collectMembers": true + }, "expectMembers": ["custom"] }, { @@ -264,7 +357,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "function": "describeThing", + "param": 0 + }, "expect": "Product | Order" }, { @@ -275,7 +372,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "touch", + "param": 0 + }, "expect": null }, { @@ -290,7 +391,11 @@ "/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}, + "target": { + "file": "/checkout/checkoutHelpers.js", + "function": "copyCustomerAddressToShipment", + "param": 0 + }, "expect": null }, { @@ -301,7 +406,11 @@ "files": { "/helpers.js": "function saveAddress(form, customer) {\n return customer.raw.getProfile();\n}\n" }, - "target": {"file": "/helpers.js", "function": "saveAddress", "param": 1}, + "target": { + "file": "/helpers.js", + "function": "saveAddress", + "param": 1 + }, "expect": null }, { @@ -313,7 +422,11 @@ "/helpers.js": "function resolveProductKey(product) {\n if (typeof product === 'string') return product;\n return product.ID;\n}\nresolveProductKey('sku-1');\nresolveProductKey(getSomeProduct());\n" }, "globals": " function getSomeProduct(): Product;", - "target": {"file": "/helpers.js", "function": "resolveProductKey", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "resolveProductKey", + "param": 0 + }, "expect": "string | Product" }, { @@ -324,7 +437,11 @@ "files": { "/helpers.js": "function readProfile(obj) {\n return obj.profile;\n}\n" }, - "target": {"file": "/helpers.js", "function": "readProfile", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "readProfile", + "param": 0 + }, "expect": null }, { @@ -335,7 +452,11 @@ "files": { "/helpers.js": "function hasPreorderableLineItem(items) {\n return items && items.length > 0;\n}\n" }, - "target": {"file": "/helpers.js", "function": "hasPreorderableLineItem", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "hasPreorderableLineItem", + "param": 0 + }, "expect": null }, { @@ -346,7 +467,11 @@ "files": { "/helpers.js": "function getTotalPrice(lineItem) {\n return lineItem.priceAdjustments.getLength();\n}\n" }, - "target": {"file": "/helpers.js", "function": "getTotalPrice", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "getTotalPrice", + "param": 0 + }, "expect": "ProductLineItem" }, { @@ -357,7 +482,11 @@ "files": { "/helpers.js": "function handlePliAttributes(pli) {\n pli.setPriceValue(0);\n}\n" }, - "target": {"file": "/helpers.js", "function": "handlePliAttributes", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "handlePliAttributes", + "param": 0 + }, "expect": "ProductLineItem" }, { @@ -368,7 +497,11 @@ "files": { "/helpers.js": "function DefaultPrice(priceModel) {\n this.price = priceModel.price;\n}\n" }, - "target": {"file": "/helpers.js", "function": "DefaultPrice", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "DefaultPrice", + "param": 0 + }, "expect": "ProductPriceModel" }, { @@ -380,7 +513,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "function": "isProductLine", + "param": 0 + }, "expect": "ProductLineItem" }, { @@ -393,7 +530,11 @@ "/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"}, + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "variant" + }, "expect": "Variant" }, { @@ -404,7 +545,11 @@ "/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}, + "target": { + "file": "/helpers.js", + "kind": "callbackParam", + "param": 0 + }, "expect": "FixtureAdj" }, { @@ -415,7 +560,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "countBonusChoices", + "param": 0 + }, "expect": "BonusDiscountLineItem" }, { @@ -426,7 +575,11 @@ "files": { "/helpers.js": "function bonusQty(bonusDiscountLineItem) {\n return bonusDiscountLineItem.getQuantity();\n}\n" }, - "target": {"file": "/helpers.js", "function": "bonusQty", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "bonusQty", + "param": 0 + }, "expect": null }, { @@ -437,7 +590,11 @@ "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}, + "target": { + "file": "/helpers.js", + "function": "surcharge", + "param": 0 + }, "expect": "ProductShippingLineItem" }, { @@ -450,7 +607,11 @@ "/full.js": "var BaseModel = require('./base');\nfunction FullModel(product) {\n BaseModel.call(this, product, 1);\n}\nnew FullModel(getProduct());\n" }, "globals": " function getProduct(): Product;", - "target": {"file": "/base.js", "function": "BaseModel", "param": 0}, + "target": { + "file": "/base.js", + "function": "BaseModel", + "param": 0 + }, "expect": "Product" }, { @@ -462,7 +623,11 @@ "/helpers.js": "function helper(product) {\n return product.ID;\n}\nfunction logged(product) {\n return helper.apply(this, arguments);\n}\nlogged(getProduct());\n" }, "globals": " function getProduct(): Product;", - "target": {"file": "/helpers.js", "function": "helper", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "helper", + "param": 0 + }, "expect": "Product" }, { @@ -474,7 +639,11 @@ "/helpers.js": "function lookup(pid) {\n return findProduct(pid);\n}\nmodule.exports = {lookup: lookup};\n" }, "globals": " function findProduct(productID: string): Product;", - "target": {"file": "/helpers.js", "function": "lookup", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "lookup", + "param": 0 + }, "expect": "string" }, { @@ -486,7 +655,11 @@ "/helpers.js": "function totals(lineItemContainer) {\n var container = lineItemContainer || {};\n return container;\n}\ntotals(getBasket());\n" }, "globals": " function getBasket(): Basket;", - "target": {"file": "/helpers.js", "kind": "variable", "name": "container"}, + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "container" + }, "expect": "Basket" }, { @@ -498,7 +671,11 @@ "/helpers.js": "function pick(useCategory) {\n var target;\n if (useCategory) {\n target = getCategory();\n } else {\n target = getProduct();\n }\n return target;\n}\n" }, "globals": " function getProduct(): Product;\n function getCategory(): Category;", - "target": {"file": "/helpers.js", "kind": "variable", "name": "target"}, + "target": { + "file": "/helpers.js", + "kind": "variable", + "name": "target" + }, "expect": "Category | Product" }, { @@ -511,7 +688,11 @@ "/helpers.js": "var collections = require('./collections');\nfunction handleVariant(variant) {\n return variant.ID;\n}\nfunction allVariants(product) {\n collections.forEach(product.getVariants(), handleVariant);\n}\nallVariants(getProduct());\n" }, "globals": " function getProduct(): Product;", - "target": {"file": "/helpers.js", "function": "handleVariant", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "handleVariant", + "param": 0 + }, "expect": "Variant" }, { @@ -523,7 +704,11 @@ "/helpers.js": "function recalculate(currentBasket) {\n applyShippingCost(currentBasket);\n}\nmodule.exports = {recalculate: recalculate};\n" }, "globals": " function applyShippingCost(basket: Basket): void;", - "target": {"file": "/helpers.js", "function": "recalculate", "param": 0}, + "target": { + "file": "/helpers.js", + "function": "recalculate", + "param": 0 + }, "expect": "Basket" }, { @@ -535,7 +720,11 @@ "files": { "/collections.js": "function forEach(collection, callback, scope) {\n var iterator = Object.hasOwnProperty.call(collection, 'iterator') ? collection.iterator() : collection;\n var index = 0;\n while (iterator.hasNext()) {\n var item = iterator.next();\n if (scope) callback.call(scope, item, index++, collection);\n else callback(item, index++, collection);\n }\n}\nforEach(getProducts(), function () {});\nforEach(getCategories(), function () {});\nforEach(getShipments(), function () {});\nforEach(getLineItems(), function () {});\nmodule.exports = {forEach: forEach};\n" }, - "target": {"file": "/collections.js", "function": "forEach", "param": 0}, + "target": { + "file": "/collections.js", + "function": "forEach", + "param": 0 + }, "expect": "Collection" }, { @@ -593,5 +782,28 @@ "name": "shipment" }, "expect": "Shipment" + }, + { + "id": "payment-hook-callhook-prefixed", + "source": "SFRA hooks.json app.payment.processor.* + CheckoutServices SubmitPayment shape", + "description": "nothing calls a payment hook's Handle export by name: the controller dispatches through HookMgr.callHook with an extension point built from the processor ID, which reaches the script hooks.json registers for app.payment.processor.basic_credit, so its basket parameter is the Basket the controller passes as the hook's first argument", + "dwTypes": ["Basket", "HookMgr", "PaymentMgr"], + "globals": "var HookManager: typeof HookMgr;\nvar PaymentManager: typeof PaymentMgr;\nfunction getCurrentBasket(): Basket;", + "hooks": [ + { + "extensionPoint": "app.payment.processor.basic_credit", + "script": "/scripts/hooks/payment/processor/basic_credit.js" + } + ], + "files": { + "/scripts/hooks/payment/processor/basic_credit.js": "var Transaction = require('dw/system/Transaction');\n\nfunction Handle(basket, paymentInformation, paymentMethodID) {\n var currentBasket = basket;\n var error = false;\n Transaction.wrap(function () {\n var existing = currentBasket.getPaymentInstruments(paymentMethodID);\n for (var i = 0; i < existing.length; i++) {\n currentBasket.removePaymentInstrument(existing[i]);\n }\n var paymentInstrument = currentBasket.createPaymentInstrument(paymentMethodID, currentBasket.totalGrossPrice);\n paymentInstrument.setCreditCardHolder(paymentInformation.cardOwner);\n });\n return {fieldErrors: {}, serverErrors: [], error: error};\n}\n\nfunction Authorize(orderNumber, paymentInstrument, paymentProcessor) {\n paymentInstrument.paymentTransaction.setTransactionID(orderNumber);\n paymentInstrument.paymentTransaction.setPaymentProcessor(paymentProcessor);\n return {fieldErrors: {}, serverErrors: [], error: false};\n}\n\nexports.Handle = Handle;\nexports.Authorize = Authorize;\n", + "/controllers/CheckoutServices.js": "function submitPayment(req, paymentForm) {\n var currentBasket = getCurrentBasket();\n var paymentMethodID = paymentForm.paymentMethod.value;\n var processor = PaymentManager.getPaymentMethod(paymentMethodID).getPaymentProcessor();\n var hookName = 'app.payment.processor.' + processor.ID.toLowerCase();\n if (!HookManager.hasHook(hookName)) return {error: true};\n return HookManager.callHook('app.payment.processor.' + processor.ID.toLowerCase(), 'Handle', currentBasket, paymentForm.paymentInformation, paymentMethodID);\n}\n\nmodule.exports = {submitPayment: submitPayment};\n" + }, + "target": { + "file": "/scripts/hooks/payment/processor/basic_credit.js", + "function": "Handle", + "param": 0 + }, + "expect": "Basket" } ] diff --git a/packages/b2c-script-types/test/corpus/corpus.test.js b/packages/b2c-script-types/test/corpus/corpus.test.js index 7e1f53c8e..622e0d4c6 100644 --- a/packages/b2c-script-types/test/corpus/corpus.test.js +++ b/packages/b2c-script-types/test/corpus/corpus.test.js @@ -78,7 +78,9 @@ 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); + // A case's `hooks` stand in for the hooks.json registrations the plugin reads from disk. + const host = corpusCase.hooks ? {hookRegistrations: () => corpusCase.hooks} : undefined; + const ctx = createInferenceContext(ts, languageService, host); assert.ok(ctx, 'expected an inference context'); const target = resolveTarget(ctx, corpusCase); 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 ecd3cf5ed..9626b581e 100644 --- a/packages/b2c-script-types/test/helpers/real-dw-types.js +++ b/packages/b2c-script-types/test/helpers/real-dw-types.js @@ -48,6 +48,8 @@ const REAL_DW_TYPES = { ShippingLineItem: dtsPath('dw', 'order', 'ShippingLineItem'), PriceAdjustment: dtsPath('dw', 'order', 'PriceAdjustment'), Transaction: dtsPath('dw', 'system', 'Transaction'), + HookMgr: dtsPath('dw', 'system', 'HookMgr'), + PaymentMgr: dtsPath('dw', 'order', 'PaymentMgr'), PaymentInstrument: dtsPath('dw', 'order', 'PaymentInstrument'), CustomerPaymentInstrument: dtsPath('dw', 'customer', 'CustomerPaymentInstrument'), }; diff --git a/packages/b2c-script-types/test/hook-registry.test.js b/packages/b2c-script-types/test/hook-registry.test.js new file mode 100644 index 000000000..b23f21f9a --- /dev/null +++ b/packages/b2c-script-types/test/hook-registry.test.js @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const assert = require('node:assert/strict'); + +const ts = require('typescript'); + +const {readHookRegistrations} = require('../src/resolver/hook-registry'); +const {createPathContainment} = require('../src/resolver/module-resolution'); + +const {isWithinRoot} = createPathContainment(ts, true); + +const CARTRIDGE = {name: 'app_custom', root: '/ws/app_custom/', rawRoot: '/ws/app_custom/'}; + +/** The registrations `CARTRIDGE` declares when the workspace holds `files` (path -> text). */ +function registrationsOf(files, cartridges = [CARTRIDGE]) { + return readHookRegistrations(cartridges, { + readFile: (fileName) => files[fileName], + fileExists: (fileName) => fileName in files, + isWithinRoot, + }); +} + +const json = (value) => JSON.stringify(value); + +describe('readHookRegistrations', () => { + it('reads the hooks.json a cartridge package.json names, resolving scripts like a require()', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({ + hooks: [ + {name: 'dw.order.calculate', script: './cartridge/scripts/hooks/cart/calculate.js'}, + {name: 'app.payment.processor.basic_credit', script: './cartridge/scripts/hooks/payment/basic_credit'}, + {name: 'app.legacy', script: './cartridge/scripts/hooks/legacy'}, + ], + }), + '/ws/app_custom/cartridge/scripts/hooks/cart/calculate.js': '', + '/ws/app_custom/cartridge/scripts/hooks/payment/basic_credit.js': '', + '/ws/app_custom/cartridge/scripts/hooks/legacy.ds': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/cartridge/scripts/hooks/cart/calculate.js'}, + { + extensionPoint: 'app.payment.processor.basic_credit', + script: '/ws/app_custom/cartridge/scripts/hooks/payment/basic_credit.js', + }, + {extensionPoint: 'app.legacy', script: '/ws/app_custom/cartridge/scripts/hooks/legacy.ds'}, + ]); + }); + + it('resolves scripts relative to a hooks.json kept inside the cartridge folder', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './cartridge/scripts/hooks.json'}), + '/ws/app_custom/cartridge/scripts/hooks.json': json({ + hooks: [{name: 'dw.order.calculate', script: './cart/calculate'}], + }), + '/ws/app_custom/cartridge/scripts/cart/calculate.js': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/cartridge/scripts/cart/calculate.js'}, + ]); + }); + + it('declares nothing without a package.json hooks entry, or when either file is missing, unreadable or malformed', () => { + const hooksJson = json({hooks: [{name: 'dw.order.calculate', script: './calculate.js'}]}); + const cases = [ + {'/ws/app_custom/hooks.json': hooksJson, '/ws/app_custom/calculate.js': ''}, + {'/ws/app_custom/package.json': json({name: 'app_custom'}), '/ws/app_custom/hooks.json': hooksJson}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'})}, + {'/ws/app_custom/package.json': '{"hooks": ', '/ws/app_custom/hooks.json': hooksJson}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'}), '/ws/app_custom/hooks.json': 'not json'}, + {'/ws/app_custom/package.json': json({hooks: './hooks.json'}), '/ws/app_custom/hooks.json': json({hooks: {}})}, + {'/ws/app_custom/package.json': json({hooks: 42}), '/ws/app_custom/hooks.json': hooksJson}, + ]; + for (const files of cases) assert.deepEqual(registrationsOf(files), [], json(files)); + const unreadable = readHookRegistrations([CARTRIDGE], { + readFile: () => { + throw new Error('EACCES'); + }, + fileExists: () => true, + isWithinRoot, + }); + assert.deepEqual(unreadable, []); + }); + + it('skips entries without a name, a script, or a script file that exists', () => { + const files = { + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({ + hooks: [ + null, + 'dw.order.calculate', + {name: 'dw.order.calculate'}, + {script: './calculate.js'}, + {name: 7, script: './calculate.js'}, + {name: 'dw.order.missing', script: './missing.js'}, + {name: 'dw.order.calculate', script: './calculate.js'}, + ], + }), + '/ws/app_custom/calculate.js': '', + }; + assert.deepEqual(registrationsOf(files), [ + {extensionPoint: 'dw.order.calculate', script: '/ws/app_custom/calculate.js'}, + ]); + }); + + it('never reads or registers a path outside the cartridge that declares it', () => { + const outside = { + '/ws/other/hooks.json': json({hooks: [{name: 'dw.order.calculate', script: './calculate.js'}]}), + '/ws/other/calculate.js': '', + }; + const readOutside = []; + const files = {...outside, '/ws/app_custom/package.json': json({hooks: '../other/hooks.json'})}; + readHookRegistrations([CARTRIDGE], { + readFile: (fileName) => { + if (fileName.startsWith('/ws/other/')) readOutside.push(fileName); + return files[fileName]; + }, + fileExists: (fileName) => fileName in files, + isWithinRoot, + }); + assert.deepEqual(readOutside, []); + + const escapingScript = { + ...outside, + '/ws/app_custom/package.json': json({hooks: './hooks.json'}), + '/ws/app_custom/hooks.json': json({hooks: [{name: 'dw.order.calculate', script: '../other/calculate.js'}]}), + }; + assert.deepEqual(registrationsOf(escapingScript), []); + }); + + it('reads every configured cartridge, in cartridge path order', () => { + const base = {name: 'app_base', root: '/ws/app_base/', rawRoot: '/ws/app_base/'}; + const declare = (root, script) => ({ + [`${root}package.json`]: json({hooks: './hooks.json'}), + [`${root}hooks.json`]: json({hooks: [{name: 'dw.order.calculate', script}]}), + [`${root}${script.slice(2)}`]: '', + }); + const files = {...declare('/ws/app_custom/', './custom.js'), ...declare('/ws/app_base/', './base.js')}; + assert.deepEqual( + registrationsOf(files, [CARTRIDGE, base]).map((registration) => registration.script), + ['/ws/app_custom/custom.js', '/ws/app_base/base.js'], + ); + }); +}); diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index a7f020960..26a64509c 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -817,3 +817,53 @@ describe('create() proxy — usage inference wiring', () => { assert.throws(() => proxy.getCompletionsAtPosition('/helper.js', 0, undefined), ts.OperationCanceledException); }); }); + +describe('create() proxy — hook registrations', () => { + // A hook script's exports are only invoked through HookMgr.callHook(). The + // plugin reads which scripts implement which extension points from each + // configured cartridge's package.json -> hooks.json, through the host. + const ROOT = '/ws/app_custom'; + const HOOK_FILES = { + [`${ROOT}/package.json`]: JSON.stringify({hooks: './hooks.json'}), + [`${ROOT}/hooks.json`]: JSON.stringify({ + hooks: [{name: 'app.payment.processor.basic_credit', script: './cartridge/scripts/hooks/payment/basic_credit'}], + }), + [`${ROOT}/cartridge/scripts/hooks/payment/basic_credit.js`]: ` + function Handle(basket, paymentInformation) { + return {error: false}; + } + exports.Handle = Handle; + `, + [`${ROOT}/cartridge/controllers/Checkout.js`]: ` + var BasketMgr = require('dw/order/BasketMgr'); + var HookMgr = require('dw/system/HookMgr'); + function submitPayment(processor) { + var currentBasket = BasketMgr.getCurrentBasket(); + return HookMgr.callHook('app.payment.processor.' + processor.ID.toLowerCase(), 'Handle', currentBasket, {}); + } + module.exports = {submitPayment: submitPayment}; + `, + }; + const HOOK_SCRIPT = `${ROOT}/cartridge/scripts/hooks/payment/basic_credit.js`; + + function hoverOnHandleBasket(files, inferUsage = true) { + const {proxy} = createPluginProxy({ + files, + config: {enabled: true, autoDiscover: false, cartridges: [{name: 'app_custom', src: ROOT}], inferUsage}, + }); + const position = files[HOOK_SCRIPT].indexOf('Handle(basket') + 'Handle('.length; + const hover = proxy.getQuickInfoAtPosition(HOOK_SCRIPT, position); + return (hover?.documentation ?? []).map((p) => p.text).join(''); + } + + it('infers a hook function parameter from the callHook calls dispatched to it', () => { + const hoverText = hoverOnHandleBasket(HOOK_FILES); + assert.ok(hoverText.includes('Inferred from usage: Basket'), `got: ${hoverText || '(no inferred note)'}`); + }); + + it('infers nothing for a hook script hooks.json does not register', () => { + const files = {...HOOK_FILES, [`${ROOT}/hooks.json`]: JSON.stringify({hooks: []})}; + const hoverText = hoverOnHandleBasket(files); + assert.ok(!hoverText.includes('Inferred from usage'), `got: ${hoverText}`); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.perf.test.js b/packages/b2c-script-types/test/usage-inference.perf.test.js index 3d84f89de..9012800b2 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -98,6 +98,10 @@ const BASELINE = { // a request after an edit to a cartridge .js file (a new Program) must add // zero getPropertiesOfType calls, not re-index the Script API. ambientClassIndexRebuildAfterJsEdit: 0, + // A hook function's parameters: its own name and its export binding, plus + // ONE project-wide search for every HookMgr.callHook call, shared by all + // the hook parameters a request infers. + hookParameters: 3, }; /** @@ -649,4 +653,49 @@ describe('usage-inference — performance baselines', () => { ); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); + + it(`searches for HookMgr.callHook calls once per request, whichever hook parameters it infers (<= ${BASELINE.hookParameters} searches)`, () => { + const files = { + '/types.d.ts': realTypesPrelude( + ['Basket', 'HookMgr', 'Customer'], + 'var HookManager: typeof HookMgr; function getBasket(): Basket; function getCustomer(): Customer;', + ), + '/hooks/payment.js': ` + function Handle(basket, customer) { + return {error: false}; + } + exports.Handle = Handle; + `, + '/checkout.js': Array.from( + {length: 20}, + (_, i) => `HookManager.callHook('app.payment.processor.p${i}', 'Handle', getBasket(), getCustomer());`, + ).join('\n'), + }; + const registrations = Array.from({length: 20}, (_, i) => ({ + extensionPoint: `app.payment.processor.p${i}`, + script: '/hooks/payment.js', + })); + const counter = withReferenceCounter(createFixtureLanguageService(files)); + let hostCalls = 0; + const ctx = createInferenceContext(ts, counter.languageService, { + hookRegistrations: () => { + hostCalls++; + return registrations; + }, + }); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/hooks/payment.js'), 'Handle'); + + const {result, elapsedMs} = timed(() => fn.parameters.map((parameter) => inferParameterType(ctx, parameter))); + + assert.deepEqual( + result.map((types) => describeTypes(ctx, types)), + ['Basket', 'Customer'], + ); + assert.ok( + counter.referenceSearches() <= BASELINE.hookParameters, + `expected <= ${BASELINE.hookParameters} reference searches, got ${counter.referenceSearches()}`, + ); + assert.equal(hostCalls, 1, 'the host is asked for its registrations once per request'); + assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); + }); }); diff --git a/packages/b2c-script-types/test/usage-inference.real-types.test.js b/packages/b2c-script-types/test/usage-inference.real-types.test.js index 8ee04d7b8..ebe1fa7ad 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 @@ -385,7 +385,7 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = ? '/base/cartridge/scripts/helpers/productHelpers.js' : undefined; const languageService = createFixtureLanguageService(files, {strict: true}); - const ctx = createInferenceContext(ts, languageService, resolver); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: resolver}); const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/productHelpers.js'); const fn = findFunctionDeclaration(overlay, 'getMemberPrice'); diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 3d7eb69d3..8e573bd9a 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -1108,7 +1108,7 @@ module.exports = getLineItems; 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 engine resolves it through ctx.host.resolveSuperModulePath (supplied by // the plugin host, which owns the cartridge order). const SUPER_RESOLVER = (containingFile) => containingFile === '/custom/cartridge/scripts/helpers/x.js' ? '/base/cartridge/scripts/helpers/x.js' : undefined; @@ -1149,7 +1149,7 @@ module.exports = getLineItems; it("resolves `module.superModule` to the overridden module's export type", () => { const languageService = createFixtureLanguageService(OVERLAY_FILES); - const ctx = createInferenceContext(ts, languageService, SUPER_RESOLVER); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: SUPER_RESOLVER}); const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); // `base` in `base.getThing(...)` — an identifier whose declaration is // the `var base = module.superModule` initializer. @@ -1169,7 +1169,7 @@ module.exports = getLineItems; // 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 ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: SUPER_RESOLVER}); const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); const wrapped = findFunctionDeclaration(overlay, 'wrapped'); @@ -1180,7 +1180,7 @@ module.exports = getLineItems; it('returns no candidates when no lower cartridge provides the module', () => { const languageService = createFixtureLanguageService(OVERLAY_FILES); - const ctx = createInferenceContext(ts, languageService, () => undefined); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: () => undefined}); const overlay = ctx.program.getSourceFile('/custom/cartridge/scripts/helpers/x.js'); const baseUse = findIdentifier(overlay, 'base', (n) => ts.isPropertyAccessExpression(n.parent)); @@ -1227,7 +1227,7 @@ module.exports = getLineItems; '/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 ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => order[f]}); const top = ctx.program.getSourceFile('/top/cartridge/scripts/helpers/x.js'); const baseUse = findIdentifier(top, 'base', (n) => ts.isReturnStatement(n.parent)); @@ -1482,7 +1482,7 @@ module.exports = getLineItems; 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 ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); const top = ctx.program.getSourceFile('/top/x.js'); const types = inferTypeForNode(ctx, findVarUse(top, 'memberPrice')); @@ -1492,7 +1492,7 @@ module.exports = getLineItems; 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 ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); const top = ctx.program.getSourceFile('/top/x.js'); const types = inferTypeForNode(ctx, findVarUse(top, 'salePrice')); @@ -1503,7 +1503,7 @@ module.exports = getLineItems; it('lists augmented members from every pass-through level for completions', () => { const {collectSuperModuleAugmentedMembers} = require('../src/usage-inference'); const languageService = createFixtureLanguageService(STACK_FILES); - const ctx = createInferenceContext(ts, languageService, (f) => STACK_ORDER[f]); + const ctx = createInferenceContext(ts, languageService, {resolveSuperModulePath: (f) => STACK_ORDER[f]}); const top = ctx.program.getSourceFile('/top/x.js'); let baseUse; const visit = (node) => { diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index f9a75209b..3c474294a 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -85,12 +85,12 @@ function findTarget(ctx, fileName, kind, name) { /** * Infers `kind` (`return`, `param`, `var`) for the first node called `name` * in `file` (default `/helper.js`), with `source` as `/helper.js` and any - * further `files` beside it. + * further `files` beside it, and what the plugin `host` supplies. */ -function infer({types = CART_TYPES, source, files = {}, file = '/helper.js', kind, name}) { +function infer({types = CART_TYPES, source, files = {}, file = '/helper.js', kind, name, host}) { const sources = source === undefined ? files : {'/helper.js': source, ...files}; const languageService = createFixtureLanguageService({'/types.d.ts': types, ...sources}); - const ctx = createInferenceContext(ts, languageService); + const ctx = createInferenceContext(ts, languageService, host); return describeTypes(ctx, INFER[kind](ctx, findTarget(ctx, file, kind, name))); } @@ -777,3 +777,111 @@ describe('usage-inference — values handed down a chain of helpers', () => { }, ]); }); + +describe('usage-inference — hooks', () => { + // Hook scripts are only ever invoked through HookMgr.callHook(extensionPoint, + // functionName, ...args), dispatched by the hooks.json registrations the + // plugin host reads (see test/hook-registry.test.js). + const HOOK_TYPES = realTypesPrelude( + ['Basket', 'HookMgr'], + ` + var HookManager: typeof HookMgr; + function getBasket(): Basket; + `, + ); + const PAYMENT_HOOK = ` + function Handle(basket, paymentInformation) { + return {error: false}; + } + function Authorize(orderNumber, paymentInstrument) { + return {authorized: true}; + } + exports.Handle = Handle; + exports.Authorize = Authorize; + `; + const PAYMENT_HOST = { + hookRegistrations: () => [{extensionPoint: 'app.payment.processor.basic_credit', script: '/hooks/basic_credit.js'}], + }; + + /** The `basket` parameter of the payment hook's Handle, with `/checkout.js` calling it as `checkout`. */ + function handleBasket(checkout, host = PAYMENT_HOST) { + return { + types: HOOK_TYPES, + files: {'/hooks/basic_credit.js': PAYMENT_HOOK, '/checkout.js': checkout}, + file: '/hooks/basic_credit.js', + kind: 'param', + name: 'basket', + host, + }; + } + + runCases([ + { + title: "a hook function's parameter is what callHook passes after the extension point and function name", + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Handle', getBasket(), {});`), + expected: 'Basket', + }, + { + title: 'an extension point built from a literal prefix reaches every registered point it starts', + ...handleBasket(` + function handlePayment(processorId) { + return HookManager.callHook('app.payment.processor.' + processorId.toLowerCase(), 'Handle', getBasket()); + } + `), + expected: 'Basket', + }, + { + title: 'a template literal extension point reaches the points its head starts', + ...handleBasket( + 'function pay(id) { HookManager.callHook(`app.payment.processor.${id}`, "Handle", getBasket()); }', + ), + expected: 'Basket', + }, + { + title: 'a call naming another function of the same hook passes this one nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Authorize', getBasket());`), + expected: '', + }, + { + title: 'a call to an extension point the script is not registered for passes it nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.paypal', 'Handle', getBasket());`), + expected: '', + }, + { + title: 'a prefix no registered point starts with reaches nothing', + ...handleBasket(`function pay(id) { HookManager.callHook('app.order.' + id, 'Handle', getBasket()); }`), + expected: '', + }, + { + title: 'without registrations from the host, callHook reaches nothing', + ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Handle', getBasket());`, {}), + expected: '', + }, + { + title: 'a hook function exported under another name is reached by its export name', + types: HOOK_TYPES, + files: { + '/hooks/default.js': 'function handle(basket) { return {}; } module.exports = {Handle: handle};', + '/checkout.js': `HookManager.callHook('app.payment.processor.default', 'Handle', getBasket());`, + }, + file: '/hooks/default.js', + kind: 'param', + name: 'basket', + host: {hookRegistrations: () => [{extensionPoint: 'app.payment.processor.default', script: '/hooks/default.js'}]}, + expected: 'Basket', + }, + { + title: 'a callHook call returns what the hook functions it dispatches to return', + types: HOOK_TYPES, + files: { + '/hooks/calculate.js': 'exports.calculate = function (basket) { return basket.getTotalGrossPrice(); };', + '/cart.js': `function recalculate() { return HookManager.callHook('dw.order.calculate', 'calculate', getBasket()); }`, + }, + file: '/cart.js', + kind: 'return', + name: 'recalculate', + host: {hookRegistrations: () => [{extensionPoint: 'dw.order.calculate', script: '/hooks/calculate.js'}]}, + expected: 'Money', + }, + ]); +}); From e02ccea287c00e9b2b872f937c99f2167616fc5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:24:26 +0000 Subject: [PATCH 60/76] docs(script-types): document value flow, call-specific results and hooks; cover them in VS Code The extension point of a HookMgr.callHook call is now also read through a local variable written once (`var hookName = 'app.payment.processor.' + id`). The IDE guide, package README and changeset describe the evidence sources added in this pass (member and array values, factory-returned models, call-specific helper results, generic Script API calls, hooks) and the remaining limitations. The VS Code integration workspace gains factory-model, collections.find and payment-hook hovers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 9 ++- packages/b2c-script-types/README.md | 8 +++ .../plugin/inference/hook-calls.js | 16 ++++- .../src/inference/hook-calls.ts | 16 ++++- .../test/usage-inference.value-flow.test.js | 21 ++++++ .../scripts/checkout/paymentHelpers.js | 19 +++++ .../scripts/helpers/valueFlowHelpers.js | 47 +++++++++++++ .../cartridge/scripts/hooks.json | 8 +++ .../scripts/hooks/payment/basicCredit.js | 13 ++++ .../cartridge/scripts/util/collections.js | 16 +++++ .../cartridges/test_cartridge/package.json | 4 ++ .../script-types-infer-usage.test.ts | 69 +++++++++++++++++++ 13 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js create mode 100644 packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 8df250b9e..26f95c194 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 cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). +Script API IntelliSense can now infer types for undocumented cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index a4fb5a0c7..f7db21099 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -135,6 +135,9 @@ The evidence it weighs: - **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). - **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. +- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. +- **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. +- **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. - **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. - **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; with no call site at all, the members are matched against every Script API class. @@ -151,7 +154,8 @@ Inferred results are heuristic and clearly labeled: - Hover text gets an appended `Inferred from usage: ` line. - Member completions synthesized this way are still offered alongside (not instead of) whatever TypeScript already resolved. -- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. +- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Different element types of one collection class read as that class (`Collection` and `Collection` show `Collection`). Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. +- Usage that a JavaScript built-in fits as well (`value.replace(...)` could be a `String`) and the TopLevel classes describing `module` and `arguments` never produce a Script API guess on their own. - Names never add a type of their own; they only break a tie when the usage fits several classes equally well. A `profile` parameter picks `dw.customer.Profile` over `ProductListRegistrant`, `lineItem` / `pli` pick `ProductLineItem`, and qualified names such as `apiProduct`, `currentBasket` or `resettingCustomer` resolve to the class they end in. This won't recover types TypeScript genuinely can't infer — for example, values that are never called with a consistent, well-typed argument anywhere in the project — and it's off by default because it's new and heuristic. @@ -161,7 +165,8 @@ This won't recover types TypeScript genuinely can't infer — for example, value - Destructured function parameters (`function f({a, b})`) - Destructured return values (`var {a, b} = undocumentedFn()`) - Constructor inheritance via `Foo.prototype = Base.prototype` -- Hook implementations reached only through `HookMgr.callHook` (the `hooks.json` mapping isn't followed) +- Hooks the platform itself invokes (OCAPI/SCAPI `dw.ocapi.*` hooks, system extension points such as `dw.order.calculate` when no cartridge calls it): with no `HookMgr.callHook` call there is no call site, so only the hook's own body is evidence. Extension points without a literal prefix (`HookMgr.callHook(hookName, ...)` where `hookName` is a parameter or a variable assigned more than once) aren't matched either +- Typed Script API uses through a value that is itself inferred (`paymentInstrument.paymentTransaction.setPaymentProcessor(processor)` where `paymentInstrument` is undocumented) don't count as usage of the argument - Values ISML templates read from `pdict` - Guessing individual custom attribute names on `.custom` (only `.custom` itself is usage evidence) diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index b9fde819e..2a762c328 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -67,6 +67,14 @@ propagates to every caller. Passing `inferUsage: true` in the plugin config way IntelliJ does: from call-site arguments (including `new`, `.call`/`.apply` and callbacks), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body. +Values are followed where they go — into object members, `this.x`, pushed +arrays, factory returns and `module.exports` — and a generic helper such as +`collections.find(basket.shipments, ...)` is inferred again for the call at +hand. Hook scripts registered in a cartridge's `hooks.json` take the arguments +of the `HookMgr.callHook(...)` calls that reach them, literal or prefixed +(`'app.payment.processor.' + id`), and those calls return what the hooks return +([src/resolver/hook-registry.ts](./src/resolver/hook-registry.ts) reads the +registrations, as JSON only). The result is surfaced as an "Inferred from usage" hover note plus synthesized member completions; call sites that disagree show a union of up to three types. It only kicks in where the checker has already given up (`any`, or a diff --git a/packages/b2c-script-types/plugin/inference/hook-calls.js b/packages/b2c-script-types/plugin/inference/hook-calls.js index 5b612191c..183843ff2 100644 --- a/packages/b2c-script-types/plugin/inference/hook-calls.js +++ b/packages/b2c-script-types/plugin/inference/hook-calls.js @@ -10,6 +10,8 @@ exports.hookImplementations = hookImplementations; const constants_1 = require("./constants"); const ast_helpers_1 = require("./ast-helpers"); const call_sites_1 = require("./call-sites"); +const member_values_1 = require("./member-values"); +const usage_profile_1 = require("./usage-profile"); // Where the Script API declares `HookMgr.callHook`, whichever copy of the dw // types a project loads. const HOOK_MANAGER_FILE = '/dw/system/HookMgr.d.ts'; @@ -54,11 +56,21 @@ function literalPrefix(ctx, expr) { return undefined; return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); } +/** What a local variable `expr` names always holds: its initializer, when nothing else is assigned to it. */ +function soleValue(ctx, expr) { + const { ts } = ctx; + const declaration = ts.isIdentifier(expr) ? (0, member_values_1.valueDeclarationOf)(ctx, expr) : undefined; + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) + return undefined; + return (0, usage_profile_1.usageProfileOf)(ctx, declaration).assignedValues.length === 0 ? declaration.initializer : undefined; +} +/** The extension points `expr` names, read through one local variable (`var hookName = 'app.payment.' + id`). */ function extensionPointPattern(ctx, expr) { - const name = stringValue(ctx, expr); + const value = soleValue(ctx, expr) ?? expr; + const name = stringValue(ctx, expr) ?? stringValue(ctx, value); if (name !== undefined) return { text: name, isPrefix: false }; - const prefix = literalPrefix(ctx, expr); + const prefix = literalPrefix(ctx, value); return prefix ? { text: prefix, isPrefix: true } : undefined; } /** True when `call` invokes the Script API's `HookMgr.callHook`, through whatever name it is required as. */ diff --git a/packages/b2c-script-types/src/inference/hook-calls.ts b/packages/b2c-script-types/src/inference/hook-calls.ts index 5e5a20265..df74b0951 100644 --- a/packages/b2c-script-types/src/inference/hook-calls.ts +++ b/packages/b2c-script-types/src/inference/hook-calls.ts @@ -21,6 +21,8 @@ import {MAX_REFERENCES_PER_CALL} from './constants'; import type {InferenceContext} from './context'; import {exportsObjectWrittenBy, getNodeAtPosition, propertyAccessNamedBy} from './ast-helpers'; import {findReferences} from './call-sites'; +import {valueDeclarationOf} from './member-values'; +import {usageProfileOf} from './usage-profile'; import type {CallSite} from './value-flow'; // Where the Script API declares `HookMgr.callHook`, whichever copy of the dw @@ -81,10 +83,20 @@ function literalPrefix(ctx: InferenceContext, expr: tsserver.Expression): string return stringValue(ctx, expr.left) || literalPrefix(ctx, expr.left); } +/** What a local variable `expr` names always holds: its initializer, when nothing else is assigned to it. */ +function soleValue(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Expression | undefined { + const {ts} = ctx; + const declaration = ts.isIdentifier(expr) ? valueDeclarationOf(ctx, expr) : undefined; + if (!declaration || !ts.isVariableDeclaration(declaration) || !declaration.initializer) return undefined; + return usageProfileOf(ctx, declaration).assignedValues.length === 0 ? declaration.initializer : undefined; +} + +/** The extension points `expr` names, read through one local variable (`var hookName = 'app.payment.' + id`). */ function extensionPointPattern(ctx: InferenceContext, expr: tsserver.Expression): ExtensionPointPattern | undefined { - const name = stringValue(ctx, expr); + const value = soleValue(ctx, expr) ?? expr; + const name = stringValue(ctx, expr) ?? stringValue(ctx, value); if (name !== undefined) return {text: name, isPrefix: false}; - const prefix = literalPrefix(ctx, expr); + const prefix = literalPrefix(ctx, value); return prefix ? {text: prefix, isPrefix: true} : undefined; } diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 3c474294a..3319f4b2b 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -837,6 +837,27 @@ describe('usage-inference — hooks', () => { ), expected: 'Basket', }, + { + title: 'an extension point held in a local variable is read through it', + ...handleBasket(` + function handlePayment(processorId) { + var hookName = 'app.payment.processor.' + processorId.toLowerCase(); + return HookManager.callHook(hookName, 'Handle', getBasket()); + } + `), + expected: 'Basket', + }, + { + title: 'a variable assigned again may hold any extension point, so it reaches none', + ...handleBasket(` + function handlePayment(processorId, fallback) { + var hookName = 'app.payment.processor.' + processorId; + if (fallback) hookName = fallback; + return HookManager.callHook(hookName, 'Handle', getBasket()); + } + `), + expected: '', + }, { title: 'a call naming another function of the same hook passes this one nothing', ...handleBasket(`HookManager.callHook('app.payment.processor.basic_credit', 'Authorize', getBasket());`), diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js new file mode 100644 index 000000000..c61b5c723 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/checkout/paymentHelpers.js @@ -0,0 +1,19 @@ +'use strict'; + +var BasketMgr = require('dw/order/BasketMgr'); +var HookMgr = require('dw/system/HookMgr'); +var PaymentMgr = require('dw/order/PaymentMgr'); + +// The extension point is built at runtime from the processor ID, like SFRA's +// checkout helpers; its literal prefix reaches every registered processor. +function handlePayment(paymentMethodID) { + var currentBasket = BasketMgr.getCurrentBasket(); + var processor = PaymentMgr.getPaymentMethod(paymentMethodID).getPaymentProcessor(); + var hookName = 'app.payment.processor.' + processor.ID.toLowerCase(); + var paymentInstrument = HookMgr.callHook(hookName, 'Handle', currentBasket, paymentMethodID); + return paymentInstrument; +} + +module.exports = { + handlePayment: handlePayment, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js new file mode 100644 index 000000000..aeea40483 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/helpers/valueFlowHelpers.js @@ -0,0 +1,47 @@ +'use strict'; + +// Values followed to where they end up: a model constructor reached only +// through the factory that returns it, and a generic collection helper whose +// result depends on the collection one call passes. Integration tests locate +// positions via indexOf — keep shapes stable. + +var BasketMgr = require('dw/order/BasketMgr'); +var ProductMgr = require('dw/catalog/ProductMgr'); +var collections = require('~/cartridge/scripts/util/collections'); + +function ProductTileModel(source) { + this.id = source.ID; +} + +function getTileModel() { + return ProductTileModel; +} + +function buildTile() { + var TileModel = getTileModel(); + return new TileModel(ProductMgr.getProduct('some-id')); +} + +function getShipmentByUUID(basket, uuid) { + return collections.find(basket.shipments, function (candidate) { + return candidate.UUID === uuid; + }); +} + +function getLineItemByUUID(basket, uuid) { + return collections.find(basket.productLineItems, function (candidate) { + return candidate.UUID === uuid; + }); +} + +function getShippingAddress(uuid) { + var currentBasket = BasketMgr.getCurrentBasket(); + var shipment = getShipmentByUUID(currentBasket, uuid); + var lineItem = getLineItemByUUID(currentBasket, uuid); + return shipment && lineItem ? shipment.shippingAddress : null; +} + +module.exports = { + buildTile: buildTile, + getShippingAddress: getShippingAddress, +}; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json new file mode 100644 index 000000000..e77f30654 --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks.json @@ -0,0 +1,8 @@ +{ + "hooks": [ + { + "name": "app.payment.processor.basic_credit", + "script": "./hooks/payment/basicCredit" + } + ] +} diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js new file mode 100644 index 000000000..d13e5662b --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/hooks/payment/basicCredit.js @@ -0,0 +1,13 @@ +'use strict'; + +// A payment processor hook. Nothing calls Handle by name: the platform +// dispatches HookMgr.callHook('app.payment.processor.', 'Handle', ...) +// here through the hooks.json this cartridge's package.json names, so +// `container` takes the arguments of the callHook call in +// scripts/checkout/paymentHelpers.js. Its own usage alone fits any +// dw.order.LineItemCtnr. +function Handle(container, paymentMethodID) { + return container.createPaymentInstrument(paymentMethodID, container.totalGrossPrice); +} + +exports.Handle = Handle; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/cartridge/scripts/util/collections.js index da77a47b5..7821a4739 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 @@ -18,7 +18,23 @@ function first(collection) { return iterator.hasNext() ? iterator.next() : null; } +// Stock SFRA shape: the matching element, or null. Its result depends on the +// collection each call passes, so inference re-reads it per call. +function find(collection, match) { + var result = null; + var iterator = collection.iterator(); + while (iterator.hasNext()) { + var item = iterator.next(); + if (match(item)) { + result = item; + break; + } + } + return result; +} + module.exports = { forEach: forEach, first: first, + find: find, }; diff --git a/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json new file mode 100644 index 000000000..ded154b6a --- /dev/null +++ b/packages/b2c-vs-extension/src/test/fixtures/infer-usage-workspace/cartridges/test_cartridge/package.json @@ -0,0 +1,4 @@ +{ + "name": "test_cartridge", + "hooks": "./cartridge/scripts/hooks.json" +} diff --git a/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts b/packages/b2c-vs-extension/src/test/integration/script-types-infer-usage.test.ts index 1185d9b16..9ded2af3e 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 @@ -852,3 +852,72 @@ suite('scriptTypesInferUsage — .call() inheritance, typed API uses, and union assert.ok(/Product \| Category/.test(text), `expected the Product | Category union, got: ${text}`); }); }); + +suite('scriptTypesInferUsage — factory-returned models, call-specific helpers, and hooks', () => { + let flowDoc: vscode.TextDocument; + let hookDoc: vscode.TextDocument; + + suiteSetup(async function () { + this.timeout(30000); + + const expectedRoot = fixtureFile(); + const openRoots = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + if (!openRoots.includes(expectedRoot)) { + this.skip(); + } + + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext, `extension ${EXTENSION_ID} must be discoverable in the test host`); + await ext!.activate(); + + flowDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'helpers', 'valueFlowHelpers.js'), + ), + ); + hookDoc = await vscode.workspace.openTextDocument( + vscode.Uri.file( + fixtureFile('cartridges', 'test_cartridge', 'cartridge', 'scripts', 'hooks', 'payment', 'basicCredit.js'), + ), + ); + await vscode.window.showTextDocument(flowDoc); + }); + + test('infers Product for a constructor reached only through the factory that returns it', async () => { + // Nothing calls ProductTileModel by name: `getTileModel()` returns it and + // `new TileModel(product)` constructs it. + const text = await hoverTextMatching( + flowDoc, + offsetPosition(flowDoc, 'function ProductTileModel(source', 'function ProductTileModel('.length), + /Product/, + true, + ); + assert.ok(/Product/.test(text), `expected Product through the factory, got: ${text}`); + }); + + test('infers Shipment for what collections.find returns for the collection this call passes', async () => { + // Across its callers `find` returns a Shipment or a ProductLineItem; for + // getShipmentByUUID's call with `basket.shipments` it is a Shipment. + const text = await hoverTextMatching( + flowDoc, + offsetPosition(flowDoc, 'var shipment = getShipmentByUUID', 'var '.length), + /Shipment/, + true, + ); + assert.ok(/Shipment/.test(text), `expected Shipment for this call, got: ${text}`); + assert.ok(!/ProductLineItem/.test(text), `the other caller's element type must not leak in: ${text}`); + }); + + test("infers Basket for a hook function's parameter from the HookMgr.callHook call hooks.json routes to it", async () => { + // Its own usage fits any LineItemCtnr; only the prefixed callHook call in + // scripts/checkout/paymentHelpers.js, matched through the cartridge's + // package.json -> hooks.json, says it is a Basket. + const text = await hoverTextMatching( + hookDoc, + offsetPosition(hookDoc, 'function Handle(container', 'function Handle('.length), + /Basket/, + true, + ); + assert.ok(/Basket/.test(text), `expected Basket from the callHook call site, got: ${text}`); + }); +}); From be362c33537478d43502adaf93d3afcc20324a9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:57:08 +0000 Subject: [PATCH 61/76] perf(script-types): replace find-all-references with a scoped reference search Usage inference asked TypeScript's find-all-references for every name it followed. Its import tracker scales with the whole project, so on real cartridge stacks most of a hover's time went into reading files that cannot name the value at all. src/inference/reference-search.ts reads only the files that can: - a name scoped to one file (a local helper, a parameter, a variable) is searched in that file only, the same scoping TypeScript applies; - a module's exports are searched in the module itself and in the files whose require() paths load it; - anything else (plain-script globals) is still read project-wide, with a per-file name prefilter before the checker confirms each hit. Matches are confirmed by symbol identity, through aliases, destructured requires and shorthand properties. Results are cached per Program, the search honors the host's cancellation token, and each uncached search is published on a diagnostics_channel so tests and tooling can count them. On the real cartridge corpus, total inference time drops 29 % (p95 44.9 ms to 31.7 ms, max 157 ms to 95 ms) and parameter precision and recall both rise slightly (one previously silent parameter now resolves). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 2 + packages/b2c-script-types/README.md | 6 + packages/b2c-script-types/plugin/index.js | 3 + .../plugin/inference/call-sites.js | 82 ++---- .../plugin/inference/constants.js | 33 ++- .../plugin/inference/hook-calls.js | 8 +- .../plugin/inference/reference-search.js | 235 +++++++++++++++ .../plugin/usage-inference.js | 1 + packages/b2c-script-types/src/index.ts | 3 + .../src/inference/call-sites.ts | 92 ++---- .../src/inference/constants.ts | 33 ++- .../b2c-script-types/src/inference/context.ts | 12 +- .../src/inference/hook-calls.ts | 13 +- .../src/inference/reference-search.ts | 277 ++++++++++++++++++ .../b2c-script-types/src/usage-inference.ts | 1 + .../test/eval/ground-truth.test.js | 58 ++-- .../test/helpers/reference-searches.js | 41 +++ .../test/reference-search.test.js | 189 ++++++++++++ .../test/usage-inference.hardening.test.js | 46 +-- .../test/usage-inference.perf.test.js | 141 +++++---- .../test/usage-inference.value-flow.test.js | 20 +- 21 files changed, 979 insertions(+), 317 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/reference-search.js create mode 100644 packages/b2c-script-types/src/inference/reference-search.ts create mode 100644 packages/b2c-script-types/test/helpers/reference-searches.js create mode 100644 packages/b2c-script-types/test/reference-search.test.js diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index f7db21099..61a6fe21f 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -150,6 +150,8 @@ Two more SFRA idioms are covered: Cross-file inference (call sites in other files, `module.superModule`) needs those files in the same TypeScript project. A `jsconfig.json` that includes all cartridge sources — like the one `b2c setup ide vscode-types` generates — provides that; without one, each open file gets its own inferred project and only same-file usage is visible. +Inference stays cheap on large cartridge stacks. A request reads only the files that can name the value it follows: a helper local to one file is looked up in that file, and a module's exports in that module and the files that `require()` it. Each hover or completion runs a bounded number of such searches, and is dropped as soon as the editor cancels it (for example, when you keep typing). + Inferred results are heuristic and clearly labeled: - Hover text gets an appended `Inferred from usage: ` line. diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index 2a762c328..930d5eee7 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -86,3 +86,9 @@ and applies one decision policy ([src/inference/policy.ts](./src/inference/polic everywhere, so parameter, member and chain hovers and completions always agree. [src/usage-inference.ts](./src/usage-inference.ts) is the barrel and lists the modules in reading order. + +A request reads only the files that can name the value it follows +([src/inference/reference-search.ts](./src/inference/reference-search.ts)): +the declaring file for a file-local helper, and the module plus the files that +`require()` it for an export. It runs a bounded number of searches and stops +when the editor cancels it. diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index f18eaedd2..40d6aa041 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -360,6 +360,9 @@ function init({ typescript: ts }) { languageService: info.languageService, resolveSuperModulePath, hookRegistrations, + // The project's own token: tsserver flags it when the editor cancels + // the request this hover or completion belongs to. + isCancellationRequested: () => host.getCancellationToken?.().isCancellationRequested() ?? false, log, }); usageInferenceResets.add(usageInference.reset); diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index a2792c1fc..cc0ecb953 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -5,43 +5,23 @@ * 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.findReferences = findReferences; exports.collectCallSites = collectCallSites; exports.forwardedParameter = forwardedParameter; const constants_1 = require("./constants"); const ast_helpers_1 = require("./ast-helpers"); const bindings_1 = require("./bindings"); const member_values_1 = require("./member-values"); +const reference_search_1 = require("./reference-search"); const type_helpers_1 = require("./type-helpers"); const value_flow_1 = require("./value-flow"); -// Reference searches are what an inference request spends its time on, and -// a Program never changes, so their results are kept for as long as the -// Program lives: hovering one parameter after another in an unchanged -// project runs each search once. Entries are plain file names and spans, so -// they pin no checker. A search served from here still spends the request's -// search budget, so a request reaches the same call sites, and the same -// answer, whether or not an earlier request warmed the cache. -const referencesByProgram = new WeakMap(); -/** Every reference to `name` across the project, from the per-Program cache. Spends no budget: callers do. */ -function findReferences(ctx, name) { - let cache = referencesByProgram.get(ctx.program); - if (!cache) { - cache = new Map(); - referencesByProgram.set(ctx.program, cache); - } - let references = cache.get(name); - if (!references) { - const sourceFile = name.getSourceFile(); - references = ctx.languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; - cache.set(name, references); - } - return references; -} /** * True when `name` declares a parameter or a variable inside a function. - * TypeScript scopes a reference search on such a local to its function, so - * it reads one file instead of scanning the program: it is not charged to - * ctx.searchBudget, though each of its hits still spends ctx.referenceBudget. + * Such a search reads that function only (see ./reference-search) and can be + * reached only through a hop from a search that was charged, so it is not + * charged to ctx.searchBudget, though each of its hits still spends + * ctx.referenceBudget. Every other search spends one unit, even one confined + * to its own file: the budget bounds how far a request follows values from + * helper to helper, not only how many files it reads. */ function isFunctionLocal(ts, name) { const declaration = name.parent; @@ -54,13 +34,28 @@ function frontierKey({ name, role }) { const sourceFile = name.getSourceFile(); return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } +/** + * True when `next` is searched now: it was not searched before in this walk + * and the request can still pay for it. Records the search and spends its + * unit of ctx.searchBudget, when it costs one. + */ +function claimSearch(ctx, next, searched) { + const key = frontierKey(next); + const charged = !isFunctionLocal(ctx.ts, next.name); + if (searched.has(key) || (charged && ctx.searchBudget <= 0)) + return false; + searched.add(key); + if (charged) + ctx.searchBudget--; + return true; +} /** * Finds actual call sites for `nameNode`, following up to * MAX_REFERENCE_HOPS names the function value flows into (see ./value-flow: * require() bindings, exports, aliases, factories returning it, parameters * it is passed to) when a reference doesn't sit directly in callee * position. Stops early once ctx.referenceBudget (result count) or - * ctx.searchBudget (project scans) runs out, returning whatever call sites + * ctx.searchBudget (searches) runs out, returning whatever call sites * were already found rather than continuing to fan out — an under-inferred * (but still heuristic, clearly-labeled) result beats hanging on a * widely-referenced helper. Results are memoized per name node for the @@ -71,7 +66,7 @@ function collectCallSites(ctx, nameNode) { if (memoized) return memoized; const calls = []; - const seenNameKeys = new Set(); + const searched = new Set(); let frontier = [{ name: nameNode, role: 'value' }]; 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++) { @@ -79,12 +74,9 @@ function collectCallSites(ctx, nameNode) { for (const next of frontier) { if (localBudget <= 0) break; - const key = frontierKey(next); - const searchable = ctx.searchBudget > 0 || isFunctionLocal(ctx.ts, next.name); - if (seenNameKeys.has(key) || !searchable) - continue; - seenNameKeys.add(key); - localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + if (claimSearch(ctx, next, searched)) { + localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + } } frontier = nextFrontier; } @@ -94,20 +86,17 @@ function collectCallSites(ctx, nameNode) { /** * Runs one reference search for `next.name` and sorts each hit into either a * resolved call site (pushed to `calls`, once per call) or a further name to - * chase on the next hop (pushed to `nextFrontier`). Consumes one unit of the - * shared search budget unless the name is function-local, and up to + * chase on the next hop (pushed to `nextFrontier`). Consumes up to * `localBudget` result slots, returning the remaining local budget so the * caller can stop fanning out once it's exhausted. */ function collectCallsFromName(ctx, next, calls, nextFrontier, localBudget) { - if (!isFunctionLocal(ctx.ts, next.name)) - ctx.searchBudget--; - for (const reference of findReferences(ctx, next.name)) { + for (const reference of (0, reference_search_1.searchReferences)(ctx, next.name)) { if (localBudget <= 0) break; localBudget--; ctx.referenceBudget--; - const target = referenceTarget(ctx, reference, next.role); + const target = (0, value_flow_1.valueTarget)(ctx, reference, next.role); if (target?.kind === 'name') nextFrontier.push(target); else if (target && !calls.some((site) => site.node === target.call.node)) @@ -115,17 +104,6 @@ function collectCallsFromName(ctx, next, calls, nextFrontier, localBudget) { } return localBudget; } -/** - * Where one reference search hit leads (see valueTarget()). Definition sites - * (the declaration itself) lead nowhere new; neither does a hit on a whole - * module (the source file itself, which has no parent — e.g. a - * `module.exports` reference landing in a file's leading comment). - */ -function referenceTarget(ctx, reference, role) { - const file = ctx.program.getSourceFile(reference.fileName); - const node = file && (0, ast_helpers_1.getNodeAtPosition)(file, ctx.ts, reference.textSpan.start); - return node?.parent ? (0, value_flow_1.valueTarget)(ctx, node, role) : undefined; -} /** * The caller's own parameter an argument passes on as is (`items` in * `getMatchingProducts(productId, items)` inside a function taking `items`), diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 06c0e4fdc..546f30757 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -33,11 +33,12 @@ exports.MAX_REFERENCE_HOPS = 4; // 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). +// get processed and how far the search fans out, but a single search still +// checks every occurrence of the name in the files that can refer to it — +// on a large project the dominant cost is that first search, and the real +// bound on it is cancellation: the search polls the host's cancellation +// token between files and throws TypeScript's OperationCanceledException, +// which the plugin's `guarded` wrapper rethrows, never swallows. exports.MAX_REFERENCES_PER_REQUEST = 200; // Caps how much of that shared request-wide budget a *single* collectCallSites // call can spend, so one widely-referenced sub-helper (e.g. reached from the @@ -57,16 +58,18 @@ exports.MAX_CHAIN_HOPS = 10; // -> 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. +// Hard cap on how many reference SEARCHES one top-level request may issue +// (one for a name declared inside a function is not counted: it reads that +// function only). This is a different axis from MAX_REFERENCES_PER_REQUEST, +// which only bounds how many search *results* get processed: every search +// costs its reads and checks even when it returns almost nothing, so a +// helper whose call sites feed it results of many DISTINCT sub-helpers (each +// searched once, each contributing only 2-3 results) drains the result +// budget at ~2-3 per search — measured at 76 searches ≈ 115ms for a single +// hover on an SFRA-sized program (~1,900 cartridge files) before this cap +// existed. Legitimate scenarios in the perf baseline suite need at most 6 +// searches; 12 doubles that headroom while keeping the worst case at ~12 +// searches per request. exports.MAX_SEARCHES_PER_REQUEST = 12; // Bounds the editor's per-Program cache of finished hovers and completion // lists during a long session without edits (hours of hovering around one diff --git a/packages/b2c-script-types/plugin/inference/hook-calls.js b/packages/b2c-script-types/plugin/inference/hook-calls.js index 183843ff2..16de6b0c5 100644 --- a/packages/b2c-script-types/plugin/inference/hook-calls.js +++ b/packages/b2c-script-types/plugin/inference/hook-calls.js @@ -9,8 +9,8 @@ exports.hookCallSites = hookCallSites; exports.hookImplementations = hookImplementations; const constants_1 = require("./constants"); const ast_helpers_1 = require("./ast-helpers"); -const call_sites_1 = require("./call-sites"); const member_values_1 = require("./member-values"); +const reference_search_1 = require("./reference-search"); const usage_profile_1 = require("./usage-profile"); // Where the Script API declares `HookMgr.callHook`, whichever copy of the dw // types a project loads. @@ -109,9 +109,7 @@ function callHookName(ctx) { /** The callHook call a reference to `callHook` is the callee of. */ function hookCallAt(ctx, reference) { const { ts } = ctx; - const file = ctx.program.getSourceFile(reference.fileName); - const node = file && !file.isDeclarationFile ? (0, ast_helpers_1.getNodeAtPosition)(file, ts, reference.textSpan.start) : undefined; - const access = node && (0, ast_helpers_1.propertyAccessNamedBy)(node, ts); + const access = (0, ast_helpers_1.propertyAccessNamedBy)(reference, ts); const call = access?.parent; return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; } @@ -123,7 +121,7 @@ function hookCalls(ctx) { const searchable = name !== undefined && ctx.searchBudget > 0; if (searchable) ctx.searchBudget--; - calls = searchable ? (0, call_sites_1.findReferences)(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + calls = searchable ? (0, reference_search_1.searchReferences)(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; hookCallsByRequest.set(ctx, calls); } return calls; diff --git a/packages/b2c-script-types/plugin/inference/reference-search.js b/packages/b2c-script-types/plugin/inference/reference-search.js new file mode 100644 index 000000000..0d32bf842 --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/reference-search.js @@ -0,0 +1,235 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.searchReferences = searchReferences; +// Reference searches, sized for the question inference asks: where is this +// name's value used? TypeScript's find-all-references answers a wider one, +// and pays for it: for any exported name it first maps every import of the +// whole program, work that grows with the project rather than with the name, +// and a single hover may search a dozen names. Inference follows imports +// itself, one hop at a time (see ./value-flow), so a search here only reads +// the files that contain the name, confirming each occurrence with the +// checker; and a name nothing outside its file can refer to (a function's or +// a CommonJS module's own variable) is searched in that one file. +// +// Each search that actually runs (not one served from the cache) is +// published on the `@salesforce/b2c-script-types:reference-search` +// diagnostics channel as `{name, projectWide}`: a deterministic cost measure +// for tests and diagnostics, free when nothing subscribes. +const node_diagnostics_channel_1 = __importDefault(require("node:diagnostics_channel")); +const ast_helpers_1 = require("./ast-helpers"); +const searchChannel = node_diagnostics_channel_1.default.channel('@salesforce/b2c-script-types:reference-search'); +// An alias is followed this many links at most (`var {helper} = require()` +// -> `exports.helper = helper` -> the function): real chains are two or +// three links, and the cap keeps a pathological re-export chain bounded. +const MAX_ALIAS_LINKS = 4; +// TypeScript reuses an unchanged file's SourceFile from one Program to the +// next, so a scanned table survives edits elsewhere in the project. +const scannedNames = new WeakMap(); +// A Program never changes, so neither does a search's answer: kept for as +// long as the Program lives, hovering one name after another in an unchanged +// project runs each search once. +const referencesByProgram = new WeakMap(); +/** + * The identifier names `file` contains. The parser records exactly this table + * on every SourceFile (`identifiers`, which TypeScript's own reference search + * reads); it is not public API, so it is used only when it has the expected + * shape, and otherwise rebuilt with the public scanner. + */ +function namesIn(ts, file) { + const parsed = file.identifiers; + if (parsed instanceof Map) + return parsed; + let names = scannedNames.get(file); + if (!names) { + const scanned = new Set(); + const scanner = ts.createScanner(file.languageVersion, true, file.languageVariant, file.text); + for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) { + if (kind === ts.SyntaxKind.Identifier) + scanned.add(scanner.getTokenValue()); + } + names = scanned; + scannedNames.set(file, names); + } + return names; +} +/** The identifiers spelled `text` in `file`, found where its text spells them. */ +function identifiersNamed(ts, file, text) { + const found = []; + for (let pos = file.text.indexOf(text); pos >= 0; pos = file.text.indexOf(text, pos + text.length)) { + const node = (0, ast_helpers_1.getNodeAtPosition)(file, ts, pos); + if (node && ts.isIdentifier(node) && node.text === text && node.getStart(file) === pos) + found.push(node); + } + return found; +} +/** + * The symbols `name` stands for. A `{helper}` shorthand is both a property + * and a use of the `helper` value; the checker names the property. + */ +function symbolsAt(ctx, name) { + const { ts, checker } = ctx; + const symbol = checker.getSymbolAtLocation(name); + const value = ts.isShorthandPropertyAssignment(name.parent) + ? checker.getShorthandAssignmentValueSymbol(name.parent) + : undefined; + return [symbol, value].filter((s) => s !== undefined); +} +/** `symbol` and the symbols it was instantiated, merged or widened from. */ +function withRoots(ctx, symbol) { + return [symbol, ...ctx.checker.getRootSymbols(symbol)]; +} +/** + * What a name found by the search may stand for: its own symbols, and every + * link of an alias chain they start (`var {helper} = require('./helpers')` -> + * `exports.helper = helper` -> the `helper` function). + */ +function identitiesOf(ctx, name) { + const { ts, checker } = ctx; + return symbolsAt(ctx, name).flatMap((symbol) => { + const links = []; + let link = symbol; + for (let hops = 0; link && hops < MAX_ALIAS_LINKS; hops++) { + links.push(...withRoots(ctx, link)); + link = link.flags & ts.SymbolFlags.Alias ? checker.getImmediateAliasedSymbol(link) : undefined; + } + return links; + }); +} +/** True when a search for `target` reads every file of the project: for its name, or for the require() calls of its module. */ +function readsProject(target) { + return target.homeFile === undefined || target.module !== undefined; +} +/** The module `symbol` is the exports of: `exports` in `module.exports = ...`, or the module symbol itself. */ +function moduleOf(ctx, symbol) { + const { ts } = ctx; + if (symbol.valueDeclaration && ts.isSourceFile(symbol.valueDeclaration)) + return symbol.valueDeclaration; + return symbol.escapedName === ts.InternalSymbolName.ExportEquals + ? symbol.declarations?.[0]?.getSourceFile() + : undefined; +} +/** True when `declaration` introduces a name into the scope around it, as opposed to a member of some object. */ +function declaresScopedName(ts, declaration) { + return (ts.isVariableDeclaration(declaration) || + ts.isBindingElement(declaration) || + ts.isParameter(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isClassDeclaration(declaration) || + ts.isClassExpression(declaration)); +} +/** + * The file `symbol` is visible in alone, when nothing elsewhere can name it: + * a variable, parameter, function or class declared inside a function, or at + * the top level of a CommonJS module. A CommonJS module exports only by + * assignment (`module.exports.helper = helper`), and that assignment is a + * reference in the same file, which inference follows on from. Undefined for + * a member (an `exports.helper` included), a global of a plain script, and an + * ES module export. + */ +function homeFileOf(ctx, symbol) { + const { ts, checker } = ctx; + const declarations = symbol.declarations ?? []; + const file = declarations[0]?.getSourceFile(); + if (!file || file.isDeclarationFile || declarations.some((d) => d.getSourceFile() !== file)) + return undefined; + if (!declarations.every((declaration) => declaresScopedName(ts, declaration))) + return undefined; + if (checker.getExportSymbolOfSymbol(symbol) !== symbol) + return undefined; + const isGlobal = checker.resolveName(symbol.name, undefined, ts.SymbolFlags.Value, false) === symbol; + return isGlobal ? undefined : file; +} +/** The one file all of `symbols` are visible in alone, or undefined when any file may refer to one of them. */ +function sharedHomeFile(ctx, symbols) { + const files = new Set(symbols.map((symbol) => homeFileOf(ctx, symbol))); + const [file] = files; + return files.size === 1 ? file : undefined; +} +function searchTargetOf(ctx, name) { + const own = symbolsAt(ctx, name); + const symbols = new Set(own.flatMap((symbol) => withRoots(ctx, symbol))); + const declarations = new Set([...symbols].flatMap((symbol) => symbol.declarations ?? [])); + const module = own.map((symbol) => moduleOf(ctx, symbol)).find((file) => file !== undefined); + return { name, symbols, declarations, homeFile: module ?? sharedHomeFile(ctx, own), module }; +} +/** True when `candidate` stands for what `target` names: the same symbol, directly or through an alias. */ +function standsFor(ctx, candidate, target) { + return identitiesOf(ctx, candidate).some((symbol) => target.symbols.has(symbol) || + (symbol.declarations ?? []).some((declaration) => target.declarations.has(declaration))); +} +/** The require() module paths `file` contains: the parser's own list of them, else the `require('…')` calls found by name. */ +function modulePathsIn(ctx, file) { + const { ts } = ctx; + const imports = file.imports; + if (Array.isArray(imports) && imports.every((node) => ts.isStringLiteralLike(node))) + return imports; + return identifiersNamed(ts, file, 'require').flatMap((callee) => { + const call = callee.parent; + const path = ts.isCallExpression(call) && call.expression === callee ? call.arguments[0] : undefined; + return path && ts.isStringLiteralLike(path) ? [path] : []; + }); +} +/** The name a module path must contain to load `module`: `productHelpers`, or `models` for models/index.js. */ +function loadedAs(module) { + const segments = module.fileName.replace(/\.[^./]+$/, '').split('/'); + const last = segments.pop() ?? ''; + return last === 'index' ? (segments.pop() ?? last) : last; +} +/** The module paths in `files` that load `module`. */ +function modulePathsLoading(ctx, module, files) { + const text = loadedAs(module); + return files.flatMap((file) => modulePathsIn(ctx, file).filter((path) => path.text.includes(text) && ctx.checker.getSymbolAtLocation(path)?.valueDeclaration === module)); +} +function throwIfCancelled(ctx) { + if (ctx.host.isCancellationRequested?.()) + throw new ctx.ts.OperationCanceledException(); +} +function runSearch(ctx, target) { + const { ts, program } = ctx; + const text = target.name.text; + const projectFiles = program.getSourceFiles().filter((file) => !file.isDeclarationFile); + const references = []; + for (const file of target.homeFile ? [target.homeFile] : projectFiles) { + if (!namesIn(ts, file).has(text)) + continue; + throwIfCancelled(ctx); + references.push(...identifiersNamed(ts, file, text).filter((candidate) => standsFor(ctx, candidate, target))); + } + if (target.module) + references.push(...modulePathsLoading(ctx, target.module, projectFiles)); + return references; +} +/** + * Every reference to what `name` declares or refers to: each identifier the + * checker resolves to the same symbol, directly or through an alias, and for + * a module's `exports`, the require() calls that load the module. A name + * that is visible in one file alone is searched in that file only; nothing + * in a declaration file is a use. Cached per Program; spends no budget, the + * caller does. + */ +function searchReferences(ctx, name) { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + throwIfCancelled(ctx); + const target = searchTargetOf(ctx, name); + if (searchChannel.hasSubscribers) + searchChannel.publish({ name: name.text, projectWide: readsProject(target) }); + references = runSearch(ctx, target); + cache.set(name, references); + } + return references; +} diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 6e5f3b8d4..1ea0c2679 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -18,6 +18,7 @@ exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.u // inference/ast-helpers - pure AST navigation (find node, return exprs, ...) // inference/type-helpers - Type utilities + hover text / completion entries // inference/value-flow - where a function value goes: calls, exports, factories, parameters +// inference/reference-search - where a name is used, read from the files that can name it // inference/call-sites - find where a function is called across the project // inference/signatures - the function a call invokes and its declared callback types // inference/generic-calls - generic calls whose result is an argument or a callback's return diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 7e426d2a1..2860f3680 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -417,6 +417,9 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { languageService: info.languageService, resolveSuperModulePath, hookRegistrations, + // The project's own token: tsserver flags it when the editor cancels + // the request this hover or completion belongs to. + isCancellationRequested: () => host.getCancellationToken?.().isCancellationRequested() ?? false, log, }); usageInferenceResets.add(usageInference.reset); diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 848016a3d..349370b64 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -20,40 +20,13 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {MAX_REFERENCE_HOPS, MAX_REFERENCES_PER_CALL} from './constants'; import type {InferenceContext} from './context'; -import {getNodeAtPosition, hasExplicitParameterType} from './ast-helpers'; +import {hasExplicitParameterType} from './ast-helpers'; import {boundArgument} from './bindings'; import {valueDeclarationOf} from './member-values'; +import {searchReferences} from './reference-search'; import {informativeParts} from './type-helpers'; import {enclosingFunction, valueTarget} from './value-flow'; -import type {CallSite, ReferenceTarget, ValueRole} from './value-flow'; - -// Reference searches are what an inference request spends its time on, and -// a Program never changes, so their results are kept for as long as the -// Program lives: hovering one parameter after another in an unchanged -// project runs each search once. Entries are plain file names and spans, so -// they pin no checker. A search served from here still spends the request's -// search budget, so a request reaches the same call sites, and the same -// answer, whether or not an earlier request warmed the cache. -const referencesByProgram = new WeakMap< - tsserver.Program, - Map ->(); - -/** Every reference to `name` across the project, from the per-Program cache. Spends no budget: callers do. */ -export function findReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly tsserver.ReferenceEntry[] { - let cache = referencesByProgram.get(ctx.program); - if (!cache) { - cache = new Map(); - referencesByProgram.set(ctx.program, cache); - } - let references = cache.get(name); - if (!references) { - const sourceFile = name.getSourceFile(); - references = ctx.languageService.getReferencesAtPosition(sourceFile.fileName, name.getStart(sourceFile)) ?? []; - cache.set(name, references); - } - return references; -} +import type {CallSite, ValueRole} from './value-flow'; /** A name whose references are searched next, and how it holds the function. */ interface FrontierName { @@ -63,9 +36,12 @@ interface FrontierName { /** * True when `name` declares a parameter or a variable inside a function. - * TypeScript scopes a reference search on such a local to its function, so - * it reads one file instead of scanning the program: it is not charged to - * ctx.searchBudget, though each of its hits still spends ctx.referenceBudget. + * Such a search reads that function only (see ./reference-search) and can be + * reached only through a hop from a search that was charged, so it is not + * charged to ctx.searchBudget, though each of its hits still spends + * ctx.referenceBudget. Every other search spends one unit, even one confined + * to its own file: the budget bounds how far a request follows values from + * helper to helper, not only how many files it reads. */ function isFunctionLocal(ts: typeof tsserver, name: tsserver.Identifier): boolean { const declaration = name.parent; @@ -79,13 +55,27 @@ function frontierKey({name, role}: FrontierName): string { return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } +/** + * True when `next` is searched now: it was not searched before in this walk + * and the request can still pay for it. Records the search and spends its + * unit of ctx.searchBudget, when it costs one. + */ +function claimSearch(ctx: InferenceContext, next: FrontierName, searched: Set): boolean { + const key = frontierKey(next); + const charged = !isFunctionLocal(ctx.ts, next.name); + if (searched.has(key) || (charged && ctx.searchBudget <= 0)) return false; + searched.add(key); + if (charged) ctx.searchBudget--; + return true; +} + /** * Finds actual call sites for `nameNode`, following up to * MAX_REFERENCE_HOPS names the function value flows into (see ./value-flow: * require() bindings, exports, aliases, factories returning it, parameters * it is passed to) when a reference doesn't sit directly in callee * position. Stops early once ctx.referenceBudget (result count) or - * ctx.searchBudget (project scans) runs out, returning whatever call sites + * ctx.searchBudget (searches) runs out, returning whatever call sites * were already found rather than continuing to fan out — an under-inferred * (but still heuristic, clearly-labeled) result beats hanging on a * widely-referenced helper. Results are memoized per name node for the @@ -95,7 +85,7 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; const calls: CallSite[] = []; - const seenNameKeys = new Set(); + const searched = new Set(); let frontier: FrontierName[] = [{name: nameNode, role: 'value'}]; let localBudget = Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget); @@ -103,11 +93,9 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident const nextFrontier: FrontierName[] = []; for (const next of frontier) { if (localBudget <= 0) break; - const key = frontierKey(next); - const searchable = ctx.searchBudget > 0 || isFunctionLocal(ctx.ts, next.name); - if (seenNameKeys.has(key) || !searchable) continue; - seenNameKeys.add(key); - localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + if (claimSearch(ctx, next, searched)) { + localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + } } frontier = nextFrontier; } @@ -119,8 +107,7 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident /** * Runs one reference search for `next.name` and sorts each hit into either a * resolved call site (pushed to `calls`, once per call) or a further name to - * chase on the next hop (pushed to `nextFrontier`). Consumes one unit of the - * shared search budget unless the name is function-local, and up to + * chase on the next hop (pushed to `nextFrontier`). Consumes up to * `localBudget` result slots, returning the remaining local budget so the * caller can stop fanning out once it's exhausted. */ @@ -131,34 +118,17 @@ function collectCallsFromName( nextFrontier: FrontierName[], localBudget: number, ): number { - if (!isFunctionLocal(ctx.ts, next.name)) ctx.searchBudget--; - for (const reference of findReferences(ctx, next.name)) { + for (const reference of searchReferences(ctx, next.name)) { if (localBudget <= 0) break; localBudget--; ctx.referenceBudget--; - const target = referenceTarget(ctx, reference, next.role); + const target = valueTarget(ctx, reference, next.role); if (target?.kind === 'name') nextFrontier.push(target); else if (target && !calls.some((site) => site.node === target.call.node)) calls.push(target.call); } return localBudget; } -/** - * Where one reference search hit leads (see valueTarget()). Definition sites - * (the declaration itself) lead nowhere new; neither does a hit on a whole - * module (the source file itself, which has no parent — e.g. a - * `module.exports` reference landing in a file's leading comment). - */ -function referenceTarget( - ctx: InferenceContext, - reference: tsserver.ReferenceEntry, - role: ValueRole, -): ReferenceTarget | undefined { - const file = ctx.program.getSourceFile(reference.fileName); - const node = file && getNodeAtPosition(file, ctx.ts, reference.textSpan.start); - return node?.parent ? valueTarget(ctx, node, role) : undefined; -} - /** * The caller's own parameter an argument passes on as is (`items` in * `getMatchingProducts(productId, items)` inside a function taking `items`), diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index f47b61752..2beee55f7 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -35,11 +35,12 @@ export const MAX_REFERENCE_HOPS = 4; // 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). +// get processed and how far the search fans out, but a single search still +// checks every occurrence of the name in the files that can refer to it — +// on a large project the dominant cost is that first search, and the real +// bound on it is cancellation: the search polls the host's cancellation +// token between files and throws TypeScript's OperationCanceledException, +// which the plugin's `guarded` wrapper rethrows, never swallows. export const MAX_REFERENCES_PER_REQUEST = 200; // Caps how much of that shared request-wide budget a *single* collectCallSites @@ -63,16 +64,18 @@ export const MAX_CHAIN_HOPS = 10; // 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. +// Hard cap on how many reference SEARCHES one top-level request may issue +// (one for a name declared inside a function is not counted: it reads that +// function only). This is a different axis from MAX_REFERENCES_PER_REQUEST, +// which only bounds how many search *results* get processed: every search +// costs its reads and checks even when it returns almost nothing, so a +// helper whose call sites feed it results of many DISTINCT sub-helpers (each +// searched once, each contributing only 2-3 results) drains the result +// budget at ~2-3 per search — measured at 76 searches ≈ 115ms for a single +// hover on an SFRA-sized program (~1,900 cartridge files) before this cap +// existed. Legitimate scenarios in the perf baseline suite need at most 6 +// searches; 12 doubles that headroom while keeping the worst case at ~12 +// searches per request. export const MAX_SEARCHES_PER_REQUEST = 12; // Bounds the editor's per-Program cache of finished hovers and completion diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index e91c10b8e..057288987 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -37,6 +37,12 @@ export interface InferenceHost { * the answer per Program. */ readonly hookRegistrations?: (program: tsserver.Program) => readonly HookRegistration[]; + /** + * True once the editor has cancelled the request (the user kept typing). + * Reference searches poll it between files and throw TypeScript's + * OperationCanceledException, the way TypeScript's own searches do. + */ + readonly isCancellationRequested?: () => boolean; } interface MemoEntry { @@ -76,9 +82,9 @@ export interface InferenceContext { 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. + * collectCallSites() for every reference search it runs, except one for a + * name declared inside a function. See MAX_SEARCHES_PER_REQUEST for why + * this needs its own budget alongside the result-count one. */ searchBudget: number; /** diff --git a/packages/b2c-script-types/src/inference/hook-calls.ts b/packages/b2c-script-types/src/inference/hook-calls.ts index df74b0951..59100bc85 100644 --- a/packages/b2c-script-types/src/inference/hook-calls.ts +++ b/packages/b2c-script-types/src/inference/hook-calls.ts @@ -19,9 +19,10 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {MAX_REFERENCES_PER_CALL} from './constants'; import type {InferenceContext} from './context'; -import {exportsObjectWrittenBy, getNodeAtPosition, propertyAccessNamedBy} from './ast-helpers'; -import {findReferences} from './call-sites'; +import {exportsObjectWrittenBy, propertyAccessNamedBy} from './ast-helpers'; import {valueDeclarationOf} from './member-values'; +import {searchReferences} from './reference-search'; +import type {Reference} from './reference-search'; import {usageProfileOf} from './usage-profile'; import type {CallSite} from './value-flow'; @@ -133,11 +134,9 @@ function callHookName(ctx: InferenceContext): tsserver.Identifier | undefined { } /** The callHook call a reference to `callHook` is the callee of. */ -function hookCallAt(ctx: InferenceContext, reference: tsserver.ReferenceEntry): HookCall | undefined { +function hookCallAt(ctx: InferenceContext, reference: Reference): HookCall | undefined { const {ts} = ctx; - const file = ctx.program.getSourceFile(reference.fileName); - const node = file && !file.isDeclarationFile ? getNodeAtPosition(file, ts, reference.textSpan.start) : undefined; - const access = node && propertyAccessNamedBy(node, ts); + const access = propertyAccessNamedBy(reference, ts); const call = access?.parent; return call && ts.isCallExpression(call) && call.expression === access ? hookCallOf(ctx, call) : undefined; } @@ -149,7 +148,7 @@ function hookCalls(ctx: InferenceContext): readonly HookCall[] { const name = callHookName(ctx); const searchable = name !== undefined && ctx.searchBudget > 0; if (searchable) ctx.searchBudget--; - calls = searchable ? findReferences(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; + calls = searchable ? searchReferences(ctx, name).flatMap((reference) => hookCallAt(ctx, reference) ?? []) : []; hookCallsByRequest.set(ctx, calls); } return calls; diff --git a/packages/b2c-script-types/src/inference/reference-search.ts b/packages/b2c-script-types/src/inference/reference-search.ts new file mode 100644 index 000000000..7b3030205 --- /dev/null +++ b/packages/b2c-script-types/src/inference/reference-search.ts @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// Reference searches, sized for the question inference asks: where is this +// name's value used? TypeScript's find-all-references answers a wider one, +// and pays for it: for any exported name it first maps every import of the +// whole program, work that grows with the project rather than with the name, +// and a single hover may search a dozen names. Inference follows imports +// itself, one hop at a time (see ./value-flow), so a search here only reads +// the files that contain the name, confirming each occurrence with the +// checker; and a name nothing outside its file can refer to (a function's or +// a CommonJS module's own variable) is searched in that one file. +// +// Each search that actually runs (not one served from the cache) is +// published on the `@salesforce/b2c-script-types:reference-search` +// diagnostics channel as `{name, projectWide}`: a deterministic cost measure +// for tests and diagnostics, free when nothing subscribes. + +import diagnosticsChannel from 'node:diagnostics_channel'; + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import type {InferenceContext} from './context'; +import {getNodeAtPosition} from './ast-helpers'; + +/** A reference a search finds: a name, or the module path of a require() call that loads the searched module. */ +export type Reference = tsserver.Identifier | tsserver.StringLiteralLike; + +const searchChannel = diagnosticsChannel.channel('@salesforce/b2c-script-types:reference-search'); + +// An alias is followed this many links at most (`var {helper} = require()` +// -> `exports.helper = helper` -> the function): real chains are two or +// three links, and the cap keeps a pathological re-export chain bounded. +const MAX_ALIAS_LINKS = 4; + +/** The names a SourceFile contains: the parser's own table, or one scanned once per SourceFile. */ +interface NameTable { + has(name: string): boolean; +} + +// TypeScript reuses an unchanged file's SourceFile from one Program to the +// next, so a scanned table survives edits elsewhere in the project. +const scannedNames = new WeakMap(); + +// A Program never changes, so neither does a search's answer: kept for as +// long as the Program lives, hovering one name after another in an unchanged +// project runs each search once. +const referencesByProgram = new WeakMap>(); + +/** + * The identifier names `file` contains. The parser records exactly this table + * on every SourceFile (`identifiers`, which TypeScript's own reference search + * reads); it is not public API, so it is used only when it has the expected + * shape, and otherwise rebuilt with the public scanner. + */ +function namesIn(ts: typeof tsserver, file: tsserver.SourceFile): NameTable { + const parsed = (file as {identifiers?: unknown}).identifiers; + if (parsed instanceof Map) return parsed; + let names = scannedNames.get(file); + if (!names) { + const scanned = new Set(); + const scanner = ts.createScanner(file.languageVersion, true, file.languageVariant, file.text); + for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) { + if (kind === ts.SyntaxKind.Identifier) scanned.add(scanner.getTokenValue()); + } + names = scanned; + scannedNames.set(file, names); + } + return names; +} + +/** The identifiers spelled `text` in `file`, found where its text spells them. */ +function identifiersNamed(ts: typeof tsserver, file: tsserver.SourceFile, text: string): tsserver.Identifier[] { + const found: tsserver.Identifier[] = []; + for (let pos = file.text.indexOf(text); pos >= 0; pos = file.text.indexOf(text, pos + text.length)) { + const node = getNodeAtPosition(file, ts, pos); + if (node && ts.isIdentifier(node) && node.text === text && node.getStart(file) === pos) found.push(node); + } + return found; +} + +/** + * The symbols `name` stands for. A `{helper}` shorthand is both a property + * and a use of the `helper` value; the checker names the property. + */ +function symbolsAt(ctx: InferenceContext, name: tsserver.Identifier): tsserver.Symbol[] { + const {ts, checker} = ctx; + const symbol = checker.getSymbolAtLocation(name); + const value = ts.isShorthandPropertyAssignment(name.parent) + ? checker.getShorthandAssignmentValueSymbol(name.parent) + : undefined; + return [symbol, value].filter((s): s is tsserver.Symbol => s !== undefined); +} + +/** `symbol` and the symbols it was instantiated, merged or widened from. */ +function withRoots(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.Symbol[] { + return [symbol, ...ctx.checker.getRootSymbols(symbol)]; +} + +/** + * What a name found by the search may stand for: its own symbols, and every + * link of an alias chain they start (`var {helper} = require('./helpers')` -> + * `exports.helper = helper` -> the `helper` function). + */ +function identitiesOf(ctx: InferenceContext, name: tsserver.Identifier): tsserver.Symbol[] { + const {ts, checker} = ctx; + return symbolsAt(ctx, name).flatMap((symbol) => { + const links: tsserver.Symbol[] = []; + let link: tsserver.Symbol | undefined = symbol; + for (let hops = 0; link && hops < MAX_ALIAS_LINKS; hops++) { + links.push(...withRoots(ctx, link)); + link = link.flags & ts.SymbolFlags.Alias ? checker.getImmediateAliasedSymbol(link) : undefined; + } + return links; + }); +} + +/** What a search looks for: the searched name's symbols and their roots, and the declarations behind them. */ +interface SearchTarget { + readonly name: tsserver.Identifier; + readonly symbols: ReadonlySet; + readonly declarations: ReadonlySet; + /** The one file whose names can refer to the target, or undefined when any file's can. */ + readonly homeFile?: tsserver.SourceFile; + /** The module the target is the exports of (`module.exports`), whose require() calls are references too. */ + readonly module?: tsserver.SourceFile; +} + +/** True when a search for `target` reads every file of the project: for its name, or for the require() calls of its module. */ +function readsProject(target: SearchTarget): boolean { + return target.homeFile === undefined || target.module !== undefined; +} + +/** The module `symbol` is the exports of: `exports` in `module.exports = ...`, or the module symbol itself. */ +function moduleOf(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.SourceFile | undefined { + const {ts} = ctx; + if (symbol.valueDeclaration && ts.isSourceFile(symbol.valueDeclaration)) return symbol.valueDeclaration; + return symbol.escapedName === ts.InternalSymbolName.ExportEquals + ? symbol.declarations?.[0]?.getSourceFile() + : undefined; +} + +/** True when `declaration` introduces a name into the scope around it, as opposed to a member of some object. */ +function declaresScopedName(ts: typeof tsserver, declaration: tsserver.Declaration): boolean { + return ( + ts.isVariableDeclaration(declaration) || + ts.isBindingElement(declaration) || + ts.isParameter(declaration) || + ts.isFunctionDeclaration(declaration) || + ts.isFunctionExpression(declaration) || + ts.isClassDeclaration(declaration) || + ts.isClassExpression(declaration) + ); +} + +/** + * The file `symbol` is visible in alone, when nothing elsewhere can name it: + * a variable, parameter, function or class declared inside a function, or at + * the top level of a CommonJS module. A CommonJS module exports only by + * assignment (`module.exports.helper = helper`), and that assignment is a + * reference in the same file, which inference follows on from. Undefined for + * a member (an `exports.helper` included), a global of a plain script, and an + * ES module export. + */ +function homeFileOf(ctx: InferenceContext, symbol: tsserver.Symbol): tsserver.SourceFile | undefined { + const {ts, checker} = ctx; + const declarations = symbol.declarations ?? []; + const file = declarations[0]?.getSourceFile(); + if (!file || file.isDeclarationFile || declarations.some((d) => d.getSourceFile() !== file)) return undefined; + if (!declarations.every((declaration) => declaresScopedName(ts, declaration))) return undefined; + if (checker.getExportSymbolOfSymbol(symbol) !== symbol) return undefined; + const isGlobal = checker.resolveName(symbol.name, undefined, ts.SymbolFlags.Value, false) === symbol; + return isGlobal ? undefined : file; +} + +/** The one file all of `symbols` are visible in alone, or undefined when any file may refer to one of them. */ +function sharedHomeFile(ctx: InferenceContext, symbols: readonly tsserver.Symbol[]) { + const files = new Set(symbols.map((symbol) => homeFileOf(ctx, symbol))); + const [file] = files; + return files.size === 1 ? file : undefined; +} + +function searchTargetOf(ctx: InferenceContext, name: tsserver.Identifier): SearchTarget { + const own = symbolsAt(ctx, name); + const symbols = new Set(own.flatMap((symbol) => withRoots(ctx, symbol))); + const declarations = new Set([...symbols].flatMap((symbol) => symbol.declarations ?? [])); + const module = own.map((symbol) => moduleOf(ctx, symbol)).find((file) => file !== undefined); + return {name, symbols, declarations, homeFile: module ?? sharedHomeFile(ctx, own), module}; +} + +/** True when `candidate` stands for what `target` names: the same symbol, directly or through an alias. */ +function standsFor(ctx: InferenceContext, candidate: tsserver.Identifier, target: SearchTarget): boolean { + return identitiesOf(ctx, candidate).some( + (symbol) => + target.symbols.has(symbol) || + (symbol.declarations ?? []).some((declaration) => target.declarations.has(declaration)), + ); +} + +/** The require() module paths `file` contains: the parser's own list of them, else the `require('…')` calls found by name. */ +function modulePathsIn(ctx: InferenceContext, file: tsserver.SourceFile): readonly tsserver.StringLiteralLike[] { + const {ts} = ctx; + const imports = (file as {imports?: unknown}).imports; + if (Array.isArray(imports) && imports.every((node) => ts.isStringLiteralLike(node))) return imports; + return identifiersNamed(ts, file, 'require').flatMap((callee) => { + const call = callee.parent; + const path = ts.isCallExpression(call) && call.expression === callee ? call.arguments[0] : undefined; + return path && ts.isStringLiteralLike(path) ? [path] : []; + }); +} + +/** The name a module path must contain to load `module`: `productHelpers`, or `models` for models/index.js. */ +function loadedAs(module: tsserver.SourceFile): string { + const segments = module.fileName.replace(/\.[^./]+$/, '').split('/'); + const last = segments.pop() ?? ''; + return last === 'index' ? (segments.pop() ?? last) : last; +} + +/** The module paths in `files` that load `module`. */ +function modulePathsLoading( + ctx: InferenceContext, + module: tsserver.SourceFile, + files: readonly tsserver.SourceFile[], +): tsserver.StringLiteralLike[] { + const text = loadedAs(module); + return files.flatMap((file) => + modulePathsIn(ctx, file).filter( + (path) => path.text.includes(text) && ctx.checker.getSymbolAtLocation(path)?.valueDeclaration === module, + ), + ); +} + +function throwIfCancelled(ctx: InferenceContext): void { + if (ctx.host.isCancellationRequested?.()) throw new ctx.ts.OperationCanceledException(); +} + +function runSearch(ctx: InferenceContext, target: SearchTarget): Reference[] { + const {ts, program} = ctx; + const text = target.name.text; + const projectFiles = program.getSourceFiles().filter((file) => !file.isDeclarationFile); + const references: Reference[] = []; + for (const file of target.homeFile ? [target.homeFile] : projectFiles) { + if (!namesIn(ts, file).has(text)) continue; + throwIfCancelled(ctx); + references.push(...identifiersNamed(ts, file, text).filter((candidate) => standsFor(ctx, candidate, target))); + } + if (target.module) references.push(...modulePathsLoading(ctx, target.module, projectFiles)); + return references; +} + +/** + * Every reference to what `name` declares or refers to: each identifier the + * checker resolves to the same symbol, directly or through an alias, and for + * a module's `exports`, the require() calls that load the module. A name + * that is visible in one file alone is searched in that file only; nothing + * in a declaration file is a use. Cached per Program; spends no budget, the + * caller does. + */ +export function searchReferences(ctx: InferenceContext, name: tsserver.Identifier): readonly Reference[] { + let cache = referencesByProgram.get(ctx.program); + if (!cache) { + cache = new Map(); + referencesByProgram.set(ctx.program, cache); + } + let references = cache.get(name); + if (!references) { + throwIfCancelled(ctx); + const target = searchTargetOf(ctx, name); + if (searchChannel.hasSubscribers) searchChannel.publish({name: name.text, projectWide: readsProject(target)}); + references = runSearch(ctx, target); + cache.set(name, references); + } + return references; +} diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 2820fb174..66e28df5c 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -16,6 +16,7 @@ // inference/ast-helpers - pure AST navigation (find node, return exprs, ...) // inference/type-helpers - Type utilities + hover text / completion entries // inference/value-flow - where a function value goes: calls, exports, factories, parameters +// inference/reference-search - where a name is used, read from the files that can name it // inference/call-sites - find where a function is called across the project // inference/signatures - the function a call invokes and its declared callback types // inference/generic-calls - generic calls whose result is an argument or a callback's return diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index d9d82f85c..77639fcf9 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -36,9 +36,8 @@ // Parameters that carry no type at all in the original source are hovered // too, to measure how often inference fires on genuinely undocumented code. // Cost is reported as wall time and as reference searches per request, -// project-wide (budgeted by MAX_SEARCHES_PER_REQUEST) and single-file ones -// apart; the search count is the same on every machine and run, so compare -// that one. +// project-wide and single-file ones apart; the search count is the same on +// every machine and run, so compare that one. // // Optional: B2C_INFERENCE_CARTRIDGE_PATH (colon-separated cartridge names) // sets the cartridge order, B2C_INFERENCE_REPORT writes the full JSON report @@ -56,6 +55,7 @@ const {getReferenceNameNode} = require('../../src/inference/value-flow'); const {inferTypeForExpression} = require('../../src/inference/core'); const {createInferenceContext, describeTypes, inferReturnType, inferTypeForNode} = require('../../src/usage-inference'); const {createPluginProxy} = require('../helpers/plugin-proxy'); +const {countReferenceSearches} = require('../helpers/reference-searches'); const CORPUS = process.env.B2C_INFERENCE_CORPUS; const INFERRED_NOTE = 'Inferred from usage: '; @@ -124,42 +124,14 @@ function createCorpusHost(fileNames, currentDirectory) { return {host, setOverride}; } -/** The innermost node of `sourceFile` spanning `position`. */ -function nodeAt(sourceFile, position) { - let found = sourceFile; - const visit = (node) => { - if (node.getStart(sourceFile) <= position && position < node.getEnd()) { - found = node; - ts.forEachChild(node, visit); - } - }; - ts.forEachChild(sourceFile, visit); - return found; -} - -/** True when the name searched at `position` is a parameter or a variable declared inside a function (the plugin's own rule). */ -function isFunctionLocalAt(sourceFile, position) { - const declaration = sourceFile ? nodeAt(sourceFile, position).parent : undefined; - if (!declaration) return false; - if (ts.isParameter(declaration)) return true; - if (!ts.isVariableDeclaration(declaration) && !ts.isBindingElement(declaration)) return false; - return ts.findAncestor(declaration, ts.isFunctionLike) !== undefined; -} - function createCorpusPlugin(cartridges, fileNames) { const {host, setOverride} = createCorpusHost(fileNames, cartridges[0]?.src ?? '/'); const languageService = ts.createLanguageService(host); // Reference searches are what an inference request spends its time on; // counting them gives a cost measure that, unlike wall time, is the same - // on every machine and run. Searches for a name declared inside a function - // scan one file only and spend no search budget, so they are counted apart. - const searches = {count: 0, local: 0}; - const getReferencesAtPosition = languageService.getReferencesAtPosition.bind(languageService); - languageService.getReferencesAtPosition = (fileName, position) => { - if (isFunctionLocalAt(languageService.getProgram().getSourceFile(fileName), position)) searches.local++; - else searches.count++; - return getReferencesAtPosition(fileName, position); - }; + // on every machine and run. A search confined to the one file that can + // refer to its name is counted apart from one that reads the project. + const searches = countReferenceSearches(); const {proxy} = createPluginProxy({ host, languageService, @@ -394,13 +366,13 @@ const SEARCH_COUNTER = Symbol('searchCounter'); function timed(report, label, compute) { const counter = report[SEARCH_COUNTER]; - const searchesBefore = counter.count; - const localBefore = counter.local; + const searchesBefore = counter.projectWide(); + const localBefore = counter.local(); const start = process.hrtime.bigint(); const result = compute(); const ms = Number(process.hrtime.bigint() - start) / 1e6; - const searches = counter.count - searchesBefore; - const localSearches = counter.local - localBefore; + const searches = counter.projectWide() - searchesBefore; + const localSearches = counter.local() - localBefore; report.timings.push(ms); report.searches.push(searches); report.localSearches.push(localSearches); @@ -525,9 +497,13 @@ function summarize(report) { [SEARCH_COUNTER]: plugin.searches, }; - evaluateUndocumented(plugin.proxy, allTargets, report); - for (const targets of allTargets) { - if (targets.params.length > 0 || targets.returns.length > 0) evaluateFile(plugin, ancestry, targets, report); + try { + evaluateUndocumented(plugin.proxy, allTargets, report); + for (const targets of allTargets) { + if (targets.params.length > 0 || targets.returns.length > 0) evaluateFile(plugin, ancestry, targets, report); + } + } finally { + plugin.searches.stop(); } const summary = summarize(report); diff --git a/packages/b2c-script-types/test/helpers/reference-searches.js b/packages/b2c-script-types/test/helpers/reference-searches.js new file mode 100644 index 000000000..3f9410541 --- /dev/null +++ b/packages/b2c-script-types/test/helpers/reference-searches.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +'use strict'; + +const diagnosticsChannel = require('node:diagnostics_channel'); + +// Usage inference publishes every reference search it runs on this channel +// as `{name, projectWide}` (see src/inference/reference-search.ts). A search +// served from the per-Program cache is not a run and is not published. +const REFERENCE_SEARCH_CHANNEL = '@salesforce/b2c-script-types:reference-search'; + +/** + * Starts counting the reference searches usage inference runs: the + * deterministic cost measure the performance baselines assert on. `total()` + * counts every search, `projectWide()` those that read every file of the + * program, and `local()` those confined to the one file that can refer to + * the searched name. Call `stop()` when done. + */ +function countReferenceSearches() { + const counts = {projectWide: 0, local: 0}; + const onSearch = (message) => { + if (message.projectWide) counts.projectWide++; + else counts.local++; + }; + diagnosticsChannel.subscribe(REFERENCE_SEARCH_CHANNEL, onSearch); + return { + total: () => counts.projectWide + counts.local, + projectWide: () => counts.projectWide, + local: () => counts.local, + reset() { + counts.projectWide = 0; + counts.local = 0; + }, + stop: () => diagnosticsChannel.unsubscribe(REFERENCE_SEARCH_CHANNEL, onSearch), + }; +} + +module.exports = {countReferenceSearches}; diff --git a/packages/b2c-script-types/test/reference-search.test.js b/packages/b2c-script-types/test/reference-search.test.js new file mode 100644 index 000000000..8b14b5fa1 --- /dev/null +++ b/packages/b2c-script-types/test/reference-search.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 ts = require('typescript'); + +const {createInferenceContext} = require('../src/usage-inference'); +const {getNodeAtPosition} = require('../src/inference/ast-helpers'); +const {searchReferences} = require('../src/inference/reference-search'); +const {createFixtureHost, createFixtureLanguageService} = require('./helpers/fixture-language-service'); +const {countReferenceSearches} = require('./helpers/reference-searches'); + +/** The identifier `text` starts with, in `fileName` (`'helper(p)'` names the `helper` before `(p)`). */ +function nameAt(ctx, fileName, text) { + const file = ctx.program.getSourceFile(fileName); + const position = file.text.indexOf(text); + assert.ok(position >= 0, `${text} not found in ${fileName}`); + const node = getNodeAtPosition(file, ts, position); + assert.ok(ts.isIdentifier(node), `${text} does not start with an identifier`); + return node; +} + +/** Each reference as ` `, so a failure shows where it was found. */ +function describeReferences(references) { + return references + .map((node) => { + const context = ts.isShorthandPropertyAssignment(node.parent) ? node.parent.parent : node.parent; + return `${node.getSourceFile().fileName} ${context.getText()}`; + }) + .sort(); +} + +/** Runs one search on a fresh Program, counting the searches it runs. */ +function search(files, fileName, text, languageService = createFixtureLanguageService(files)) { + const searches = countReferenceSearches(); + try { + const ctx = createInferenceContext(ts, languageService); + const references = searchReferences(ctx, nameAt(ctx, fileName, text)); + return {references: describeReferences(references), projectWide: searches.projectWide(), runs: searches.total()}; + } finally { + searches.stop(); + } +} + +describe('reference search', () => { + it("reads only a CommonJS module's own file for its top-level function, and keeps a same-named one elsewhere out", () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nhelper(1);\nmodule.exports = {helper: helper};', + '/other.js': 'function helper(x) { return x; }\nhelper(2);\nmodule.exports = helper;', + }, + '/helpers.js', + 'helper(p)', + ); + + assert.deepEqual(result.references, [ + '/helpers.js function helper(p) { return p; }', + '/helpers.js helper(1)', + '/helpers.js helper: helper', + ]); + assert.equal(result.projectWide, 0, 'nothing outside a CommonJS module names its own function'); + }); + + it('reads the whole project for a global of a plain script', () => { + const result = search( + { + '/globals.js': 'function formatMoney(value) { return value; }', + '/page.js': "var util = require('./util');\nformatMoney(util.price);", + '/util.js': 'module.exports = {price: 1};', + }, + '/globals.js', + 'formatMoney(value)', + ); + + assert.deepEqual(result.references, [ + '/globals.js function formatMoney(value) { return value; }', + '/page.js formatMoney(util.price)', + ]); + assert.equal(result.projectWide, 1); + }); + + it("finds the require() calls that load a module through its `module.exports`, an index.js through its folder's name", () => { + const files = { + '/models/product.js': 'function Product() {}\nmodule.exports = Product;', + '/models/index.js': "module.exports = {product: require('./product')};", + '/productUtils.js': 'module.exports = {};', + '/a.js': "var Product = require('./models/product');\nnew Product();", + '/b.js': + "var P = require('./models/product');\nvar models = require('./models');\nvar utils = require('./productUtils');", + }; + + assert.deepEqual(search(files, '/models/product.js', 'exports = Product').references, [ + "/a.js require('./models/product')", + "/b.js require('./models/product')", + "/models/index.js require('./product')", + '/models/product.js module.exports', + ]); + assert.deepEqual(search(files, '/models/index.js', 'exports = {').references, [ + "/b.js require('./models')", + '/models/index.js module.exports', + ]); + }); + + it('reaches an export through the aliases that read it: a destructured require, a property access, a local call', () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nexports.helper = helper;', + '/c.js': "var {helper} = require('./helpers');\nvar h = require('./helpers');\nh.helper(1);\nhelper(2);", + }, + '/helpers.js', + 'helper = helper', + ); + + assert.deepEqual(result.references, [ + '/c.js h.helper', + '/c.js helper', + '/c.js helper(2)', + '/helpers.js exports.helper', + ]); + }); + + it('searches a shorthand export as both the property and the value it copies', () => { + const result = search( + { + '/helpers.js': 'function helper(p) { return p; }\nmodule.exports = {helper};', + '/c.js': "var h = require('./helpers');\nh.helper(1);", + }, + '/helpers.js', + 'helper}', + ); + + assert.deepEqual(result.references, [ + '/c.js h.helper', + '/helpers.js function helper(p) { return p; }', + '/helpers.js {helper}', + ]); + }); + + it('reports no uses in declaration files', () => { + const result = search( + { + '/types.d.ts': 'declare function getProduct(): {ID: string};', + '/a.js': 'getProduct();\nmodule.exports = {};', + }, + '/a.js', + 'getProduct()', + ); + + assert.deepEqual(result.references, ['/a.js getProduct()']); + }); + + it('runs each search once per Program, whichever request asks', () => { + const languageService = createFixtureLanguageService({ + '/helpers.js': 'function helper(p) { return p; }\nhelper(1);\nmodule.exports = {helper: helper};', + }); + const searches = countReferenceSearches(); + try { + const first = createInferenceContext(ts, languageService); + const second = createInferenceContext(ts, languageService); + const references = searchReferences(first, nameAt(first, '/helpers.js', 'helper(p)')); + + assert.equal(searchReferences(second, nameAt(second, '/helpers.js', 'helper(p)')), references); + assert.equal(searches.total(), 1); + } finally { + searches.stop(); + } + }); + + it("finds the same references when a SourceFile lacks the parser's name table", () => { + const files = { + '/helpers.js': 'function helper(p) { return p; }\nmodule.exports = {helper};', + '/c.js': "var h = require('./helpers');\nh.helper(1);", + }; + // A private registry: the SourceFiles altered here are shared with no other test. + const languageService = ts.createLanguageService(createFixtureHost(files)); + for (const file of languageService.getProgram().getSourceFiles()) delete file.identifiers; + + assert.deepEqual(search(files, '/helpers.js', 'helper}', languageService).references, [ + '/c.js h.helper', + '/helpers.js function helper(p) { return p; }', + '/helpers.js {helper}', + ]); + }); +}); diff --git a/packages/b2c-script-types/test/usage-inference.hardening.test.js b/packages/b2c-script-types/test/usage-inference.hardening.test.js index 231db2693..08adc6dd8 100644 --- a/packages/b2c-script-types/test/usage-inference.hardening.test.js +++ b/packages/b2c-script-types/test/usage-inference.hardening.test.js @@ -28,7 +28,7 @@ const {realTypesPrelude} = require('./helpers/real-dw-types'); describe('usage-inference hardening', () => { describe('mid-inference cancellation', () => { - it('rethrows OperationCanceledException raised inside getReferencesAtPosition', () => { + it('throws OperationCanceledException once the host cancels the request mid-search', () => { const files = { '/types.d.ts': 'declare function getProduct(): {ID: string};', '/helper.js': ` @@ -38,25 +38,14 @@ describe('usage-inference hardening', () => { 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); + let polls = 0; + // Cancelled from the second poll on: the search has started. + const host = {isCancellationRequested: () => ++polls > 1}; + const ctx = createInferenceContext(ts, createFixtureLanguageService(files), host); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); assert.throws(() => inferParameterType(ctx, fn.parameters[0]), ts.OperationCanceledException); + assert.ok(polls > 1, 'the search polls the host while it runs'); }); it('plugin guarded() rethrows cancellation from inference (does not degrade to empty hover)', () => { @@ -69,21 +58,9 @@ describe('usage-inference hardening', () => { `, }; 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 languageService = ts.createLanguageService(host, sharedDocumentRegistry); + const source = files['/helper.js']; + const paramPos = positionOf(source, 'product)'); const {proxy} = createPluginProxy({ host, languageService, @@ -93,8 +70,9 @@ describe('usage-inference hardening', () => { cartridges: [{name: 'test_cartridge', src: '/'}], }, }); - const source = files['/helper.js']; - const paramPos = positionOf(source, 'product)'); + // TypeScript read the host's token when the service was created, so the + // plain hover underneath still completes; inference polls it itself. + host.getCancellationToken = () => ({isCancellationRequested: () => true}); assert.throws(() => proxy.getQuickInfoAtPosition('/helper.js', paramPos), ts.OperationCanceledException); }); 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 9012800b2..e1a5043d1 100644 --- a/packages/b2c-script-types/test/usage-inference.perf.test.js +++ b/packages/b2c-script-types/test/usage-inference.perf.test.js @@ -17,6 +17,7 @@ const { sharedDocumentRegistry, } = require('./helpers/fixture-language-service'); const {createPluginProxy} = require('./helpers/plugin-proxy'); +const {countReferenceSearches} = require('./helpers/reference-searches'); const {realTypesPrelude} = require('./helpers/real-dw-types'); // --------------------------------------------------------------------------- @@ -24,9 +25,10 @@ const {realTypesPrelude} = require('./helpers/real-dw-types'); // // 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 +// far is reference searches (each checks every occurrence of a name across +// the files that can refer to it), so the STRICT baselines below are +// deterministic *counters* of how many searches a pathological input may +// trigger, read off the engine's diagnostics channel. Counters don't flake // on slow CI runners and pinpoint exactly which cap stopped working. // // The wall-clock ceilings are deliberately generous (they'd pass on a very @@ -104,25 +106,14 @@ const BASELINE = { hookParameters: 3, }; -/** - * 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)}; +// Every reference-search counter a test starts, stopped when it finishes. +const searchCounters = []; + +/** Counts the reference searches run from here on (see ./helpers/reference-searches). */ +function countSearches() { + const counter = countReferenceSearches(); + searchCounters.push(counter); + return counter; } /** @@ -150,6 +141,10 @@ function timed(fn) { } describe('usage-inference — performance baselines', () => { + afterEach(() => { + for (const counter of searchCounters.splice(0)) counter.stop(); + }); + it(`caps the cost of a widely-referenced helper (300 call sites, <= ${BASELINE.widelyReferencedHelper} searches)`, () => { const callSites = Array.from({length: 300}, () => 'helper(getProduct());').join('\n'); const files = { @@ -163,8 +158,8 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); @@ -173,17 +168,14 @@ describe('usage-inference — performance baselines', () => { // are more than enough to type this parameter. assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); assert.ok( - counter.referenceSearches() <= BASELINE.widelyReferencedHelper, - `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.referenceSearches()}`, + counter.total() <= BASELINE.widelyReferencedHelper, + `expected <= ${BASELINE.widelyReferencedHelper} reference searches, got ${counter.total()}`, ); // The per-call reference budget must actually engage (not short-circuit to // 0 searches / 0 hits while still somehow typing the parameter). const spent = 200 - ctx.referenceBudget; assert.equal(spent, 50, `expected the per-call cap (50) to fully engage on 300 call sites, spent ${spent}`); - assert.ok( - counter.referenceSearches() >= 1, - 'expected at least one project-wide reference search for a named helper', - ); + assert.ok(counter.total() >= 1, 'expected at least one reference search for a named helper'); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); @@ -202,15 +194,15 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/chain.js'), 'resolveChain'); const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); assert.equal(types.length, 0); assert.equal( - counter.referenceSearches(), + counter.total(), BASELINE.overlongMethodChain, 'the chain cap must fire before the receiver parameter is ever reference-searched', ); @@ -230,14 +222,14 @@ describe('usage-inference — performance baselines', () => { '/deep.js': `${helpers}\nmodule.exports = {h1};`, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/deep.js'), 'h1'); const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); - assert.equal(counter.referenceSearches(), BASELINE.nativeHelperChain); + assert.equal(counter.total(), BASELINE.nativeHelperChain); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); @@ -256,30 +248,30 @@ describe('usage-inference — performance baselines', () => { '/deep.js': `${helpers}\nf1(getProduct());\nmodule.exports = {f1};`, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); + const counter = countSearches(); // Return direction (hover on f1): the depth cap truncates before the // chain's deep end ever resolves a parameter, so no search happens. - const ctx = createInferenceContext(ts, counter.languageService); + const ctx = createInferenceContext(ts, base); const sourceFile = ctx.program.getSourceFile('/deep.js'); const {result: returnTypes, elapsedMs: returnMs} = timed(() => inferReturnType(ctx, findFunctionDeclaration(sourceFile, 'f1')), ); assert.equal(returnTypes.length, 0, 'truncated by the depth cap — flat cost regardless of chain length'); - assert.equal(counter.referenceSearches(), BASELINE.deepForwardingChainReturns); + assert.equal(counter.total(), BASELINE.deepForwardingChainReturns); assert.ok(returnMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(returnMs)}ms`); // Parameter direction (hover on f12's x): each level passes `x` on as is, // which costs no depth, so one search per level until the search budget // stops the climb. counter.reset(); - const ctx2 = createInferenceContext(ts, counter.languageService); + const ctx2 = createInferenceContext(ts, base); 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()}`, + counter.total() <= BASELINE.deepForwardingChainParam, + `expected <= ${BASELINE.deepForwardingChainParam} searches from the deep end, got ${counter.total()}`, ); assert.ok(paramMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(paramMs)}ms`); }); @@ -303,16 +295,16 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/fanout.js'), 'caller'); const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); assert.ok(describeTypes(ctx, types).includes('ID'), 'fan-out must still infer the shared type'); assert.ok( - counter.referenceSearches() <= BASELINE.wideFanOutMemoized, - `expected the request memo to collapse 20 branches into <= ${BASELINE.wideFanOutMemoized} searches, got ${counter.referenceSearches()}`, + counter.total() <= BASELINE.wideFanOutMemoized, + `expected the request memo to collapse 20 branches into <= ${BASELINE.wideFanOutMemoized} searches, got ${counter.total()}`, ); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); @@ -330,10 +322,10 @@ describe('usage-inference — performance baselines', () => { }; const host = createFixtureHost(files); const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); - const counter = withReferenceCounter(baseLs); + const counter = countSearches(); const {proxy} = createPluginProxy({ host, - languageService: counter.languageService, + languageService: baseLs, config: {enabled: true, autoDiscover: false, cartridges: [{name: 'c', src: '/'}], inferUsage: true}, }); const paramPos = files['/helper.js'].indexOf('product)'); @@ -346,7 +338,7 @@ describe('usage-inference — performance baselines', () => { assert.ok((second?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); assert.equal( - counter.referenceSearches(), + counter.total(), BASELINE.repeatedHoverCached, 'an unchanged project version must be served from the inference cache without re-searching', ); @@ -379,8 +371,8 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/tree.js'), 'hot'); const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); @@ -389,8 +381,8 @@ describe('usage-inference — performance baselines', () => { // enough to resolve the parameter's type. assert.equal(describeTypes(ctx, 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()}`, + counter.total() <= BASELINE.distinctSubHelperTree, + `expected the search budget to bound project scans at <= ${BASELINE.distinctSubHelperTree}, got ${counter.total()}`, ); // The scenario must genuinely pressure the cap — if it stops needing to, // it no longer guards anything and needs rebuilding. @@ -416,16 +408,16 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/pick.js'), 'pick'); const {result: types, elapsedMs} = timed(() => inferReturnType(ctx, fn)); assert.equal(describeTypes(ctx, types), '{ ID: string; } | { displayName: string; }'); assert.ok( - counter.referenceSearches() <= BASELINE.multiParamHelper, - `expected the call-site memo to dedupe sibling-parameter searches to <= ${BASELINE.multiParamHelper}, got ${counter.referenceSearches()}`, + counter.total() <= BASELINE.multiParamHelper, + `expected the call-site memo to dedupe sibling-parameter searches to <= ${BASELINE.multiParamHelper}, got ${counter.total()}`, ); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); }); @@ -441,20 +433,21 @@ describe('usage-inference — performance baselines', () => { `, '/consumer.js': `var helpers = require('./helper'); helpers.helper(getProduct());`, }; - const counter = withReferenceCounter(createFixtureLanguageService(files)); + const languageService = createFixtureLanguageService(files); + const counter = countSearches(); const infer = () => { - const ctx = createInferenceContext(ts, counter.languageService); + const ctx = createInferenceContext(ts, languageService); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'helper'); return {ctx, description: describeTypes(ctx, inferParameterType(ctx, fn.parameters[0]))}; }; const cold = infer(); - const coldSearches = counter.referenceSearches(); + const coldSearches = counter.total(); counter.reset(); const warm = infer(); assert.ok(coldSearches > 0, 'the first request must search for call sites'); - assert.equal(counter.referenceSearches(), BASELINE.repeatedRequestSameProgram); + assert.equal(counter.total(), BASELINE.repeatedRequestSameProgram); assert.equal(warm.description, '{ ID: string; }'); assert.equal(warm.description, cold.description); // A cached search still spends budget, so a warm request reaches exactly @@ -482,16 +475,16 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const counter = countSearches(); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/huge.js'), 'hotPrice'); const {result: types, elapsedMs} = timed(() => inferParameterType(ctx, fn.parameters[0])); assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; }'); assert.ok( - counter.referenceSearches() <= BASELINE.hugeGeneratedFile, - `expected <= ${BASELINE.hugeGeneratedFile} searches, got ${counter.referenceSearches()}`, + counter.total() <= BASELINE.hugeGeneratedFile, + `expected <= ${BASELINE.hugeGeneratedFile} searches, got ${counter.total()}`, ); const spent = 200 - ctx.referenceBudget; assert.ok(spent <= 50, `expected the per-call cap (50) to bound processed hits, spent ${spent}`); @@ -514,11 +507,11 @@ describe('usage-inference — performance baselines', () => { }; const host = createFixtureHost(files); const baseLs = ts.createLanguageService(host, sharedDocumentRegistry); - const counter = withReferenceCounter(baseLs); + const counter = countSearches(); let projectVersion = 1; const {proxy} = createPluginProxy({ host, - languageService: counter.languageService, + languageService: baseLs, projectVersion: () => String(projectVersion), config: {enabled: true, autoDiscover: false, cartridges: [{name: 'c', src: '/'}], inferUsage: true}, }); @@ -533,7 +526,7 @@ describe('usage-inference — performance baselines', () => { assert.ok((second?.documentation ?? []).some((p) => p.text.includes('Inferred from usage'))); assert.equal( - counter.referenceSearches(), + counter.total(), BASELINE.versionBumpSameProgram, 'a version bump with an unchanged program must be served from the inference cache', ); @@ -597,8 +590,7 @@ describe('usage-inference — performance baselines', () => { `, }; const base = createFixtureLanguageService(files); - const counter = withReferenceCounter(base); - const ctx = createInferenceContext(ts, counter.languageService); + const ctx = createInferenceContext(ts, base); const fn = findFunctionDeclaration(ctx.program.getSourceFile('/recursive.js'), 'a'); const {elapsedMs} = timed(() => inferReturnType(ctx, fn)); @@ -675,9 +667,10 @@ describe('usage-inference — performance baselines', () => { extensionPoint: `app.payment.processor.p${i}`, script: '/hooks/payment.js', })); - const counter = withReferenceCounter(createFixtureLanguageService(files)); + const languageService = createFixtureLanguageService(files); + const counter = countSearches(); let hostCalls = 0; - const ctx = createInferenceContext(ts, counter.languageService, { + const ctx = createInferenceContext(ts, languageService, { hookRegistrations: () => { hostCalls++; return registrations; @@ -692,8 +685,8 @@ describe('usage-inference — performance baselines', () => { ['Basket', 'Customer'], ); assert.ok( - counter.referenceSearches() <= BASELINE.hookParameters, - `expected <= ${BASELINE.hookParameters} reference searches, got ${counter.referenceSearches()}`, + counter.total() <= BASELINE.hookParameters, + `expected <= ${BASELINE.hookParameters} reference searches, got ${counter.total()}`, ); assert.equal(hostCalls, 1, 'the host is asked for its registrations once per request'); assert.ok(elapsedMs < WALL_CLOCK_CEILING_MS, `catastrophic slowdown: ${Math.round(elapsedMs)}ms`); diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 3319f4b2b..3f835020d 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -25,6 +25,7 @@ const { } = require('../src/usage-inference'); const {createFixtureLanguageService} = require('./helpers/fixture-language-service'); const {REAL_DW_TYPES, realTypesPrelude} = require('./helpers/real-dw-types'); +const {countReferenceSearches} = require('./helpers/reference-searches'); const CART_TYPES = realTypesPrelude( ['Basket', 'Collection', 'Product', 'ProductLineItem'], @@ -498,20 +499,19 @@ describe('usage-inference — constructors as values', () => { function run() { var render = show; render(getProduct()); } `, }); - let searches = 0; - const getReferencesAtPosition = languageService.getReferencesAtPosition.bind(languageService); - languageService.getReferencesAtPosition = (fileName, position) => { - searches++; - return getReferencesAtPosition(fileName, position); - }; + const searches = countReferenceSearches(); const ctx = createInferenceContext(ts, languageService); ctx.searchBudget = 1; - const types = inferParameterType(ctx, findTarget(ctx, '/helper.js', 'param', 'product')); + try { + const types = inferParameterType(ctx, findTarget(ctx, '/helper.js', 'param', 'product')); - assert.equal(describeTypes(ctx, types), 'Product'); - assert.equal(searches, 2, 'one search for show, one for the local render'); - assert.equal(ctx.searchBudget, 0, 'only the search for show is charged'); + assert.equal(describeTypes(ctx, types), 'Product'); + assert.equal(searches.total(), 2, 'one search for show, one for the local render'); + assert.equal(ctx.searchBudget, 0, 'only the search for show is charged'); + } finally { + searches.stop(); + } }); }); From 98996a497658a2923dc4163bb2f3efd3b8120d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:08:05 +0000 Subject: [PATCH 62/76] feat(script-types): infer what callbacks return for one call; read {Array} as a placeholder collections.map(basket.shipments, function (shipment) {...}) said nothing: SFRA documents map as `@returns {Array}`, which counted as a deliberate annotation, and the callback it calls is a parameter with no declaration of its own. Both are fixed: - A bare `{Array}`, `{Object[]}` or `{Array.<*>}` says no more about its elements than `{Object}` says about a value, so it no longer blocks inference (a deliberate `{any[]}` still does). - When a helper is inferred for one call, a call of a bound callback parameter (`callback(item)`, `callback.call(scope, item)`) returns what the function that call passes returns, with that function's parameters bound to these arguments, also through wrappers that pass it on. - A union across a helper's callers no longer outranks the answer for the call at hand: that call is one of the callers, and the union may have missed it when a budget ran out (SFRA's arrayHelper.find). On the real cartridges getShippingModels now returns ShippingModel[], sort comparators over mapped arrays see the mapped shape, and no previous answer regressed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 4 +- packages/b2c-script-types/README.md | 6 +- .../plugin/inference/ast-helpers.js | 24 ++- .../plugin/inference/bindings.js | 52 +++++- .../b2c-script-types/plugin/inference/core.js | 22 ++- .../plugin/inference/generic-calls.js | 11 +- .../plugin/inference/signatures.js | 9 + .../src/inference/ast-helpers.ts | 24 ++- .../src/inference/bindings.ts | 61 ++++++- .../b2c-script-types/src/inference/core.ts | 34 +++- .../src/inference/generic-calls.ts | 9 +- .../src/inference/signatures.ts | 11 ++ .../test/usage-inference.test.js | 30 ++++ .../test/usage-inference.value-flow.test.js | 163 +++++++++++++++++- 14 files changed, 403 insertions(+), 57 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 61a6fe21f..81daf16e9 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -127,7 +127,7 @@ 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 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. +JSDoc-documented functions get full hover/completion support when the annotation names a real Script API type (`@param {dw.customer.Customer}` / `@param {Customer}`), because TypeScript reads those directly — the same happy path the IntelliJ SFCC plugin relies on. Plain, undocumented helpers don't, and neither do the placeholder SFRA annotations that show up constantly in real cartridges (`@param {Object}`, `{obj}`, `{*}`, `{}`, and arrays of those such as `{Array}`): those widen to an uninformative type and silence completion for everything downstream. Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or pass `inferUsage: true` in the plugin config (`init_options.plugins` for other LSP hosts) to have the plugin infer a plausible type for these cases the way IntelliJ does: from how the value is actually used across the project, not from what it is called. Deliberate `@param {any}` / `: any` annotations are still respected and never second-guessed; real `dw.*` JSDoc is left alone too. @@ -136,7 +136,7 @@ The evidence it weighs: - **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). - **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. - **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. -- **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. +- **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. - **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. - **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. - **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; with no call site at all, the members are matched against every Script API class. diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index 930d5eee7..908ddda2c 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -70,8 +70,10 @@ to, and the members and `instanceof`/`typeof` checks in the helper's own body. Values are followed where they go — into object members, `this.x`, pushed arrays, factory returns and `module.exports` — and a generic helper such as `collections.find(basket.shipments, ...)` is inferred again for the call at -hand. Hook scripts registered in a cartridge's `hooks.json` take the arguments -of the `HookMgr.callHook(...)` calls that reach them, literal or prefixed +hand, together with the callback it is passed +(`collections.map(basket.shipments, fn)` is an array of what `fn` returns). +Hook scripts registered in a cartridge's `hooks.json` take the arguments of +the `HookMgr.callHook(...)` calls that reach them, literal or prefixed (`'app.payment.processor.' + id`), and those calls return what the hooks return ([src/resolver/hook-registry.ts](./src/resolver/hook-registry.ts) reads the registrations, as JSON only). diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index 73304351e..c0efacfec 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -69,13 +69,15 @@ function memberCompletionAccess(sourceFile, ts, position) { /** * SFRA helpers are often "documented" with a placeholder type that carries no * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also - * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`). 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. + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`), and + * arrays of those (`{Array}`, `{Object[]}`, `{Array.<*>}`), which say nothing + * about their elements. Those are ubiquitous in real cartridges (and IntelliJ + * mainly helps when authors write a real `dw.*` JSDoc), so treating them as + * deliberate annotations would permanently silence usage inference on the + * exact helpers that need it most. * * Deliberate `{any}` / `: any` is *not* weak: that is an author saying "do not - * pretend you know this type", and we still respect it. + * pretend you know this type", and we still respect it (`{any[]}` too). */ function isWeakTypeNode(typeNode, ts) { const node = unwrapTypeNode(typeNode, ts); @@ -84,6 +86,14 @@ function isWeakTypeNode(typeNode, ts) { const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); } + if (ts.isArrayTypeNode(node)) + return isWeakTypeNode(node.elementType, ts); + if (isArrayReference(node, ts)) + return node.typeArguments?.every((element) => isWeakTypeNode(element, ts)) ?? true; + return isPlaceholderTypeNode(node, ts); +} +/** One placeholder on its own: `{*}`, `{}`, `{object}`, `{Object}` or `{obj}`. */ +function isPlaceholderTypeNode(node, ts) { // JSDoc `{*}` — "any value", not a real shape. if (node.kind === ts.SyntaxKind.JSDocAllType) return true; @@ -108,6 +118,10 @@ function unwrapTypeNode(typeNode, ts) { } return node; } +/** `Array`, `Array` or JSDoc's `Array.`. */ +function isArrayReference(node, ts) { + return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Array'; +} /** `null` or `undefined` as a type. */ function isNullishTypeNode(node, ts) { if (node.kind === ts.SyntaxKind.UndefinedKeyword) diff --git a/packages/b2c-script-types/plugin/inference/bindings.js b/packages/b2c-script-types/plugin/inference/bindings.js index e75f836ff..169fc0417 100644 --- a/packages/b2c-script-types/plugin/inference/bindings.js +++ b/packages/b2c-script-types/plugin/inference/bindings.js @@ -9,9 +9,11 @@ exports.NO_BINDINGS = void 0; exports.withBindings = withBindings; exports.argumentBindings = argumentBindings; exports.boundArgument = boundArgument; +exports.boundCallback = boundCallback; exports.hasAnyTypeArgument = hasAnyTypeArgument; exports.narrows = narrows; const member_values_1 = require("./member-values"); +const signatures_1 = require("./signatures"); const type_helpers_1 = require("./type-helpers"); /** No parameter bound: what every inference outside a call-specific retry runs with. */ exports.NO_BINDINGS = new Map(); @@ -27,17 +29,17 @@ function withBindings(ctx, bindings, compute) { } } /** - * `fn`'s plain parameters bound to the arguments `call` passes them, on top of + * `fn`'s plain parameters bound to the arguments `args` of one call, on top of * the bindings already in force (a helper nested in a bound function still * sees its parameters). Arguments after a spread, rest parameters and * destructured parameters are left unbound; `undefined` when nothing binds. */ -function argumentBindings(ctx, fn, call, depth) { +function argumentBindings(ctx, fn, args, depth) { const { ts } = ctx; const outer = ctx.bindings; const bindings = new Map(outer); for (const [index, parameter] of fn.parameters.entries()) { - const argument = call.arguments[index]; + const argument = args[index]; if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) break; if (ts.isIdentifier(parameter.name)) @@ -53,6 +55,43 @@ function boundArgument(ctx, expr) { const declaration = (0, member_values_1.valueDeclarationOf)(ctx, expr); return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; } +/** + * The function the bound parameter `expr` holds: the function its argument + * is or names, followed through parameters each enclosing call passes on as + * is (`function mapAll(items, fn) { return collections.map(items, fn); }`). + * Each step moves to the bindings in force where that call was written, + * which hold fewer parameters, so the walk ends. + */ +function boundFunction(ctx, expr) { + let binding = boundArgument(ctx, expr); + while (binding) { + const fn = (0, signatures_1.functionOf)(ctx, binding.argument); + if (fn) + return fn; + const { argument, outer } = binding; + binding = withBindings(ctx, outer, () => boundArgument(ctx, argument)); + } + return undefined; +} +/** + * `call` read as a call of the function a bound parameter holds, with the + * arguments that function receives: `callback(item)`, or `callback.call(scope, + * item)` without its `this` argument. A `callback.apply(scope, args)` call + * binds none of them. + */ +function boundCallback(ctx, call) { + const { ts } = ctx; + const callee = call.expression; + const borrowed = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined; + if (borrowed !== undefined && borrowed !== 'call' && borrowed !== 'apply') + return undefined; + const fn = boundFunction(ctx, ts.isPropertyAccessExpression(callee) ? callee.expression : callee); + if (!fn) + return undefined; + if (borrowed === 'apply') + return { fn, args: [] }; + return { fn, args: borrowed === 'call' ? call.arguments.slice(1) : call.arguments }; +} /** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ function hasAnyTypeArgument(ctx, type) { const { ts, checker } = ctx; @@ -64,10 +103,9 @@ function hasAnyTypeArgument(ctx, type) { } /** * True when `types`, recovered for one call, may stand in for the `general` - * types the same expression has without it: when those say nothing, or when - * every recovered type is assignable to one of them (`Collection` - * for a parameter declared `{dw.util.Collection}`; `Shipment` out of a - * helper returning `Shipment | ProductLineItem` across its callers). + * types the checker gives the same expression without it: when those say + * nothing, or when every recovered type is assignable to one of them + * (`Collection` for a parameter declared `{dw.util.Collection}`). * Anything else is left alone, and TypeScript builds without the public * `isTypeAssignableTo` never narrow a type that says something. */ diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 6c1dc31d2..0b5b25654 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -265,15 +265,28 @@ function signatureReturnTypes(ctx, methodType, depth) { * What `fn` returns for this `call`. Its return across all callers, unless * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a * helper handed either collection): then what it returns for the arguments - * this call passes it (see ./bindings), wherever that is narrower. + * this call passes it (see ./bindings), when that says anything. A choice + * across callers is the union of what each of them gets, and this call is + * one of them, so its own answer is the more precise one, also where the + * union missed it (a caller the depth or reference budget left out). */ function calleeReturnTypes(ctx, fn, call, depth) { const general = inferReturnType(ctx, fn, depth + 1); if (general.length === 1 || (0, ast_helpers_1.hasExplicitReturnType)(fn, ctx.ts)) return general; - const bindings = (0, bindings_1.argumentBindings)(ctx, fn, call, depth); + const bindings = (0, bindings_1.argumentBindings)(ctx, fn, call.arguments, depth); const specific = bindings ? (0, bindings_1.withBindings)(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; - return (0, bindings_1.narrows)(ctx, general, specific) ? specific : general; + return specific.length > 0 ? specific : general; +} +/** + * What a call of a bound parameter returns (`callback(item)` inside + * `collections.map`, inferred for one call): what the function the call + * being resolved passes returns, with its parameters bound to these + * arguments. + */ +function boundCallbackReturnTypes(ctx, { fn, args }, depth) { + const bindings = (0, bindings_1.argumentBindings)(ctx, fn, args, depth) ?? ctx.bindings; + return (0, bindings_1.withBindings)(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)); } /** * Resolves an `any` call expression: first by inferring the callee's own @@ -282,6 +295,9 @@ function calleeReturnTypes(ctx, fn, call, depth) { * the receiver's inferred type(s). */ function resolveCallResultTypes(ctx, expr, depth, chainHops) { + const callback = ctx.bindings.size > 0 ? (0, bindings_1.boundCallback)(ctx, expr) : undefined; + if (callback) + return boundCallbackReturnTypes(ctx, callback, depth); const calleeFn = (0, signatures_1.resolveCalleeDeclaration)(ctx, expr); const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) diff --git a/packages/b2c-script-types/plugin/inference/generic-calls.js b/packages/b2c-script-types/plugin/inference/generic-calls.js index 6d0b23ff6..d70b1c5e3 100644 --- a/packages/b2c-script-types/plugin/inference/generic-calls.js +++ b/packages/b2c-script-types/plugin/inference/generic-calls.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.genericResultSource = genericResultSource; +const signatures_1 = require("./signatures"); /** True when `node` is a bare reference to the type parameter `name` (`T`). */ function isTypeParameterReference(ts, node, name) { return (node !== undefined && @@ -14,14 +15,6 @@ function isTypeParameterReference(ts, node, name) { node.typeName.text === name && node.typeArguments === undefined); } -/** The function `argument` is or names (`function () {...}`, `() => x`, a local function's identifier). */ -function functionOf(ctx, argument) { - const { ts, checker } = ctx; - if (ts.isFunctionExpression(argument) || ts.isArrowFunction(argument)) - return argument; - const declaration = ts.isIdentifier(argument) ? checker.getSymbolAtLocation(argument)?.valueDeclaration : undefined; - return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; -} /** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ function returnedTypeParameter(ts, declaration) { const result = declaration.type; @@ -49,7 +42,7 @@ function genericResultSource(ctx, call) { if (isTypeParameterReference(ts, type, typeParameter)) return { kind: 'value', argument }; const fn = ts.isFunctionTypeNode(type) && isTypeParameterReference(ts, type.type, typeParameter); - const callback = fn ? functionOf(ctx, argument) : undefined; + const callback = fn ? (0, signatures_1.functionOf)(ctx, argument) : undefined; if (callback) return { kind: 'return', fn: callback }; } diff --git a/packages/b2c-script-types/plugin/inference/signatures.js b/packages/b2c-script-types/plugin/inference/signatures.js index 40147546b..1a05f29d7 100644 --- a/packages/b2c-script-types/plugin/inference/signatures.js +++ b/packages/b2c-script-types/plugin/inference/signatures.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveCalleeDeclaration = resolveCalleeDeclaration; +exports.functionOf = functionOf; exports.isElementFirstCallbackCall = isElementFirstCallbackCall; exports.acceptsArgumentCount = acceptsArgumentCount; exports.callbackParameterTypes = callbackParameterTypes; @@ -25,6 +26,14 @@ function resolveCalleeDeclaration(ctx, call) { const sigDecl = checker.getResolvedSignature(call)?.declaration; return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } +/** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +function functionOf(ctx, value) { + const { ts, checker } = ctx; + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) + return value; + const declaration = ts.isIdentifier(value) ? checker.getSymbolAtLocation(value)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} /** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ function isElementFirstCallbackCall(ctx, call) { const { ts } = ctx; diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index 6571862b9..be9ca6624 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -77,13 +77,15 @@ export function memberCompletionAccess( /** * SFRA helpers are often "documented" with a placeholder type that carries no * Script API information — `@param {Object}`, `{obj}`, `{*}`, or `{}`, also - * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`). 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. + * when nullable or optional (`{Object|null}`, `{?Object}`, `{Object=}`), and + * arrays of those (`{Array}`, `{Object[]}`, `{Array.<*>}`), which say nothing + * about their elements. Those are ubiquitous in real cartridges (and IntelliJ + * mainly helps when authors write a real `dw.*` JSDoc), so treating them as + * deliberate annotations would permanently silence usage inference on the + * exact helpers that need it most. * * Deliberate `{any}` / `: any` is *not* weak: that is an author saying "do not - * pretend you know this type", and we still respect it. + * pretend you know this type", and we still respect it (`{any[]}` too). */ function isWeakTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): boolean { const node = unwrapTypeNode(typeNode, ts); @@ -92,6 +94,13 @@ function isWeakTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): boole const present = node.types.filter((member) => !isNullishTypeNode(member, ts)); return present.length > 0 && present.every((member) => isWeakTypeNode(member, ts)); } + if (ts.isArrayTypeNode(node)) return isWeakTypeNode(node.elementType, ts); + if (isArrayReference(node, ts)) return node.typeArguments?.every((element) => isWeakTypeNode(element, ts)) ?? true; + return isPlaceholderTypeNode(node, ts); +} + +/** One placeholder on its own: `{*}`, `{}`, `{object}`, `{Object}` or `{obj}`. */ +function isPlaceholderTypeNode(node: tsserver.TypeNode, ts: typeof tsserver): boolean { // JSDoc `{*}` — "any value", not a real shape. if (node.kind === ts.SyntaxKind.JSDocAllType) return true; // Empty object literal type `{}`. @@ -117,6 +126,11 @@ function unwrapTypeNode(typeNode: tsserver.TypeNode, ts: typeof tsserver): tsser return node; } +/** `Array`, `Array` or JSDoc's `Array.`. */ +function isArrayReference(node: tsserver.TypeNode, ts: typeof tsserver): node is tsserver.TypeReferenceNode { + return ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === 'Array'; +} + /** `null` or `undefined` as a type. */ function isNullishTypeNode(node: tsserver.TypeNode, ts: typeof tsserver): boolean { if (node.kind === ts.SyntaxKind.UndefinedKeyword) return true; diff --git a/packages/b2c-script-types/src/inference/bindings.ts b/packages/b2c-script-types/src/inference/bindings.ts index 099392839..b2c564baf 100644 --- a/packages/b2c-script-types/src/inference/bindings.ts +++ b/packages/b2c-script-types/src/inference/bindings.ts @@ -10,13 +10,16 @@ // ProductLineItem for `basket.productLineItems`. Across all of its callers // the helper's return says nothing, so ./core infers it once more for the call // being resolved, with the helper's parameters bound to that call's -// arguments. Neither IntelliJ nor the checker substitutes arguments into an -// undocumented JavaScript function this way. +// arguments. A callback argument is bound too: `collections.map(collection, +// callback)` pushes what `callback(item)` returns, which is what the function +// this call passes returns for that item. Neither IntelliJ nor the checker +// substitutes arguments into an undocumented JavaScript function this way. import type tsserver from 'typescript/lib/tsserverlibrary'; import type {InferenceContext} from './context'; import {valueDeclarationOf} from './member-values'; +import {functionOf} from './signatures'; import {isAnyType} from './type-helpers'; /** One parameter bound to the argument a single call passes it. */ @@ -46,8 +49,14 @@ export function withBindings(ctx: InferenceContext, bindings: Bindings, compu } } +/** A function a bound parameter holds, called with `args` (`callback(item)`, `callback.call(scope, item)`). */ +export interface BoundCallback { + readonly fn: tsserver.SignatureDeclaration; + readonly args: readonly tsserver.Expression[]; +} + /** - * `fn`'s plain parameters bound to the arguments `call` passes them, on top of + * `fn`'s plain parameters bound to the arguments `args` of one call, on top of * the bindings already in force (a helper nested in a bound function still * sees its parameters). Arguments after a spread, rest parameters and * destructured parameters are left unbound; `undefined` when nothing binds. @@ -55,14 +64,14 @@ export function withBindings(ctx: InferenceContext, bindings: Bindings, compu export function argumentBindings( ctx: InferenceContext, fn: tsserver.SignatureDeclaration, - call: tsserver.CallExpression, + args: readonly tsserver.Expression[], depth: number, ): Bindings | undefined { const {ts} = ctx; const outer = ctx.bindings; const bindings = new Map(outer); for (const [index, parameter] of fn.parameters.entries()) { - const argument = call.arguments[index]; + const argument = args[index]; if (!argument || ts.isSpreadElement(argument) || parameter.dotDotDotToken) break; if (ts.isIdentifier(parameter.name)) bindings.set(parameter, {argument, depth, outer}); } @@ -77,6 +86,41 @@ export function boundArgument(ctx: InferenceContext, expr: tsserver.Expression): return declaration && ts.isParameter(declaration) ? ctx.bindings.get(declaration) : undefined; } +/** + * The function the bound parameter `expr` holds: the function its argument + * is or names, followed through parameters each enclosing call passes on as + * is (`function mapAll(items, fn) { return collections.map(items, fn); }`). + * Each step moves to the bindings in force where that call was written, + * which hold fewer parameters, so the walk ends. + */ +function boundFunction(ctx: InferenceContext, expr: tsserver.Expression): tsserver.SignatureDeclaration | undefined { + let binding = boundArgument(ctx, expr); + while (binding) { + const fn = functionOf(ctx, binding.argument); + if (fn) return fn; + const {argument, outer} = binding; + binding = withBindings(ctx, outer, () => boundArgument(ctx, argument)); + } + return undefined; +} + +/** + * `call` read as a call of the function a bound parameter holds, with the + * arguments that function receives: `callback(item)`, or `callback.call(scope, + * item)` without its `this` argument. A `callback.apply(scope, args)` call + * binds none of them. + */ +export function boundCallback(ctx: InferenceContext, call: tsserver.CallExpression): BoundCallback | undefined { + const {ts} = ctx; + const callee = call.expression; + const borrowed = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined; + if (borrowed !== undefined && borrowed !== 'call' && borrowed !== 'apply') return undefined; + const fn = boundFunction(ctx, ts.isPropertyAccessExpression(callee) ? callee.expression : callee); + if (!fn) return undefined; + if (borrowed === 'apply') return {fn, args: []}; + return {fn, args: borrowed === 'call' ? call.arguments.slice(1) : call.arguments}; +} + /** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ export function hasAnyTypeArgument(ctx: InferenceContext, type: tsserver.Type): boolean { const {ts, checker} = ctx; @@ -87,10 +131,9 @@ export function hasAnyTypeArgument(ctx: InferenceContext, type: tsserver.Type): /** * True when `types`, recovered for one call, may stand in for the `general` - * types the same expression has without it: when those say nothing, or when - * every recovered type is assignable to one of them (`Collection` - * for a parameter declared `{dw.util.Collection}`; `Shipment` out of a - * helper returning `Shipment | ProductLineItem` across its callers). + * types the checker gives the same expression without it: when those say + * nothing, or when every recovered type is assignable to one of them + * (`Collection` for a parameter declared `{dw.util.Collection}`). * Anything else is left alone, and TypeScript builds without the public * `isTypeAssignableTo` never narrow a type that says something. */ diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 68fac2612..3c55cc850 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -25,8 +25,16 @@ import { hasExplicitReturnType, hasExplicitVariableType, } from './ast-helpers'; -import {NO_BINDINGS, argumentBindings, boundArgument, hasAnyTypeArgument, narrows, withBindings} from './bindings'; -import type {ArgumentBinding} from './bindings'; +import { + NO_BINDINGS, + argumentBindings, + boundArgument, + boundCallback, + hasAnyTypeArgument, + narrows, + withBindings, +} from './bindings'; +import type {ArgumentBinding, BoundCallback} from './bindings'; import {collectCallSites, forwardedParameter} from './call-sites'; import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; @@ -379,7 +387,10 @@ function signatureReturnTypes(ctx: InferenceContext, methodType: tsserver.Type, * What `fn` returns for this `call`. Its return across all callers, unless * that says nothing or leaves a choice (`Shipment | ProductLineItem` from a * helper handed either collection): then what it returns for the arguments - * this call passes it (see ./bindings), wherever that is narrower. + * this call passes it (see ./bindings), when that says anything. A choice + * across callers is the union of what each of them gets, and this call is + * one of them, so its own answer is the more precise one, also where the + * union missed it (a caller the depth or reference budget left out). */ function calleeReturnTypes( ctx: InferenceContext, @@ -389,9 +400,20 @@ function calleeReturnTypes( ): tsserver.Type[] { const general = inferReturnType(ctx, fn, depth + 1); if (general.length === 1 || hasExplicitReturnType(fn, ctx.ts)) return general; - const bindings = argumentBindings(ctx, fn, call, depth); + const bindings = argumentBindings(ctx, fn, call.arguments, depth); const specific = bindings ? withBindings(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)) : []; - return narrows(ctx, general, specific) ? specific : general; + return specific.length > 0 ? specific : general; +} + +/** + * What a call of a bound parameter returns (`callback(item)` inside + * `collections.map`, inferred for one call): what the function the call + * being resolved passes returns, with its parameters bound to these + * arguments. + */ +function boundCallbackReturnTypes(ctx: InferenceContext, {fn, args}: BoundCallback, depth: number): tsserver.Type[] { + const bindings = argumentBindings(ctx, fn, args, depth) ?? ctx.bindings; + return withBindings(ctx, bindings, () => inferReturnType(ctx, fn, depth + 1)); } /** @@ -406,6 +428,8 @@ function resolveCallResultTypes( depth: number, chainHops: number, ): tsserver.Type[] { + const callback = ctx.bindings.size > 0 ? boundCallback(ctx, expr) : undefined; + if (callback) return boundCallbackReturnTypes(ctx, callback, depth); const calleeFn = resolveCalleeDeclaration(ctx, expr); const inferred = calleeFn ? calleeReturnTypes(ctx, calleeFn, expr, depth) : []; if (inferred.length > 0) return inferred; diff --git a/packages/b2c-script-types/src/inference/generic-calls.ts b/packages/b2c-script-types/src/inference/generic-calls.ts index 44f0586cb..0f6d5654c 100644 --- a/packages/b2c-script-types/src/inference/generic-calls.ts +++ b/packages/b2c-script-types/src/inference/generic-calls.ts @@ -13,6 +13,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import type {InferenceContext} from './context'; +import {functionOf} from './signatures'; /** Where a generic call's result comes from: an argument itself, or what a function argument returns. */ export type GenericResultSource = @@ -30,14 +31,6 @@ function isTypeParameterReference(ts: typeof tsserver, node: tsserver.TypeNode | ); } -/** The function `argument` is or names (`function () {...}`, `() => x`, a local function's identifier). */ -function functionOf(ctx: InferenceContext, argument: tsserver.Expression): tsserver.SignatureDeclaration | undefined { - const {ts, checker} = ctx; - if (ts.isFunctionExpression(argument) || ts.isArrowFunction(argument)) return argument; - const declaration = ts.isIdentifier(argument) ? checker.getSymbolAtLocation(argument)?.valueDeclaration : undefined; - return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; -} - /** The type parameter a generic declaration returns as is (`T` of `wrap(...): T`). */ function returnedTypeParameter(ts: typeof tsserver, declaration: tsserver.SignatureDeclaration): string | undefined { const result = declaration.type; diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts index e103061cb..181e37630 100644 --- a/packages/b2c-script-types/src/inference/signatures.ts +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -35,6 +35,17 @@ export function resolveCalleeDeclaration( return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } +/** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ +export function functionOf( + ctx: InferenceContext, + value: tsserver.Expression, +): tsserver.SignatureDeclaration | undefined { + const {ts, checker} = ctx; + if (ts.isFunctionExpression(value) || ts.isArrowFunction(value)) return value; + const declaration = ts.isIdentifier(value) ? checker.getSymbolAtLocation(value)?.valueDeclaration : undefined; + return declaration && ts.isFunctionLike(declaration) ? declaration : undefined; +} + /** True when `call` invokes one of the {@link ELEMENT_FIRST_CALLBACK_CALLEES} (`collections.forEach(coll, fn)`). */ export function isElementFirstCallbackCall(ctx: InferenceContext, call: tsserver.CallExpression): boolean { const {ts} = ctx; diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index 8e573bd9a..a0222fb65 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -687,6 +687,36 @@ module.exports = getLineItems; }); } + // An array of placeholders says no more about its elements than the + // placeholder does; a deliberate `{any[]}` still blocks inference. + for (const [annotation, expected] of [ + ['Array', 'ProductLineItem[]'], + ['Object[]', 'ProductLineItem[]'], + ['Array.<*>', 'ProductLineItem[]'], + ['?Array', 'ProductLineItem[]'], + ['any[]', ''], + ['Array', ''], + ]) { + it(`${expected ? 'infers through' : 'respects'} @param {${annotation}}`, () => { + const languageService = createFixtureLanguageService({ + '/types.d.ts': realTypesPrelude(['ProductLineItem'], ' function getLineItems(): ProductLineItem[];'), + '/helper.js': ` + /** + * @param {${annotation}} lineItems + */ + function countItems(lineItems) { + return lineItems.length; + } + countItems(getLineItems()); + `, + }); + const ctx = createInferenceContext(ts, languageService); + const fn = findFunctionDeclaration(ctx.program.getSourceFile('/helper.js'), 'countItems'); + + assert.equal(describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])), expected); + }); + } + it('still respects a nullable real type mixed with a placeholder (`{Customer|Object|null}`)', () => { const languageService = createFixtureLanguageService({ '/types.d.ts': realTypesPrelude(['Customer'], ''), diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 3f835020d..9b7a06435 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -527,7 +527,7 @@ describe('usage-inference — call-specific returns', () => { } `; - // SFRA's collections.find and collections.first, verbatim but for comments. + // SFRA's collections.find, first and map, verbatim but for comments. const COLLECTIONS = ` /** * @param {dw.util.Collection} collection - Collection subclass instance to find value in @@ -556,7 +556,28 @@ describe('usage-inference — call-specific returns', () => { var iterator = collection.iterator(); return iterator.hasNext() ? iterator.next() : null; } - module.exports = {find: find, first: first}; + /** + * @param {dw.util.Collection} collection - Collection subclass instance to map over + * @param {Function} callback - Callback function for each item + * @param {Object} [scope] - Optional execution scope to pass to callback + * @returns {Array} Array of results of map + */ + function map(collection, callback, scope) { + var iterator = Object.hasOwnProperty.call(collection, 'iterator') + ? collection.iterator() + : collection; + var index = 0; + var item = null; + var result = []; + while (iterator.hasNext()) { + item = iterator.next(); + result.push(scope ? callback.call(scope, item, index, collection) + : callback(item, index, collection)); + index++; + } + return result; + } + module.exports = {find: find, first: first, map: map}; `; const callers = (source) => ({ @@ -678,6 +699,104 @@ describe('usage-inference — call-specific returns', () => { }, ]); + describe('callbacks', () => { + // What `callback(item)` returns inside a helper inferred for one call is + // what the function that call passes returns, for those arguments. + const helpers = (source) => ({'/collections.js': COLLECTIONS, '/helper.js': source}); + + runCases([ + { + title: 'collections.map returns an array of what its callback returns for each element', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function addresses(basket) { + return collections.map(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + kind: 'return', + name: 'addresses', + expected: 'OrderAddress[]', + }, + { + title: 'a model constructed in the callback makes an array of that model', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function ShippingModel(shipment) { this.uuid = shipment.UUID; } + function shippingModels(basket) { + return collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); }); + } + shippingModels(getBasket()); + `), + kind: 'return', + name: 'shippingModels', + expected: 'ShippingModel[]', + }, + { + title: 'a callback a wrapper passes on as is is the one called', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function mapShipments(basket, fn) { return collections.map(basket.shipments, fn); } + function addresses(basket) { + return mapShipments(basket, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + kind: 'return', + name: 'addresses', + expected: 'OrderAddress[]', + }, + { + title: 'the callback’s parameters are what it is called with', + types: COLLECTION_TYPES, + files: helpers(` + function applyTo(value, fn) { return fn(value); } + function defaultShipmentOf() { + return applyTo(getBasket(), function (basket) { return basket.defaultShipment; }); + } + `), + kind: 'return', + name: 'defaultShipmentOf', + expected: 'Shipment', + }, + { + title: 'callback.call(scope, x) passes x, not scope, as the first parameter', + types: COLLECTION_TYPES, + files: helpers(` + function withFirst(collection, fn) { + var iterator = collection.iterator(); + return fn.call(null, iterator.next()); + } + function firstAddress(basket) { + return withFirst(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + firstAddress(getBasket()); + `), + kind: 'return', + name: 'firstAddress', + expected: 'OrderAddress', + }, + { + title: 'collections.map itself, across all of its callers, stays silent', + types: COLLECTION_TYPES, + files: helpers(` + var collections = require('./collections'); + function addresses(basket) { + return collections.map(basket.shipments, function (shipment) { return shipment.shippingAddress; }); + } + addresses(getBasket()); + `), + file: '/collections.js', + kind: 'return', + name: 'map', + expected: '', + }, + ]); + }); + it('keeps call-specific results out of the request memo', () => { const languageService = createFixtureLanguageService({ '/types.d.ts': COLLECTION_TYPES, @@ -694,6 +813,46 @@ describe('usage-inference — call-specific returns', () => { assert.equal(returnOf('/helper.js', 'lineItemByUUID'), 'ProductLineItem'); assert.equal(ctx.bindings.size, 0, 'bindings are released after each call'); }); + + it('prefers the call’s own answer to a union across callers that missed it', () => { + // SFRA's arrayHelper.find. The reference budget runs out before the + // general answer reaches the last caller, so across its callers find + // returns `string | number`; the line item call still gets its element. + const languageService = createFixtureLanguageService({ + '/types.d.ts': COLLECTION_TYPES, + '/helper.js': ` + /** + * @param {Array} array - Array of elements to find the match in. + * @param {Array} matcher - function that returns true if match is found + * @return {Object|undefined} element that matches provided testing function or undefined. + */ + function find(array, matcher) { + for (var i = 0, l = array.length; i < l; i++) { + if (matcher(array[i], i)) { + return array[i]; + } + } + return undefined; + } + find(['a'], function (value) { return value === 'a'; }); + find([1], function (value) { return value === 1; }); + function lineItemByUUID(uuid) { + return find(getBasket().productLineItems.toArray(), function (item) { return item.UUID === uuid; }); + } + `, + }); + const ctx = createInferenceContext(ts, languageService); + ctx.referenceBudget = 3; + + assert.equal( + describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, '/helper.js', 'return', 'find'))), + 'string | number', + ); + assert.equal( + describeTypes(ctx, inferReturnType(ctx, findTarget(ctx, '/helper.js', 'return', 'lineItemByUUID'))), + 'ProductLineItem', + ); + }); }); describe('usage-inference — values handed down a chain of helpers', () => { From bf4300b6c8519ca4ae676f1a21f97d9a7a485584 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:18:33 +0000 Subject: [PATCH 63/76] feat(script-types): infer arrays of unrelated values as a union array An array filled with values of different classes (`[product, basket]`, or push() calls with each) was left silent. It now reads as an array of their union, decided by the same union policy as any other choice of types (at most three, else a shared superclass, else silent), and its elements flow into native array callbacks as either type. This replaces a wrong usage-only guess on real cartridges (a view-model array read as MediaFile) with the objects the code actually pushes. Unions inside arrays now render with the same class names as everywhere else (`(Product | Basket)[]`, not the checker's `Product`), and an array of booleans reads as `boolean[]`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 2 +- .../b2c-script-types/plugin/inference/core.js | 5 +- .../plugin/inference/type-helpers.js | 53 +++++++++++++----- .../b2c-script-types/src/inference/core.ts | 5 +- .../src/inference/type-helpers.ts | 55 +++++++++++++------ .../test/usage-inference.value-flow.test.js | 40 +++++++++++++- 6 files changed, 122 insertions(+), 38 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 81daf16e9..9d50d53a1 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -135,7 +135,7 @@ The evidence it weighs: - **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). - **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. -- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. +- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), a union when unrelated values were (`(Product | Basket)[]`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. - **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. - **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. - **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 0b5b25654..54201c2e7 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -180,14 +180,13 @@ function resolveElementAccessTypes(ctx, expr, depth, chainHops) { } return (0, type_helpers_1.dedupeTypes)(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); } -/** `[a, ...rest]`: an array of the one type its elements share; elements of unrelated types name no array. */ +/** `[a, ...rest]`: an array of what its elements are, decided like any other choice of types (see ./policy). */ function resolveArrayTypes(ctx, elements, depth, chainHops) { const { ts } = ctx; const elementTypes = elements.flatMap((element) => ts.isSpreadElement(element) ? resolveElementTypes(ctx, element.expression, depth, chainHops) : resolveExpressionTypes(ctx, element, depth, chainHops + 1)); - const [element, ...others] = (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, elementTypes)); - const array = element && others.length === 0 ? (0, type_helpers_1.arrayTypeOf)(ctx, element) : undefined; + const array = (0, type_helpers_1.arrayTypeOf)(ctx, (0, policy_1.limitUnion)(ctx, (0, policy_1.normalizeCandidates)(ctx, elementTypes))); return array ? [array] : []; } function resolveAlternatives(ctx, alternatives, depth, chainHops) { diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index 9ab790007..d799c6475 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -135,21 +135,38 @@ function classDisplayName(checker, symbol) { .replace(/^global\./, '') .replace(/^"[^"]*"\./, ''); } +/** + * True for a union shown as its members. The checker reads `true | false` + * back as `boolean` (an enum's members as the enum), so it renders those. + */ +function isDisplayedAsUnion(ts, type) { + const collapsed = ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral; + return type.isUnion() && !type.types.some((member) => member.flags & collapsed); +} +/** `Product[]`, or `(Product | Basket)[]` for an array of a union. */ +function arrayDisplayString(ts, checker, array) { + const [element] = checker.getTypeArguments(array); + if (!element) + return 'any[]'; + const display = computeTypeDisplayString(ts, checker, element); + return element.isUnion() && !(element.flags & ts.TypeFlags.Boolean) ? `(${display})[]` : `${display}[]`; +} /** * Renders a candidate type for hover text and dedupe keys. A class or * interface instantiated only with default type arguments reads as the bare * class name (`Product`, `LineItemCtnr`); one with real type arguments * renders them the same way (`Collection`, not the checker's - * `Collection>`), and an array reads as its element's - * display (`Product[]`). Everything else — primitives, object literals, lib - * types — is rendered by the checker as TypeScript itself would. + * `Collection>`), an array reads as its element's display + * (`Product[]`) and a union as its members' (`(Product | Basket)[]`). + * Everything else — primitives, object literals, lib types — is rendered by + * the checker as TypeScript itself would. */ function computeTypeDisplayString(ts, checker, type) { - if (checker.isArrayType(type)) { - const [element] = checker.getTypeArguments(type); - const display = element ? computeTypeDisplayString(ts, checker, element) : 'any'; - return element?.isUnion() ? `(${display})[]` : `${display}[]`; + if (isDisplayedAsUnion(ts, type)) { + return type.types.map((member) => computeTypeDisplayString(ts, checker, member)).join(' | '); } + if (checker.isArrayType(type)) + return arrayDisplayString(ts, checker, type); const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; if (!isClassOrInterface) @@ -251,14 +268,22 @@ function elementTypeOf(ctx, type, location) { return collectionElementType(ctx, type, location); } /** - * `element[]`, built by the checker itself so an inferred array reads, and - * resolves members (`filter`, `[0]`, `forEach`), exactly like a declared one. - * The factory is internal to TypeScript, so it is feature-detected: a - * TypeScript without it leaves inferred arrays silent rather than wrong. + * An array of `elements` (`ProductLineItem[]`, or `(OrderAddress | Shipment)[]` + * for an array filled with either), built by the checker itself so an + * inferred array reads, and resolves members (`filter`, `[0]`, `forEach`), + * exactly like a declared one. The factories are internal to TypeScript, so + * they are feature-detected: a TypeScript without them leaves inferred + * arrays silent rather than wrong. */ -function arrayTypeOf(ctx, element) { - const factory = ctx.checker; - return typeof factory.createArrayType === 'function' ? factory.createArrayType(element) : undefined; +function arrayTypeOf(ctx, elements) { + const factories = ctx.checker; + if (typeof factories.createArrayType !== 'function') + return undefined; + if (elements.length === 1) + return factories.createArrayType(elements[0]); + if (elements.length === 0 || typeof factories.getUnionType !== 'function') + return undefined; + return factories.createArrayType(factories.getUnionType([...elements])); } /** Renders candidate types as hover text, e.g. `"Product | Category"`. */ function describeTypes(ctx, types) { diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 3c55cc850..968442b27 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -266,7 +266,7 @@ function resolveElementAccessTypes( return dedupeTypes(ctx, resolveElementTypes(ctx, expr.expression, depth, chainHops)); } -/** `[a, ...rest]`: an array of the one type its elements share; elements of unrelated types name no array. */ +/** `[a, ...rest]`: an array of what its elements are, decided like any other choice of types (see ./policy). */ function resolveArrayTypes( ctx: InferenceContext, elements: readonly tsserver.Expression[], @@ -279,8 +279,7 @@ function resolveArrayTypes( ? resolveElementTypes(ctx, element.expression, depth, chainHops) : resolveExpressionTypes(ctx, element, depth, chainHops + 1), ); - const [element, ...others] = limitUnion(ctx, normalizeCandidates(ctx, elementTypes)); - const array = element && others.length === 0 ? arrayTypeOf(ctx, element) : undefined; + const array = arrayTypeOf(ctx, limitUnion(ctx, normalizeCandidates(ctx, elementTypes))); return array ? [array] : []; } diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 967217179..41989786c 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -137,21 +137,38 @@ function classDisplayName(checker: tsserver.TypeChecker, symbol: tsserver.Symbol .replace(/^"[^"]*"\./, ''); } +/** + * True for a union shown as its members. The checker reads `true | false` + * back as `boolean` (an enum's members as the enum), so it renders those. + */ +function isDisplayedAsUnion(ts: typeof tsserver, type: tsserver.Type): type is tsserver.UnionType { + const collapsed = ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral; + return type.isUnion() && !type.types.some((member) => member.flags & collapsed); +} + +/** `Product[]`, or `(Product | Basket)[]` for an array of a union. */ +function arrayDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, array: tsserver.TypeReference): string { + const [element] = checker.getTypeArguments(array); + if (!element) return 'any[]'; + const display = computeTypeDisplayString(ts, checker, element); + return element.isUnion() && !(element.flags & ts.TypeFlags.Boolean) ? `(${display})[]` : `${display}[]`; +} + /** * Renders a candidate type for hover text and dedupe keys. A class or * interface instantiated only with default type arguments reads as the bare * class name (`Product`, `LineItemCtnr`); one with real type arguments * renders them the same way (`Collection`, not the checker's - * `Collection>`), and an array reads as its element's - * display (`Product[]`). Everything else — primitives, object literals, lib - * types — is rendered by the checker as TypeScript itself would. + * `Collection>`), an array reads as its element's display + * (`Product[]`) and a union as its members' (`(Product | Basket)[]`). + * Everything else — primitives, object literals, lib types — is rendered by + * the checker as TypeScript itself would. */ function computeTypeDisplayString(ts: typeof tsserver, checker: tsserver.TypeChecker, type: tsserver.Type): string { - if (checker.isArrayType(type)) { - const [element] = checker.getTypeArguments(type as tsserver.TypeReference); - const display = element ? computeTypeDisplayString(ts, checker, element) : 'any'; - return element?.isUnion() ? `(${display})[]` : `${display}[]`; + if (isDisplayedAsUnion(ts, type)) { + return type.types.map((member) => computeTypeDisplayString(ts, checker, member)).join(' | '); } + if (checker.isArrayType(type)) return arrayDisplayString(ts, checker, type as tsserver.TypeReference); const symbol = type.getSymbol(); const isClassOrInterface = symbol !== undefined && (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) !== 0; @@ -272,20 +289,26 @@ export function elementTypeOf( return collectionElementType(ctx, type, location); } -/** The checker's own array factory, which TypeScript keeps off its public typings. */ -interface ArrayTypeFactory { +/** The checker's own type factories, which TypeScript keeps off its public typings. */ +interface TypeFactories { createArrayType?(elementType: tsserver.Type): tsserver.Type; + getUnionType?(types: tsserver.Type[]): tsserver.Type; } /** - * `element[]`, built by the checker itself so an inferred array reads, and - * resolves members (`filter`, `[0]`, `forEach`), exactly like a declared one. - * The factory is internal to TypeScript, so it is feature-detected: a - * TypeScript without it leaves inferred arrays silent rather than wrong. + * An array of `elements` (`ProductLineItem[]`, or `(OrderAddress | Shipment)[]` + * for an array filled with either), built by the checker itself so an + * inferred array reads, and resolves members (`filter`, `[0]`, `forEach`), + * exactly like a declared one. The factories are internal to TypeScript, so + * they are feature-detected: a TypeScript without them leaves inferred + * arrays silent rather than wrong. */ -export function arrayTypeOf(ctx: InferenceContext, element: tsserver.Type): tsserver.Type | undefined { - const factory = ctx.checker as tsserver.TypeChecker & ArrayTypeFactory; - return typeof factory.createArrayType === 'function' ? factory.createArrayType(element) : undefined; +export function arrayTypeOf(ctx: InferenceContext, elements: readonly tsserver.Type[]): tsserver.Type | undefined { + const factories = ctx.checker as tsserver.TypeChecker & TypeFactories; + if (typeof factories.createArrayType !== 'function') return undefined; + if (elements.length === 1) return factories.createArrayType(elements[0]); + if (elements.length === 0 || typeof factories.getUnionType !== 'function') return undefined; + return factories.createArrayType(factories.getUnionType([...elements])); } /** Renders candidate types as hover text, e.g. `"Product | Category"`. */ diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 9b7a06435..ea3ff0af6 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -49,6 +49,16 @@ const LINE_ITEM_HELPERS = ` } `; +// An array filled with two unrelated Script API classes. +const MIXED_ITEMS = ` + function mixedItems() { + var items = []; + items.push(getProduct()); + items.push(getBasket()); + return items; + } +`; + /** The function a case targets: a declaration, or a function expression assigned to `x.name` / `name:`. */ function isFunctionNamed(node, name) { if (ts.isFunctionDeclaration(node)) return node.name?.text === name; @@ -183,12 +193,40 @@ describe('usage-inference — arrays and element access', () => { expected: 'ProductLineItem[]', }, { - title: 'an array literal mixing unrelated types names no array', + title: 'an array literal mixing unrelated types is an array of their union', source: 'function pair(product, basket) { return [product, basket]; } pair(getProduct(), getBasket());', kind: 'return', name: 'pair', + expected: '(Product | Basket)[]', + }, + { + title: 'an array filled by push() with unrelated types is an array of their union', + source: MIXED_ITEMS, + kind: 'return', + name: 'mixedItems', + expected: '(Product | Basket)[]', + }, + { + title: "a native array method's callback parameter on a mixed array is either element", + source: `${MIXED_ITEMS} function ids() { return mixedItems().map(function (entry) { return entry.UUID; }); }`, + kind: 'param', + name: 'entry', + expected: 'Product | Basket', + }, + { + title: 'an array literal of more unrelated types than a union shows names no array', + source: "function row(product, basket) { return [product, basket, 'x', 1]; } row(getProduct(), getBasket());", + kind: 'return', + name: 'row', expected: '', }, + { + title: 'an array of booleans reads as boolean[]', + source: 'function flags(product) { return [product.online, product.searchable]; } flags(getProduct());', + kind: 'return', + name: 'flags', + expected: 'boolean[]', + }, ]); }); From 7972268f33357a9e07701405e9ae1d32b9bf2423 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:30:28 +0000 Subject: [PATCH 64/76] feat(script-types): type a callback's parameters from what its helper calls it with A callback passed to an undocumented helper used to get a type only when the helper's name was on a fixed element-first list (`collections.forEach`, `map`, `find`, ...), and only for its first parameter. The helper's own body now says it: each `callback(item)` / `callback.call(scope, item)` call is read, and its arguments are resolved with the helper's parameters bound to the call at hand. `collections.reduce(lineItems, function (total, lineItem) {...})` types `lineItem`, a forEach index is a number, and a project's own helpers and wrappers that hand the callback on to another helper work the same way. An accumulator the callback's result is fed back into stays unknown, as with a native reduce. The name list remains as the fallback for helpers whose source can't be read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 2 +- packages/b2c-script-types/README.md | 3 +- .../plugin/inference/ast-helpers.js | 14 +++ .../plugin/inference/bindings.js | 15 +-- .../plugin/inference/callback-arguments.js | 72 ++++++++++++ .../plugin/inference/constants.js | 8 +- .../b2c-script-types/plugin/inference/core.js | 37 +++++- .../src/inference/ast-helpers.ts | 19 +++ .../src/inference/bindings.ts | 12 +- .../src/inference/callback-arguments.ts | 110 ++++++++++++++++++ .../src/inference/constants.ts | 8 +- .../b2c-script-types/src/inference/core.ts | 44 +++++-- .../test/usage-inference.value-flow.test.js | 100 ++++++++++++++++ 13 files changed, 404 insertions(+), 40 deletions(-) create mode 100644 packages/b2c-script-types/plugin/inference/callback-arguments.js create mode 100644 packages/b2c-script-types/src/inference/callback-arguments.ts diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 9d50d53a1..de84b2fdf 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -145,7 +145,7 @@ The evidence it weighs: 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. +- **Iteration callbacks** — a callback's parameters are what the helper it is passed to calls it with, for that call's arguments: `collections.forEach(product.getVariants(), function (variant) {...})` types `variant` as a `Variant`, and `collections.reduce(basket.productLineItems, function (total, lineItem) {...}, 0)` types `lineItem`, the second parameter, as a `ProductLineItem`. This reads the helper's own `callback(item)` / `callback.call(scope, item)` calls, so a project's own helpers (`eachShipment(basket, function (shipment) {...})`) and wrappers that hand the callback on to another helper work the same way; an accumulator the callback's result is fed back into stays unknown, as with a native `reduce`. A helper whose source isn't available falls back to its name: `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` take an element of the collection travelling alongside the callback (anything with `iterator()`/`next()`, i.e. `dw.util.Collection` and friends). Manual iterator loops (`var iter = coll.iterator(); while (iter.hasNext()) { var item = iter.next(); }`) and ternary returns like stock `collections.first` (`return it.hasNext() ? it.next() : null`) resolve through the same chain machinery. - **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature. 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/README.md b/packages/b2c-script-types/README.md index 908ddda2c..df0d58663 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -71,7 +71,8 @@ Values are followed where they go — into object members, `this.x`, pushed arrays, factory returns and `module.exports` — and a generic helper such as `collections.find(basket.shipments, ...)` is inferred again for the call at hand, together with the callback it is passed -(`collections.map(basket.shipments, fn)` is an array of what `fn` returns). +(`collections.map(basket.shipments, fn)` is an array of what `fn` returns, +and `fn`'s parameter is what `collections.map` calls it with: a `Shipment`). Hook scripts registered in a cartridge's `hooks.json` take the arguments of the `HookMgr.callHook(...)` calls that reach them, literal or prefixed (`'app.payment.processor.' + id`), and those calls return what the hooks return diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index c0efacfec..a3e32c36e 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -15,6 +15,7 @@ exports.memberAssignmentOf = memberAssignmentOf; exports.isExportsObject = isExportsObject; exports.isModuleExports = isModuleExports; exports.exportsObjectWrittenBy = exportsObjectWrittenBy; +exports.invocationOf = invocationOf; exports.collectReturnExpressions = collectReturnExpressions; /** * Finds the most specific node whose span contains `pos`. Standard technique @@ -185,6 +186,19 @@ function exportsObjectWrittenBy(stmt, ts) { return target.expression; return isModuleExports(target, ts) ? target : undefined; } +/** + * `call` as an invocation of the function its callee names: `fn(a)`, + * `fn.call(scope, a)` without its `this` argument, or `fn.apply(scope, args)`. + * Any other method call (`list.push(a)`) invokes a member, not its receiver. + */ +function invocationOf(call, ts) { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) + return { callee, args: call.arguments }; + if (callee.name.text === 'call') + return { callee: callee.expression, args: call.arguments.slice(1) }; + return callee.name.text === 'apply' ? { callee: callee.expression, args: [] } : undefined; +} /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/plugin/inference/bindings.js b/packages/b2c-script-types/plugin/inference/bindings.js index 169fc0417..3f36d129f 100644 --- a/packages/b2c-script-types/plugin/inference/bindings.js +++ b/packages/b2c-script-types/plugin/inference/bindings.js @@ -12,6 +12,7 @@ exports.boundArgument = boundArgument; exports.boundCallback = boundCallback; exports.hasAnyTypeArgument = hasAnyTypeArgument; exports.narrows = narrows; +const ast_helpers_1 = require("./ast-helpers"); const member_values_1 = require("./member-values"); const signatures_1 = require("./signatures"); const type_helpers_1 = require("./type-helpers"); @@ -80,17 +81,9 @@ function boundFunction(ctx, expr) { * binds none of them. */ function boundCallback(ctx, call) { - const { ts } = ctx; - const callee = call.expression; - const borrowed = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined; - if (borrowed !== undefined && borrowed !== 'call' && borrowed !== 'apply') - return undefined; - const fn = boundFunction(ctx, ts.isPropertyAccessExpression(callee) ? callee.expression : callee); - if (!fn) - return undefined; - if (borrowed === 'apply') - return { fn, args: [] }; - return { fn, args: borrowed === 'call' ? call.arguments.slice(1) : call.arguments }; + const invocation = (0, ast_helpers_1.invocationOf)(call, ctx.ts); + const fn = invocation && boundFunction(ctx, invocation.callee); + return fn && { fn, args: invocation.args }; } /** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ function hasAnyTypeArgument(ctx, type) { diff --git a/packages/b2c-script-types/plugin/inference/callback-arguments.js b/packages/b2c-script-types/plugin/inference/callback-arguments.js new file mode 100644 index 000000000..2e3cad66a --- /dev/null +++ b/packages/b2c-script-types/plugin/inference/callback-arguments.js @@ -0,0 +1,72 @@ +"use strict"; +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.NO_CALLBACK_USES = void 0; +exports.callbackUses = callbackUses; +const ast_helpers_1 = require("./ast-helpers"); +/** A helper that never calls or hands on its callback. */ +exports.NO_CALLBACK_USES = { passed: [], forwarded: [] }; +/** + * True when `call`'s result is assigned back to the variable `argument` + * names: an accumulator (`value = callback(value, item)`), which holds an + * initial value, an element or the callback's own result, by call. Like the + * accumulator of a native `reduce`, it stays unknown. + */ +function isFedBack(ctx, call, argument) { + const { ts, checker } = ctx; + const assignment = call.parent; + const assigns = ts.isBinaryExpression(assignment) && + assignment.right === call && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(assignment.left); + return (assigns && + ts.isIdentifier(argument) && + checker.getSymbolAtLocation(assignment.left) === checker.getSymbolAtLocation(argument)); +} +/** + * How `helper` uses its parameter `callbackIndex`: the argument each call of + * it (`callback(item)`, `callback.call(scope, item)`) passes as parameter + * `paramIndex`, and the calls it is handed on to. Arguments after a spread + * are not known by position and are left out, and so is an accumulator (see + * {@link isFedBack}). + */ +function callbackUses(ctx, helper, callbackIndex, paramIndex) { + const { ts, checker } = ctx; + const parameter = helper.parameters[callbackIndex]; + const body = helper.body; + if (!body || !parameter || parameter.dotDotDotToken || !ts.isIdentifier(parameter.name)) + return exports.NO_CALLBACK_USES; + const { text } = parameter.name; + const symbol = checker.getSymbolAtLocation(parameter.name); + const namesCallback = (node) => ts.isIdentifier(node) && node.text === text && checker.getSymbolAtLocation(node) === symbol; + const passed = []; + const forwarded = []; + const record = (call) => { + const invocation = (0, ast_helpers_1.invocationOf)(call, ts); + if (invocation && namesCallback(invocation.callee)) { + const argument = argumentAt(ts, invocation.args, paramIndex); + if (argument && !isFedBack(ctx, call, argument)) + passed.push(argument); + return; + } + const argIndex = call.arguments.findIndex(namesCallback); + if (argIndex >= 0) + forwarded.push({ call, argIndex }); + }; + const visit = (node) => { + if (ts.isCallExpression(node)) + record(node); + ts.forEachChild(node, visit); + }; + visit(body); + return { passed, forwarded }; +} +/** The argument at `index`, unless a spread at or before it leaves its position unknown. */ +function argumentAt(ts, args, index) { + const known = args.slice(0, index + 1); + return known.length > index && !known.some((arg) => ts.isSpreadElement(arg)) ? known[index] : undefined; +} diff --git a/packages/b2c-script-types/plugin/inference/constants.js b/packages/b2c-script-types/plugin/inference/constants.js index 546f30757..f1013d301 100644 --- a/packages/b2c-script-types/plugin/inference/constants.js +++ b/packages/b2c-script-types/plugin/inference/constants.js @@ -117,9 +117,11 @@ exports.BUILTIN_VALUE_TYPES = [ 'RegExp', ]; // 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. +// (`collections.forEach(coll, function (item) {...})`). A project helper's +// callbacks are typed from what its body passes them; this list only covers +// helpers whose body can't be read, and for those only these names get the +// sibling-collection element-type heuristic. `reduce` (accumulator first) +// and other unknown helpers stay out. exports.ELEMENT_FIRST_CALLBACK_CALLEES = new Set([ 'forEach', 'map', diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 54201c2e7..fc4a31c0f 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -13,6 +13,7 @@ const constants_1 = require("./constants"); const context_1 = require("./context"); const ast_helpers_1 = require("./ast-helpers"); const bindings_1 = require("./bindings"); +const callback_arguments_1 = require("./callback-arguments"); const call_sites_1 = require("./call-sites"); const value_flow_1 = require("./value-flow"); const framework_contracts_1 = require("./framework-contracts"); @@ -67,11 +68,12 @@ function resolveValues(ctx, values, depth, chainHops) { * Infers a callback parameter's type from the call the callback is passed * to. A method on a known receiver declares it (`value: T` of * `items.filter(function (item) {...})` once `items` is inferred as a - * `ProductLineItem[]`). Otherwise, for an element-first helper - * (`collections.forEach(coll, fn)`, see {@link isElementFirstCallbackCall}), - * the first parameter is an element of the collection travelling alongside - * it. Unknown helpers are left alone — applying the heuristic to an arbitrary - * helper would guess wrong more often than it helps. + * `ProductLineItem[]`). A project helper says it by what it passes the + * callback (see {@link invokedCallbackTypes}). A helper that can't be read, + * when it is an element-first one by name (`collections.forEach(coll, fn)`, + * see {@link isElementFirstCallbackCall}), hands the first parameter an + * element of the collection travelling alongside it. Other unknown helpers + * are left alone: guessing their shape would be wrong more often than not. */ function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { const call = fn.parent; @@ -82,10 +84,33 @@ function inferCallbackParameterTypes(ctx, fn, paramIndex, depth) { const declared = ctx.ts.isPropertyAccessExpression(access) ? (0, signatures_1.callbackParameterTypes)(ctx, memberTypesOfReceiver(ctx, access.expression, access.name, depth, 0), call, argIndex, paramIndex) : []; - if (declared.length > 0 || paramIndex !== 0 || !(0, signatures_1.isElementFirstCallbackCall)(ctx, call)) + if (declared.length > 0) return declared; + const invoked = invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth); + if (invoked.length > 0 || paramIndex !== 0 || !(0, signatures_1.isElementFirstCallbackCall)(ctx, call)) + return invoked; return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); } +/** + * What the project helper `call` invokes passes parameter `paramIndex` of + * the callback it receives as argument `argIndex`, resolved with the + * helper's parameters bound to this call's arguments: an element of the + * collection this call passes, for `collections.reduce(lineItems, function + * (total, lineItem) {...})` as for a project's own `eachShipment(basket, + * fn)`. A helper handing the callback on to another one is followed into + * that one, a level deeper. + */ +function invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth) { + const helper = depth < constants_1.MAX_INFERENCE_DEPTH ? (0, signatures_1.resolveCalleeDeclaration)(ctx, call) : undefined; + const { passed, forwarded } = helper ? (0, callback_arguments_1.callbackUses)(ctx, helper, argIndex, paramIndex) : callback_arguments_1.NO_CALLBACK_USES; + if (!helper || (passed.length === 0 && forwarded.length === 0)) + return []; + const bindings = (0, bindings_1.argumentBindings)(ctx, helper, call.arguments, depth) ?? ctx.bindings; + return (0, bindings_1.withBindings)(ctx, bindings, () => [ + ...passed.flatMap((value) => resolveExpressionTypes(ctx, value, depth + 1)), + ...forwarded.flatMap((use) => invokedCallbackTypes(ctx, use.call, use.argIndex, paramIndex, depth + 1)), + ]); +} /** * Resolves the candidate type(s) of `expr`: the checker's own type when it is * informative, otherwise a type recovered through whatever `expr` is built diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index be9ca6624..e6b2d67f7 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -205,6 +205,25 @@ export function exportsObjectWrittenBy(stmt: tsserver.Statement, ts: typeof tsse return isModuleExports(target, ts) ? target : undefined; } +/** A call read as an invocation of the function its callee names, with the arguments that function receives. */ +export interface Invocation { + readonly callee: tsserver.Expression; + /** Empty for `fn.apply(scope, args)`, whose arguments are not known one by one. */ + readonly args: readonly tsserver.Expression[]; +} + +/** + * `call` as an invocation of the function its callee names: `fn(a)`, + * `fn.call(scope, a)` without its `this` argument, or `fn.apply(scope, args)`. + * Any other method call (`list.push(a)`) invokes a member, not its receiver. + */ +export function invocationOf(call: tsserver.CallExpression, ts: typeof tsserver): Invocation | undefined { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return {callee, args: call.arguments}; + if (callee.name.text === 'call') return {callee: callee.expression, args: call.arguments.slice(1)}; + return callee.name.text === 'apply' ? {callee: callee.expression, args: []} : undefined; +} + /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/src/inference/bindings.ts b/packages/b2c-script-types/src/inference/bindings.ts index b2c564baf..7f66a4b07 100644 --- a/packages/b2c-script-types/src/inference/bindings.ts +++ b/packages/b2c-script-types/src/inference/bindings.ts @@ -17,6 +17,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {invocationOf} from './ast-helpers'; import type {InferenceContext} from './context'; import {valueDeclarationOf} from './member-values'; import {functionOf} from './signatures'; @@ -111,14 +112,9 @@ function boundFunction(ctx: InferenceContext, expr: tsserver.Expression): tsserv * binds none of them. */ export function boundCallback(ctx: InferenceContext, call: tsserver.CallExpression): BoundCallback | undefined { - const {ts} = ctx; - const callee = call.expression; - const borrowed = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined; - if (borrowed !== undefined && borrowed !== 'call' && borrowed !== 'apply') return undefined; - const fn = boundFunction(ctx, ts.isPropertyAccessExpression(callee) ? callee.expression : callee); - if (!fn) return undefined; - if (borrowed === 'apply') return {fn, args: []}; - return {fn, args: borrowed === 'call' ? call.arguments.slice(1) : call.arguments}; + const invocation = invocationOf(call, ctx.ts); + const fn = invocation && boundFunction(ctx, invocation.callee); + return fn && {fn, args: invocation.args}; } /** True when `type` instantiates a generic with `any` (`Collection` of a bare `{dw.util.Collection}`). */ diff --git a/packages/b2c-script-types/src/inference/callback-arguments.ts b/packages/b2c-script-types/src/inference/callback-arguments.ts new file mode 100644 index 000000000..fa6d0b535 --- /dev/null +++ b/packages/b2c-script-types/src/inference/callback-arguments.ts @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +// What a project helper hands the callback it is passed. SFRA's +// `collections.reduce(collection, function (total, item) {...})` calls its +// callback as `callback(value, item, index, collection)`, so the callback's +// second parameter is whatever `item` is: an element of the collection one +// call passes. Reading the helper's own calls of its callback parameter, +// rather than assuming an element-first shape from the helper's name, types +// the callbacks of any helper a project defines +// (`eachShipment(basket, function (shipment) {...})`), whichever parameter +// receives what. ./core resolves the arguments found here with the helper's +// parameters bound to the call (see ./bindings). + +import type tsserver from 'typescript/lib/tsserverlibrary'; + +import {invocationOf} from './ast-helpers'; +import type {InferenceContext} from './context'; + +/** A call a helper hands its callback on to, as argument `argIndex`. */ +export interface ForwardedCallback { + readonly call: tsserver.CallExpression; + readonly argIndex: number; +} + +/** How a helper's body uses the callback parameter it is passed. */ +export interface CallbackUses { + /** What the helper passes as the callback's parameter, one argument per call of the callback. */ + readonly passed: readonly tsserver.Expression[]; + /** Calls the helper hands the callback on to (`collections.map(items, fn)` in a wrapper). */ + readonly forwarded: readonly ForwardedCallback[]; +} + +/** A helper that never calls or hands on its callback. */ +export const NO_CALLBACK_USES: CallbackUses = {passed: [], forwarded: []}; + +/** + * True when `call`'s result is assigned back to the variable `argument` + * names: an accumulator (`value = callback(value, item)`), which holds an + * initial value, an element or the callback's own result, by call. Like the + * accumulator of a native `reduce`, it stays unknown. + */ +function isFedBack(ctx: InferenceContext, call: tsserver.CallExpression, argument: tsserver.Expression): boolean { + const {ts, checker} = ctx; + const assignment = call.parent; + const assigns = + ts.isBinaryExpression(assignment) && + assignment.right === call && + assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(assignment.left); + return ( + assigns && + ts.isIdentifier(argument) && + checker.getSymbolAtLocation(assignment.left) === checker.getSymbolAtLocation(argument) + ); +} + +/** + * How `helper` uses its parameter `callbackIndex`: the argument each call of + * it (`callback(item)`, `callback.call(scope, item)`) passes as parameter + * `paramIndex`, and the calls it is handed on to. Arguments after a spread + * are not known by position and are left out, and so is an accumulator (see + * {@link isFedBack}). + */ +export function callbackUses( + ctx: InferenceContext, + helper: tsserver.SignatureDeclaration, + callbackIndex: number, + paramIndex: number, +): CallbackUses { + const {ts, checker} = ctx; + const parameter = helper.parameters[callbackIndex]; + const body = (helper as tsserver.FunctionLikeDeclaration).body; + if (!body || !parameter || parameter.dotDotDotToken || !ts.isIdentifier(parameter.name)) return NO_CALLBACK_USES; + const {text} = parameter.name; + const symbol = checker.getSymbolAtLocation(parameter.name); + const namesCallback = (node: tsserver.Node): boolean => + ts.isIdentifier(node) && node.text === text && checker.getSymbolAtLocation(node) === symbol; + const passed: tsserver.Expression[] = []; + const forwarded: ForwardedCallback[] = []; + const record = (call: tsserver.CallExpression): void => { + const invocation = invocationOf(call, ts); + if (invocation && namesCallback(invocation.callee)) { + const argument = argumentAt(ts, invocation.args, paramIndex); + if (argument && !isFedBack(ctx, call, argument)) passed.push(argument); + return; + } + const argIndex = call.arguments.findIndex(namesCallback); + if (argIndex >= 0) forwarded.push({call, argIndex}); + }; + const visit = (node: tsserver.Node): void => { + if (ts.isCallExpression(node)) record(node); + ts.forEachChild(node, visit); + }; + visit(body); + return {passed, forwarded}; +} + +/** The argument at `index`, unless a spread at or before it leaves its position unknown. */ +function argumentAt( + ts: typeof tsserver, + args: readonly tsserver.Expression[], + index: number, +): tsserver.Expression | undefined { + const known = args.slice(0, index + 1); + return known.length > index && !known.some((arg) => ts.isSpreadElement(arg)) ? known[index] : undefined; +} diff --git a/packages/b2c-script-types/src/inference/constants.ts b/packages/b2c-script-types/src/inference/constants.ts index 2beee55f7..6ce0ebba7 100644 --- a/packages/b2c-script-types/src/inference/constants.ts +++ b/packages/b2c-script-types/src/inference/constants.ts @@ -132,9 +132,11 @@ export const BUILTIN_VALUE_TYPES: readonly string[] = [ ]; // 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. +// (`collections.forEach(coll, function (item) {...})`). A project helper's +// callbacks are typed from what its body passes them; this list only covers +// helpers whose body can't be read, and for those only these names get the +// sibling-collection element-type heuristic. `reduce` (accumulator first) +// and other unknown helpers stay out. export const ELEMENT_FIRST_CALLBACK_CALLEES: ReadonlySet = new Set([ 'forEach', 'map', diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 968442b27..5599b3745 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -16,7 +16,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {MAX_CHAIN_HOPS} from './constants'; +import {MAX_CHAIN_HOPS, MAX_INFERENCE_DEPTH} from './constants'; import {withCycleGuard, withInferenceGuards} from './context'; import type {InferenceContext} from './context'; import { @@ -35,6 +35,7 @@ import { withBindings, } from './bindings'; import type {ArgumentBinding, BoundCallback} from './bindings'; +import {NO_CALLBACK_USES, callbackUses} from './callback-arguments'; import {collectCallSites, forwardedParameter} from './call-sites'; import {getReferenceNameNode} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; @@ -118,11 +119,12 @@ function resolveValues( * Infers a callback parameter's type from the call the callback is passed * to. A method on a known receiver declares it (`value: T` of * `items.filter(function (item) {...})` once `items` is inferred as a - * `ProductLineItem[]`). Otherwise, for an element-first helper - * (`collections.forEach(coll, fn)`, see {@link isElementFirstCallbackCall}), - * the first parameter is an element of the collection travelling alongside - * it. Unknown helpers are left alone — applying the heuristic to an arbitrary - * helper would guess wrong more often than it helps. + * `ProductLineItem[]`). A project helper says it by what it passes the + * callback (see {@link invokedCallbackTypes}). A helper that can't be read, + * when it is an element-first one by name (`collections.forEach(coll, fn)`, + * see {@link isElementFirstCallbackCall}), hands the first parameter an + * element of the collection travelling alongside it. Other unknown helpers + * are left alone: guessing their shape would be wrong more often than not. */ function inferCallbackParameterTypes( ctx: InferenceContext, @@ -143,10 +145,38 @@ function inferCallbackParameterTypes( paramIndex, ) : []; - if (declared.length > 0 || paramIndex !== 0 || !isElementFirstCallbackCall(ctx, call)) return declared; + if (declared.length > 0) return declared; + const invoked = invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth); + if (invoked.length > 0 || paramIndex !== 0 || !isElementFirstCallbackCall(ctx, call)) return invoked; return call.arguments.filter((arg) => arg !== fn).flatMap((arg) => resolveElementTypes(ctx, arg, depth, 0)); } +/** + * What the project helper `call` invokes passes parameter `paramIndex` of + * the callback it receives as argument `argIndex`, resolved with the + * helper's parameters bound to this call's arguments: an element of the + * collection this call passes, for `collections.reduce(lineItems, function + * (total, lineItem) {...})` as for a project's own `eachShipment(basket, + * fn)`. A helper handing the callback on to another one is followed into + * that one, a level deeper. + */ +function invokedCallbackTypes( + ctx: InferenceContext, + call: tsserver.CallExpression, + argIndex: number, + paramIndex: number, + depth: number, +): tsserver.Type[] { + const helper = depth < MAX_INFERENCE_DEPTH ? resolveCalleeDeclaration(ctx, call) : undefined; + const {passed, forwarded} = helper ? callbackUses(ctx, helper, argIndex, paramIndex) : NO_CALLBACK_USES; + if (!helper || (passed.length === 0 && forwarded.length === 0)) return []; + const bindings = argumentBindings(ctx, helper, call.arguments, depth) ?? ctx.bindings; + return withBindings(ctx, bindings, () => [ + ...passed.flatMap((value) => resolveExpressionTypes(ctx, value, depth + 1)), + ...forwarded.flatMap((use) => invokedCallbackTypes(ctx, use.call, use.argIndex, paramIndex, depth + 1)), + ]); +} + /** * Resolves the candidate type(s) of `expr`: the checker's own type when it is * informative, otherwise a type recovered through whatever `expr` is built diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index ea3ff0af6..1991b9c73 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -835,6 +835,106 @@ describe('usage-inference — call-specific returns', () => { ]); }); + describe('callback parameters', () => { + // A callback's parameter is what the helper it is passed to calls it + // with, for the arguments of that one call. The helpers are undocumented + // project code, so no name-based shape is assumed, and the callbacks only + // use `.UUID`, which every class has: their usage says nothing. + const ITERATING_HELPERS = ` + function reduce(collection, callback, initial) { + var iterator = collection.iterator(); + var value = arguments.length > 2 ? initial : iterator.next(); + while (iterator.hasNext()) { + value = callback(value, iterator.next()); + } + return value; + } + function forEachIn(collection, fn, scope) { + var iterator = collection.iterator(); + while (iterator.hasNext()) { + fn.call(scope, iterator.next()); + } + } + function eachLineItem(basket, fn) { forEachIn(basket.productLineItems, fn); } + function withBasket(fn) { return fn(getBasket()); } + function register(handlers, name, fn) { handlers[name] = fn; } + `; + + runCases([ + { + title: 'a reduce-style helper hands each element to the second parameter', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function lineItemUUIDs(basket) { + return reduce(basket.productLineItems, function (uuids, entry) { return uuids + entry.UUID; }, ''); + } + function shipmentUUIDs(basket) { + return reduce(basket.shipments, function (uuids, other) { return uuids + other.UUID; }, ''); + } + lineItemUUIDs(getBasket()); + shipmentUUIDs(getBasket()); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'an accumulator the callback’s result is fed back into stays silent, like a native reduce’s', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function byUUID(basket) { + return reduce(basket.productLineItems, function (index, entry) { index[entry.UUID] = entry; return index; }, {}); + } + byUUID(getBasket()); + `, + kind: 'param', + name: 'index', + expected: '', + }, + { + title: 'callback.call(scope, x) hands x, not scope, to the first parameter', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + function shipmentUUIDs(basket) { forEachIn(basket.shipments, function (entry) { return entry.UUID; }, this); } + shipmentUUIDs(getBasket()); + `, + kind: 'param', + name: 'entry', + expected: 'Shipment', + }, + { + title: 'a wrapper handing its callback on to another helper is followed into it', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + eachLineItem(getBasket(), function (entry) { return entry.UUID; }); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'a callback may be handed any value, not only an element', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + withBasket(function (current) { return current.UUID; }); + `, + kind: 'param', + name: 'current', + expected: 'Basket', + }, + { + title: 'a helper that never calls its callback says nothing about its parameters', + types: COLLECTION_TYPES, + source: `${ITERATING_HELPERS} + register({}, 'basket', function (event) { return event; }); + `, + kind: 'param', + name: 'event', + expected: '', + }, + ]); + }); + it('keeps call-specific results out of the request memo', () => { const languageService = createFixtureLanguageService({ '/types.d.ts': COLLECTION_TYPES, From 39f814f9fae3a5c093114c4b10da4364b849b270 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:51:18 +0000 Subject: [PATCH 65/76] perf(script-types): read only the subtrees that spell a searched name Walks looking for identifiers by name (reference searches, usage profiles, this-property and callback-use scans) now skip every subtree whose source text holds none of the names, found by one indexOf pass and a binary search per node. Subtrees holding a unicode escape are always read, so escaped identifiers are still found. Callee resolution skips the checker's overload resolution, which checks every argument a call passes, when the callee has only one signature, and editor hooks reuse the Program they already hold instead of asking the language service for it again. Same results on the real-cartridge ground truth; about 9% less total inference time, with a lower p50 and max. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/ast-helpers.js | 51 ++++++++++++--- .../plugin/inference/callback-arguments.js | 3 + .../plugin/inference/context.js | 12 +++- .../plugin/inference/editor-hooks.js | 19 ++---- .../plugin/inference/reference-search.js | 15 +++-- .../plugin/inference/signatures.js | 18 +++++- .../plugin/inference/this-properties.js | 4 ++ .../plugin/inference/usage-profile.js | 10 ++- .../src/inference/ast-helpers.ts | 62 +++++++++++++++---- .../src/inference/callback-arguments.ts | 4 +- .../b2c-script-types/src/inference/context.ts | 17 ++++- .../src/inference/editor-hooks.ts | 19 +++--- .../src/inference/reference-search.ts | 15 +++-- .../src/inference/signatures.ts | 22 ++++++- .../src/inference/this-properties.ts | 3 + .../src/inference/usage-profile.ts | 17 +++-- .../test/reference-search.test.js | 25 ++++++++ 17 files changed, 242 insertions(+), 74 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index a3e32c36e..9939e7387 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.getNodeAtPosition = getNodeAtPosition; +exports.spellingFilter = spellingFilter; exports.propertyAccessNamedBy = propertyAccessNamedBy; exports.memberCompletionAccess = memberCompletionAccess; exports.hasExplicitParameterType = hasExplicitParameterType; @@ -24,19 +25,20 @@ exports.collectReturnExpressions = collectReturnExpressions; * * 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. + * ordered and non-overlapping), and skips the siblings before `pos` by their + * end alone, so a node's start (which means scanning its leading comments) + * is computed only for the one sibling that reaches `pos`. Without that, a + * hover in a file whose top-level (or any enclosing) node has thousands of + * children — a generated data file with an 8,000-element array literal, say + * — pays for the full child list. */ function getNodeAtPosition(sourceFile, ts, pos) { let result; const visit = (node) => { - if (pos < node.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 + if (pos < node.getStart(sourceFile)) + return true; // walked past pos — later siblings can't contain it result = node; ts.forEachChild(node, visit); return true; // containing child handled — siblings don't overlap @@ -44,6 +46,41 @@ function getNodeAtPosition(sourceFile, ts, pos) { visit(sourceFile); return result; } +/** + * A pre-test for walks of `scope` looking for identifiers named one of + * `names`: true when a node's text spells one of them. An identifier's text + * starts with its name, so a subtree the test is false for holds none of + * them and the walk can skip it, reading only the few paths that lead to + * one. An identifier written with a unicode escape sequence spells its + * name in other characters, so a subtree holding an escape is always read. + */ +function spellingFilter(scope, names) { + const { text } = scope.getSourceFile(); + const offsets = []; + for (const spelling of new Set([...names, '\\u'])) { + let at = text.indexOf(spelling, scope.pos); + for (; at >= 0 && at < scope.end; at = text.indexOf(spelling, at + spelling.length)) + offsets.push(at); + } + offsets.sort((a, b) => a - b); + return (node) => { + const next = firstAtOrAfter(offsets, node.pos); + return next < offsets.length && offsets[next] < node.end; + }; +} +/** The index of the first of the ascending `offsets` that is at or after `pos`. */ +function firstAtOrAfter(offsets, pos) { + let low = 0; + let high = offsets.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (offsets[middle] < pos) + low = middle + 1; + else + high = middle; + } + return low; +} /** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ function propertyAccessNamedBy(node, ts) { const { parent } = node; diff --git a/packages/b2c-script-types/plugin/inference/callback-arguments.js b/packages/b2c-script-types/plugin/inference/callback-arguments.js index 2e3cad66a..a9d52d6dd 100644 --- a/packages/b2c-script-types/plugin/inference/callback-arguments.js +++ b/packages/b2c-script-types/plugin/inference/callback-arguments.js @@ -57,7 +57,10 @@ function callbackUses(ctx, helper, callbackIndex, paramIndex) { if (argIndex >= 0) forwarded.push({ call, argIndex }); }; + const spellsCallback = (0, ast_helpers_1.spellingFilter)(body, [text]); const visit = (node) => { + if (!spellsCallback(node)) + return; if (ts.isCallExpression(node)) record(node); ts.forEachChild(node, visit); diff --git a/packages/b2c-script-types/plugin/inference/context.js b/packages/b2c-script-types/plugin/inference/context.js index f6dbfd76e..de1f5bd25 100644 --- a/packages/b2c-script-types/plugin/inference/context.js +++ b/packages/b2c-script-types/plugin/inference/context.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.createInferenceContext = createInferenceContext; +exports.contextForProgram = contextForProgram; exports.withCycleGuard = withCycleGuard; exports.withInferenceGuards = withInferenceGuards; const bindings_1 = require("./bindings"); @@ -16,13 +17,18 @@ const constants_1 = require("./constants"); */ function createInferenceContext(ts, languageService, host = {}, triggerPosition) { const program = languageService.getProgram(); - if (!program) - return undefined; + return program && contextForProgram(ts, program, host, triggerPosition); +} +/** + * Like {@link createInferenceContext}, for a caller that already holds the + * request's Program: asking the language service for it again brings it up + * to date with the host again, which reads the version of every file. + */ +function contextForProgram(ts, program, host = {}, triggerPosition) { return { ts, program, checker: program.getTypeChecker(), - languageService, visiting: new Set(), memo: new Map(), referenceBudget: constants_1.MAX_REFERENCES_PER_REQUEST, diff --git a/packages/b2c-script-types/plugin/inference/editor-hooks.js b/packages/b2c-script-types/plugin/inference/editor-hooks.js index 3e7842171..364b92689 100644 --- a/packages/b2c-script-types/plugin/inference/editor-hooks.js +++ b/packages/b2c-script-types/plugin/inference/editor-hooks.js @@ -66,9 +66,6 @@ function openFile(env, fileName) { function isInferenceTarget(ts, checker, expr) { return (0, type_helpers_1.isOpenForUsageInference)(ts, checker.getTypeAtLocation(expr)) || !!(0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); } -function newContext(env, triggerPosition) { - return (0, context_1.createInferenceContext)(env.ts, env.languageService, env, triggerPosition); -} /** * The declaration a member-name hover documents: the member every inferred * receiver type resolves the name to. Receivers that resolve it to different @@ -99,10 +96,8 @@ function documentationOf(checker, symbol) { * Documentation comes from the member's declaration, or from the inferred * class when there is exactly one. */ -function inferHover(env, node) { - const ctx = newContext(env); - if (!ctx) - return undefined; +function inferHover(env, file, node) { + const ctx = (0, context_1.contextForProgram)(env.ts, file.program, env); const access = (0, ast_helpers_1.propertyAccessNamedBy)(node, env.ts); const types = access ? (0, core_1.inferTypeForExpression)(ctx, access) : (0, core_1.inferTypeForNode)(ctx, node); if (types.length === 0) @@ -150,7 +145,7 @@ function decorateQuickInfo(env, cache, fileName, position, original) { if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) return original; const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; - const inferred = cache.get(key, file.program, () => inferHover(env, node)); + const inferred = cache.get(key, file.program, () => inferHover(env, file, node)); return inferred ? withHoverInference(original, inferred) : original; } /** @@ -159,10 +154,8 @@ function decorateQuickInfo(env, cache, fileName, position, original) { * (`module.exports = base; module.exports.extra = fn;`), which no candidate * type can carry. */ -function inferMemberEntries(env, receiver, position) { - const ctx = newContext(env, position); - if (!ctx) - return []; +function inferMemberEntries(env, file, receiver, position) { + const ctx = (0, context_1.contextForProgram)(env.ts, file.program, env, position); const augmented = (0, super_module_1.collectSuperModuleAugmentedMembers)(ctx, receiver).map((member) => (0, type_helpers_1.inferredCompletionEntry)(env.ts, member.name, member.isMethod)); return [...(0, type_helpers_1.typesToCompletionEntries)(env.ts, ctx.checker, (0, core_1.inferTypeForExpression)(ctx, receiver)), ...augmented]; } @@ -196,7 +189,7 @@ function decorateCompletions(env, cache, fileName, position, original) { // nested receivers share a start (`a` and `a.b`), so the key spans it. const receiver = access.expression; const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; - return mergeCompletions(original, cache.get(key, file.program, () => inferMemberEntries(env, receiver, position))); + return mergeCompletions(original, cache.get(key, file.program, () => inferMemberEntries(env, file, receiver, position))); } /** * Creates the hover and completion decorators for one language service. Each diff --git a/packages/b2c-script-types/plugin/inference/reference-search.js b/packages/b2c-script-types/plugin/inference/reference-search.js index 0d32bf842..4ca368932 100644 --- a/packages/b2c-script-types/plugin/inference/reference-search.js +++ b/packages/b2c-script-types/plugin/inference/reference-search.js @@ -60,14 +60,19 @@ function namesIn(ts, file) { } return names; } -/** The identifiers spelled `text` in `file`, found where its text spells them. */ +/** The identifiers named `text` in `file`, read along the paths that lead to where its text spells them. */ function identifiersNamed(ts, file, text) { + const spells = (0, ast_helpers_1.spellingFilter)(file, [text]); const found = []; - for (let pos = file.text.indexOf(text); pos >= 0; pos = file.text.indexOf(text, pos + text.length)) { - const node = (0, ast_helpers_1.getNodeAtPosition)(file, ts, pos); - if (node && ts.isIdentifier(node) && node.text === text && node.getStart(file) === pos) + const visit = (node) => { + if (!spells(node)) + return; + if (!ts.isIdentifier(node)) + ts.forEachChild(node, visit); + else if (node.text === text) found.push(node); - } + }; + visit(file); return found; } /** diff --git a/packages/b2c-script-types/plugin/inference/signatures.js b/packages/b2c-script-types/plugin/inference/signatures.js index 1a05f29d7..79d80e90e 100644 --- a/packages/b2c-script-types/plugin/inference/signatures.js +++ b/packages/b2c-script-types/plugin/inference/signatures.js @@ -15,7 +15,10 @@ 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. + * (`var Model = require('./model'); new Model(x)`) — the signature the call + * resolves to. Only a callee with several signatures needs the checker to + * pick one, which checks every argument the call passes (callback bodies + * included); a callee with one signature can only mean that one. */ function resolveCalleeDeclaration(ctx, call) { const { checker, ts } = ctx; @@ -23,9 +26,20 @@ function resolveCalleeDeclaration(ctx, call) { const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; if (decl && ts.isFunctionLike(decl)) return decl; - const sigDecl = checker.getResolvedSignature(call)?.declaration; + const signatures = calleeSignatures(ctx, call); + const sigDecl = (signatures.length > 1 ? checker.getResolvedSignature(call) : signatures[0])?.declaration; return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } +/** + * The signatures `call` chooses from: a `new` call's construct signatures, + * or the call signatures of a callee without any (a JS constructor + * function, which `new` calls like any other function). + */ +function calleeSignatures(ctx, call) { + const callee = ctx.checker.getTypeAtLocation(call.expression); + const construct = ctx.ts.isNewExpression(call) ? callee.getConstructSignatures() : []; + return construct.length > 0 ? construct : callee.getCallSignatures(); +} /** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ function functionOf(ctx, value) { const { ts, checker } = ctx; diff --git a/packages/b2c-script-types/plugin/inference/this-properties.js b/packages/b2c-script-types/plugin/inference/this-properties.js index ba65d92fc..5d61968b1 100644 --- a/packages/b2c-script-types/plugin/inference/this-properties.js +++ b/packages/b2c-script-types/plugin/inference/this-properties.js @@ -7,6 +7,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.thisMembersStoring = thisMembersStoring; exports.isStoredMemberRead = isStoredMemberRead; +const ast_helpers_1 = require("./ast-helpers"); /** `this.x = value`: a plain assignment to a member of `this`. */ function isThisMemberStore(ts, node) { return (ts.isBinaryExpression(node) && @@ -30,7 +31,10 @@ function thisMembersStoring(ctx, param) { const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; if (!body || !paramSymbol) return members; + const spellsParam = (0, ast_helpers_1.spellingFilter)(body, [paramSymbol.name]); const visit = (node) => { + if (!spellsParam(node)) + return; const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; if (member) diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js index 0402a85ee..f8519c815 100644 --- a/packages/b2c-script-types/plugin/inference/usage-profile.js +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -155,8 +155,9 @@ function isReferenceTo(ctx, target, node) { } /** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ function testsPresence(ctx, target, condition) { - const visit = (node) => (isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || - ctx.ts.forEachChild(node, visit) === true; + const visit = (node) => target.mayReference(node) && + ((isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true); return visit(condition); } /** True when `statement` always leaves its block: a `return`/`throw`, or a block ending in one. */ @@ -339,6 +340,8 @@ function recordReference(use, profile) { function collectProfile(ctx, target, scope) { const profile = emptyProfile(); const visit = (node, inVariantBranch) => { + if (!target.mayReference(node)) + return; if (isReferenceTo(ctx, target, node)) recordReference({ ctx, target, reference: node, inVariantBranch }, profile); const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); @@ -387,7 +390,8 @@ function ownProfileOf(ctx, declaration) { if (!scope) return EMPTY_PROFILE; const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); - const profile = collectProfile(ctx, { symbol, declarationName, declaredType, storedIn }, scope); + const mayReference = (0, ast_helpers_1.spellingFilter)(scope, [declarationName.text, ...[...storedIn].map((member) => member.name)]); + const profile = collectProfile(ctx, { symbol, declarationName, declaredType, storedIn, mayReference }, scope); ctx.profiles.set(symbol, profile); return profile; } diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index e6b2d67f7..2b7636ccd 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -5,12 +5,12 @@ */ // Small, self-contained helpers for walking the TypeScript AST: finding the -// node under the cursor, the property access a member name or completion -// belongs to, 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. +// node under the cursor, skipping the subtrees a walk for a name can't find +// it in, the property access a member name or completion belongs to, +// checking whether a parameter/return/variable already has an explicit type, +// 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'; @@ -21,11 +21,12 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; * * 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. + * ordered and non-overlapping), and skips the siblings before `pos` by their + * end alone, so a node's start (which means scanning its leading comments) + * is computed only for the one sibling that reaches `pos`. Without that, a + * hover in a file whose top-level (or any enclosing) node has thousands of + * children — a generated data file with an 8,000-element array literal, say + * — pays for the full child list. */ export function getNodeAtPosition( sourceFile: tsserver.SourceFile, @@ -34,8 +35,8 @@ export function getNodeAtPosition( ): 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 + if (pos < node.getStart(sourceFile)) return true; // walked past pos — later siblings can't contain it result = node; ts.forEachChild(node, visit); return true; // containing child handled — siblings don't overlap @@ -44,6 +45,43 @@ export function getNodeAtPosition( return result; } +/** True when `node`'s text holds one of the identifiers a {@link spellingFilter} looks for. */ +export type SpellingFilter = (node: tsserver.Node) => boolean; + +/** + * A pre-test for walks of `scope` looking for identifiers named one of + * `names`: true when a node's text spells one of them. An identifier's text + * starts with its name, so a subtree the test is false for holds none of + * them and the walk can skip it, reading only the few paths that lead to + * one. An identifier written with a unicode escape sequence spells its + * name in other characters, so a subtree holding an escape is always read. + */ +export function spellingFilter(scope: tsserver.Node, names: Iterable): SpellingFilter { + const {text} = scope.getSourceFile(); + const offsets: number[] = []; + for (const spelling of new Set([...names, '\\u'])) { + let at = text.indexOf(spelling, scope.pos); + for (; at >= 0 && at < scope.end; at = text.indexOf(spelling, at + spelling.length)) offsets.push(at); + } + offsets.sort((a, b) => a - b); + return (node) => { + const next = firstAtOrAfter(offsets, node.pos); + return next < offsets.length && offsets[next] < node.end; + }; +} + +/** The index of the first of the ascending `offsets` that is at or after `pos`. */ +function firstAtOrAfter(offsets: readonly number[], pos: number): number { + let low = 0; + let high = offsets.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (offsets[middle] < pos) low = middle + 1; + else high = middle; + } + return low; +} + /** The property access whose member name `node` is (`productLineItems` in `shipment.productLineItems`), if any. */ export function propertyAccessNamedBy( node: tsserver.Node, diff --git a/packages/b2c-script-types/src/inference/callback-arguments.ts b/packages/b2c-script-types/src/inference/callback-arguments.ts index fa6d0b535..c1778c74e 100644 --- a/packages/b2c-script-types/src/inference/callback-arguments.ts +++ b/packages/b2c-script-types/src/inference/callback-arguments.ts @@ -17,7 +17,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {invocationOf} from './ast-helpers'; +import {invocationOf, spellingFilter} from './ast-helpers'; import type {InferenceContext} from './context'; /** A call a helper hands its callback on to, as argument `argIndex`. */ @@ -91,7 +91,9 @@ export function callbackUses( const argIndex = call.arguments.findIndex(namesCallback); if (argIndex >= 0) forwarded.push({call, argIndex}); }; + const spellsCallback = spellingFilter(body, [text]); const visit = (node: tsserver.Node): void => { + if (!spellsCallback(node)) return; if (ts.isCallExpression(node)) record(node); ts.forEachChild(node, visit); }; diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 057288987..1d4c1ac74 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -61,7 +61,6 @@ 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 @@ -161,12 +160,24 @@ export function createInferenceContext( triggerPosition?: number, ): InferenceContext | undefined { const program = languageService.getProgram(); - if (!program) return undefined; + return program && contextForProgram(ts, program, host, triggerPosition); +} + +/** + * Like {@link createInferenceContext}, for a caller that already holds the + * request's Program: asking the language service for it again brings it up + * to date with the host again, which reads the version of every file. + */ +export function contextForProgram( + ts: typeof tsserver, + program: tsserver.Program, + host: InferenceHost = {}, + triggerPosition?: number, +): InferenceContext { return { ts, program, checker: program.getTypeChecker(), - languageService, visiting: new Set(), memo: new Map(), referenceBudget: MAX_REFERENCES_PER_REQUEST, diff --git a/packages/b2c-script-types/src/inference/editor-hooks.ts b/packages/b2c-script-types/src/inference/editor-hooks.ts index 9254c983d..093be220f 100644 --- a/packages/b2c-script-types/src/inference/editor-hooks.ts +++ b/packages/b2c-script-types/src/inference/editor-hooks.ts @@ -16,7 +16,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {getNodeAtPosition, memberCompletionAccess, propertyAccessNamedBy} from './ast-helpers'; import {MAX_DISPLAY_CACHE_ENTRIES} from './constants'; import type {HookRegistration} from '../resolver/hook-registry'; -import {createInferenceContext} from './context'; +import {contextForProgram} from './context'; import type {InferenceContext, InferenceHost} from './context'; import {inferTypeForExpression, inferTypeForNode} from './core'; import {collectSuperModuleAugmentedMembers, traceSuperModuleAccess} from './super-module'; @@ -126,10 +126,6 @@ function isInferenceTarget(ts: typeof tsserver, checker: tsserver.TypeChecker, e return isOpenForUsageInference(ts, checker.getTypeAtLocation(expr)) || !!traceSuperModuleAccess(ts, checker, expr); } -function newContext(env: HookEnvironment, triggerPosition?: number): InferenceContext | undefined { - return createInferenceContext(env.ts, env.languageService, env, triggerPosition); -} - /** * The declaration a member-name hover documents: the member every inferred * receiver type resolves the name to. Receivers that resolve it to different @@ -168,9 +164,8 @@ function documentationOf( * Documentation comes from the member's declaration, or from the inferred * class when there is exactly one. */ -function inferHover(env: HookEnvironment, node: tsserver.Identifier): HoverInference | undefined { - const ctx = newContext(env); - if (!ctx) return undefined; +function inferHover(env: HookEnvironment, file: OpenFile, node: tsserver.Identifier): HoverInference | undefined { + const ctx = contextForProgram(env.ts, file.program, env); const access = propertyAccessNamedBy(node, env.ts); const types = access ? inferTypeForExpression(ctx, access) : inferTypeForNode(ctx, node); if (types.length === 0) return undefined; @@ -227,7 +222,7 @@ function decorateQuickInfo( const node = file && getNodeAtPosition(file.sourceFile, ts, position); if (!file || !node || !ts.isIdentifier(node) || !isInferenceTarget(ts, file.checker, node)) return original; const key = `hover:${fileName}:${node.getStart(file.sourceFile)}`; - const inferred = cache.get(key, file.program, () => inferHover(env, node)); + const inferred = cache.get(key, file.program, () => inferHover(env, file, node)); return inferred ? withHoverInference(original, inferred) : original; } @@ -239,11 +234,11 @@ function decorateQuickInfo( */ function inferMemberEntries( env: HookEnvironment, + file: OpenFile, receiver: tsserver.Expression, position: number, ): tsserver.CompletionEntry[] { - const ctx = newContext(env, position); - if (!ctx) return []; + const ctx = contextForProgram(env.ts, file.program, env, position); const augmented = collectSuperModuleAugmentedMembers(ctx, receiver).map((member) => inferredCompletionEntry(env.ts, member.name, member.isMethod), ); @@ -288,7 +283,7 @@ function decorateCompletions( const key = `completions:${fileName}:${receiver.getStart(file.sourceFile)}-${receiver.getEnd()}`; return mergeCompletions( original, - cache.get(key, file.program, () => inferMemberEntries(env, receiver, position)), + cache.get(key, file.program, () => inferMemberEntries(env, file, receiver, position)), ); } diff --git a/packages/b2c-script-types/src/inference/reference-search.ts b/packages/b2c-script-types/src/inference/reference-search.ts index 7b3030205..8513d3413 100644 --- a/packages/b2c-script-types/src/inference/reference-search.ts +++ b/packages/b2c-script-types/src/inference/reference-search.ts @@ -24,7 +24,7 @@ import diagnosticsChannel from 'node:diagnostics_channel'; import type tsserver from 'typescript/lib/tsserverlibrary'; import type {InferenceContext} from './context'; -import {getNodeAtPosition} from './ast-helpers'; +import {spellingFilter} from './ast-helpers'; /** A reference a search finds: a name, or the module path of a require() call that loads the searched module. */ export type Reference = tsserver.Identifier | tsserver.StringLiteralLike; @@ -72,13 +72,16 @@ function namesIn(ts: typeof tsserver, file: tsserver.SourceFile): NameTable { return names; } -/** The identifiers spelled `text` in `file`, found where its text spells them. */ +/** The identifiers named `text` in `file`, read along the paths that lead to where its text spells them. */ function identifiersNamed(ts: typeof tsserver, file: tsserver.SourceFile, text: string): tsserver.Identifier[] { + const spells = spellingFilter(file, [text]); const found: tsserver.Identifier[] = []; - for (let pos = file.text.indexOf(text); pos >= 0; pos = file.text.indexOf(text, pos + text.length)) { - const node = getNodeAtPosition(file, ts, pos); - if (node && ts.isIdentifier(node) && node.text === text && node.getStart(file) === pos) found.push(node); - } + const visit = (node: tsserver.Node): void => { + if (!spells(node)) return; + if (!ts.isIdentifier(node)) ts.forEachChild(node, visit); + else if (node.text === text) found.push(node); + }; + visit(file); return found; } diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts index 181e37630..92a0318d3 100644 --- a/packages/b2c-script-types/src/inference/signatures.ts +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -21,7 +21,10 @@ import {typeDisplayString} 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. + * (`var Model = require('./model'); new Model(x)`) — the signature the call + * resolves to. Only a callee with several signatures needs the checker to + * pick one, which checks every argument the call passes (callback bodies + * included); a callee with one signature can only mean that one. */ export function resolveCalleeDeclaration( ctx: InferenceContext, @@ -31,10 +34,25 @@ export function resolveCalleeDeclaration( const sym = checker.getSymbolAtLocation(call.expression); const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; if (decl && ts.isFunctionLike(decl)) return decl; - const sigDecl = checker.getResolvedSignature(call)?.declaration; + const signatures = calleeSignatures(ctx, call); + const sigDecl = (signatures.length > 1 ? checker.getResolvedSignature(call) : signatures[0])?.declaration; return sigDecl && ts.isFunctionLike(sigDecl) ? sigDecl : undefined; } +/** + * The signatures `call` chooses from: a `new` call's construct signatures, + * or the call signatures of a callee without any (a JS constructor + * function, which `new` calls like any other function). + */ +function calleeSignatures( + ctx: InferenceContext, + call: tsserver.CallExpression | tsserver.NewExpression, +): readonly tsserver.Signature[] { + const callee = ctx.checker.getTypeAtLocation(call.expression); + const construct = ctx.ts.isNewExpression(call) ? callee.getConstructSignatures() : []; + return construct.length > 0 ? construct : callee.getCallSignatures(); +} + /** The function `value` is or names (`function () {...}`, `() => x`, a local function's identifier). */ export function functionOf( ctx: InferenceContext, diff --git a/packages/b2c-script-types/src/inference/this-properties.ts b/packages/b2c-script-types/src/inference/this-properties.ts index 4e4738c97..699808afb 100644 --- a/packages/b2c-script-types/src/inference/this-properties.ts +++ b/packages/b2c-script-types/src/inference/this-properties.ts @@ -15,6 +15,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; +import {spellingFilter} from './ast-helpers'; import type {InferenceContext} from './context'; /** `this.x = value`: a plain assignment to a member of `this`. */ @@ -49,7 +50,9 @@ export function thisMembersStoring( const body = (param.parent as tsserver.FunctionLikeDeclaration).body; const paramSymbol = ts.isIdentifier(param.name) ? checker.getSymbolAtLocation(param.name) : undefined; if (!body || !paramSymbol) return members; + const spellsParam = spellingFilter(body, [paramSymbol.name]); const visit = (node: tsserver.Node): void => { + if (!spellsParam(node)) return; const stores = isThisMemberStore(ts, node) && checker.getSymbolAtLocation(node.right) === paramSymbol; const member = stores ? checker.getSymbolAtLocation(node.left.name) : undefined; if (member) members.add(member); diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts index 97ddd3c82..6a1ea6174 100644 --- a/packages/b2c-script-types/src/inference/usage-profile.ts +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -14,7 +14,8 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {hasExplicitParameterType} from './ast-helpers'; +import {hasExplicitParameterType, spellingFilter} from './ast-helpers'; +import type {SpellingFilter} from './ast-helpers'; import {MAX_USAGE_FORWARDING_HOPS} from './constants'; import type {InferenceContext} from './context'; import {acceptsArgumentCount} from './signatures'; @@ -229,13 +230,16 @@ function usedAsType(ctx: InferenceContext, reference: tsserver.Expression): tsse /** * The value a profile describes: its symbol, the name it is declared by, its * own (unnarrowed) type, and the members of `this` it is stored in (see - * ./this-properties), whose reads are reads of the value. + * ./this-properties), whose reads are reads of the value. `mayReference` + * tells the walks which subtrees spell one of those names at all; no other + * one can hold a reference. */ interface ProfileTarget { readonly symbol: tsserver.Symbol; readonly declarationName: tsserver.Identifier; readonly declaredType: tsserver.Type; readonly storedIn: ReadonlySet; + readonly mayReference: SpellingFilter; } function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsserver.Node): node is tsserver.Expression { @@ -250,8 +254,9 @@ function isReferenceTo(ctx: InferenceContext, target: ProfileTarget, node: tsser /** True when `condition` tests the value for a member (`'m' in x`, `x.hasOwnProperty('m')`, ...). */ function testsPresence(ctx: InferenceContext, target: ProfileTarget, condition: tsserver.Node): boolean { const visit = (node: tsserver.Node): boolean => - (isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || - ctx.ts.forEachChild(node, visit) === true; + target.mayReference(node) && + ((isReferenceTo(ctx, target, node) && presenceTestedMember(ctx, node) !== undefined) || + ctx.ts.forEachChild(node, visit) === true); return visit(condition); } @@ -480,6 +485,7 @@ function recordReference(use: ReferenceUse, profile: ProfileBuilder): void { function collectProfile(ctx: InferenceContext, target: ProfileTarget, scope: tsserver.Node): UsageProfile { const profile = emptyProfile(); const visit = (node: tsserver.Node, inVariantBranch: boolean): void => { + if (!target.mayReference(node)) return; if (isReferenceTo(ctx, target, node)) recordReference({ctx, target, reference: node, inVariantBranch}, profile); const branches = inVariantBranch ? [] : variantBranches(ctx, target, node); ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); @@ -532,7 +538,8 @@ function ownProfileOf( const scope = profileScope(ctx, declaration, storedIn); if (!scope) return EMPTY_PROFILE; const declaredType = checker.getTypeOfSymbolAtLocation(symbol, declarationName); - const profile = collectProfile(ctx, {symbol, declarationName, declaredType, storedIn}, scope); + const mayReference = spellingFilter(scope, [declarationName.text, ...[...storedIn].map((member) => member.name)]); + const profile = collectProfile(ctx, {symbol, declarationName, declaredType, storedIn, mayReference}, scope); ctx.profiles.set(symbol, profile); return profile; } diff --git a/packages/b2c-script-types/test/reference-search.test.js b/packages/b2c-script-types/test/reference-search.test.js index 8b14b5fa1..333f5fe0d 100644 --- a/packages/b2c-script-types/test/reference-search.test.js +++ b/packages/b2c-script-types/test/reference-search.test.js @@ -15,6 +15,8 @@ const {searchReferences} = require('../src/inference/reference-search'); const {createFixtureHost, createFixtureLanguageService} = require('./helpers/fixture-language-service'); const {countReferenceSearches} = require('./helpers/reference-searches'); +const BACKSLASH = '\\'; + /** The identifier `text` starts with, in `fileName` (`'helper(p)'` names the `helper` before `(p)`). */ function nameAt(ctx, fileName, text) { const file = ctx.program.getSourceFile(fileName); @@ -141,6 +143,29 @@ describe('reference search', () => { ]); }); + it('finds a use whose name is spelled with a unicode escape, and one deep inside nested code', () => { + // `helper` with its `e` written as a unicode escape sequence. + const e = `${BACKSLASH}u0065`; + const result = search( + { + '/helpers.js': [ + 'function helper(p) { return p; }', + `h${e}lper(1);`, + 'function outer() { return [{run: function () { if (true) { return helper(2); } }}]; }', + 'module.exports = {};', + ].join('\n'), + }, + '/helpers.js', + 'helper(p)', + ); + + assert.deepEqual(result.references, [ + '/helpers.js function helper(p) { return p; }', + `/helpers.js h${e}lper(1)`, + '/helpers.js helper(2)', + ]); + }); + it('reports no uses in declaration files', () => { const result = search( { From a986cef8317475a7afd72b6501c75151c8845b88 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:53:29 +0000 Subject: [PATCH 66/76] fix(script-types): type `this` in SFRA middleware as the route SFRA calls each middleware step with its Route as `this`, and steps register route event listeners with `this.on('route:BeforeComplete', function (req, res) {...})`. The Middleware type now declares that `this`, so Route.on's listener signature types those parameters; on the real cartridges 27 listener parameters go from `any` to Request/Response. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-sfra-route-this.md | 6 +++ packages/b2c-script-types/test/index.test.js | 38 +++++++++++++++++++ .../b2c-script-types/types/sfra/server.d.ts | 8 +++- 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 .changeset/script-types-sfra-route-this.md diff --git a/.changeset/script-types-sfra-route-this.md b/.changeset/script-types-sfra-route-this.md new file mode 100644 index 000000000..7d92a80fe --- /dev/null +++ b/.changeset/script-types-sfra-route-this.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API IntelliSense now types `this` in SFRA middleware as the route, so the `req`/`res` of listeners registered with `this.on('route:BeforeComplete', function (req, res) {...})` get completions instead of `any`. diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 26a64509c..86943ca38 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -689,6 +689,44 @@ describe('create() proxy — usage inference wiring', () => { assert.ok(names.includes('geolocation'), `expected geolocation among completions, got: ${names.join(', ')}`); }); + it("types a route event listener's parameters through the route a middleware step runs on", () => { + // SFRA calls each middleware step with the route as `this`, so the + // listeners a step registers with `this.on(...)` are typed by Route.on. + const files = { + '/c/cartridge/controllers/Cart.js': ` + var server = require('server'); + server.get('Show', function (req, res, next) { + this.on('route:BeforeComplete', function (request, response) { + var data = response.getViewData(); + }); + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const controllerFile = '/c/cartridge/controllers/Cart.js'; + const source = files[controllerFile]; + + const hover = proxy.getQuickInfoAtPosition(controllerFile, source.indexOf('response)')); + const hoverText = (hover?.displayParts ?? []).map((p) => p.text).join(''); + assert.match(hoverText, /response: Response/); + const dotPos = source.indexOf('response.getViewData') + 'response.'.length; + const names = (proxy.getCompletionsAtPosition(controllerFile, dotPos, undefined)?.entries ?? []).map((e) => e.name); + assert.ok(names.includes('setViewData'), `expected setViewData among completions, got: ${names.join(', ')}`); + }); + it('resolves members across a multi-cartridge superModule stack, including intermediate augmentations', () => { const files = { '/types.d.ts': AMBIENT_TYPES, diff --git a/packages/b2c-script-types/types/sfra/server.d.ts b/packages/b2c-script-types/types/sfra/server.d.ts index 046f99dbd..5474e8174 100644 --- a/packages/b2c-script-types/types/sfra/server.d.ts +++ b/packages/b2c-script-types/types/sfra/server.d.ts @@ -30,8 +30,12 @@ declare module 'server' { } declare module 'server/server' { - /** Middleware function executed within a route chain. */ - export type Middleware = (req: Request, res: Response, next: NextFunction) => void; + /** + * Middleware function executed within a route chain. The route calls each + * step with itself as `this`, so a step can register route event listeners + * (`this.on('route:BeforeComplete', ...)`). + */ + export type Middleware = (this: Route, req: Request, res: Response, next: NextFunction) => void; /** Continuation passed to middleware. Pass an Error to abort the chain. */ export type NextFunction = (err?: Error) => void; From b4096f7fbf5c9f6f7d9c27d23d8ae5f37c4cfd99 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:06:41 +0000 Subject: [PATCH 67/76] feat(script-types): type functions handed to declared APIs from their callback types A named function passed to a callee no project parameter receives (an SFRA middleware module handed to `server.get('Show', cache.applyDefaultCache)`, a helper passed to a native `forEach`) had no call site to learn from. Such an argument is now a handoff: the callee's declared callback type, rest parameters included, types the function's parameters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 4 +- packages/b2c-script-types/README.md | 6 +- .../plugin/inference/call-sites.js | 52 +++++++++------ .../b2c-script-types/plugin/inference/core.js | 10 ++- .../plugin/inference/signatures.js | 32 +++++++++- .../plugin/inference/value-flow.js | 15 ++++- .../src/inference/call-sites.ts | 57 ++++++++++------- .../b2c-script-types/src/inference/context.ts | 4 +- .../b2c-script-types/src/inference/core.ts | 17 +++-- .../src/inference/signatures.ts | 45 +++++++++++-- .../src/inference/value-flow.ts | 45 +++++++++++-- .../test/eval/ground-truth.test.js | 6 +- packages/b2c-script-types/test/index.test.js | 43 +++++++++++++ .../test/usage-inference.value-flow.test.js | 64 +++++++++++++++++++ 15 files changed, 325 insertions(+), 77 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 26f95c194..9fa3551d2 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 cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). +Script API IntelliSense can now infer types for undocumented cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), the callback types of the APIs a function is handed to (SFRA middleware modules passed to `server.get`), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index de84b2fdf..1106fb174 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -133,7 +133,7 @@ Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or The evidence it weighs: -- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). +- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). A function handed to a documented API rather than to a project helper takes the parameter types of the callback that API declares: `basket.getProductLineItems().toArray().forEach(keep)` types `keep`'s parameter as a `ProductLineItem`. - **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. - **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), a union when unrelated values were (`(Product | Basket)[]`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. - **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. @@ -146,7 +146,7 @@ The evidence it weighs: Two more SFRA idioms are covered: - **Iteration callbacks** — a callback's parameters are what the helper it is passed to calls it with, for that call's arguments: `collections.forEach(product.getVariants(), function (variant) {...})` types `variant` as a `Variant`, and `collections.reduce(basket.productLineItems, function (total, lineItem) {...}, 0)` types `lineItem`, the second parameter, as a `ProductLineItem`. This reads the helper's own `callback(item)` / `callback.call(scope, item)` calls, so a project's own helpers (`eachShipment(basket, function (shipment) {...})`) and wrappers that hand the callback on to another helper work the same way; an accumulator the callback's result is fed back into stays unknown, as with a native `reduce`. A helper whose source isn't available falls back to its name: `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` take an element of the collection travelling alongside the callback (anything with `iterator()`/`next()`, i.e. `dw.util.Collection` and friends). Manual iterator loops (`var iter = coll.iterator(); while (iter.hasNext()) { var item = iter.next(); }`) and ternary returns like stock `collections.first` (`return it.hasNext() ? it.next() : null`) resolve through the same chain machinery. -- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature. Inference deliberately stays out of the way there. +- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature, and `this` as the route, so the `req`/`res` of a `this.on('route:BeforeComplete', function (req, res) {...})` listener are typed as well. Inference deliberately stays out of the way there. A named middleware function that lives in its own module (`server.get('Show', cache.applyDefaultCache, ...)`, `consentTracking.consent`) has no contextual type of its own; with inference on, it takes `req`/`res`/`next` from the routes it is handed to. 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/README.md b/packages/b2c-script-types/README.md index df0d58663..0612f7d7c 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -65,8 +65,10 @@ value widened to `any` by plain TypeScript inference, and that `any` propagates to every caller. Passing `inferUsage: true` in the plugin config (off by default) makes the plugin infer a plausible type for these cases the way IntelliJ does: from call-site arguments (including `new`, `.call`/`.apply` -and callbacks), return values, the typed Script API calls a value is passed -to, and the members and `instanceof`/`typeof` checks in the helper's own body. +and callbacks), the callback types of the declared APIs a function is handed to +(`server.get('Show', cache.applyDefaultCache)`), return values, the typed Script +API calls a value is passed to, and the members and `instanceof`/`typeof` +checks in the helper's own body. Values are followed where they go — into object members, `this.x`, pushed arrays, factory returns and `module.exports` — and a generic helper such as `collections.find(basket.shipments, ...)` is inferred again for the call at diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index cc0ecb953..7d25f160d 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -50,22 +50,22 @@ function claimSearch(ctx, next, searched) { return true; } /** - * Finds actual call sites for `nameNode`, following up to - * MAX_REFERENCE_HOPS names the function value flows into (see ./value-flow: - * require() bindings, exports, aliases, factories returning it, parameters - * it is passed to) when a reference doesn't sit directly in callee - * position. Stops early once ctx.referenceBudget (result count) or - * ctx.searchBudget (searches) runs out, returning whatever call sites - * were already found rather than continuing to fan out — an under-inferred - * (but still heuristic, clearly-labeled) result beats hanging on a - * widely-referenced helper. Results are memoized per name node for the + * Finds actual call sites for `nameNode`, and the handoffs to declared + * callees, following up to MAX_REFERENCE_HOPS names the function value flows + * into (see ./value-flow: require() bindings, exports, aliases, factories + * returning it, parameters it is passed to) when a reference doesn't sit + * directly in callee position. Stops early once ctx.referenceBudget (result + * count) or ctx.searchBudget (searches) runs out, returning whatever call + * sites were already found rather than continuing to fan out — an + * under-inferred (but still heuristic, clearly-labeled) result beats hanging + * on a widely-referenced helper. Results are memoized per name node for the * duration of the request. */ function collectCallSites(ctx, nameNode) { const memoized = ctx.callSiteMemo.get(nameNode); if (memoized) return memoized; - const calls = []; + const found = { calls: [], handoffs: [] }; const searched = new Set(); let frontier = [{ name: nameNode, role: 'value' }]; let localBudget = Math.min(constants_1.MAX_REFERENCES_PER_CALL, ctx.referenceBudget); @@ -75,22 +75,22 @@ function collectCallSites(ctx, nameNode) { if (localBudget <= 0) break; if (claimSearch(ctx, next, searched)) { - localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + localBudget = collectCallsFromName(ctx, next, found, nextFrontier, localBudget); } } frontier = nextFrontier; } - ctx.callSiteMemo.set(nameNode, calls); - return calls; + ctx.callSiteMemo.set(nameNode, found); + return found; } /** - * Runs one reference search for `next.name` and sorts each hit into either a - * resolved call site (pushed to `calls`, once per call) or a further name to - * chase on the next hop (pushed to `nextFrontier`). Consumes up to - * `localBudget` result slots, returning the remaining local budget so the - * caller can stop fanning out once it's exhausted. + * Runs one reference search for `next.name` and sorts each hit into a + * resolved call site or handoff (recorded in `found`, once per call) or a + * further name to chase on the next hop (pushed to `nextFrontier`). Consumes + * up to `localBudget` result slots, returning the remaining local budget so + * the caller can stop fanning out once it's exhausted. */ -function collectCallsFromName(ctx, next, calls, nextFrontier, localBudget) { +function collectCallsFromName(ctx, next, found, nextFrontier, localBudget) { for (const reference of (0, reference_search_1.searchReferences)(ctx, next.name)) { if (localBudget <= 0) break; @@ -99,11 +99,21 @@ function collectCallsFromName(ctx, next, calls, nextFrontier, localBudget) { const target = (0, value_flow_1.valueTarget)(ctx, reference, next.role); if (target?.kind === 'name') nextFrontier.push(target); - else if (target && !calls.some((site) => site.node === target.call.node)) - calls.push(target.call); + else if (target) + record(found, target); } return localBudget; } +/** Records a call site or handoff in `found`, unless a reference found earlier already led to the same call. */ +function record(found, target) { + if (target.kind === 'call') { + if (!found.calls.some((site) => site.node === target.call.node)) + found.calls.push(target.call); + } + else if (!found.handoffs.some((handoff) => handoff.call === target.handoff.call)) { + found.handoffs.push(target.handoff); + } +} /** * The caller's own parameter an argument passes on as is (`items` in * `getMatchingProducts(productId, items)` inside a function taking `items`), diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index fc4a31c0f..aaceb06b6 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -361,17 +361,21 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { * The argument types a parameter receives: at every call site of its * function across the project (`helper(x)`, `new Helper(x)`, * `Helper.call(this, x)`, and for a hook script's export, the - * `HookMgr.callHook(...)` calls dispatched to it), or — for an anonymous - * callback with no name to search for — from the collection it iterates. + * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it + * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an + * anonymous callback with no name to search for — from the collection it + * iterates. */ function parameterEvidence(ctx, fn, paramIndex, depth) { const nameNode = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts); if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - return [...(0, call_sites_1.collectCallSites)(ctx, nameNode), ...(0, hook_calls_1.hookCallSites)(ctx, fn)].flatMap((site) => { + const { calls, handoffs } = (0, call_sites_1.collectCallSites)(ctx, nameNode); + const passed = [...calls, ...(0, hook_calls_1.hookCallSites)(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; }); + return [...passed, ...handoffs.flatMap((handoff) => (0, signatures_1.handedOffParameterTypes)(ctx, handoff, paramIndex))]; } /** * The types one call-site argument passes. A caller's own untyped parameter diff --git a/packages/b2c-script-types/plugin/inference/signatures.js b/packages/b2c-script-types/plugin/inference/signatures.js index 79d80e90e..5b230d047 100644 --- a/packages/b2c-script-types/plugin/inference/signatures.js +++ b/packages/b2c-script-types/plugin/inference/signatures.js @@ -10,6 +10,7 @@ exports.functionOf = functionOf; exports.isElementFirstCallbackCall = isElementFirstCallbackCall; exports.acceptsArgumentCount = acceptsArgumentCount; exports.callbackParameterTypes = callbackParameterTypes; +exports.handedOffParameterTypes = handedOffParameterTypes; const constants_1 = require("./constants"); const type_helpers_1 = require("./type-helpers"); /** @@ -64,12 +65,28 @@ function acceptsArgumentCount(ctx, signature, count) { const required = parameters.filter((parameter) => !checker.isOptionalParameter(parameter)).length; return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); } +/** + * The type `signature` gives the argument at `argIndex`: its parameter's + * type, or for an argument a rest parameter collects (`...middleware: + * Middleware[]`), the element type of that parameter's array. + */ +function argumentTypeAt(ctx, signature, argIndex, location) { + const { ts, checker } = ctx; + const parameters = signature.getParameters(); + const last = parameters[parameters.length - 1]; + const rest = last?.valueDeclaration && ts.isParameter(last.valueDeclaration) && ts.isRestParameter(last.valueDeclaration); + if (argIndex < parameters.length - (rest ? 1 : 0)) { + return checker.getTypeOfSymbolAtLocation(parameters[argIndex], location); + } + return rest + ? checker.getIndexTypeOfType(checker.getTypeOfSymbolAtLocation(last, location), ts.IndexKind.Number) + : undefined; +} /** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ function callbackParameterType(ctx, signature, call, argIndex, paramIndex) { const { checker } = ctx; - const parameter = signature.getParameters()[argIndex]; - const callbackType = parameter && checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(parameter, call)); - const callbackParameter = callbackType?.getCallSignatures()[0]?.getParameters()[paramIndex]; + const callbackType = argumentTypeAt(ctx, signature, argIndex, call); + const callbackParameter = callbackType && checker.getNonNullableType(callbackType).getCallSignatures()[0]?.getParameters()[paramIndex]; return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); } /** @@ -89,3 +106,12 @@ function callbackParameterTypes(ctx, methodTypes, call, argIndex, paramIndex) { return first && rest.every((type) => type && (0, type_helpers_1.typeDisplayString)(ctx, type) === key) ? [first] : []; }); } +/** + * What the callee of `handoff` declares it passes parameter `paramIndex` of + * the function it is handed: `Request` for `req` of the middleware step in + * `server.get('Show', cache.applyDefaultCache)`. + */ +function handedOffParameterTypes(ctx, handoff, paramIndex) { + const callee = ctx.checker.getTypeAtLocation(handoff.call.expression); + return callbackParameterTypes(ctx, [callee], handoff.call, handoff.argIndex, paramIndex); +} diff --git a/packages/b2c-script-types/plugin/inference/value-flow.js b/packages/b2c-script-types/plugin/inference/value-flow.js index c915ea61c..177481fdc 100644 --- a/packages/b2c-script-types/plugin/inference/value-flow.js +++ b/packages/b2c-script-types/plugin/inference/value-flow.js @@ -190,12 +190,21 @@ function returnedBy(ctx, value) { const name = factory && getReferenceNameNode(factory, ctx.ts); return name && { kind: 'name', name, role: 'factory' }; } +/** The call `value` is an argument of, as a {@link Handoff}: what {@link receivingParameter} leaves over. */ +function handedOff(ctx, value) { + const call = value.parent; + if (!ctx.ts.isCallExpression(call)) + return undefined; + const argIndex = call.arguments.indexOf(value); + return argIndex >= 0 ? { kind: 'handoff', handoff: { call, argIndex } } : undefined; +} /** * Where one reference to the function leads. In the `value` role the * reference is the function itself: a call invoking it * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, * `require('./helper')(x)`) is a call site; a binding, an assignment, an - * argument or a `return` leads on to the next name holding it. In the + * argument a project function receives or a `return` leads on to the next + * name holding it; any other argument is a handoff. In the * `factory` role the reference names a function returning it, so a call of * that reference evaluates to the function and is followed as a value in * turn. @@ -209,5 +218,7 @@ function valueTarget(ctx, reference, role) { return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); } const call = directCall(value, ts) ?? borrowedCall(value, ts); - return call ? { kind: 'call', call } : (heldBy(ctx, value, 'value') ?? returnedBy(ctx, value)); + if (call) + return { kind: 'call', call }; + return heldBy(ctx, value, 'value') ?? returnedBy(ctx, value) ?? handedOff(ctx, value); } diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index 349370b64..d0e0bd780 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -11,7 +11,9 @@ // function, a parameter it is passed to — see ./value-flow), so this layer // follows a bounded number of those hops to reach the real call expressions. // "Call" includes `new Helper(x)` and `Helper.call(this, x)` — the shapes -// SFRA's constructor-function "class" models are invoked and inherited with. +// SFRA's constructor-function "class" models are invoked and inherited with +// — and a function handed to a declared API that calls it (`server.get( +// 'Show', cache.applyDefaultCache)`), whose declaration types the arguments. // An argument that is the caller's own untyped parameter is recognized here // too, so ./core can follow a value handed down a chain of helpers to where // it really comes from. @@ -26,7 +28,7 @@ import {valueDeclarationOf} from './member-values'; import {searchReferences} from './reference-search'; import {informativeParts} from './type-helpers'; import {enclosingFunction, valueTarget} from './value-flow'; -import type {CallSite, ValueRole} from './value-flow'; +import type {CallSites, ReferenceTarget, ValueRole} from './value-flow'; /** A name whose references are searched next, and how it holds the function. */ interface FrontierName { @@ -70,21 +72,21 @@ function claimSearch(ctx: InferenceContext, next: FrontierName, searched: Set(); let frontier: FrontierName[] = [{name: nameNode, role: 'value'}]; let localBudget = Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget); @@ -94,27 +96,27 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident for (const next of frontier) { if (localBudget <= 0) break; if (claimSearch(ctx, next, searched)) { - localBudget = collectCallsFromName(ctx, next, calls, nextFrontier, localBudget); + localBudget = collectCallsFromName(ctx, next, found, nextFrontier, localBudget); } } frontier = nextFrontier; } - ctx.callSiteMemo.set(nameNode, calls); - return calls; + ctx.callSiteMemo.set(nameNode, found); + return found; } /** - * Runs one reference search for `next.name` and sorts each hit into either a - * resolved call site (pushed to `calls`, once per call) or a further name to - * chase on the next hop (pushed to `nextFrontier`). Consumes up to - * `localBudget` result slots, returning the remaining local budget so the - * caller can stop fanning out once it's exhausted. + * Runs one reference search for `next.name` and sorts each hit into a + * resolved call site or handoff (recorded in `found`, once per call) or a + * further name to chase on the next hop (pushed to `nextFrontier`). Consumes + * up to `localBudget` result slots, returning the remaining local budget so + * the caller can stop fanning out once it's exhausted. */ function collectCallsFromName( ctx: InferenceContext, next: FrontierName, - calls: CallSite[], + found: CallSites, nextFrontier: FrontierName[], localBudget: number, ): number { @@ -124,11 +126,20 @@ function collectCallsFromName( ctx.referenceBudget--; const target = valueTarget(ctx, reference, next.role); if (target?.kind === 'name') nextFrontier.push(target); - else if (target && !calls.some((site) => site.node === target.call.node)) calls.push(target.call); + else if (target) record(found, target); } return localBudget; } +/** Records a call site or handoff in `found`, unless a reference found earlier already led to the same call. */ +function record(found: CallSites, target: Exclude): void { + if (target.kind === 'call') { + if (!found.calls.some((site) => site.node === target.call.node)) found.calls.push(target.call); + } else if (!found.handoffs.some((handoff) => handoff.call === target.handoff.call)) { + found.handoffs.push(target.handoff); + } +} + /** * The caller's own parameter an argument passes on as is (`items` in * `getMatchingProducts(productId, items)` inside a function taking `items`), diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 1d4c1ac74..54f8e1327 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -14,7 +14,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {NO_BINDINGS} from './bindings'; import type {Bindings} from './bindings'; -import type {CallSite} from './value-flow'; +import type {CallSites} from './value-flow'; import {MAX_INFERENCE_DEPTH, MAX_REFERENCES_PER_REQUEST, MAX_SEARCHES_PER_REQUEST} from './constants'; import type {UsageProfile} from './usage-profile'; import type {HookRegistration} from '../resolver/hook-registry'; @@ -95,7 +95,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 5599b3745..0890363ee 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -43,7 +43,12 @@ import {genericResultSource} from './generic-calls'; import {hookCallSites, hookImplementations} from './hook-calls'; import {localMemberValues, memberValueExpressions, valueDeclarationOf} from './member-values'; import {decideType, limitUnion, normalizeCandidates} from './policy'; -import {callbackParameterTypes, isElementFirstCallbackCall, resolveCalleeDeclaration} from './signatures'; +import { + callbackParameterTypes, + handedOffParameterTypes, + isElementFirstCallbackCall, + resolveCalleeDeclaration, +} from './signatures'; import type {ExpressionResolver} from './super-module'; import {resolveSuperModuleMemberTypes, resolveSuperModuleTypes, traceSuperModuleAccess} from './super-module'; import { @@ -512,8 +517,10 @@ function resolveIdentifierTypes( * The argument types a parameter receives: at every call site of its * function across the project (`helper(x)`, `new Helper(x)`, * `Helper.call(this, x)`, and for a hook script's export, the - * `HookMgr.callHook(...)` calls dispatched to it), or — for an anonymous - * callback with no name to search for — from the collection it iterates. + * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it + * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an + * anonymous callback with no name to search for — from the collection it + * iterates. */ function parameterEvidence( ctx: InferenceContext, @@ -523,10 +530,12 @@ function parameterEvidence( ): tsserver.Type[] { const nameNode = getReferenceNameNode(fn, ctx.ts); if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - return [...collectCallSites(ctx, nameNode), ...hookCallSites(ctx, fn)].flatMap((site) => { + const {calls, handoffs} = collectCallSites(ctx, nameNode); + const passed = [...calls, ...hookCallSites(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; }); + return [...passed, ...handoffs.flatMap((handoff) => handedOffParameterTypes(ctx, handoff, paramIndex))]; } /** diff --git a/packages/b2c-script-types/src/inference/signatures.ts b/packages/b2c-script-types/src/inference/signatures.ts index 92a0318d3..1d7bc1c89 100644 --- a/packages/b2c-script-types/src/inference/signatures.ts +++ b/packages/b2c-script-types/src/inference/signatures.ts @@ -10,13 +10,16 @@ // (`items` built by `items.push(lineItem)`) gets no contextual typing from the // checker, so `items.filter(function (item) {...})` leaves `item` untyped; // the method's own declaration on the inferred type still says what `item` -// is, exactly as IntelliJ reads it. +// is, exactly as IntelliJ reads it. A named function handed to a declared +// API (`server.get('Show', cache.applyDefaultCache)`) is not contextually +// typed either, and is read the same way. import type tsserver from 'typescript/lib/tsserverlibrary'; import {ELEMENT_FIRST_CALLBACK_CALLEES} from './constants'; import type {InferenceContext} from './context'; import {typeDisplayString} from './type-helpers'; +import type {Handoff} from './value-flow'; /** * Resolves the function-like declaration a call expression's callee refers @@ -81,6 +84,30 @@ export function acceptsArgumentCount(ctx: InferenceContext, signature: tsserver. return count >= required && (count <= parameters.length || ts.hasRestParameter(declaration)); } +/** + * The type `signature` gives the argument at `argIndex`: its parameter's + * type, or for an argument a rest parameter collects (`...middleware: + * Middleware[]`), the element type of that parameter's array. + */ +function argumentTypeAt( + ctx: InferenceContext, + signature: tsserver.Signature, + argIndex: number, + location: tsserver.Node, +): tsserver.Type | undefined { + const {ts, checker} = ctx; + const parameters = signature.getParameters(); + const last = parameters[parameters.length - 1]; + const rest = + last?.valueDeclaration && ts.isParameter(last.valueDeclaration) && ts.isRestParameter(last.valueDeclaration); + if (argIndex < parameters.length - (rest ? 1 : 0)) { + return checker.getTypeOfSymbolAtLocation(parameters[argIndex], location); + } + return rest + ? checker.getIndexTypeOfType(checker.getTypeOfSymbolAtLocation(last, location), ts.IndexKind.Number) + : undefined; +} + /** The type `signature` gives parameter `paramIndex` of the callback it takes as argument `argIndex`. */ function callbackParameterType( ctx: InferenceContext, @@ -90,9 +117,9 @@ function callbackParameterType( paramIndex: number, ): tsserver.Type | undefined { const {checker} = ctx; - const parameter = signature.getParameters()[argIndex]; - const callbackType = parameter && checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(parameter, call)); - const callbackParameter = callbackType?.getCallSignatures()[0]?.getParameters()[paramIndex]; + const callbackType = argumentTypeAt(ctx, signature, argIndex, call); + const callbackParameter = + callbackType && checker.getNonNullableType(callbackType).getCallSignatures()[0]?.getParameters()[paramIndex]; return callbackParameter && checker.getTypeOfSymbolAtLocation(callbackParameter, call); } @@ -121,3 +148,13 @@ export function callbackParameterTypes( return first && rest.every((type) => type && typeDisplayString(ctx, type) === key) ? [first] : []; }); } + +/** + * What the callee of `handoff` declares it passes parameter `paramIndex` of + * the function it is handed: `Request` for `req` of the middleware step in + * `server.get('Show', cache.applyDefaultCache)`. + */ +export function handedOffParameterTypes(ctx: InferenceContext, handoff: Handoff, paramIndex: number): tsserver.Type[] { + const callee = ctx.checker.getTypeAtLocation(handoff.call.expression); + return callbackParameterTypes(ctx, [callee], handoff.call, handoff.argIndex, paramIndex); +} diff --git a/packages/b2c-script-types/src/inference/value-flow.ts b/packages/b2c-script-types/src/inference/value-flow.ts index cc4dbaee4..2ae33e643 100644 --- a/packages/b2c-script-types/src/inference/value-flow.ts +++ b/packages/b2c-script-types/src/inference/value-flow.ts @@ -10,10 +10,12 @@ // `module.exports = Model` (consumers reach it through `require()`), a // factory that picks a model (`return require('…/boolean')`, then // `var Model = getModel(definition); new Model(...)`), and a model passed on -// as an argument (`createRefinement(search, definition, Model)`). -// valueTarget() says where one such position leads: to a call that invokes -// the value, or to the next name that holds it. ./call-sites runs the -// reference searches and follows those names, a bounded number of hops. +// as an argument (`createRefinement(search, definition, Model)`), or to a +// callee outside the project that calls it (`server.get('Show', +// cache.applyDefaultCache)`). valueTarget() says where one such position +// leads: to a call that invokes the value, to a call handing it to such a +// callee, or to the next name that holds it. ./call-sites runs the reference +// searches and follows those names, a bounded number of hops. import type tsserver from 'typescript/lib/tsserverlibrary'; @@ -36,9 +38,28 @@ export interface CallSite { /** How a searched name holds the function: as the function itself, or as a factory whose calls return it. */ export type ValueRole = 'factory' | 'value'; -/** Where a reference leads: a call site, or a further name whose references lead on. */ +/** + * A call handing the function as an argument to a callee that is not a + * project function receiving it in a parameter: a Script API or SFRA + * declaration (`server.get('Show', cache.applyDefaultCache)`, + * `lineItems.forEach(addLineItem)`). The callee calls it, with what its + * declaration says the callback at `argIndex` receives. + */ +export interface Handoff { + readonly call: tsserver.CallExpression; + readonly argIndex: number; +} + +/** Where a function is called: its call sites, and the calls handing it to a declared callee that calls it. */ +export interface CallSites { + readonly calls: CallSite[]; + readonly handoffs: Handoff[]; +} + +/** Where a reference leads: a call site, a handoff, or a further name whose references lead on. */ export type ReferenceTarget = | {readonly kind: 'call'; readonly call: CallSite} + | {readonly kind: 'handoff'; readonly handoff: Handoff} | {readonly kind: 'name'; readonly name: tsserver.Identifier; readonly role: ValueRole}; /** A function-like declaration, or the class a constructor is invoked through. */ @@ -242,12 +263,21 @@ function returnedBy(ctx: InferenceContext, value: tsserver.Node): ReferenceTarge return name && {kind: 'name', name, role: 'factory'}; } +/** The call `value` is an argument of, as a {@link Handoff}: what {@link receivingParameter} leaves over. */ +function handedOff(ctx: InferenceContext, value: tsserver.Node): ReferenceTarget | undefined { + const call = value.parent; + if (!ctx.ts.isCallExpression(call)) return undefined; + const argIndex = call.arguments.indexOf(value as tsserver.Expression); + return argIndex >= 0 ? {kind: 'handoff', handoff: {call, argIndex}} : undefined; +} + /** * Where one reference to the function leads. In the `value` role the * reference is the function itself: a call invoking it * (`helper(x)`, `new Helper(x)`, `Helper.call(this, x)`, * `require('./helper')(x)`) is a call site; a binding, an assignment, an - * argument or a `return` leads on to the next name holding it. In the + * argument a project function receives or a `return` leads on to the next + * name holding it; any other argument is a handoff. In the * `factory` role the reference names a function returning it, so a call of * that reference evaluates to the function and is followed as a value in * turn. @@ -265,5 +295,6 @@ export function valueTarget( return invoked ? valueTarget(ctx, call, 'value') : heldBy(ctx, value, 'factory'); } const call = directCall(value, ts) ?? borrowedCall(value, ts); - return call ? {kind: 'call', call} : (heldBy(ctx, value, 'value') ?? returnedBy(ctx, value)); + if (call) return {kind: 'call', call}; + return heldBy(ctx, value, 'value') ?? returnedBy(ctx, value) ?? handedOff(ctx, value); } diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 77639fcf9..2f72c621f 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -352,12 +352,12 @@ function silentReason(languageService, fileName, position) { /** Classifies a silent parameter of a named function by what its call sites gave. */ function callSiteSilence(ctx, nameNode, param) { - const sites = collectCallSites(ctx, nameNode); + const {calls, handoffs} = collectCallSites(ctx, nameNode); const index = param.parent.parameters.indexOf(param); - const evidence = sites.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); + const evidence = calls.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); if (evidence.length > 0) return 'dropped-by-policy'; if (ctx.searchBudget <= 0 || ctx.referenceBudget <= 0) return 'budget'; - return sites.length === 0 ? 'no-call-sites' : 'no-evidence'; + return calls.length + handoffs.length === 0 ? 'no-call-sites' : 'no-evidence'; } const SLOWEST_REPORTED = 20; diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 86943ca38..f1e074027 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -689,6 +689,49 @@ describe('create() proxy — usage inference wiring', () => { assert.ok(names.includes('geolocation'), `expected geolocation among completions, got: ${names.join(', ')}`); }); + it('types a middleware module function from the server.get() routes it is handed to', () => { + // SFRA's own middleware (cache.applyDefaultCache, csrf.validateRequest, + // ...) is written as plain exported functions and only ever handed to + // server.get/post/use by reference, where nothing types its parameters + // contextually; the Middleware type those take still says what they get. + const files = { + '/c/cartridge/scripts/middleware/cache.js': ` + function applyDefaultCache(req, res, next) { + res.cachePeriod = 24; + next(); + } + module.exports = {applyDefaultCache: applyDefaultCache}; + `, + '/c/cartridge/controllers/Home.js': ` + var server = require('server'); + var cache = require('*/cartridge/scripts/middleware/cache'); + server.get('Show', cache.applyDefaultCache, function (req, res, next) { + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const middlewareFile = '/c/cartridge/scripts/middleware/cache.js'; + const source = files[middlewareFile]; + + const hover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('res, next')); + const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); + assert.match(hoverText, /res: Response/); + assert.match(hoverText, /Inferred from usage: Response/); + }); + it("types a route event listener's parameters through the route a middleware step runs on", () => { // SFRA calls each middleware step with the route as `this`, so the // listeners a step registers with `this.on(...)` are typed by Route.on. diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index 1991b9c73..f739042c6 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -553,6 +553,70 @@ describe('usage-inference — constructors as values', () => { }); }); +describe('usage-inference — functions handed to declared APIs', () => { + // A named function handed to a declared API is not contextually typed the + // way an inline function expression there is; the API's declared callback + // type still says what the function is called with. The parameters below + // have names and usage that say nothing on their own. + const HANDOFF_TYPES = realTypesPrelude( + ['Basket', 'Product'], + ` + function getBasket(): Basket; + function onBasketChange(name: string, ...listeners: Array<(container: Basket, changed: Product) => void>): void; + function later(task: Function): void; + `, + ); + const LISTENER = ` + function recalculate(container, changed) { + return container.getProductLineItems(); + } + module.exports = {recalculate: recalculate}; + `; + const LISTENER_USE = { + '/listeners.js': "var helper = require('./helper'); onBasketChange('cart', helper.recalculate);", + }; + + runCases([ + { + title: "an exported function handed to a declared API's rest parameter receives what its callback type declares", + types: HANDOFF_TYPES, + source: LISTENER, + files: LISTENER_USE, + kind: 'param', + name: 'container', + expected: 'Basket', + }, + { + title: 'a parameter the body never uses is typed by the callback declaration alone', + types: HANDOFF_TYPES, + source: LISTENER, + files: LISTENER_USE, + kind: 'param', + name: 'changed', + expected: 'Product', + }, + { + title: "a function handed to a native array method receives the array's elements", + types: HANDOFF_TYPES, + source: ` + function keep(entry) { return entry; } + getBasket().getProductLineItems().toArray().forEach(keep); + `, + kind: 'param', + name: 'entry', + expected: 'ProductLineItem', + }, + { + title: 'a callback type that declares no parameters says nothing', + types: HANDOFF_TYPES, + source: 'function task(entry) { return entry; } later(task);', + kind: 'param', + name: 'entry', + expected: '', + }, + ]); +}); + describe('usage-inference — call-specific returns', () => { // `{dw.util.Collection}` the way SFRA's JSDoc writes it: no type argument, // so the checker reads every element as `any`. From 0c161b65b8779d62aa0d4579fee4afc99b4e65d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:11:09 +0000 Subject: [PATCH 68/76] feat(script-types): don't count test doubles of a class as a type of their own Unit tests call SFRA middleware helpers with object literals that mimic the real argument (`{httpHeaders: {get: ...}}` for a `Request`). Each one took a slot of the three-type union, cluttering hovers (`Request | {...}`) and, with enough mocks, silencing the parameter altogether. An object literal whose properties a class candidate all has now stands in for that class instead: on the real cartridges 6 more placeholder-documented parameters are typed and 11 unions read as the class, with no other result changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 2 +- packages/b2c-script-types/README.md | 4 +- .../plugin/inference/policy.js | 42 +++++++++++++---- .../b2c-script-types/src/inference/policy.ts | 47 +++++++++++++++---- .../test/usage-inference.real-types.test.js | 35 ++++++++++++++ 5 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 1106fb174..d4649157f 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -156,7 +156,7 @@ Inferred results are heuristic and clearly labeled: - Hover text gets an appended `Inferred from usage: ` line. - Member completions synthesized this way are still offered alongside (not instead of) whatever TypeScript already resolved. -- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Different element types of one collection class read as that class (`Collection` and `Collection` show `Collection`). Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. +- Call sites that pass different types show their union, as IntelliJ does (`Product | Category`), up to three types. Different element types of one collection class read as that class (`Collection` and `Collection` show `Collection`). An object literal with only members of a class another call site passes, such as a unit test's `{httpHeaders: {get: ...}}` stand-in for a `Request`, isn't counted as a type of its own. Wider evidence collapses to the closest shared superclass that still fits the body, or the hover stays silent. - Usage that a JavaScript built-in fits as well (`value.replace(...)` could be a `String`) and the TopLevel classes describing `module` and `arguments` never produce a Script API guess on their own. - Names never add a type of their own; they only break a tie when the usage fits several classes equally well. A `profile` parameter picks `dw.customer.Profile` over `ProductListRegistrant`, `lineItem` / `pli` pick `ProductLineItem`, and qualified names such as `apiProduct`, `currentBasket` or `resettingCustomer` resolve to the class they end in. diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index 0612f7d7c..3873d4269 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -82,7 +82,9 @@ the `HookMgr.callHook(...)` calls that reach them, literal or prefixed registrations, as JSON only). The result is surfaced as an "Inferred from usage" hover note plus synthesized member completions; call sites that disagree show a union of up to three -types. It only kicks in where the checker has already given up (`any`, or a +types, not counting object literals that only stand in for one of them (a +unit test's `{httpHeaders: ...}` passed where production code passes a +`Request`). It only kicks in where the checker has already given up (`any`, or a placeholder SFRA JSDoc such as `@param {Object}`), never overriding a real type from TypeScript or JSDoc. diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index 0e15480a1..d4cefec5b 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -83,6 +83,30 @@ function fitsEveryUse(ctx, type, uses) { const fits = (use) => checker.isTypeAssignableTo(type, use) || lineageOf(ctx, use).some((useClass) => lineage.includes(useClass)); return uses.every((alternatives) => alternatives.some(fits)); } +/** + * True when `type` is an object literal's (`{ID: 'x'}`), as opposed to a + * declared class's. Read from its symbol: the widened type of a literal (what + * a variable holding it has) no longer carries the literal's object flag. + */ +function isObjectLiteralType(ctx, type) { + return ((type.getSymbol()?.flags ?? 0) & ctx.ts.SymbolFlags.ObjectLiteral) !== 0; +} +/** + * Drops every object literal a candidate class has all the properties of: a + * stand-in for that class (a test double, `{httpHeaders: {get: ...}}` for a + * `Request`, or a partial default), which offers nothing the class doesn't. + */ +function withoutStandIns(ctx, types) { + const { checker } = ctx; + const classes = types.filter((type) => classOf(ctx, type) !== undefined); + if (classes.length === 0) + return [...types]; + const standsIn = (literal) => { + const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); + return classes.some((candidateClass) => (0, type_helpers_1.hasAllMembers)(checker, candidateClass, names)); + }; + return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); +} /** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ function mostGeneral(ctx, types) { const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); @@ -124,11 +148,12 @@ function mergeInstantiations(ctx, types) { return (0, type_helpers_1.dedupeTypes)(ctx, merged); } /** - * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES; wider evidence - * merged by generic class, else the closest shared superclass, else silence. + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, stand-ins + * aside; wider evidence merged by generic class, else the closest shared + * superclass, else silence. */ function limitUnion(ctx, types, memberNames = NO_MEMBERS) { - const general = mostGeneral(ctx, types); + const general = mostGeneral(ctx, withoutStandIns(ctx, types)); if (general.length <= constants_1.MAX_UNION_TYPES) return general; const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); @@ -168,11 +193,12 @@ function fitsBuiltin(ctx, memberNames) { * Silent when no class has every member; when only ubiquitous members * (`.custom`, `.UUID`) were used and several classes fit; when a single * member fits several classes the identifier name can't choose between; and - * when a JavaScript built-in fits as well and the name doesn't pick a class. A name that denotes a class the usage does *not* fit - * never falls back to a vaguer reading of the same name - * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and - * silences a single-member signature outright: `lineItem.preorderable` - * uniquely matching ProductInventoryRecord is a coincidence, not a hint. + * when a JavaScript built-in fits as well and the name doesn't pick a class. + * A name that denotes a class the usage does *not* fit never falls back to a + * vaguer reading of the same name (`bonusDiscountLineItem.getQuantity()` is + * not a ProductLineItem hint), and silences a single-member signature + * outright: `lineItem.preorderable` uniquely matching ProductInventoryRecord + * is a coincidence, not a hint. * * @param identifierName - the parameter's or variable's own name, used only * to pick among classes the usage already fits (see ./naming). diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index 95c898ff8..90d66f6b9 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -22,7 +22,9 @@ // only its subclasses have is a downcast: it narrows to the one subclass // that has them, or stays itself when several do. // 3. Union like IntelliJ: up to MAX_UNION_TYPES, after dropping any candidate -// whose superclass is also a candidate (`Variant | Product` is `Product`). +// whose superclass is also a candidate (`Variant | Product` is `Product`) +// and any object literal standing in for a candidate class (a test +// double's `{httpHeaders: ...}` next to the `Request` it mimics). // Wider evidence first reads instantiations of one generic class as the // class (`Collection`, `Collection` -> `Collection`), // then collapses to the closest shared superclass that still fits, or @@ -132,6 +134,31 @@ function fitsEveryUse(ctx: InferenceContext, type: tsserver.Type, uses: readonly return uses.every((alternatives) => alternatives.some(fits)); } +/** + * True when `type` is an object literal's (`{ID: 'x'}`), as opposed to a + * declared class's. Read from its symbol: the widened type of a literal (what + * a variable holding it has) no longer carries the literal's object flag. + */ +function isObjectLiteralType(ctx: InferenceContext, type: tsserver.Type): boolean { + return ((type.getSymbol()?.flags ?? 0) & ctx.ts.SymbolFlags.ObjectLiteral) !== 0; +} + +/** + * Drops every object literal a candidate class has all the properties of: a + * stand-in for that class (a test double, `{httpHeaders: {get: ...}}` for a + * `Request`, or a partial default), which offers nothing the class doesn't. + */ +function withoutStandIns(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { + const {checker} = ctx; + const classes = types.filter((type) => classOf(ctx, type) !== undefined); + if (classes.length === 0) return [...types]; + const standsIn = (literal: tsserver.Type): boolean => { + const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); + return classes.some((candidateClass) => hasAllMembers(checker, candidateClass, names)); + }; + return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); +} + /** Drops every candidate whose superclass (or implemented interface) is also a candidate. */ function mostGeneral(ctx: InferenceContext, types: readonly tsserver.Type[]): tsserver.Type[] { const candidateClasses = new Set(types.map((type) => classOf(ctx, type))); @@ -183,15 +210,16 @@ function mergeInstantiations(ctx: InferenceContext, types: readonly tsserver.Typ } /** - * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES; wider evidence - * merged by generic class, else the closest shared superclass, else silence. + * Step 3: an IntelliJ-style union of at most MAX_UNION_TYPES, stand-ins + * aside; wider evidence merged by generic class, else the closest shared + * superclass, else silence. */ export function limitUnion( ctx: InferenceContext, types: readonly tsserver.Type[], memberNames: ReadonlySet = NO_MEMBERS, ): tsserver.Type[] { - const general = mostGeneral(ctx, types); + const general = mostGeneral(ctx, withoutStandIns(ctx, types)); if (general.length <= MAX_UNION_TYPES) return general; const merged = mostGeneral(ctx, mergeInstantiations(ctx, general)); if (merged.length <= MAX_UNION_TYPES) return merged; @@ -239,11 +267,12 @@ function fitsBuiltin(ctx: InferenceContext, memberNames: ReadonlySet): b * Silent when no class has every member; when only ubiquitous members * (`.custom`, `.UUID`) were used and several classes fit; when a single * member fits several classes the identifier name can't choose between; and - * when a JavaScript built-in fits as well and the name doesn't pick a class. A name that denotes a class the usage does *not* fit - * never falls back to a vaguer reading of the same name - * (`bonusDiscountLineItem.getQuantity()` is not a ProductLineItem hint), and - * silences a single-member signature outright: `lineItem.preorderable` - * uniquely matching ProductInventoryRecord is a coincidence, not a hint. + * when a JavaScript built-in fits as well and the name doesn't pick a class. + * A name that denotes a class the usage does *not* fit never falls back to a + * vaguer reading of the same name (`bonusDiscountLineItem.getQuantity()` is + * not a ProductLineItem hint), and silences a single-member signature + * outright: `lineItem.preorderable` uniquely matching ProductInventoryRecord + * is a coincidence, not a hint. * * @param identifierName - the parameter's or variable's own name, used only * to pick among classes the usage already fits (see ./naming). 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 ebe1fa7ad..1d06c3f8a 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 @@ -443,6 +443,41 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = assert.equal(describeTypes(ctx, types), 'Product | Category'); }); + describe('object literals standing in for a class', () => { + const STAND_IN_TYPES = realTypesPrelude(['Order'], ' function getSomeOrder(): Order;'); + + function inferTotalOf(callers) { + const files = { + '/types.d.ts': STAND_IN_TYPES, + '/consumer.js': ` + function totalOf(container) { + return container.getTotalGrossPrice(); + } + totalOf(getSomeOrder()); + ${callers} + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'totalOf'); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + it('drops a test double that has only members of the class another call site passes', () => { + const types = inferTotalOf(` + totalOf({getTotalGrossPrice: function () { return null; }}); + var order = {orderNo: '00001', getTotalGrossPrice: function () { return null; }}; + totalOf(order); + `); + + assert.equal(types, 'Order'); + }); + + it('keeps an object literal with a member the class lacks', () => { + const types = inferTotalOf('totalOf({getTotalGrossPrice: function () { return null; }, giftWrap: true});'); + + assert.equal(types, 'Order | { getTotalGrossPrice: () => null; giftWrap: boolean; }'); + }); + }); + it('drops a call-site argument of a different kind than the Product the body passes the parameter on as', () => { // SFRA's product bonusUnitPrice decorator is called once with the // discount line item UUID in the product slot; the body hands the From 5fca158a43517c6c0c92eccb09797be85750fbaf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:20:39 +0000 Subject: [PATCH 69/76] feat(script-types): don't require members a body only tests for or falls back from The fit rule dropped every call-site candidate lacking a member the body reads, including members read defensively: `if (res.setStatusCode)`, `(res && res.statusCode) || res.errorCode`, `lineItemCtnr.orderNo || lineItemCtnr.UUID`. A member read only as a condition, a negation, a `typeof` operand, the left side of `&&` or a side of `||`/`??` is now optional, like one tested with `'m' in x`; one the body also relies on elsewhere stays required. On the real cartridges this keeps the SFRA `Response` handed to middleware that probes for non-SFRA members, with no change to the documented ground truth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 2 +- .../plugin/inference/ast-helpers.js | 36 ++++++++++++++++ .../plugin/inference/usage-profile.js | 7 ++++ .../src/inference/ast-helpers.ts | 42 ++++++++++++++++++- .../src/inference/usage-profile.ts | 16 +++++-- .../test/usage-inference.real-types.test.js | 34 +++++++++++++++ 6 files changed, 131 insertions(+), 6 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index d4649157f..a13b9dd11 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -139,7 +139,7 @@ The evidence it weighs: - **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. - **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. - **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. -- **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; with no call site at all, the members are matched against every Script API class. +- **The helper's own body** — the members it reads (`shipment.shippingAddress`), its `'member' in value` checks, and its `instanceof` / `typeof` tests. A call-site type that lacks a member the body relies on is dropped, so a duck-typed view model can't pass for the Script API class the body needs; a member the body only tests for or falls back from (`if (res.setStatusCode)`, `lineItemCtnr.orderNo || lineItemCtnr.UUID`) is not relied on. With no call site at all, the members are matched against every Script API class. `module.superModule` is understood too: in an overlay cartridge that extends a base module (`var base = module.superModule;`), hover and completions on `base` and on values derived from it resolve against the same-path module in the next cartridge down the cartridge path — including recursing into the base module's own undocumented helpers, and across multi-cartridge plugin stacks where intermediate levels re-export the base and add members (`module.exports = base; module.exports.extra = extra;`). diff --git a/packages/b2c-script-types/plugin/inference/ast-helpers.js b/packages/b2c-script-types/plugin/inference/ast-helpers.js index 9939e7387..2f1818bad 100644 --- a/packages/b2c-script-types/plugin/inference/ast-helpers.js +++ b/packages/b2c-script-types/plugin/inference/ast-helpers.js @@ -17,6 +17,7 @@ exports.isExportsObject = isExportsObject; exports.isModuleExports = isModuleExports; exports.exportsObjectWrittenBy = exportsObjectWrittenBy; exports.invocationOf = invocationOf; +exports.isTestedOrAlternative = isTestedOrAlternative; exports.collectReturnExpressions = collectReturnExpressions; /** * Finds the most specific node whose span contains `pos`. Standard technique @@ -236,6 +237,41 @@ function invocationOf(call, ts) { return { callee: callee.expression, args: call.arguments.slice(1) }; return callee.name.text === 'apply' ? { callee: callee.expression, args: [] } : undefined; } +/** True when `node` is the right side of `a && node`, which evaluates to it whenever it is reached. */ +function isRightOfAnd(node, ts) { + const parent = node.parent; + return (ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parent.right === node); +} +/** True when `operator` offers its operands as alternatives: `a || b`, `a ?? b`. */ +function isAlternativeOperator(operator, ts) { + return operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.QuestionQuestionToken; +} +/** + * True when the value `expr` evaluates to is only tested, or offered as one + * of several alternatives: the condition of an `if`, a loop or a `?:`, the + * operand of `!` or `typeof`, the left side of `&&`, or either side of `||` / + * `??` (`if (res.setStatusCode)`, `(res && res.statusCode) || res.errorCode`). + * Code that reads a member only this way expects values that lack it. + */ +function isTestedOrAlternative(expr, ts) { + let value = expr; + while (ts.isParenthesizedExpression(value.parent) || isRightOfAnd(value, ts)) + value = value.parent; + const parent = value.parent; + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + return (isAlternativeOperator(operator, ts) || + (operator === ts.SyntaxKind.AmpersandAmpersandToken && parent.left === value)); + } + if (ts.isPrefixUnaryExpression(parent)) + return parent.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isConditionalExpression(parent)) + return parent.condition === value; + const tests = ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent); + return ts.isTypeOfExpression(parent) || (tests && parent.expression === value); +} /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/plugin/inference/usage-profile.js b/packages/b2c-script-types/plugin/inference/usage-profile.js index f8519c815..4df0ed9fa 100644 --- a/packages/b2c-script-types/plugin/inference/usage-profile.js +++ b/packages/b2c-script-types/plugin/inference/usage-profile.js @@ -16,6 +16,7 @@ function emptyProfile() { return { memberNames: new Set(), optionalMemberNames: new Set(), + reliedOnMemberNames: new Set(), guardTypes: [], contextualTypes: [], assignedValues: [], @@ -275,6 +276,8 @@ function recordMemberAccess({ ctx, reference, inVariantBranch }, profile) { const member = accessedMemberName(ctx, access); if (member !== undefined) recordMember(profile, member, inVariantBranch); + if (member !== undefined && !(0, ast_helpers_1.isTestedOrAlternative)(access, ts)) + profile.reliedOnMemberNames.add(member); recordWrite(ctx, access, member, profile); return true; } @@ -348,6 +351,10 @@ function collectProfile(ctx, target, scope) { ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); }; visit(scope, false); + for (const name of profile.memberNames) { + if (!profile.reliedOnMemberNames.has(name)) + profile.optionalMemberNames.add(name); + } return profile; } /** Walks up from `node` to the body of the nearest enclosing function, if any. */ diff --git a/packages/b2c-script-types/src/inference/ast-helpers.ts b/packages/b2c-script-types/src/inference/ast-helpers.ts index 2b7636ccd..c148f4915 100644 --- a/packages/b2c-script-types/src/inference/ast-helpers.ts +++ b/packages/b2c-script-types/src/inference/ast-helpers.ts @@ -8,7 +8,8 @@ // node under the cursor, skipping the subtrees a walk for a name can't find // it in, the property access a member name or completion belongs to, // checking whether a parameter/return/variable already has an explicit type, -// and collecting a function's return expressions. Everything here depends +// whether a value is only tested, and collecting a function's return +// expressions. Everything here depends // only on the `ts` namespace — no checker, no inference context — so it's // the safest, most reusable layer to read first. @@ -262,6 +263,45 @@ export function invocationOf(call: tsserver.CallExpression, ts: typeof tsserver) return callee.name.text === 'apply' ? {callee: callee.expression, args: []} : undefined; } +/** True when `node` is the right side of `a && node`, which evaluates to it whenever it is reached. */ +function isRightOfAnd(node: tsserver.Node, ts: typeof tsserver): boolean { + const parent = node.parent; + return ( + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + parent.right === node + ); +} + +/** True when `operator` offers its operands as alternatives: `a || b`, `a ?? b`. */ +function isAlternativeOperator(operator: tsserver.SyntaxKind, ts: typeof tsserver): boolean { + return operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.QuestionQuestionToken; +} + +/** + * True when the value `expr` evaluates to is only tested, or offered as one + * of several alternatives: the condition of an `if`, a loop or a `?:`, the + * operand of `!` or `typeof`, the left side of `&&`, or either side of `||` / + * `??` (`if (res.setStatusCode)`, `(res && res.statusCode) || res.errorCode`). + * Code that reads a member only this way expects values that lack it. + */ +export function isTestedOrAlternative(expr: tsserver.Expression, ts: typeof tsserver): boolean { + let value: tsserver.Node = expr; + while (ts.isParenthesizedExpression(value.parent) || isRightOfAnd(value, ts)) value = value.parent; + const parent = value.parent; + if (ts.isBinaryExpression(parent)) { + const operator = parent.operatorToken.kind; + return ( + isAlternativeOperator(operator, ts) || + (operator === ts.SyntaxKind.AmpersandAmpersandToken && parent.left === value) + ); + } + if (ts.isPrefixUnaryExpression(parent)) return parent.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isConditionalExpression(parent)) return parent.condition === value; + const tests = ts.isIfStatement(parent) || ts.isWhileStatement(parent) || ts.isDoStatement(parent); + return ts.isTypeOfExpression(parent) || (tests && parent.expression === value); +} + /** * Recursively walks a function body collecting `return` expressions, without * descending into nested function-like boundaries (their returns belong to diff --git a/packages/b2c-script-types/src/inference/usage-profile.ts b/packages/b2c-script-types/src/inference/usage-profile.ts index 6a1ea6174..a5147c678 100644 --- a/packages/b2c-script-types/src/inference/usage-profile.ts +++ b/packages/b2c-script-types/src/inference/usage-profile.ts @@ -14,7 +14,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {hasExplicitParameterType, spellingFilter} from './ast-helpers'; +import {hasExplicitParameterType, isTestedOrAlternative, spellingFilter} from './ast-helpers'; import type {SpellingFilter} from './ast-helpers'; import {MAX_USAGE_FORWARDING_HOPS} from './constants'; import type {InferenceContext} from './context'; @@ -43,9 +43,10 @@ export interface UsageProfile { readonly memberNames: ReadonlySet; /** * Members the code tests for before relying on them (`'ID' in x`, - * `x.hasOwnProperty('ID')`, `Object.hasOwnProperty.call(x, 'ID')`), and - * members used only in a branch such a test picks: a hint at what the - * value is, but not a member every value passed in must have. + * `x.hasOwnProperty('ID')`, `Object.hasOwnProperty.call(x, 'ID')`), + * members used only in a branch such a test picks, and members only ever + * read as a test or an alternative (`if (x.m)`, `x.a || x.b`): a hint at + * what the value is, but not a member every value passed in must have. */ readonly optionalMemberNames: ReadonlySet; /** Types the value is tested against: `x instanceof dw.order.ProductLineItem`, `typeof x === 'string'`. */ @@ -65,6 +66,8 @@ export interface UsageProfile { interface ProfileBuilder { readonly memberNames: Set; readonly optionalMemberNames: Set; + /** Members some access relies on: reads them other than as a test or an alternative. */ + readonly reliedOnMemberNames: Set; readonly guardTypes: tsserver.Type[]; readonly contextualTypes: tsserver.Type[]; readonly assignedValues: tsserver.Expression[]; @@ -77,6 +80,7 @@ function emptyProfile(): ProfileBuilder { return { memberNames: new Set(), optionalMemberNames: new Set(), + reliedOnMemberNames: new Set(), guardTypes: [], contextualTypes: [], assignedValues: [], @@ -415,6 +419,7 @@ function recordMemberAccess({ctx, reference, inVariantBranch}: ReferenceUse, pro if (!isAccess || access.expression !== reference) return false; const member = accessedMemberName(ctx, access); if (member !== undefined) recordMember(profile, member, inVariantBranch); + if (member !== undefined && !isTestedOrAlternative(access, ts)) profile.reliedOnMemberNames.add(member); recordWrite(ctx, access, member, profile); return true; } @@ -491,6 +496,9 @@ function collectProfile(ctx: InferenceContext, target: ProfileTarget, scope: tss ctx.ts.forEachChild(node, (child) => visit(child, inVariantBranch || branches.includes(child))); }; visit(scope, false); + for (const name of profile.memberNames) { + if (!profile.reliedOnMemberNames.has(name)) profile.optionalMemberNames.add(name); + } return profile; } 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 1d06c3f8a..03602e83b 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 @@ -478,6 +478,40 @@ describe('usage-inference — real dw.* Script API types (Product, Order)', () = }); }); + describe('members read only as a test or an alternative', () => { + const BASKET_TYPES = realTypesPrelude(['Order', 'Basket'], ' function getSomeBasket(): Basket;'); + + function inferContainerOf(body) { + const files = { + '/types.d.ts': BASKET_TYPES, + '/consumer.js': ` + function describeContainer(container) { + ${body} + return container.getTotalGrossPrice(); + } + describeContainer(getSomeBasket()); + `, + }; + const {ctx, fn} = setupInference(files, '/consumer.js', 'describeContainer'); + return describeTypes(ctx, inferParameterType(ctx, fn.parameters[0])); + } + + for (const [title, body] of [ + ['an alternative of `||`', 'var id = container.orderNo || container.UUID;'], + ['a guarded alternative', 'var id = (container && container.orderNo) || container.UUID;'], + ['an `if` condition', "if (container.orderNo) { log('order'); }"], + ['a negated test', "if (!container.orderNo) { log('basket'); }"], + ]) { + it(`keeps a Basket whose body reads orderNo only as ${title}`, () => { + assert.equal(inferContainerOf(body), 'Basket'); + }); + } + + it('drops the Basket once the body relies on orderNo, leaving the class that has it', () => { + assert.equal(inferContainerOf('var id = container.orderNo.toUpperCase();'), 'Order'); + }); + }); + it('drops a call-site argument of a different kind than the Product the body passes the parameter on as', () => { // SFRA's product bonusUnitPrice decorator is called once with the // discount line item UUID in the product slot; the body hands the From db65720d4be343bb2feb986f8beba4c1cf2ded69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:21:30 +0000 Subject: [PATCH 70/76] feat(script-types): type the middleware a factory returns from where its calls go An anonymous function a factory returns has no name to search for, so its parameters stayed `any`: the common SFRA pattern `server.append('Confirm', jsonResponse.responseHandler(fieldMap))`, with `responseHandler` returning `function (req, res, next) {...}`, or a project calling the result (`var listener = listenerFor('cart'); listener(basket)`). The call-site search now starts from the factory in its factory role, so the function is followed wherever the factory's calls take it, handoffs to declared APIs included. Call-site results are memoized per searched name and role. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-infer-usage.md | 2 +- docs/guide/ide-integration.md | 2 +- packages/b2c-script-types/README.md | 8 ++-- .../plugin/inference/call-sites.js | 17 ++++---- .../b2c-script-types/plugin/inference/core.js | 11 +++--- .../plugin/inference/value-flow.js | 16 ++++++++ .../src/inference/call-sites.ts | 33 +++++++--------- .../b2c-script-types/src/inference/context.ts | 4 +- .../b2c-script-types/src/inference/core.ts | 13 ++++--- .../src/inference/value-flow.ts | 23 ++++++++++- .../test/eval/ground-truth.test.js | 12 +++--- packages/b2c-script-types/test/index.test.js | 39 +++++++++++++++++++ .../test/usage-inference.value-flow.test.js | 28 +++++++++++++ 13 files changed, 155 insertions(+), 53 deletions(-) diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 9fa3551d2..6cc910fb8 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 cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), the callback types of the APIs a function is handed to (SFRA middleware modules passed to `server.get`), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). +Script API IntelliSense can now infer types for undocumented cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), the callback types of the APIs a function is handed to (SFRA middleware modules and middleware factories' results passed to `server.get`), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index a13b9dd11..b6d8482b5 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -146,7 +146,7 @@ The evidence it weighs: Two more SFRA idioms are covered: - **Iteration callbacks** — a callback's parameters are what the helper it is passed to calls it with, for that call's arguments: `collections.forEach(product.getVariants(), function (variant) {...})` types `variant` as a `Variant`, and `collections.reduce(basket.productLineItems, function (total, lineItem) {...}, 0)` types `lineItem`, the second parameter, as a `ProductLineItem`. This reads the helper's own `callback(item)` / `callback.call(scope, item)` calls, so a project's own helpers (`eachShipment(basket, function (shipment) {...})`) and wrappers that hand the callback on to another helper work the same way; an accumulator the callback's result is fed back into stays unknown, as with a native `reduce`. A helper whose source isn't available falls back to its name: `collections.forEach` / `map` / `filter` / `every` / `some` / `find` / `first` take an element of the collection travelling alongside the callback (anything with `iterator()`/`next()`, i.e. `dw.util.Collection` and friends). Manual iterator loops (`var iter = coll.iterator(); while (iter.hasNext()) { var item = iter.next(); }`) and ternary returns like stock `collections.first` (`return it.hasNext() ? it.next() : null`) resolve through the same chain machinery. -- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature, and `this` as the route, so the `req`/`res` of a `this.on('route:BeforeComplete', function (req, res) {...})` listener are typed as well. Inference deliberately stays out of the way there. A named middleware function that lives in its own module (`server.get('Show', cache.applyDefaultCache, ...)`, `consentTracking.consent`) has no contextual type of its own; with inference on, it takes `req`/`res`/`next` from the routes it is handed to. +- **Controller middleware** — `server.append('Show', function (req, res, next) {...})` needs no inference at all: when a `modules` cartridge is present, the plugin injects its bundled SFRA ambient declarations and TypeScript types `req`/`res`/`next` contextually from the typed `append` signature, and `this` as the route, so the `req`/`res` of a `this.on('route:BeforeComplete', function (req, res) {...})` listener are typed as well. Inference deliberately stays out of the way there. A named middleware function that lives in its own module (`server.get('Show', cache.applyDefaultCache, ...)`, `consentTracking.consent`) has no contextual type of its own; with inference on, it takes `req`/`res`/`next` from the routes it is handed to. So does the middleware a factory returns (`server.append('Confirm', jsonResponse.responseHandler(fieldMap))`, where `responseHandler` returns `function (req, res, next) {...}`). Cross-file inference (call sites in other files, `module.superModule`) needs those files in the same TypeScript project. A `jsconfig.json` that includes all cartridge sources — like the one `b2c setup ide vscode-types` generates — provides that; without one, each open file gets its own inferred project and only same-file usage is visible. diff --git a/packages/b2c-script-types/README.md b/packages/b2c-script-types/README.md index 3873d4269..a7359b328 100644 --- a/packages/b2c-script-types/README.md +++ b/packages/b2c-script-types/README.md @@ -65,10 +65,10 @@ value widened to `any` by plain TypeScript inference, and that `any` propagates to every caller. Passing `inferUsage: true` in the plugin config (off by default) makes the plugin infer a plausible type for these cases the way IntelliJ does: from call-site arguments (including `new`, `.call`/`.apply` -and callbacks), the callback types of the declared APIs a function is handed to -(`server.get('Show', cache.applyDefaultCache)`), return values, the typed Script -API calls a value is passed to, and the members and `instanceof`/`typeof` -checks in the helper's own body. +and callbacks), the callback types of the declared APIs a function, or a +factory's result, is handed to (`server.get('Show', cache.applyDefaultCache)`), +return values, the typed Script API calls a value is passed to, and the members +and `instanceof`/`typeof` checks in the helper's own body. Values are followed where they go — into object members, `this.x`, pushed arrays, factory returns and `module.exports` — and a generic helper such as `collections.find(basket.shipments, ...)` is inferred again for the call at diff --git a/packages/b2c-script-types/plugin/inference/call-sites.js b/packages/b2c-script-types/plugin/inference/call-sites.js index 7d25f160d..3f0402476 100644 --- a/packages/b2c-script-types/plugin/inference/call-sites.js +++ b/packages/b2c-script-types/plugin/inference/call-sites.js @@ -30,7 +30,7 @@ function isFunctionLocal(ts, name) { const isVariable = ts.isVariableDeclaration(declaration) || ts.isBindingElement(declaration); return isVariable && (0, value_flow_1.enclosingFunction)(declaration, ts) !== undefined; } -function frontierKey({ name, role }) { +function holderKey({ name, role }) { const sourceFile = name.getSourceFile(); return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } @@ -40,7 +40,7 @@ function frontierKey({ name, role }) { * unit of ctx.searchBudget, when it costs one. */ function claimSearch(ctx, next, searched) { - const key = frontierKey(next); + const key = holderKey(next); const charged = !isFunctionLocal(ctx.ts, next.name); if (searched.has(key) || (charged && ctx.searchBudget <= 0)) return false; @@ -50,8 +50,8 @@ function claimSearch(ctx, next, searched) { return true; } /** - * Finds actual call sites for `nameNode`, and the handoffs to declared - * callees, following up to MAX_REFERENCE_HOPS names the function value flows + * Finds the actual call sites of the function `holder` holds (see + * ./value-flow's holderOf), and the handoffs to declared callees, following up to MAX_REFERENCE_HOPS names the function value flows * into (see ./value-flow: require() bindings, exports, aliases, factories * returning it, parameters it is passed to) when a reference doesn't sit * directly in callee position. Stops early once ctx.referenceBudget (result @@ -61,13 +61,14 @@ function claimSearch(ctx, next, searched) { * on a widely-referenced helper. Results are memoized per name node for the * duration of the request. */ -function collectCallSites(ctx, nameNode) { - const memoized = ctx.callSiteMemo.get(nameNode); +function collectCallSites(ctx, holder) { + const key = holderKey(holder); + const memoized = ctx.callSiteMemo.get(key); if (memoized) return memoized; const found = { calls: [], handoffs: [] }; const searched = new Set(); - let frontier = [{ name: nameNode, role: 'value' }]; + let frontier = [holder]; let localBudget = Math.min(constants_1.MAX_REFERENCES_PER_CALL, ctx.referenceBudget); for (let hop = 0; hop <= constants_1.MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { const nextFrontier = []; @@ -80,7 +81,7 @@ function collectCallSites(ctx, nameNode) { } frontier = nextFrontier; } - ctx.callSiteMemo.set(nameNode, found); + ctx.callSiteMemo.set(key, found); return found; } /** diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index aaceb06b6..6fdf5ebce 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -351,7 +351,7 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { // Only a search of another function's call sites costs depth: an anonymous // callback's parameter is read off the call it is passed to, in this body. if (decl && ts.isParameter(decl)) { - return inferParameterType(ctx, decl, (0, value_flow_1.getReferenceNameNode)(decl.parent, ts) ? depth + 1 : depth); + return inferParameterType(ctx, decl, (0, value_flow_1.holderOf)(ctx, decl.parent) ? depth + 1 : depth); } if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); @@ -364,13 +364,14 @@ function resolveIdentifierTypes(ctx, expr, depth, chainHops) { * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an * anonymous callback with no name to search for — from the collection it - * iterates. + * iterates. An anonymous function a factory returns is called wherever the + * factory's calls take it (`server.append('Show', responseHandler(map))`). */ function parameterEvidence(ctx, fn, paramIndex, depth) { - const nameNode = (0, value_flow_1.getReferenceNameNode)(fn, ctx.ts); - if (!nameNode) + const holder = (0, value_flow_1.holderOf)(ctx, fn); + if (!holder) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - const { calls, handoffs } = (0, call_sites_1.collectCallSites)(ctx, nameNode); + const { calls, handoffs } = (0, call_sites_1.collectCallSites)(ctx, holder); const passed = [...calls, ...(0, hook_calls_1.hookCallSites)(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; diff --git a/packages/b2c-script-types/plugin/inference/value-flow.js b/packages/b2c-script-types/plugin/inference/value-flow.js index 177481fdc..caf96ab48 100644 --- a/packages/b2c-script-types/plugin/inference/value-flow.js +++ b/packages/b2c-script-types/plugin/inference/value-flow.js @@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.getReferenceNameNode = getReferenceNameNode; exports.enclosingFunction = enclosingFunction; exports.receivingParameter = receivingParameter; +exports.holderOf = holderOf; exports.valueTarget = valueTarget; const signatures_1 = require("./signatures"); /** The name a function declaration, method or class declares itself by. */ @@ -198,6 +199,21 @@ function handedOff(ctx, value) { const argIndex = call.arguments.indexOf(value); return argIndex >= 0 ? { kind: 'handoff', handoff: { call, argIndex } } : undefined; } +/** + * Where a search for the calls of `fn` starts: the name it is declared or + * bound by (see {@link getReferenceNameNode}), or for an anonymous function a + * factory returns (`return function (req, res, next) {...}`), the factory, + * whose calls evaluate to it. + */ +function holderOf(ctx, fn) { + const { ts } = ctx; + const name = getReferenceNameNode(fn, ts); + if (name) + return { name, role: 'value' }; + const anonymous = ts.isFunctionExpression(fn) || ts.isArrowFunction(fn); + const factory = anonymous ? returnedBy(ctx, outermostValue(ts, fn)) : undefined; + return factory?.kind === 'name' ? factory : undefined; +} /** * Where one reference to the function leads. In the `value` role the * reference is the function itself: a call invoking it diff --git a/packages/b2c-script-types/src/inference/call-sites.ts b/packages/b2c-script-types/src/inference/call-sites.ts index d0e0bd780..ea1c7f114 100644 --- a/packages/b2c-script-types/src/inference/call-sites.ts +++ b/packages/b2c-script-types/src/inference/call-sites.ts @@ -28,13 +28,7 @@ import {valueDeclarationOf} from './member-values'; import {searchReferences} from './reference-search'; import {informativeParts} from './type-helpers'; import {enclosingFunction, valueTarget} from './value-flow'; -import type {CallSites, ReferenceTarget, ValueRole} from './value-flow'; - -/** A name whose references are searched next, and how it holds the function. */ -interface FrontierName { - readonly name: tsserver.Identifier; - readonly role: ValueRole; -} +import type {CallSites, ReferenceTarget, ValueHolder} from './value-flow'; /** * True when `name` declares a parameter or a variable inside a function. @@ -52,7 +46,7 @@ function isFunctionLocal(ts: typeof tsserver, name: tsserver.Identifier): boolea return isVariable && enclosingFunction(declaration, ts) !== undefined; } -function frontierKey({name, role}: FrontierName): string { +function holderKey({name, role}: ValueHolder): string { const sourceFile = name.getSourceFile(); return `${role}:${sourceFile.fileName}:${name.getStart(sourceFile)}`; } @@ -62,8 +56,8 @@ function frontierKey({name, role}: FrontierName): string { * and the request can still pay for it. Records the search and spends its * unit of ctx.searchBudget, when it costs one. */ -function claimSearch(ctx: InferenceContext, next: FrontierName, searched: Set): boolean { - const key = frontierKey(next); +function claimSearch(ctx: InferenceContext, next: ValueHolder, searched: Set): boolean { + const key = holderKey(next); const charged = !isFunctionLocal(ctx.ts, next.name); if (searched.has(key) || (charged && ctx.searchBudget <= 0)) return false; searched.add(key); @@ -72,8 +66,8 @@ function claimSearch(ctx: InferenceContext, next: FrontierName, searched: Set(); - let frontier: FrontierName[] = [{name: nameNode, role: 'value'}]; + let frontier: ValueHolder[] = [holder]; let localBudget = Math.min(MAX_REFERENCES_PER_CALL, ctx.referenceBudget); for (let hop = 0; hop <= MAX_REFERENCE_HOPS && frontier.length > 0 && localBudget > 0; hop++) { - const nextFrontier: FrontierName[] = []; + const nextFrontier: ValueHolder[] = []; for (const next of frontier) { if (localBudget <= 0) break; if (claimSearch(ctx, next, searched)) { @@ -102,7 +97,7 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident frontier = nextFrontier; } - ctx.callSiteMemo.set(nameNode, found); + ctx.callSiteMemo.set(key, found); return found; } @@ -115,9 +110,9 @@ export function collectCallSites(ctx: InferenceContext, nameNode: tsserver.Ident */ function collectCallsFromName( ctx: InferenceContext, - next: FrontierName, + next: ValueHolder, found: CallSites, - nextFrontier: FrontierName[], + nextFrontier: ValueHolder[], localBudget: number, ): number { for (const reference of searchReferences(ctx, next.name)) { diff --git a/packages/b2c-script-types/src/inference/context.ts b/packages/b2c-script-types/src/inference/context.ts index 54f8e1327..46bb46255 100644 --- a/packages/b2c-script-types/src/inference/context.ts +++ b/packages/b2c-script-types/src/inference/context.ts @@ -88,14 +88,14 @@ export interface InferenceContext { searchBudget: number; /** * Request-scoped memo of collectCallSites() results, keyed by the searched - * name node. Two different parameters of the same function (or two return + * name and the role it holds the function in (see ./value-flow). Two different parameters of the same function (or two return * paths reaching the same parameter set) otherwise each re-run the exact * same reference searches within one request. Reuse is sound because the * budgets only ever decrease during a request: a memoized result was * computed with at least as much budget as any later call would have had, * so it can only be equally or more complete. */ - readonly callSiteMemo: Map; + 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 0890363ee..6f027621d 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -37,7 +37,7 @@ import { import type {ArgumentBinding, BoundCallback} from './bindings'; import {NO_CALLBACK_USES, callbackUses} from './callback-arguments'; import {collectCallSites, forwardedParameter} from './call-sites'; -import {getReferenceNameNode} from './value-flow'; +import {holderOf} from './value-flow'; import {frameworkParameterTypes} from './framework-contracts'; import {genericResultSource} from './generic-calls'; import {hookCallSites, hookImplementations} from './hook-calls'; @@ -507,7 +507,7 @@ function resolveIdentifierTypes( // Only a search of another function's call sites costs depth: an anonymous // callback's parameter is read off the call it is passed to, in this body. if (decl && ts.isParameter(decl)) { - return inferParameterType(ctx, decl, getReferenceNameNode(decl.parent, ts) ? depth + 1 : depth); + return inferParameterType(ctx, decl, holderOf(ctx, decl.parent) ? depth + 1 : depth); } if (decl && ts.isVariableDeclaration(decl)) return resolveVariableTypes(ctx, decl, depth, chainHops + 1); return []; @@ -520,7 +520,8 @@ function resolveIdentifierTypes( * `HookMgr.callHook(...)` calls dispatched to it), from the declared APIs it * is handed to (`server.get('Show', cache.applyDefaultCache)`), or — for an * anonymous callback with no name to search for — from the collection it - * iterates. + * iterates. An anonymous function a factory returns is called wherever the + * factory's calls take it (`server.append('Show', responseHandler(map))`). */ function parameterEvidence( ctx: InferenceContext, @@ -528,9 +529,9 @@ function parameterEvidence( paramIndex: number, depth: number, ): tsserver.Type[] { - const nameNode = getReferenceNameNode(fn, ctx.ts); - if (!nameNode) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); - const {calls, handoffs} = collectCallSites(ctx, nameNode); + const holder = holderOf(ctx, fn); + if (!holder) return inferCallbackParameterTypes(ctx, fn, paramIndex, depth); + const {calls, handoffs} = collectCallSites(ctx, holder); const passed = [...calls, ...hookCallSites(ctx, fn)].flatMap((site) => { const arg = site.args[paramIndex]; return arg ? argumentEvidence(ctx, arg, depth) : []; diff --git a/packages/b2c-script-types/src/inference/value-flow.ts b/packages/b2c-script-types/src/inference/value-flow.ts index 2ae33e643..82730f8ca 100644 --- a/packages/b2c-script-types/src/inference/value-flow.ts +++ b/packages/b2c-script-types/src/inference/value-flow.ts @@ -38,6 +38,12 @@ export interface CallSite { /** How a searched name holds the function: as the function itself, or as a factory whose calls return it. */ export type ValueRole = 'factory' | 'value'; +/** A name whose references lead to a function's calls, and how it holds the function. */ +export interface ValueHolder { + readonly name: tsserver.Identifier; + readonly role: ValueRole; +} + /** * A call handing the function as an argument to a callee that is not a * project function receiving it in a parameter: a Script API or SFRA @@ -60,7 +66,7 @@ export interface CallSites { export type ReferenceTarget = | {readonly kind: 'call'; readonly call: CallSite} | {readonly kind: 'handoff'; readonly handoff: Handoff} - | {readonly kind: 'name'; readonly name: tsserver.Identifier; readonly role: ValueRole}; + | ({readonly kind: 'name'} & ValueHolder); /** A function-like declaration, or the class a constructor is invoked through. */ type Callable = tsserver.SignatureDeclaration | tsserver.ClassLikeDeclaration; @@ -271,6 +277,21 @@ function handedOff(ctx: InferenceContext, value: tsserver.Node): ReferenceTarget return argIndex >= 0 ? {kind: 'handoff', handoff: {call, argIndex}} : undefined; } +/** + * Where a search for the calls of `fn` starts: the name it is declared or + * bound by (see {@link getReferenceNameNode}), or for an anonymous function a + * factory returns (`return function (req, res, next) {...}`), the factory, + * whose calls evaluate to it. + */ +export function holderOf(ctx: InferenceContext, fn: tsserver.SignatureDeclaration): ValueHolder | undefined { + const {ts} = ctx; + const name = getReferenceNameNode(fn, ts); + if (name) return {name, role: 'value'}; + const anonymous = ts.isFunctionExpression(fn) || ts.isArrowFunction(fn); + const factory = anonymous ? returnedBy(ctx, outermostValue(ts, fn)) : undefined; + return factory?.kind === 'name' ? factory : undefined; +} + /** * Where one reference to the function leads. In the `value` role the * reference is the function itself: a call invoking it diff --git a/packages/b2c-script-types/test/eval/ground-truth.test.js b/packages/b2c-script-types/test/eval/ground-truth.test.js index 2f72c621f..a5b9c6266 100644 --- a/packages/b2c-script-types/test/eval/ground-truth.test.js +++ b/packages/b2c-script-types/test/eval/ground-truth.test.js @@ -51,7 +51,7 @@ const ts = require('typescript'); const {discoverCartridgesOnDisk, orderCartridges} = require('../../src/resolver/cartridge-discovery'); const {collectCallSites} = require('../../src/inference/call-sites'); -const {getReferenceNameNode} = require('../../src/inference/value-flow'); +const {holderOf} = require('../../src/inference/value-flow'); const {inferTypeForExpression} = require('../../src/inference/core'); const {createInferenceContext, describeTypes, inferReturnType, inferTypeForNode} = require('../../src/usage-inference'); const {createPluginProxy} = require('../helpers/plugin-proxy'); @@ -346,13 +346,13 @@ function silentReason(languageService, fileName, position) { const request = freshRequest(languageService, fileName); const param = request && findNodeAt(request.sourceFile, position, ts.isIdentifier)?.parent; if (!param || !ts.isParameter(param)) return 'not-a-parameter'; - const nameNode = getReferenceNameNode(param.parent, ts); - return nameNode ? callSiteSilence(request.ctx, nameNode, param) : 'anonymous-callback'; + const holder = holderOf(request.ctx, param.parent); + return holder ? callSiteSilence(request.ctx, holder, param) : 'anonymous-callback'; } -/** Classifies a silent parameter of a named function by what its call sites gave. */ -function callSiteSilence(ctx, nameNode, param) { - const {calls, handoffs} = collectCallSites(ctx, nameNode); +/** Classifies a silent parameter of a function whose calls can be searched by what its call sites gave. */ +function callSiteSilence(ctx, holder, param) { + const {calls, handoffs} = collectCallSites(ctx, holder); const index = param.parent.parameters.indexOf(param); const evidence = calls.flatMap((site) => (site.args[index] ? inferTypeForExpression(ctx, site.args[index]) : [])); if (evidence.length > 0) return 'dropped-by-policy'; diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index f1e074027..2750a2c5c 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -732,6 +732,45 @@ describe('create() proxy — usage inference wiring', () => { assert.match(hoverText, /Inferred from usage: Response/); }); + it('types the middleware a factory returns from the routes its calls are handed to', () => { + const files = { + '/c/cartridge/scripts/middleware/jsonResponse.js': ` + function responseHandler(fieldMap) { + return function (req, res, next) { + res.json(fieldMap); + next(); + }; + } + module.exports = {responseHandler: responseHandler}; + `, + '/c/cartridge/controllers/Order.js': ` + var server = require('server'); + var jsonResponse = require('*/cartridge/scripts/middleware/jsonResponse'); + server.append('Confirm', jsonResponse.responseHandler({orderNo: 'orderNo'})); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const middlewareFile = '/c/cartridge/scripts/middleware/jsonResponse.js'; + const source = files[middlewareFile]; + + const hover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('res, next')); + const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); + assert.match(hoverText, /res: Response/); + assert.match(hoverText, /Inferred from usage: Response/); + }); + it("types a route event listener's parameters through the route a middleware step runs on", () => { // SFRA calls each middleware step with the route as `this`, so the // listeners a step registers with `this.on(...)` are typed by Route.on. diff --git a/packages/b2c-script-types/test/usage-inference.value-flow.test.js b/packages/b2c-script-types/test/usage-inference.value-flow.test.js index f739042c6..f247eb51c 100644 --- a/packages/b2c-script-types/test/usage-inference.value-flow.test.js +++ b/packages/b2c-script-types/test/usage-inference.value-flow.test.js @@ -614,6 +614,34 @@ describe('usage-inference — functions handed to declared APIs', () => { name: 'entry', expected: '', }, + { + title: 'an anonymous function a factory returns is handed on wherever the factory’s calls go', + types: HANDOFF_TYPES, + source: ` + function listenerFor(name) { + return function (container, changed) { return name; }; + } + module.exports = {listenerFor: listenerFor}; + `, + files: {'/listeners.js': "var helper = require('./helper'); onBasketChange('cart', helper.listenerFor('cart'));"}, + kind: 'param', + name: 'container', + expected: 'Basket', + }, + { + title: 'an anonymous function a factory returns is called wherever the factory’s result is', + types: HANDOFF_TYPES, + source: ` + function listenerFor(name) { + return function (container) { return name; }; + } + var listener = listenerFor('cart'); + listener(getBasket()); + `, + kind: 'param', + name: 'container', + expected: 'Basket', + }, ]); }); From a5f86583667a2e4a05a8fa84e5a3f8812d9e0d33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:38:18 +0000 Subject: [PATCH 71/76] perf(script-types): stop re-reading the file list and the class names on every request tsserver asks the plugin for the project's file names on every language service request; the plugin normalized every path and probed the disk each time to decide whether to inject the ambient declarations. The answer is now kept until a file name or the configuration changes. Usage inference rebuilt a lowercased name table over every ambient class and copied the used member names once per class on each match. The table is now built once per Program and the member scan shared by both matchers. Ground truth results are unchanged; the engine's own time on the heaviest hovers drops by about 40%. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .changeset/script-types-file-list-cost.md | 6 +++ packages/b2c-script-types/plugin/index.js | 25 ++++++++++- .../plugin/inference/ambient-index.js | 6 +++ .../plugin/inference/naming.js | 36 ++++++++++----- .../plugin/inference/policy.js | 5 +-- packages/b2c-script-types/src/index.ts | 28 +++++++++++- .../src/inference/ambient-index.ts | 6 +++ .../b2c-script-types/src/inference/naming.ts | 31 +++++++++---- .../b2c-script-types/src/inference/policy.ts | 12 ++--- packages/b2c-script-types/test/index.test.js | 45 +++++++++++++++++++ 10 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 .changeset/script-types-file-list-cost.md diff --git a/.changeset/script-types-file-list-cost.md b/.changeset/script-types-file-list-cost.md new file mode 100644 index 000000000..c75ce558d --- /dev/null +++ b/.changeset/script-types-file-list-cost.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API IntelliSense no longer re-reads every project file path on each hover, completion and diagnostics request, which added a few milliseconds to every request in large cartridge workspaces. diff --git a/packages/b2c-script-types/plugin/index.js b/packages/b2c-script-types/plugin/index.js index 40d6aa041..956e0cd0f 100644 --- a/packages/b2c-script-types/plugin/index.js +++ b/packages/b2c-script-types/plugin/index.js @@ -23,6 +23,10 @@ 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, '/'); +/** True when two file lists name the same files in the same order. */ +function sameFileNames(a, b) { + return a.length === b.length && a.every((name, i) => name === b[i]); +} function init({ typescript: ts }) { // tsserver calls this factory function fresh for every project that loads // the plugin (once per tsconfig/jsconfig root), so these variables are a @@ -230,14 +234,31 @@ function init({ typescript: ts }) { // contains at least one cartridge file (isCartridgeFile is false while the // plugin is disabled or no cartridge is known). Projects that already // include them via a jsconfig include glob are unaffected. + // + // tsserver asks for this list on every language-service request (each + // hover, completion and diagnostics pass), building a new array each time + // from the same names while the project's files stay put. The answer is + // kept until a name or the configuration changes, so a request costs one + // name-by-name comparison rather than normalizing every path in the + // project and probing the disk. const origGetScriptFileNames = host.getScriptFileNames.bind(host); - host.getScriptFileNames = () => { - const list = origGetScriptFileNames(); + const withAmbientDeclarations = (list) => { if (!list.some((f) => isCartridgeFile(f))) return list; const additions = missingAmbientDeclarations(list); return additions.length > 0 ? [...list, ...additions] : list; }; + let lastFileNames; + host.getScriptFileNames = () => { + const list = origGetScriptFileNames(); + const last = lastFileNames; + if (last?.cartridges === cartridges && last.enabled === enabled && sameFileNames(last.list, list)) { + return last.result; + } + const result = withAmbientDeclarations(list); + lastFileNames = { list, cartridges, enabled, result }; + return result; + }; // Shared by both host resolution hooks below (the modern // resolveModuleNameLiterals and the legacy TS 4.x resolveModuleNames): // tries dw/* types, then SFCC cartridge-relative requires, then the SFRA diff --git a/packages/b2c-script-types/plugin/inference/ambient-index.js b/packages/b2c-script-types/plugin/inference/ambient-index.js index 9f74963ef..44b551691 100644 --- a/packages/b2c-script-types/plugin/inference/ambient-index.js +++ b/packages/b2c-script-types/plugin/inference/ambient-index.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.getAmbientClasses = getAmbientClasses; +exports.ambientClassesWithMembers = ambientClassesWithMembers; exports.ambientClassType = ambientClassType; exports.findAmbientClassType = findAmbientClassType; exports.builtinValueTypes = builtinValueTypes; @@ -49,6 +50,11 @@ function getAmbientClasses(ctx) { classesByProgram.set(program, classes); return classes; } +/** The indexed classes that have every member in `memberNames`, in index order. */ +function ambientClassesWithMembers(ctx, memberNames) { + const names = [...memberNames]; + return getAmbientClasses(ctx).filter((ambientClass) => names.every((name) => ambientClass.memberNames.has(name))); +} /** The class's declared (uninstantiated) type, resolved with the current checker. */ function ambientClassType(ctx, ambientClass) { const { checker } = ctx; diff --git a/packages/b2c-script-types/plugin/inference/naming.js b/packages/b2c-script-types/plugin/inference/naming.js index 7aea9be78..1f95435d8 100644 --- a/packages/b2c-script-types/plugin/inference/naming.js +++ b/packages/b2c-script-types/plugin/inference/naming.js @@ -25,6 +25,26 @@ const ABBREVIATIONS = new Map([ // All-lowercase qualifiers SFRA code glues onto a class name // (`apiproduct`, `currentbasket`): stripping them leaves the class. const QUALIFIER_PREFIXES = ['api', 'current', 'default', 'selected', 'new']; +// Candidates by lowercased name, per candidate list. Every request on a +// Program picks among the same ambient class list (see ./ambient-index), so +// its table is built once per Program instead of once per pick. +const tablesByCandidates = new WeakMap(); +function byLowercaseName(candidates) { + const cached = tablesByCandidates.get(candidates); + if (cached) + return cached; + const table = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = table.get(key); + if (named) + named.push(candidate); + else + table.set(key, [candidate]); + } + tablesByCandidates.set(candidates, table); + return table; +} // The expansion goes first: SFRA's `lineItem` parameters hold a // ProductLineItem, even where the usage would also fit the LineItem base. function withAbbreviation(key) { @@ -58,23 +78,15 @@ function conventionalClassNames(identifier) { * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). */ function pickByName(identifier, candidates) { - const byName = new Map(); - for (const candidate of candidates) { - const key = candidate.name.toLowerCase(); - const named = byName.get(key); - if (named) - named.push(candidate); - else - byName.set(key, [candidate]); - } + const byName = byLowercaseName(candidates); for (const key of conventionalClassNames(identifier)) { const named = byName.get(key); if (named?.length === 1) return named[0]; } - if (identifier === identifier.toLowerCase()) - return undefined; const lower = identifier.toLowerCase(); - const tails = candidates.filter((candidate) => candidate.name.toLowerCase().endsWith(lower)); + if (identifier === lower) + return undefined; + const tails = [...byName].filter(([name]) => name.endsWith(lower)).flatMap(([, named]) => named); return tails.length === 1 ? tails[0] : undefined; } diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index d4cefec5b..66d55a473 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -207,8 +207,7 @@ function matchAmbientTypesByUsage(ctx, memberNames, identifierName) { if (memberNames.size === 0) return []; const classes = (0, ambient_index_1.getAmbientClasses)(ctx); - const fitting = classes.filter((ambientClass) => !constants_1.GLOBAL_OBJECT_CLASSES.has(ambientClass.name) && - [...memberNames].every((name) => ambientClass.memberNames.has(name))); + const fitting = (0, ambient_index_1.ambientClassesWithMembers)(ctx, memberNames).filter((ambientClass) => !constants_1.GLOBAL_OBJECT_CLASSES.has(ambientClass.name)); const onlyWeakMembers = [...memberNames].every((name) => constants_1.WEAK_USAGE_MEMBERS.has(name)); if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; @@ -237,7 +236,7 @@ function downcastOf(ctx, type, memberNames) { const own = classOf(ctx, type); if (!own || constants_1.UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) return []; - const fitting = (0, ambient_index_1.getAmbientClasses)(ctx).filter((ambientClass) => [...memberNames].every((name) => ambientClass.memberNames.has(name))); + const fitting = (0, ambient_index_1.ambientClassesWithMembers)(ctx, memberNames); const subclasses = mostGeneral(ctx, resolveMatches(ctx, fitting) .map((match) => match.type) .filter((subclass) => isAncestorOf(ctx, type, subclass))); diff --git a/packages/b2c-script-types/src/index.ts b/packages/b2c-script-types/src/index.ts index 2860f3680..f909c8bd3 100644 --- a/packages/b2c-script-types/src/index.ts +++ b/packages/b2c-script-types/src/index.ts @@ -35,6 +35,11 @@ const GLOBAL_DTS = path.join(TYPES_DIR, 'global.d.ts').replace(/\\/g, '/'); // 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, '/'); +/** True when two file lists name the same files in the same order. */ +function sameFileNames(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((name, i) => name === b[i]); +} + function init({typescript: ts}: {typescript: typeof tsserver}) { // tsserver calls this factory function fresh for every project that loads // the plugin (once per tsconfig/jsconfig root), so these variables are a @@ -260,13 +265,32 @@ function init({typescript: ts}: {typescript: typeof tsserver}) { // contains at least one cartridge file (isCartridgeFile is false while the // plugin is disabled or no cartridge is known). Projects that already // include them via a jsconfig include glob are unaffected. + // + // tsserver asks for this list on every language-service request (each + // hover, completion and diagnostics pass), building a new array each time + // from the same names while the project's files stay put. The answer is + // kept until a name or the configuration changes, so a request costs one + // name-by-name comparison rather than normalizing every path in the + // project and probing the disk. const origGetScriptFileNames = host.getScriptFileNames.bind(host); - host.getScriptFileNames = () => { - const list = origGetScriptFileNames(); + const withAmbientDeclarations = (list: string[]): string[] => { if (!list.some((f) => isCartridgeFile(f))) return list; const additions = missingAmbientDeclarations(list); return additions.length > 0 ? [...list, ...additions] : list; }; + let lastFileNames: + | {list: string[]; cartridges: NormalizedCartridge[]; enabled: boolean; result: string[]} + | undefined; + host.getScriptFileNames = () => { + const list = origGetScriptFileNames(); + const last = lastFileNames; + if (last?.cartridges === cartridges && last.enabled === enabled && sameFileNames(last.list, list)) { + return last.result; + } + const result = withAmbientDeclarations(list); + lastFileNames = {list, cartridges, enabled, result}; + return result; + }; // Shared by both host resolution hooks below (the modern // resolveModuleNameLiterals and the legacy TS 4.x resolveModuleNames): diff --git a/packages/b2c-script-types/src/inference/ambient-index.ts b/packages/b2c-script-types/src/inference/ambient-index.ts index 48e62a763..43bb3ec18 100644 --- a/packages/b2c-script-types/src/inference/ambient-index.ts +++ b/packages/b2c-script-types/src/inference/ambient-index.ts @@ -69,6 +69,12 @@ export function getAmbientClasses(ctx: InferenceContext): readonly AmbientClass[ return classes; } +/** The indexed classes that have every member in `memberNames`, in index order. */ +export function ambientClassesWithMembers(ctx: InferenceContext, memberNames: ReadonlySet): AmbientClass[] { + const names = [...memberNames]; + return getAmbientClasses(ctx).filter((ambientClass) => names.every((name) => ambientClass.memberNames.has(name))); +} + /** The class's declared (uninstantiated) type, resolved with the current checker. */ export function ambientClassType(ctx: InferenceContext, ambientClass: AmbientClass): tsserver.Type | undefined { const {checker} = ctx; diff --git a/packages/b2c-script-types/src/inference/naming.ts b/packages/b2c-script-types/src/inference/naming.ts index 5fbece644..ef2b9a4fa 100644 --- a/packages/b2c-script-types/src/inference/naming.ts +++ b/packages/b2c-script-types/src/inference/naming.ts @@ -30,6 +30,25 @@ interface Named { readonly name: string; } +// Candidates by lowercased name, per candidate list. Every request on a +// Program picks among the same ambient class list (see ./ambient-index), so +// its table is built once per Program instead of once per pick. +const tablesByCandidates = new WeakMap>(); + +function byLowercaseName(candidates: readonly T[]): ReadonlyMap { + const cached = tablesByCandidates.get(candidates) as ReadonlyMap | undefined; + if (cached) return cached; + const table = new Map(); + for (const candidate of candidates) { + const key = candidate.name.toLowerCase(); + const named = table.get(key); + if (named) named.push(candidate); + else table.set(key, [candidate]); + } + tablesByCandidates.set(candidates, table); + return table; +} + // The expansion goes first: SFRA's `lineItem` parameters hold a // ProductLineItem, even where the usage would also fit the LineItem base. function withAbbreviation(key: string): string[] { @@ -65,19 +84,13 @@ function conventionalClassNames(identifier: string): string[] { * `ProductPriceModel`, `availabilityModel` → `ProductAvailabilityModel`). */ export function pickByName(identifier: string, candidates: readonly T[]): T | undefined { - const byName = new Map(); - for (const candidate of candidates) { - const key = candidate.name.toLowerCase(); - const named = byName.get(key); - if (named) named.push(candidate); - else byName.set(key, [candidate]); - } + const byName = byLowercaseName(candidates); for (const key of conventionalClassNames(identifier)) { const named = byName.get(key); if (named?.length === 1) return named[0]; } - if (identifier === identifier.toLowerCase()) return undefined; const lower = identifier.toLowerCase(); - const tails = candidates.filter((candidate) => candidate.name.toLowerCase().endsWith(lower)); + if (identifier === lower) return undefined; + const tails = [...byName].filter(([name]) => name.endsWith(lower)).flatMap(([, named]) => named); return tails.length === 1 ? tails[0] : undefined; } diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index 90d66f6b9..9996242d8 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -37,7 +37,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; -import {ambientClassType, builtinValueTypes, getAmbientClasses} from './ambient-index'; +import {ambientClassesWithMembers, ambientClassType, builtinValueTypes, getAmbientClasses} from './ambient-index'; import type {AmbientClass} from './ambient-index'; import { GLOBAL_OBJECT_CLASSES, @@ -284,10 +284,8 @@ export function matchAmbientTypesByUsage( ): tsserver.Type[] { if (memberNames.size === 0) return []; const classes = getAmbientClasses(ctx); - const fitting = classes.filter( - (ambientClass) => - !GLOBAL_OBJECT_CLASSES.has(ambientClass.name) && - [...memberNames].every((name) => ambientClass.memberNames.has(name)), + const fitting = ambientClassesWithMembers(ctx, memberNames).filter( + (ambientClass) => !GLOBAL_OBJECT_CLASSES.has(ambientClass.name), ); const onlyWeakMembers = [...memberNames].every((name) => WEAK_USAGE_MEMBERS.has(name)); if (fitting.length === 0 || (onlyWeakMembers && fitting.length > 1)) return []; @@ -318,9 +316,7 @@ export function matchAmbientTypesByUsage( function downcastOf(ctx: InferenceContext, type: tsserver.Type, memberNames: ReadonlySet): tsserver.Type[] { const own = classOf(ctx, type); if (!own || UNINFORMATIVE_ANCESTORS.has(own.symbol?.name ?? '')) return []; - const fitting = getAmbientClasses(ctx).filter((ambientClass) => - [...memberNames].every((name) => ambientClass.memberNames.has(name)), - ); + const fitting = ambientClassesWithMembers(ctx, memberNames); const subclasses = mostGeneral( ctx, resolveMatches(ctx, fitting) diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 2750a2c5c..0fda3bb45 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -6,6 +6,7 @@ 'use strict'; const assert = require('node:assert/strict'); +const path = require('node:path'); const ts = require('typescript'); @@ -938,6 +939,50 @@ describe('create() proxy — usage inference wiring', () => { }); }); +describe('create() proxy — ambient declarations', () => { + // The plugin adds the bundled SFCC globals (and, with a `modules` + // cartridge, the SFRA typings) to a cartridge project's file list. + const TYPES_DIR = path.resolve(__dirname, '..', 'types').replace(/\\/g, '/'); + const GLOBAL_DTS = `${TYPES_DIR}/global.d.ts`; + const SFRA_SERVER_DTS = `${TYPES_DIR}/sfra/server.d.ts`; + const ambientIn = (list) => list.filter((f) => f.startsWith(TYPES_DIR)); + + function pluginOver(files, cartridges) { + const host = createFixtureHost(files); + const {plugin} = createPluginProxy({host, config: {enabled: true, autoDiscover: false, cartridges}}); + return {host, plugin}; + } + + it('adds the SFCC globals to a cartridge project once, and the SFRA typings only with a modules cartridge', () => { + const files = {'/app/cartridge/a.js': 'module.exports = 1;'}; + assert.deepEqual(ambientIn(pluginOver(files, [{name: 'app', src: '/app'}]).host.getScriptFileNames()), [ + GLOBAL_DTS, + ]); + const withModules = pluginOver({...files, '/modules/server.js': ''}, [ + {name: 'app', src: '/app'}, + {name: 'modules', src: '/modules'}, + ]); + assert.deepEqual(ambientIn(withModules.host.getScriptFileNames()), [GLOBAL_DTS, SFRA_SERVER_DTS]); + const included = pluginOver({...files, [GLOBAL_DTS]: ''}, [{name: 'app', src: '/app'}]); + assert.deepEqual(ambientIn(included.host.getScriptFileNames()), [GLOBAL_DTS]); + }); + + it('answers repeated requests from an unchanged file list and recomputes when the files or configuration change', () => { + const files = {'/other/a.js': ''}; + const {host, plugin} = pluginOver(files, [{name: 'app', src: '/app'}]); + assert.deepEqual(ambientIn(host.getScriptFileNames()), []); + assert.equal(host.getScriptFileNames(), host.getScriptFileNames()); + + files['/app/cartridge/a.js'] = 'module.exports = 1;'; + assert.deepEqual(ambientIn(host.getScriptFileNames()), [GLOBAL_DTS]); + + plugin.onConfigurationChanged({enabled: false}); + assert.deepEqual(ambientIn(host.getScriptFileNames()), []); + plugin.onConfigurationChanged({enabled: true, cartridges: [{name: 'other', src: '/other'}]}); + assert.deepEqual(ambientIn(host.getScriptFileNames()), [GLOBAL_DTS]); + }); +}); + describe('create() proxy — hook registrations', () => { // A hook script's exports are only invoked through HookMgr.callHook(). The // plugin reads which scripts implement which extension points from each From 2b6759cf39def1cc6ed17ebd1fb8963a982f226c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:47:03 +0000 Subject: [PATCH 72/76] fix(script-types): treat function types as real type information A declared function type such as SFRA's `NextFunction` is an anonymous type literal with no properties, so the open-type check took it for an empty `{}`. Hovers on contextually typed callback parameters ran inference for nothing, and a function type passed down as call-site or callback evidence was discarded as uninformative. Now a callable type closes the gate and counts as evidence, so a middleware factory's `next` shows `NextFunction`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- .../plugin/inference/type-helpers.js | 6 ++- .../src/inference/type-helpers.ts | 7 +++- packages/b2c-script-types/test/index.test.js | 39 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index d799c6475..f5966bea5 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -23,6 +23,10 @@ const constants_1 = require("./constants"); function isAnyType(ts, type) { return (type.flags & ts.TypeFlags.Any) !== 0; } +/** True when `type` can be called or constructed: a function type, whatever members it lacks. */ +function isCallable(type) { + return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; +} /** * True when the checker's type is too uninformative to prefer over usage * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or @@ -46,7 +50,7 @@ function isOpenForUsageInference(ts, type) { if (type.flags & ts.TypeFlags.NonPrimitive) return true; const symbol = type.getSymbol(); - if (symbol?.getName() === '__type' && type.getProperties().length === 0) + if (symbol?.getName() === '__type' && type.getProperties().length === 0 && !isCallable(type)) return true; return symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; } diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 41989786c..430073ebf 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -23,6 +23,11 @@ export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { return (type.flags & ts.TypeFlags.Any) !== 0; } +/** True when `type` can be called or constructed: a function type, whatever members it lacks. */ +function isCallable(type: tsserver.Type): boolean { + return type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0; +} + /** * True when the checker's type is too uninformative to prefer over usage * inference: `any`, the `object` non-primitive, an empty `{}` type literal, or @@ -44,7 +49,7 @@ export function isOpenForUsageInference(ts: typeof tsserver, type: tsserver.Type if (isAnyType(ts, type)) return true; if (type.flags & ts.TypeFlags.NonPrimitive) return true; const symbol = type.getSymbol(); - if (symbol?.getName() === '__type' && type.getProperties().length === 0) return true; + if (symbol?.getName() === '__type' && type.getProperties().length === 0 && !isCallable(type)) return true; return symbol?.getName() === 'Object' && (type.flags & ts.TypeFlags.Object) !== 0; } diff --git a/packages/b2c-script-types/test/index.test.js b/packages/b2c-script-types/test/index.test.js index 0fda3bb45..2caf1fe5f 100644 --- a/packages/b2c-script-types/test/index.test.js +++ b/packages/b2c-script-types/test/index.test.js @@ -770,6 +770,45 @@ describe('create() proxy — usage inference wiring', () => { const hoverText = [...(hover?.displayParts ?? []), ...(hover?.documentation ?? [])].map((p) => p.text).join(''); assert.match(hoverText, /res: Response/); assert.match(hoverText, /Inferred from usage: Response/); + + // `next` is typed by the declared callback's function type alias. + const nextHover = proxy.getQuickInfoAtPosition(middlewareFile, source.indexOf('next)')); + const nextText = (nextHover?.documentation ?? []).map((p) => p.text).join(''); + assert.match(nextText, /Inferred from usage: NextFunction/); + }); + + it('leaves a parameter the checker types as a function to the checker', () => { + // A function type has no properties, like the empty `{}` the gate treats + // as open, but its call signature is real type information: hovering the + // contextually typed `next` must not run inference or repeat its type. + const files = { + '/c/cartridge/controllers/Home.js': ` + var server = require('server'); + server.get('Show', function (req, res, next) { + next(); + }); + module.exports = server.exports(); + `, + }; + const {proxy} = createPluginProxy({ + files, + config: { + enabled: true, + autoDiscover: false, + cartridges: [ + {name: 'c', src: '/c/'}, + {name: 'modules', src: '/modules/'}, + ], + inferUsage: true, + }, + }); + const controller = '/c/cartridge/controllers/Home.js'; + + const hover = proxy.getQuickInfoAtPosition(controller, files[controller].indexOf('next)')); + const header = (hover?.displayParts ?? []).map((p) => p.text).join(''); + const docText = (hover?.documentation ?? []).map((p) => p.text).join(''); + assert.equal(header, '(parameter) next: NextFunction'); + assert.ok(!docText.includes('Inferred from usage'), `got: ${docText}`); }); it("types a route event listener's parameters through the route a middleware step runs on", () => { From a4d86bc639572d9f1a78d9178681297fbd85a9c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:49:43 +0000 Subject: [PATCH 73/76] docs(script-types): list the callback-argument and hook-call modules in the engine map Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- packages/b2c-script-types/plugin/usage-inference.js | 2 ++ packages/b2c-script-types/src/usage-inference.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/b2c-script-types/plugin/usage-inference.js b/packages/b2c-script-types/plugin/usage-inference.js index 1ea0c2679..09c20a34d 100644 --- a/packages/b2c-script-types/plugin/usage-inference.js +++ b/packages/b2c-script-types/plugin/usage-inference.js @@ -21,11 +21,13 @@ exports.createUsageInferenceHooks = exports.matchAmbientTypesByUsage = exports.u // inference/reference-search - where a name is used, read from the files that can name it // inference/call-sites - find where a function is called across the project // inference/signatures - the function a call invokes and its declared callback types +// inference/callback-arguments - what a project helper passes the callback it is handed // inference/generic-calls - generic calls whose result is an argument or a callback's return // inference/member-values - the values an untyped member is built with // inference/super-module - module.superModule detection and resolution // inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/hook-calls - hook script exports called through HookMgr.callHook and hooks.json // inference/bindings - a helper's parameters bound to one call's arguments // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches diff --git a/packages/b2c-script-types/src/usage-inference.ts b/packages/b2c-script-types/src/usage-inference.ts index 66e28df5c..ac3c372c0 100644 --- a/packages/b2c-script-types/src/usage-inference.ts +++ b/packages/b2c-script-types/src/usage-inference.ts @@ -19,11 +19,13 @@ // inference/reference-search - where a name is used, read from the files that can name it // inference/call-sites - find where a function is called across the project // inference/signatures - the function a call invokes and its declared callback types +// inference/callback-arguments - what a project helper passes the callback it is handed // inference/generic-calls - generic calls whose result is an argument or a callback's return // inference/member-values - the values an untyped member is built with // inference/super-module - module.superModule detection and resolution // inference/this-properties - constructor arguments kept on `this` and read in prototype methods // inference/usage-profile - what a value's own scope says about it (members, guards, uses) +// inference/hook-calls - hook script exports called through HookMgr.callHook and hooks.json // inference/bindings - a helper's parameters bound to one call's arguments // inference/ambient-index - every ambient class by member names // inference/naming - identifier-name tiebreak between usage matches From 2a84f1e94afaa9e8675d8c97978b3dad364da219 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:31:38 +0000 Subject: [PATCH 74/76] fix(script-types): type `this` arguments and Object.defineProperty(this) members Two inference regressions from the evidence/policy restructure: - A `this` argument passed from inside a constructor function or class produced no evidence. The checker types it as the open `this` type, which the engine drops as a type parameter; it now reads as the instance that type stands for (SFRA queryString's `parameterToArray(this, ...)`). - Members a constructor defines with `Object.defineProperty(this, 'x', ...)` are missing from the checker's instance type, so the policy's member fit dropped such a model whenever the body used one of them (SFRA's Request passed to Route). Policy member checks now count those members; the scan is cached per declaration and skips nested functions. On the real-cartridge corpus, placeholder-JSDoc params typed go from 440 to 446 (all six verified against their call sites); documented-param ground truth, search counts and latency are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V8VMhfY1J4xS85RwACb5hg --- docs/guide/ide-integration.md | 4 +- .../b2c-script-types/plugin/inference/core.js | 15 ++- .../plugin/inference/policy.js | 13 ++- .../plugin/inference/this-properties.js | 57 +++++++++++ .../plugin/inference/type-helpers.js | 16 +++- .../b2c-script-types/src/inference/core.ts | 15 ++- .../b2c-script-types/src/inference/policy.ts | 17 ++-- .../src/inference/this-properties.ts | 62 +++++++++++- .../src/inference/type-helpers.ts | 20 ++-- .../test/usage-inference.test.js | 96 +++++++++++++++++++ 10 files changed, 283 insertions(+), 32 deletions(-) diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index b6d8482b5..d937253e1 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -133,9 +133,9 @@ Enable the `b2c-dx.features.scriptTypesInferUsage` setting (default: `false`) or The evidence it weighs: -- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). A function handed to a documented API rather than to a project helper takes the parameter types of the callback that API declares: `basket.getProductLineItems().toArray().forEach(keep)` types `keep`'s parameter as a `ProductLineItem`. +- **Call sites** — the arguments a helper receives anywhere in the project: plain calls, `new Model(x)` (constructor functions and ES6 classes alike), SFRA model inheritance through `BaseModel.call(this, x)` and `helper.apply(this, arguments)`, `this` passed from inside a constructor or class (`parameterToArray(this, key, value)` passes the model being built), calls through export maps and aliases (`module.exports = {helper: helper}`, `var run = helper`), and named functions passed as callbacks (`collections.forEach(items, handleItem)`). A function handed to a documented API rather than to a project helper takes the parameter types of the callback that API declares: `basket.getProductLineItems().toArray().forEach(keep)` types `keep`'s parameter as a `ProductLineItem`. - **Return values and chains** — return statements, followed through undocumented call chains (a helper calling a helper calling a helper), multi-hop method chains (`product.getPriceModel().getPrice()`) and intermediate local variables (`var priceModel = product.getPriceModel();`), including variables assigned in several branches and `lineItemContainer || {}` fallbacks. -- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), a union when unrelated values were (`(Product | Basket)[]`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. +- **Values and the members they are stored in** — a member is what was put into it: `{apiProduct: apiProduct}`, `this.productSearch = productSearch`, `result.matchingProducts = ...`. Arrays are what was pushed into them (`var items = []; items.push(lineItem)`), a union when unrelated values were (`(Product | Basket)[]`), and element access, `filter`/`find`/`[0]` and native `forEach`/`map`/`reduce` callbacks read their elements. A constructor or model function is followed wherever its value goes — `module.exports = Model`, a factory that returns it (`var Model = getModel(definition)`), or an argument it is passed as (`createModel(..., Model)` → `new Model(...)`) — and a constructor's prototype methods count as usage of what it stores on `this`. Members a constructor defines with `Object.defineProperty(this, 'locale', {...})`, as SFRA's request model does, count as members of the model even though TypeScript leaves them off its type. - **Call-specific results** — when a generic helper returns nothing in particular across all of its callers, it is inferred again for the call at hand: `collections.find(basket.shipments, ...)` is a `Shipment`, `collections.find(basket.productLineItems, ...)` a `ProductLineItem`. A callback passed to such a helper is part of the call: `collections.map(basket.shipments, function (shipment) { return new ShippingModel(shipment); })` is a `ShippingModel[]`. Generic Script API calls such as `Transaction.wrap(function () { return order; })` return what their callback returns. IntelliJ does neither. - **Hooks** — nothing calls a hook script's exports by name; the platform dispatches `HookMgr.callHook('dw.order.calculate', 'calculate', basket)` to the scripts a cartridge's `hooks.json` (named by the `hooks` entry of its `package.json`) registers for that extension point. The plugin reads those registrations, so a hook function's parameters take the arguments of the `callHook` calls that reach it (from the third argument on), and a `callHook` call returns what the matching hook functions return. Extension points built at runtime (`'app.payment.processor.' + processor.ID.toLowerCase()` or a template literal, inline or in a local variable) reach every registered extension point their literal prefix starts. IntelliJ follows hooks in neither direction. - **Typed Script API uses** — a value handed to a documented API takes that API's parameter type, even when the helper is never called: `ShippingMgr.applyShippingCost(basket)` makes `basket` a `LineItemCtnr`, and `ProductMgr.getProduct(pid)` makes `pid` a `string`. diff --git a/packages/b2c-script-types/plugin/inference/core.js b/packages/b2c-script-types/plugin/inference/core.js index 6fdf5ebce..97df80cd9 100644 --- a/packages/b2c-script-types/plugin/inference/core.js +++ b/packages/b2c-script-types/plugin/inference/core.js @@ -111,6 +111,19 @@ function invokedCallbackTypes(ctx, call, argIndex, paramIndex, depth) { ...forwarded.flatMap((use) => invokedCallbackTypes(ctx, use.call, use.argIndex, paramIndex, depth + 1)), ]); } +/** + * The checker's type of `expr`. Inside a class or a constructor function the + * checker types `this` as an open `this` type; what it holds is the instance + * that type stands for (`helper(this)` in `function Basket() {…}` passes a + * `Basket`). + */ +function checkerTypeOf(ctx, expr) { + const { ts, checker } = ctx; + const type = checker.getTypeAtLocation(expr); + if (expr.kind !== ts.SyntaxKind.ThisKeyword || !(type.flags & ts.TypeFlags.TypeParameter)) + return type; + return checker.getBaseConstraintOfType(type) ?? type; +} /** * Resolves the candidate type(s) of `expr`: the checker's own type when it is * informative, otherwise a type recovered through whatever `expr` is built @@ -136,7 +149,7 @@ function resolveExpressionTypes(ctx, expr, depth, chainHops = 0) { const superAccess = (0, super_module_1.traceSuperModuleAccess)(ts, checker, expr); if (superAccess) return (0, super_module_1.resolveSuperModuleTypes)(ctx, superAccess, nextHop(ctx, depth, chainHops)); - const direct = (0, type_helpers_1.informativeParts)(ctx, checker.getTypeAtLocation(expr)); + const direct = (0, type_helpers_1.informativeParts)(ctx, checkerTypeOf(ctx, expr)); const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; if (bound) return bound; diff --git a/packages/b2c-script-types/plugin/inference/policy.js b/packages/b2c-script-types/plugin/inference/policy.js index 66d55a473..f6ad80cc9 100644 --- a/packages/b2c-script-types/plugin/inference/policy.js +++ b/packages/b2c-script-types/plugin/inference/policy.js @@ -103,7 +103,7 @@ function withoutStandIns(ctx, types) { return [...types]; const standsIn = (literal) => { const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); - return classes.some((candidateClass) => (0, type_helpers_1.hasAllMembers)(checker, candidateClass, names)); + return classes.some((candidateClass) => (0, type_helpers_1.hasAllMembers)(ctx, candidateClass, names)); }; return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); } @@ -118,7 +118,7 @@ function closestCommonAncestor(ctx, types, memberNames) { const restAncestors = rest.map((type) => ancestorsOf(ctx, type)); return ancestorsOf(ctx, first).find((ancestor) => !constants_1.UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && restAncestors.every((ancestors) => ancestors.includes(ancestor)) && - (0, type_helpers_1.hasAllMembers)(ctx.checker, ancestor, memberNames)); + (0, type_helpers_1.hasAllMembers)(ctx, ancestor, memberNames)); } /** * The generic class `type` instantiates, when its declared type reads as the @@ -168,7 +168,7 @@ function limitUnion(ctx, types, memberNames = NO_MEMBERS) { * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. */ function mostSpecificUse(ctx, profile, required) { - const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => (0, type_helpers_1.hasAllMembers)(ctx.checker, type, required)); + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => (0, type_helpers_1.hasAllMembers)(ctx, type, required)); const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); return mostSpecific.length === 1 ? mostSpecific : []; } @@ -185,7 +185,7 @@ function resolveMatches(ctx, classes) { } /** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ function fitsBuiltin(ctx, memberNames) { - return (0, ambient_index_1.builtinValueTypes)(ctx).some((builtin) => (0, type_helpers_1.hasAllMembers)(ctx.checker, builtin, memberNames)); + return (0, ambient_index_1.builtinValueTypes)(ctx).some((builtin) => (0, type_helpers_1.hasAllMembers)(ctx, builtin, memberNames)); } /** * Step 4b: matches a usage signature against every ambient class (see @@ -249,8 +249,7 @@ function downcastOf(ctx, type, memberNames) { * would otherwise drop every candidate, the right one included. */ function checkableMembers(ctx, required, candidates) { - const { checker } = ctx; - const declaredBy = (types, name) => types.some((type) => (0, type_helpers_1.getMemberOfType)(checker, type, name) !== undefined); + const declaredBy = (types, name) => types.some((type) => (0, type_helpers_1.hasMember)(ctx, type, name)); const declared = (name) => declaredBy(candidates, name) || (0, ambient_index_1.getAmbientClasses)(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || declaredBy((0, ambient_index_1.builtinValueTypes)(ctx), name); @@ -275,7 +274,7 @@ function decideType(ctx, evidence, profile, name, fitUsage) { const fit = (type) => { if (guardKeys.has((0, type_helpers_1.typeDisplayString)(ctx, type))) return [type]; - const fitted = (0, type_helpers_1.hasAllMembers)(ctx.checker, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + const fitted = (0, type_helpers_1.hasAllMembers)(ctx, type, checkable) ? [type] : downcastOf(ctx, type, checkable); return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); }; const fitting = fitUsage ? (0, type_helpers_1.dedupeTypes)(ctx, candidates.flatMap(fit)) : candidates; diff --git a/packages/b2c-script-types/plugin/inference/this-properties.js b/packages/b2c-script-types/plugin/inference/this-properties.js index 5d61968b1..7dc9ed892 100644 --- a/packages/b2c-script-types/plugin/inference/this-properties.js +++ b/packages/b2c-script-types/plugin/inference/this-properties.js @@ -6,6 +6,7 @@ */ Object.defineProperty(exports, "__esModule", { value: true }); exports.thisMembersStoring = thisMembersStoring; +exports.isMemberDefinedOnThis = isMemberDefinedOnThis; exports.isStoredMemberRead = isStoredMemberRead; const ast_helpers_1 = require("./ast-helpers"); /** `this.x = value`: a plain assignment to a member of `this`. */ @@ -45,6 +46,62 @@ function thisMembersStoring(ctx, param) { ts.forEachChild(body, visit); return members; } +/** True when `callee` is `Object.defineProperty`. */ +function isObjectDefineProperty(ts, callee) { + return (ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' && + callee.name.text === 'defineProperty'); +} +/** `Object.defineProperty(this, 'name', …)`: the name of the member the call defines on `this`. */ +function memberDefinedOnThisBy(ts, node) { + if (!ts.isCallExpression(node) || !isObjectDefineProperty(ts, node.expression)) + return undefined; + const [target, key] = node.arguments; + if (target?.kind !== ts.SyntaxKind.ThisKeyword || !key || !ts.isStringLiteralLike(key)) + return undefined; + return key.text; +} +/** The body that builds an instance: a constructor function's own, or a class's constructor. */ +function constructorBody(ts, declaration) { + const constructor = ts.isVariableDeclaration(declaration) ? declaration.initializer : declaration; + if (constructor && (ts.isFunctionDeclaration(constructor) || ts.isFunctionExpression(constructor))) { + return constructor.body; + } + return ts.isClassLike(declaration) ? declaration.members.find(ts.isConstructorDeclaration)?.body : undefined; +} +// Keyed by declaration node: a syntax tree never changes, and tsserver reuses +// it across Programs for as long as its file is unchanged. +const membersDefinedByDeclaration = new WeakMap(); +/** The members the constructor `declaration` declares defines on its own `this` (not a nested function's). */ +function membersDefinedIn(ts, declaration) { + const cached = membersDefinedByDeclaration.get(declaration); + if (cached) + return cached; + const members = new Set(); + const visit = (node) => { + const member = memberDefinedOnThisBy(ts, node); + if (member !== undefined) + members.add(member); + if (!hasOwnThis(ts, node)) + ts.forEachChild(node, visit); + }; + const body = constructorBody(ts, declaration); + if (body) + ts.forEachChild(body, visit); + membersDefinedByDeclaration.set(declaration, members); + return members; +} +/** + * True when `type`'s constructor defines `name` with + * `Object.defineProperty(this, 'name', …)` (SFRA's request model defines most + * of its members this way). The checker binds such calls on exports and + * prototypes but not on `this`, so the instance type lacks these members. + */ +function isMemberDefinedOnThis(ts, type, name) { + const declarations = type.getSymbol()?.declarations ?? []; + return declarations.some((declaration) => membersDefinedIn(ts, declaration).has(name)); +} /** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ function isStoredMemberRead(ctx, members, node) { const { ts, checker } = ctx; diff --git a/packages/b2c-script-types/plugin/inference/type-helpers.js b/packages/b2c-script-types/plugin/inference/type-helpers.js index f5966bea5..9210f9b85 100644 --- a/packages/b2c-script-types/plugin/inference/type-helpers.js +++ b/packages/b2c-script-types/plugin/inference/type-helpers.js @@ -12,6 +12,7 @@ exports.typeDisplayString = typeDisplayString; exports.dedupeTypes = dedupeTypes; exports.dedupeKnownTypes = dedupeKnownTypes; exports.getMemberOfType = getMemberOfType; +exports.hasMember = hasMember; exports.hasAllMembers = hasAllMembers; exports.elementTypeOf = elementTypeOf; exports.arrayTypeOf = arrayTypeOf; @@ -19,6 +20,7 @@ exports.describeTypes = describeTypes; exports.inferredCompletionEntry = inferredCompletionEntry; exports.typesToCompletionEntries = typesToCompletionEntries; const constants_1 = require("./constants"); +const this_properties_1 = require("./this-properties"); /** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ function isAnyType(ts, type) { return (type.flags & ts.TypeFlags.Any) !== 0; @@ -225,10 +227,18 @@ function getNonNullableApparentType(checker, type) { function getMemberOfType(checker, type, name) { return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); } -/** True when `type` exposes every member name in `memberNames` (vacuously true for none). */ -function hasAllMembers(checker, type, memberNames) { +/** + * True when `type` has a member called `name`: one the checker knows, or one + * its constructor defines on `this` with `Object.defineProperty` (see + * {@link isMemberDefinedOnThis}). + */ +function hasMember(ctx, type, name) { + return getMemberOfType(ctx.checker, type, name) !== undefined || (0, this_properties_1.isMemberDefinedOnThis)(ctx.ts, type, name); +} +/** True when `type` has every member in `memberNames` (vacuously true for none); see {@link hasMember}. */ +function hasAllMembers(ctx, type, memberNames) { for (const name of memberNames) { - if (!getMemberOfType(checker, type, name)) + if (!hasMember(ctx, type, name)) return false; } return true; diff --git a/packages/b2c-script-types/src/inference/core.ts b/packages/b2c-script-types/src/inference/core.ts index 6f027621d..0d50a5734 100644 --- a/packages/b2c-script-types/src/inference/core.ts +++ b/packages/b2c-script-types/src/inference/core.ts @@ -182,6 +182,19 @@ function invokedCallbackTypes( ]); } +/** + * The checker's type of `expr`. Inside a class or a constructor function the + * checker types `this` as an open `this` type; what it holds is the instance + * that type stands for (`helper(this)` in `function Basket() {…}` passes a + * `Basket`). + */ +function checkerTypeOf(ctx: InferenceContext, expr: tsserver.Expression): tsserver.Type { + const {ts, checker} = ctx; + const type = checker.getTypeAtLocation(expr); + if (expr.kind !== ts.SyntaxKind.ThisKeyword || !(type.flags & ts.TypeFlags.TypeParameter)) return type; + return checker.getBaseConstraintOfType(type) ?? type; +} + /** * Resolves the candidate type(s) of `expr`: the checker's own type when it is * informative, otherwise a type recovered through whatever `expr` is built @@ -211,7 +224,7 @@ function resolveExpressionTypes( // circular `typeof base` that would pass for informative. const superAccess = traceSuperModuleAccess(ts, checker, expr); if (superAccess) return resolveSuperModuleTypes(ctx, superAccess, nextHop(ctx, depth, chainHops)); - const direct = informativeParts(ctx, checker.getTypeAtLocation(expr)); + const direct = informativeParts(ctx, checkerTypeOf(ctx, expr)); const bound = ctx.bindings.size > 0 ? resolveBoundTypes(ctx, expr, direct, depth, chainHops) : undefined; if (bound) return bound; if (direct.length > 0) return direct; diff --git a/packages/b2c-script-types/src/inference/policy.ts b/packages/b2c-script-types/src/inference/policy.ts index 9996242d8..983ba1eba 100644 --- a/packages/b2c-script-types/src/inference/policy.ts +++ b/packages/b2c-script-types/src/inference/policy.ts @@ -48,7 +48,7 @@ import { } from './constants'; import type {InferenceContext} from './context'; import {pickByName} from './naming'; -import {dedupeTypes, getMemberOfType, hasAllMembers, informativeParts, typeDisplayString} from './type-helpers'; +import {dedupeTypes, hasAllMembers, hasMember, informativeParts, typeDisplayString} from './type-helpers'; import type {UsageProfile} from './usage-profile'; const NO_MEMBERS: ReadonlySet = new Set(); @@ -154,7 +154,7 @@ function withoutStandIns(ctx: InferenceContext, types: readonly tsserver.Type[]) if (classes.length === 0) return [...types]; const standsIn = (literal: tsserver.Type): boolean => { const names = new Set(checker.getPropertiesOfType(literal).map((property) => property.name)); - return classes.some((candidateClass) => hasAllMembers(checker, candidateClass, names)); + return classes.some((candidateClass) => hasAllMembers(ctx, candidateClass, names)); }; return types.filter((type) => !isObjectLiteralType(ctx, type) || !standsIn(type)); } @@ -177,7 +177,7 @@ function closestCommonAncestor( (ancestor) => !UNINFORMATIVE_ANCESTORS.has(ancestor.symbol?.name ?? '') && restAncestors.every((ancestors) => ancestors.includes(ancestor)) && - hasAllMembers(ctx.checker, ancestor, memberNames), + hasAllMembers(ctx, ancestor, memberNames), ); } @@ -233,9 +233,7 @@ export function limitUnion( * `LineItemCtnr` is expected is a `Basket`). Conflicting uses decide nothing. */ function mostSpecificUse(ctx: InferenceContext, profile: UsageProfile, required: ReadonlySet): tsserver.Type[] { - const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => - hasAllMembers(ctx.checker, type, required), - ); + const uses = normalizeCandidates(ctx, profile.contextualTypes).filter((type) => hasAllMembers(ctx, type, required)); const mostSpecific = uses.filter((type) => uses.every((other) => other === type || isAncestorOf(ctx, other, type))); return mostSpecific.length === 1 ? mostSpecific : []; } @@ -258,7 +256,7 @@ function resolveMatches(ctx: InferenceContext, classes: readonly AmbientClass[]) /** True when a JavaScript built-in (`String`, `Array`, ...) has every member in `memberNames`. */ function fitsBuiltin(ctx: InferenceContext, memberNames: ReadonlySet): boolean { - return builtinValueTypes(ctx).some((builtin) => hasAllMembers(ctx.checker, builtin, memberNames)); + return builtinValueTypes(ctx).some((builtin) => hasAllMembers(ctx, builtin, memberNames)); } /** @@ -337,9 +335,8 @@ function checkableMembers( required: ReadonlySet, candidates: readonly tsserver.Type[], ): ReadonlySet { - const {checker} = ctx; const declaredBy = (types: readonly tsserver.Type[], name: string): boolean => - types.some((type) => getMemberOfType(checker, type, name) !== undefined); + types.some((type) => hasMember(ctx, type, name)); const declared = (name: string): boolean => declaredBy(candidates, name) || getAmbientClasses(ctx).some((ambientClass) => ambientClass.memberNames.has(name)) || @@ -371,7 +368,7 @@ export function decideType( const uses = constrainingUses(ctx, profile); const fit = (type: tsserver.Type): tsserver.Type[] => { if (guardKeys.has(typeDisplayString(ctx, type))) return [type]; - const fitted = hasAllMembers(ctx.checker, type, checkable) ? [type] : downcastOf(ctx, type, checkable); + const fitted = hasAllMembers(ctx, type, checkable) ? [type] : downcastOf(ctx, type, checkable); return fitted.filter((fittedType) => fitsEveryUse(ctx, fittedType, uses)); }; const fitting = fitUsage ? dedupeTypes(ctx, candidates.flatMap(fit)) : candidates; diff --git a/packages/b2c-script-types/src/inference/this-properties.ts b/packages/b2c-script-types/src/inference/this-properties.ts index 699808afb..d434c6112 100644 --- a/packages/b2c-script-types/src/inference/this-properties.ts +++ b/packages/b2c-script-types/src/inference/this-properties.ts @@ -11,7 +11,9 @@ // ./usage-profile: the checker binds every `this.refinementValue` of the // constructor's prototype methods to the member the constructor declares, so // a read of that member anywhere in the file is a use of the value stored in -// it. +// it. Constructors also define members with `Object.defineProperty(this, …)`, +// which the checker leaves off the instance type; ./type-helpers counts them +// as members all the same. import type tsserver from 'typescript/lib/tsserverlibrary'; @@ -62,6 +64,64 @@ export function thisMembersStoring( return members; } +/** True when `callee` is `Object.defineProperty`. */ +function isObjectDefineProperty(ts: typeof tsserver, callee: tsserver.Expression): boolean { + return ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Object' && + callee.name.text === 'defineProperty' + ); +} + +/** `Object.defineProperty(this, 'name', …)`: the name of the member the call defines on `this`. */ +function memberDefinedOnThisBy(ts: typeof tsserver, node: tsserver.Node): string | undefined { + if (!ts.isCallExpression(node) || !isObjectDefineProperty(ts, node.expression)) return undefined; + const [target, key] = node.arguments; + if (target?.kind !== ts.SyntaxKind.ThisKeyword || !key || !ts.isStringLiteralLike(key)) return undefined; + return key.text; +} + +/** The body that builds an instance: a constructor function's own, or a class's constructor. */ +function constructorBody(ts: typeof tsserver, declaration: tsserver.Declaration): tsserver.Node | undefined { + const constructor = ts.isVariableDeclaration(declaration) ? declaration.initializer : declaration; + if (constructor && (ts.isFunctionDeclaration(constructor) || ts.isFunctionExpression(constructor))) { + return constructor.body; + } + return ts.isClassLike(declaration) ? declaration.members.find(ts.isConstructorDeclaration)?.body : undefined; +} + +// Keyed by declaration node: a syntax tree never changes, and tsserver reuses +// it across Programs for as long as its file is unchanged. +const membersDefinedByDeclaration = new WeakMap>(); + +/** The members the constructor `declaration` declares defines on its own `this` (not a nested function's). */ +function membersDefinedIn(ts: typeof tsserver, declaration: tsserver.Declaration): ReadonlySet { + const cached = membersDefinedByDeclaration.get(declaration); + if (cached) return cached; + const members = new Set(); + const visit = (node: tsserver.Node): void => { + const member = memberDefinedOnThisBy(ts, node); + if (member !== undefined) members.add(member); + if (!hasOwnThis(ts, node)) ts.forEachChild(node, visit); + }; + const body = constructorBody(ts, declaration); + if (body) ts.forEachChild(body, visit); + membersDefinedByDeclaration.set(declaration, members); + return members; +} + +/** + * True when `type`'s constructor defines `name` with + * `Object.defineProperty(this, 'name', …)` (SFRA's request model defines most + * of its members this way). The checker binds such calls on exports and + * prototypes but not on `this`, so the instance type lacks these members. + */ +export function isMemberDefinedOnThis(ts: typeof tsserver, type: tsserver.Type, name: string): boolean { + const declarations = type.getSymbol()?.declarations ?? []; + return declarations.some((declaration) => membersDefinedIn(ts, declaration).has(name)); +} + /** True when `node` reads one of `members` off `this` (`this.refinementValue`, not `this.refinementValue = x`). */ export function isStoredMemberRead( ctx: InferenceContext, diff --git a/packages/b2c-script-types/src/inference/type-helpers.ts b/packages/b2c-script-types/src/inference/type-helpers.ts index 430073ebf..58f0aa2f6 100644 --- a/packages/b2c-script-types/src/inference/type-helpers.ts +++ b/packages/b2c-script-types/src/inference/type-helpers.ts @@ -17,6 +17,7 @@ import type tsserver from 'typescript/lib/tsserverlibrary'; import {INFERRED_COMPLETION_SOURCE} from './constants'; import type {InferenceContext} from './context'; +import {isMemberDefinedOnThis} from './this-properties'; /** True when `type` is (or includes) `any` — the signal that the checker gave up and usage inference should try to help. */ export function isAnyType(ts: typeof tsserver, type: tsserver.Type): boolean { @@ -238,14 +239,19 @@ export function getMemberOfType( return checker.getPropertyOfType(getNonNullableApparentType(checker, type), name); } -/** True when `type` exposes every member name in `memberNames` (vacuously true for none). */ -export function hasAllMembers( - checker: tsserver.TypeChecker, - type: tsserver.Type, - memberNames: ReadonlySet, -): boolean { +/** + * True when `type` has a member called `name`: one the checker knows, or one + * its constructor defines on `this` with `Object.defineProperty` (see + * {@link isMemberDefinedOnThis}). + */ +export function hasMember(ctx: InferenceContext, type: tsserver.Type, name: string): boolean { + return getMemberOfType(ctx.checker, type, name) !== undefined || isMemberDefinedOnThis(ctx.ts, type, name); +} + +/** True when `type` has every member in `memberNames` (vacuously true for none); see {@link hasMember}. */ +export function hasAllMembers(ctx: InferenceContext, type: tsserver.Type, memberNames: ReadonlySet): boolean { for (const name of memberNames) { - if (!getMemberOfType(checker, type, name)) return false; + if (!hasMember(ctx, type, name)) return false; } return true; } diff --git a/packages/b2c-script-types/test/usage-inference.test.js b/packages/b2c-script-types/test/usage-inference.test.js index a0222fb65..3bdd8097e 100644 --- a/packages/b2c-script-types/test/usage-inference.test.js +++ b/packages/b2c-script-types/test/usage-inference.test.js @@ -100,6 +100,70 @@ describe('usage-inference', () => { assert.equal(describeTypes(ctx, types), '{ ID: string; name: string; } | { quantity: number; }'); }); + it('infers a constructor function or class instance from a `this` argument', () => { + // The checker types `this` in a constructor as an open `this` type; + // the value passed is the instance under construction. + const files = { + '/helper.js': ` + function store(target, key, value) { + return target[key] ? [target[key], value] : value; + } + function label(model) { + return model.id; + } + var Params = function (raw) { + var pair = raw.split('='); + this.raw = raw; + this[pair[0]] = store(this, pair[0], pair[1]); + }; + class Model { + constructor(id) { + this.id = id; + } + describe() { + return label(this); + } + } + module.exports = {Params: Params, Model: Model}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + + const target = findFunctionDeclaration(sourceFile, 'store').parameters[0]; + const model = findFunctionDeclaration(sourceFile, 'label').parameters[0]; + + assert.equal(describeTypes(ctx, inferParameterType(ctx, target)), 'Params'); + assert.equal(describeTypes(ctx, inferParameterType(ctx, model)), 'Model'); + }); + + it('stays silent on a `this` argument outside a constructor', () => { + const files = { + '/helper.js': ` + function label(model) { + return model.id; + } + function plain() { + return label(this); + } + function Model(ids) { + this.id = ids[0]; + ids.forEach(function () { + label(this); + }, this); + } + module.exports = {plain: plain, Model: Model}; + `, + }; + const languageService = createFixtureLanguageService(files); + const ctx = createInferenceContext(ts, languageService); + const sourceFile = ctx.program.getSourceFile('/helper.js'); + const model = findFunctionDeclaration(sourceFile, 'label').parameters[0]; + + assert.equal(inferParameterType(ctx, model).length, 0); + }); + it('infers through a mix of plain-call and `new` when every site passes the same type', () => { const files = { '/types.d.ts': AMBIENT_TYPES, @@ -500,6 +564,38 @@ module.exports = getLineItems; assert.equal(inferHelperParam(files, 'unitPrice'), '{ ID: string; name: string; }'); }); + it('counts the members a constructor defines with Object.defineProperty(this, ...)', () => { + // `locale` and `currency` are members a Script API class declares, so + // the body's use of them is checked against the argument's type. + const files = { + '/types.d.ts': 'declare class SiteLocale { locale: string; currency: string; }', + '/helper.js': ` + function Request(raw) { + this.path = raw.path; + Object.defineProperty(this, 'locale', { + get: function () { + Object.defineProperty(this, 'currency', {value: raw.currency}); + return raw.locale; + }, + }); + } + function pageTitle(req) { + return req.path + req.locale; + } + function priceLabel(req) { + return req.path + req.currency; + } + pageTitle(new Request({path: '/', locale: 'en'})); + priceLabel(new Request({path: '/', currency: 'EUR'})); + module.exports = Request; + `, + }; + + assert.equal(inferHelperParam(files, 'pageTitle'), 'Request'); + // A nested function's `this` is another value. + assert.equal(inferHelperParam(files, 'priceLabel'), ''); + }); + it('keeps a call-site argument whose use only a branch that narrowed it constrains', () => { const files = { '/types.d.ts': `${AMBIENT_TYPES}declare function lookup(id: string): {ID: string; name: string};`, From 5d7a9fc66d1f456bbdcab26939f20df05f2c733c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:51:20 +0000 Subject: [PATCH 75/76] Split workspace-trust changeset and shorten the inference changeset Address review feedback: the VS Code extension's Restricted Mode (untrusted workspace) change gets its own minor changeset, the tsserver plugin hardening stays a patch, and the usage-inference changeset is cut down to a release-notes summary that points at the IDE integration guide. Document the workspace-trust requirement in the guide. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVfax6Ld8eB9oDGszpnWEa --- .changeset/script-types-infer-usage.md | 2 +- .changeset/script-types-workspace-trust.md | 2 +- .changeset/vs-extension-untrusted-workspaces.md | 5 +++++ docs/guide/ide-integration.md | 2 ++ 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/vs-extension-untrusted-workspaces.md diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 6cc910fb8..52813dc12 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 cartridge helpers the way IntelliJ does, instead of falling back to `any` and losing hover and completions for everything downstream. Types come from call-site arguments (including `new`, `.call`/`.apply` and callbacks), the callback types of the APIs a function is handed to (SFRA middleware modules and middleware factories' results passed to `server.get`), return values, the typed Script API calls a value is passed to, and the members and `instanceof`/`typeof` checks in the helper's own body, followed through object members, arrays, factory-returned models and `HookMgr.callHook` calls into the hook scripts `hooks.json` registers; call sites that disagree show a union of up to three types (`Product | Category`). Results are labeled "Inferred from usage", real `dw.*` and `{any}` JSDoc are never overridden, and placeholder SFRA JSDoc such as `@param {Object}` no longer blocks inference. It is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the plugin config for other LSP hosts (the plugin from `b2c setup ide tsserver-plugin`). +Script API IntelliSense can now infer types for undocumented cartridge helpers from how they are used across the project: call sites, return values, the Script API calls a value reaches, SFRA middleware and `hooks.json` hooks. Hover and completions keep working where they used to fall back to `any`, and inferred types are labeled "Inferred from usage". The feature is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the tsserver plugin config for other editors. See the IDE integration guide for details. diff --git a/.changeset/script-types-workspace-trust.md b/.changeset/script-types-workspace-trust.md index 93ccd37e3..7eb58b3ad 100644 --- a/.changeset/script-types-workspace-trust.md +++ b/.changeset/script-types-workspace-trust.md @@ -3,4 +3,4 @@ 'b2c-vs-extension': patch --- -Hardened Script API IntelliSense against malicious repositories: the tsserver plugin keeps every resolved `require()` path (including a cartridge `package.json` `main`) inside the bundled types and the cartridge roots, and caps the size of the `dw.json`/`package.json` files it reads. The VS Code extension now runs only in trusted workspaces; trust the workspace to use it. +Hardened Script API IntelliSense against malicious repositories: the tsserver plugin keeps every resolved `require()` path (including a cartridge `package.json` `main`) inside the bundled types and the cartridge roots, and caps the size of the `dw.json`/`package.json` files it reads. diff --git a/.changeset/vs-extension-untrusted-workspaces.md b/.changeset/vs-extension-untrusted-workspaces.md new file mode 100644 index 000000000..8612832b6 --- /dev/null +++ b/.changeset/vs-extension-untrusted-workspaces.md @@ -0,0 +1,5 @@ +--- +'b2c-vs-extension': minor +--- + +The VS Code extension is now disabled in untrusted (Restricted Mode) workspaces, because it reads instance settings and credentials from workspace files; trust the workspace to use it. diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index d937253e1..09be2caa9 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -24,6 +24,8 @@ If you have the Salesforce B2C Commerce VS Code extension installed, IntelliSens You can disable the feature with the `b2c-dx.features.scriptTypes` setting (default: `true`). +The extension only runs in trusted workspaces. It reads instance settings and credentials from workspace files such as `dw.json`, so VS Code keeps it disabled in Restricted Mode; trust the workspace to use it. + The plugin also resolves SFCC cartridge-style requires, matching runtime semantics: - `require('~/cartridge/scripts/foo')` — resolves only within the cartridge that contains the current file (the SFCC `~` shortcut for "current cartridge"). If `foo` doesn't exist there, IntelliSense reports it unresolved — same as runtime. From e78a7faa899fbfb231f22b4e7472ae4167779b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:04:29 +0000 Subject: [PATCH 76/76] Join the Script API IntelliSense changesets Fold the SFRA `this` typing, the request-cost fix and the plugin hardening into one short patch changeset, and trim the usage-inference changeset further. The VS Code extension's Restricted Mode change keeps its own changeset, as requested in review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVfax6Ld8eB9oDGszpnWEa --- .changeset/script-types-file-list-cost.md | 6 ------ .changeset/script-types-fixes.md | 6 ++++++ .changeset/script-types-infer-usage.md | 2 +- .changeset/script-types-sfra-route-this.md | 6 ------ .changeset/script-types-workspace-trust.md | 6 ------ 5 files changed, 7 insertions(+), 19 deletions(-) delete mode 100644 .changeset/script-types-file-list-cost.md create mode 100644 .changeset/script-types-fixes.md delete mode 100644 .changeset/script-types-sfra-route-this.md delete mode 100644 .changeset/script-types-workspace-trust.md diff --git a/.changeset/script-types-file-list-cost.md b/.changeset/script-types-file-list-cost.md deleted file mode 100644 index c75ce558d..000000000 --- a/.changeset/script-types-file-list-cost.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Script API IntelliSense no longer re-reads every project file path on each hover, completion and diagnostics request, which added a few milliseconds to every request in large cartridge workspaces. diff --git a/.changeset/script-types-fixes.md b/.changeset/script-types-fixes.md new file mode 100644 index 000000000..6720d674a --- /dev/null +++ b/.changeset/script-types-fixes.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-cli': patch +'b2c-vs-extension': patch +--- + +Script API IntelliSense fixes: `this` in SFRA middleware is now typed as the route, requests are faster in large cartridge workspaces, and the tsserver plugin no longer resolves `require()` paths outside the bundled types and cartridge roots of a malicious repository. diff --git a/.changeset/script-types-infer-usage.md b/.changeset/script-types-infer-usage.md index 52813dc12..426960eb9 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 cartridge helpers from how they are used across the project: call sites, return values, the Script API calls a value reaches, SFRA middleware and `hooks.json` hooks. Hover and completions keep working where they used to fall back to `any`, and inferred types are labeled "Inferred from usage". The feature is off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the tsserver plugin config for other editors. See the IDE integration guide for details. +Script API IntelliSense can now infer types for undocumented cartridge helpers from how they are used across the project, so hover and completions keep working where they used to fall back to `any`. Off by default: enable the `b2c-dx.features.scriptTypesInferUsage` VS Code setting, or pass `inferUsage: true` in the tsserver plugin config for other editors. See the IDE integration guide for details. diff --git a/.changeset/script-types-sfra-route-this.md b/.changeset/script-types-sfra-route-this.md deleted file mode 100644 index 7d92a80fe..000000000 --- a/.changeset/script-types-sfra-route-this.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Script API IntelliSense now types `this` in SFRA middleware as the route, so the `req`/`res` of listeners registered with `this.on('route:BeforeComplete', function (req, res) {...})` get completions instead of `any`. diff --git a/.changeset/script-types-workspace-trust.md b/.changeset/script-types-workspace-trust.md deleted file mode 100644 index 7eb58b3ad..000000000 --- a/.changeset/script-types-workspace-trust.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@salesforce/b2c-cli': patch -'b2c-vs-extension': patch ---- - -Hardened Script API IntelliSense against malicious repositories: the tsserver plugin keeps every resolved `require()` path (including a cartridge `package.json` `main`) inside the bundled types and the cartridge roots, and caps the size of the `dw.json`/`package.json` files it reads.