Skip to content

Script API IntelliSense: infer types for undocumented helpers from usage#586

Open
taurgis wants to merge 44 commits into
SalesforceCommerceCloud:mainfrom
taurgis:feature/script-types-usage-inference
Open

Script API IntelliSense: infer types for undocumented helpers from usage#586
taurgis wants to merge 44 commits into
SalesforceCommerceCloud:mainfrom
taurgis:feature/script-types-usage-inference

Conversation

@taurgis

@taurgis taurgis commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Today, an undocumented SFCC helper function (no JSDoc) gets its parameters and return value widened to any by TypeScript, and that any silently poisons every downstream caller — hover and autocomplete just stop working for anything that touches it. This is extremely common in real SFRA cartridges.

This PR adds an opt-in, heuristic fallback to @salesforce/b2c-script-types: when the checker has already given up with any, the plugin looks at how the value is actually used elsewhere in the project — call-site arguments, return expressions, chained property/method access, module.superModule overlays — and infers a plausible type from that usage. It surfaces the result as hover text ("Inferred from usage: …") and synthesized member completions, without ever overriding a type TypeScript or JSDoc already resolved correctly.

  • New usage-inference engine in @salesforce/b2c-script-types, wired into the plugin's getQuickInfoAtPosition/getCompletionsAtPosition, behind a new inferUsage config flag (off by default).
  • New VS Code setting b2c-dx.features.scriptTypesInferUsage (off by default, labeled Preview/heuristic) plumbed through to the plugin.
  • Handles common CommonJS export shapes, literal widening, nullable unions, multi-hop method chains on real dw.* classes, callback/iterator element types, and multi-cartridge superModule overlays.

Also included, hardening the feature before it ships:

  • Security hardening against malicious repositories. Every resolved require() path — including a cartridge's package.json main — is canonicalized and contained, so a crafted import specifier or an in-repo symlink can no longer resolve to a file outside the bundled types directory or the cartridge roots. dw.json/package.json parsing is size-bounded to guard against oversized-file denial-of-service. The VS Code extension declares capabilities.untrustedWorkspaces and refuses to forward cartridge paths or run inference until the workspace is trusted.
  • Code organization. The inference engine and the plugin entry are split into focused, single-purpose modules behind stable public entry points, and the resolution/path-containment logic was pulled out into standalone, unit-testable functions.

Screenshots

Setting is off by default and clearly labeled as a preview/heuristic feature:

Screenshot 2026-07-21 at 17 32 05

Hover surfaces the inferred type plus the real declaration's own docs, labeled so it's clear it's a heuristic guess, not a resolved type:

Screenshot 2026-07-21 at 17 30 46

Completions for an otherwise-any value are populated from the inferred type's real members:

Screenshot 2026-07-21 at 17 31 25

Testing

  • Unit tests for @salesforce/b2c-script-types (node --test): the inference engine against both ambient stand-ins and the real bundled dw.* declarations (happy paths, deep chains, nullable types, unioned call-site types, budget/depth caps, cancellation), the tsserver plugin's proxy wiring (hover + completions), and a dedicated path-traversal/JSON-size security suite with one regression test per traversal shape.
  • VS Code integration suite (vscode-test) drives the real extension headless and asserts hover/completion against the real dw.catalog.Product type across single-file, cross-file, chained, nullable, callback/iterator, and multi-cartridge superModule scenarios.
  • Full repo lint, typecheck, and format checks pass.

Dependencies

  • No net-new third-party dependencies were added
  • If net-new third-party dependencies were added, rationale/discussion is included and 3pl-approved is set by a maintainer

  • Tests pass (pnpm test)
  • Code is formatted (pnpm run format)

claude and others added 26 commits July 20, 2026 10:35
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
… real dw.* type test coverage

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018cSJNMGwtCicNegmdA83e6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6
… inference

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6
…s end-to-end

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6
…est server.append

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3fJk97pe2QcEeMKFJqQv6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFvM84JTUzSx4oaXjMabHb
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/../../../<abs>` escapes the bundled types dir
  - resolveCartridgeModule: `~/..`, `*/..`, `<name>/..` 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, ~/, */, <name>/, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GzksxTHf9NL8qyj59iRfr
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
taurgis and others added 2 commits July 21, 2026 18:04
…cess

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 <noreply@anthropic.com>
Covers the real-world case fixed in 63d67de: 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 <noreply@anthropic.com>
taurgis and others added 16 commits July 21, 2026 18:44
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…e test DocumentRegistry

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
…matches

Dogfood-derived golden cases (anonymized) cover Customer/Basket/Order/Profile
silence traps, and ambient matching now includes Product<T> as Product<any>
so JSDoc-less product helpers match IntelliJ-style Script API IntelliSense.

Co-authored-by: Cursor <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…inference

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants