Skip to content

Latest commit

 

History

History
550 lines (463 loc) · 28.9 KB

type-aware-analysis.md

File metadata and controls

550 lines (463 loc) · 28.9 KB

Type-aware TypeScript analysis

Fallow's default analysis is syntactic and independent of Node.js or TypeScript. --type-aware opts into a bounded TypeScript-Go semantic pass after Fallow's normal project analysis. The capability is a stable, optional Fallow contract. Its exact-version TypeScript-Go adapter uses an upstream unstable API, but that instability is contained behind Fallow's versioned wire protocol and fail-closed evidence boundary.

Type-aware analysis provides five project-wide capabilities:

  • exact symbol-use, TypeScript contract, and validated framework-contract decisions for existing dead-code candidates
  • symbol traces with namespaces, aliases, and re-export hops
  • package API surfaces and cross-file private type leaks
  • exact-symbol impact paths and targeted-test suggestions
  • advisory public-signature type coupling

Repository development uses the checked-in companion:

npm ci --prefix tools/type-aware-sidecar
FALLOW_TYPE_AWARE_BIN="$PWD/tools/type-aware-sidecar/fallow-type-aware.mjs" \
  target/release/fallow dead-code --unused-class-members --type-aware \
  --format json --quiet

Normal npm installations receive the exact matching optional companion package:

npm install --save-dev fallow
npx fallow dead-code --unused-class-members --type-aware --format json --quiet

Install fallow-type-aware at the same version manually only when optional npm dependencies are disabled. The fallow launcher discovers an exact version match and passes its trusted executable path to the native binary.

The companion pins typescript@7.0.2 and uses typescript/unstable/sync, which runs the native TypeScript-Go backend. The canonical protocol manifest at crates/api/type-aware-protocol.json owns the wire version, semantic schema, operations, backend identity, and session envelope. Generated Rust and JavaScript constants keep the native client, sidecar, corpus, and editor bundle on that single contract. Fallow only changes a finding when the checker resolves an exact declaration identity. Name-only matches are never enough.

Experimental tsrs backend

FALLOW_TYPE_AWARE_BACKEND=tsrs selects tsrs, a Rust port of the TypeScript 7 checker. It serves the same unstable/sync API. The manifest backend object is the default backend, and experimental_backends lists the opt-in backends, each with its npm package and exact version. The native client and the sidecar both read the selection, and the client rejects a status or analysis response from a backend family or version that the selection does not pin.

An experimental backend refines findings, but its evidence never makes a fix eligible: guarded fixes need the corpus evidence of the default backend. The audit base-snapshot cache key includes the selection. The corpus runner forwards the selection, and it accepts a non-default backend only for prepare, focused, discover, measure, evidence and verify-ledger with a non-canonical --out-dir. The tracked evidence stays on the default backend.

tsrs is an optional peer dependency of fallow-type-aware. Install the pinned version where the companion can resolve it:

npm install --save-dev tsrs@0.18.0-ts7.1.0-dev.20260929
FALLOW_TYPE_AWARE_BACKEND=tsrs npx fallow type-aware status

tsrs follows TypeScript 7.1, and the 7.1 API differs from 7.0. The sidecar module src/backend.mjs keeps the 7.0 contract for the semantic code:

  • updateSnapshot(changes) derives each snapshot from the newest one, and a disposed snapshot stays alive while it is the newest.
  • 7.0 fileChanges become 7.1 fileNotifications, and every update sets ensurePrograms, so programs reflect the changed files.
  • getProject(configFileName) falls back to getConfiguredProject.
  • A readFile callback returns serverFS.useOS to read from disk.

The 7.1 AST also differs: a heritage clause holds a TypeReference with typeName instead of an ExpressionWithTypeArguments with expression.

TypeScript 7.1.0-beta makes two more changes, and src/backend.mjs handles both:

  • The API moves from typescript/unstable/sync to typescript/sync (also ast/is and fs). The sidecar imports the new path first and falls back to unstable/, which TypeScript 7.0 and tsrs still use.
  • Compiler options move from project.compilerOptions to project.parsedCommandLine.options. Read them through projectCompilerOptions.

The beta also drops the deprecated openProject request field. Use openProjects.

When the default backend moves to TypeScript 7.1, port the semantic code to the 7.1 snapshot API and delete the 7.0 emulation in src/backend.mjs in the same change.

The sidecar suite runs on both backends:

node --test tools/type-aware-sidecar/test/*.test.mjs
FALLOW_TYPE_AWARE_BACKEND=tsrs node --test tools/type-aware-sidecar/test/*.test.mjs

tsrs has no Windows or macOS x64 binaries, and the backend stays opt-in while tsrs is a 0.x release. The VS Code extension bundles only the default backend, so the tsrs backend needs an npm or CLI install. Fallow reads the selection once per process and rejects an unknown or non-Unicode value before it starts the companion.

Ownership boundary with tsc and Oxlint

Type-aware Fallow does not emit TypeScript compiler diagnostics or general typed lint findings. Keep tsc --noEmit responsible for compiler correctness. Keep Oxlint responsible for local lint rules, including unused ECMAScript #private members. TypeScript already reports unused TypeScript private members when the relevant compiler checks are enabled.

Fallow owns the project-wide questions those tools do not answer: why an exact symbol is reachable, whether a class member is required by an interface or base class, how a public export crosses package boundaries, which tests are affected, and where public signatures create coupling. The companion returns only semantic evidence, completeness, omissions, and stable reason codes. It never forwards a compiler diagnostic as a Fallow issue.

fallow mutation --typecheck uses the same companion with the one-shot mutant-typecheck operation. It compares the error diagnostics of a mutated file with the original file and returns only the diagnostic codes of new errors. See mutation-testing.md.

Five semantic capabilities

Refine existing dead-code findings conservatively:

fallow dead-code --type-aware --unused-exports --unused-types \
  --unused-class-members --format json --quiet

Export refinement follows the checker identity through renamed barrels, generic and conditional types, declaration merging, import("./module").Type, and typeof import("./module").Value. Fallow only asks these semantic questions for exports its normal module graph already considers unused.

Trace one exact exported symbol through aliases and re-exports:

fallow dead-code --type-aware --trace src/api.ts:Client \
  --format json --quiet

Inspect the package API surface and cross-file private type leaks:

fallow dead-code --type-aware --private-type-leaks --format json --quiet

Find exact consumers, affected files, and targeted tests:

fallow dead-code --type-aware --symbol-impact src/api.ts:Client \
  --format json --quiet
fallow dead-code --type-aware --symbol-impact src/repository.ts:UserRepository.save \
  --format json --quiet

The class-method selector resolves the exact exported class and method. It never chooses between overloads or same-named classes heuristically. Unsupported or ambiguous targets remain advisory with a stable reason.

Inspect advisory public-signature type coupling without changing the health score:

fallow health --type-aware --type-coupling --format json --quiet

A type-coupling result is one report for the whole project, so it can carry up to 10,000 evidence items. Symbol, impact, and API-surface results keep the limit of 40 items. Above the limit, the report is partial with the evidence-limit reason, and --type-aware-require complete fails the run.

Preview and apply class-member cleanup only when Fallow has complete closed-world evidence and an exact declaration guard:

fallow fix --type-aware --dry-run --format json --quiet
fallow fix --type-aware --yes --format json --quiet

Fail-closed behavior

Every candidate has one of five outcomes:

  • confirmed-used: exact static references were found, so Fallow removes the syntactic finding
  • contract-preserved: an interface or abstract member requires the declaration, an override changes inherited behavior, or a detected framework contract resolves to the exact package declaration, so Fallow removes the syntactic finding
  • confirmed-no-static-references: every owning project was checked and no static references were found, so Fallow keeps the finding
  • retained-unresolved: the exact declaration or owning project could not be resolved, so Fallow keeps the finding
  • retained-abstained: dynamic behavior, unsafe syntax, incomplete coverage, or another known gap prevents a closed-world decision, so Fallow keeps the finding

confirmed-no-static-references does not prove that a declaration is unused. Reflection, framework registration, external consumers, and runtime behavior can still exist beyond the checker-visible world. Existing export and type removal actions remain automatically applicable only after a complete negative semantic decision. Class-member findings also require complete evidence from every owning project, no contract or dynamic gap, and a matching declaration hash. fallow fix reruns the same semantic analysis and verifies the declaration hash immediately before editing.

Closed-world eligibility is proved over the program the sidecar could see, which is the program the run parsed. It is therefore not evidence that the run read every file it discovered, and it does not answer the reachability caveat. A class-member finding carrying reachability_caveats keeps auto_fixable: false even after a confirmed-no-static-references verdict with closed-world eligibility, and fallow fix withholds its removal, because a member whose only call site lives in a file the size guard skipped is absent from the closed world for exactly the reason it is absent from the syntactic verdict. Resolve the files named in workspace_diagnostics[] and re-run.

The current safety policy abstains for every candidate in a TypeScript project when configuration, program, syntax, or bind diagnostics make its structure unsafe. It also abstains per candidate for decorators, dynamic computed member access, optional contracts, accessor pairs, overload sets, abstract declarations, attached comments, or a dynamic import that can consume an export without an exact static property reference. Ordinary semantic diagnostics do not invalidate an exact declaration match, so Fallow does not request a separate full semantic diagnostic pass before scanning. It also avoids TypeScript's project-wide global diagnostic pass, which eagerly checks the whole program without adding confidence to an exact declaration match. Fallow scopes a literal dynamic import to its resolved module. A non-literal import(variable) can target any local module, so every export candidate in that owning TypeScript project remains advisory. A namespace object, such as ns after import * as ns, import ns = require(), export * as ns or a JavaScript require(), can read every export of its module. A static member read of the object, such as ns.name, ns["name"], ns.Type or a destructure with literal keys, credits the export that it names. A type such as typeof ns reads no value. Any other use, such as a call argument, an alias, an export { ns }, a rest element or a computed key, records dynamic-behavior for every export that the object can reach, re-exports included. A literal key that names a nested namespace, such as const { inner } = outer, records it for the exports of that nested module. A dynamic import, such as import("./barrel"), does the same for the module that it loads. A computed destructure key, such as const { [key]: run } = service, records dynamic-member-access as service[key] does. Fallow also abstains when no project can be selected. If an external framework declaration cannot be attributed to an exact package, Fallow records framework-contract-provenance and keeps the candidate. Warnings and stable reason codes explain each abstention class.

Svelte virtual-module exports

TypeScript-Go's raw project host does not currently expose the virtual TypeScript modules that Svelte language tooling creates for .svelte files. When project source imports, re-exports, or star-exports named bindings from a .svelte module and the checker cannot resolve those names to declarations, Fallow marks the semantic project unavailable with svelte-virtual-module-exports. It does not claim complete exact-symbol or type-coupling evidence for a program that cannot see the component's module-script exports.

Treat svelte-check as the framework authority for Svelte compiler diagnostics. Do not move valid component-owned exports into a .ts file solely to satisfy Fallow. Use best-effort to keep the semantic gap advisory, or omit the type-aware capability for that run. Default-only .svelte imports and named exports backed by real checker declarations remain supported. A complete framework-aware path remains dependent on a supported TypeScript-Go host seam that preserves virtual-file identity and source maps.

Use repeatable --type-aware-project PATH options when automatic project selection does not include a consumer project. Every healthy explicit project is scanned, so a member declared in one referenced package can be confirmed by a use in another. Paths may point to an ancestor config outside --root:

fallow dead-code --root packages/app --unused-class-members --type-aware \
  --type-aware-project ../../tsconfig.json --format json --quiet

Fallow never searches project node_modules/.bin or PATH for this executable. It accepts an explicit FALLOW_TYPE_AWARE_BIN path, or a regular non-symlinked sidecar next to the active Fallow executable. The fallow npm launcher and the Node-API loader resolve a version-matched fallow-type-aware npm package themselves and wire it through FALLOW_TYPE_AWARE_BIN, marked as wrapper-provided. type-aware status reports the resolution path as discovery_source: environment-override (user-set FALLOW_TYPE_AWARE_BIN), npm-wrapper (companion resolved from node_modules by a fallow npm wrapper), or installed-sibling (found next to the active executable). Requests, responses, warnings, candidate counts, project counts, and execution time are bounded. The child process receives a minimal environment. Relative, missing, and project-local search-path entries are removed before its Node interpreter is resolved. Its complete process group is terminated on timeout. One request may take up to two minutes, raised or lowered with FALLOW_TYPE_AWARE_TIMEOUT_SECS: a semantic query costs a whole-program scan, so a very large TypeScript program can need more time than the default allows. When the limit is reached and typeAware.require is best-effort, the run reports the conservative syntactic findings and records the reason in _meta.type_aware.warnings instead of failing; complete still fails the run.

Output and integration contract

The first stable semantic wire contract is version 6. Earlier development protocols are intentionally rejected rather than maintained as hidden compatibility paths. Future breaking wire changes require a new protocol version, an explicit compatibility decision, generated contract updates, and the normal semver review described in backwards-compatibility.md.

Type-aware runs default the opt-in private-type-leaks rule to warn when the config leaves it unset, because semantic confirmation makes the findings trustworthy. An explicit "private-type-leaks": "off" in rules always wins and disables the findings entirely, type-aware or not. When the rule is off, hosts also skip the API-surface semantic capability request, so the sidecar performs no API-surface work for a rule whose findings would be discarded.

Private-type-leak reconciliation sends only Fallow's bounded candidate set and receives stable candidate IDs. A complete response may remove an unmatched syntactic candidate. If project selection or an entry point is incomplete, the response is partial and Fallow retains every unconfirmed candidate. The sidecar never turns an unexpected backend or programming failure into an unsupported-syntax abstention.

Protocol version, sidecar version, backend version, selected configs, per-project status, per-candidate decisions, contract evidence, outcome counts, guarded-fix eligibility, abstention reasons, warnings, total elapsed time, and project setup, diagnostics, and symbol-scan timings are recorded under _meta.type_aware in JSON output. Each selected project constructs one Program. Symbol use, trace, and impact queries share one batched source traversal per Program. Editor sessions retain one root-bound Program across saved snapshots, apply source changes incrementally, and fully invalidate on TypeScript, Fallow, package, lockfile, or declaration-resolution changes. Audit head and base remain isolated. The command's top-level elapsed_ms includes the semantic pass. Human, compact, markdown, JSON, grouped JSON, and SARIF preserve the semantic analysis identity or a bounded provenance summary.

Combined analysis, audit, MCP, LSP, VS Code, the Rust API, and Node bindings use the same exact-version protocol. The VS Code package bundles the companion and its TypeScript runtime. CI integrations install the matching optional package. Baseline and regression comparisons include semantic mode identity so a syntactic run is never silently compared with a type-aware run.

fallow audit --gate new-only compares base and head under one semantic identity when both sides resolve the same one. When they cannot be compared (changed tsconfigs between base and head, a sidecar available on only one side, or differing incomplete-query omissions), the audit does not fail. It falls back to the identity-independent syntactic key sets captured before refinement on each side and prints a warning naming the cause. Type-aware refinement still applies to head findings; findings that exist only because of semantic evidence stay advisory for that run, and a genuinely new syntactic finding still gates. To keep the audit gate fully syntactic while typeAware.enabled stays on for cleanup commands, set audit.typeAware: false in config. The global --no-type-aware flag forces any single run syntactic; precedence is CLI flags, then FALLOW_TYPE_AWARE, then audit.typeAware, then typeAware.enabled.

When no semantic query is needed, the companion is not started. _meta.type_aware.executed is false, companion provenance is omitted, and the deferred analysis identity avoids inventing a concrete TypeScript project hash. --type-aware-require complete turns an incomplete semantic result into an analysis error after the conservative result has been produced; the default best-effort policy keeps it advisory.

Implementation boundaries

The native integration has four explicit layers:

  • manifest owns generated protocol constants.
  • client builds bounded queries, validates responses, plans a narrow result delta, and commits that delta only after every requested capability validates.
  • transport owns trusted discovery, one-shot process IO, and persistent session lifecycle.
  • session owns revisions, file invalidation, one bounded restart, and cancellation behavior.

The sidecar keeps protocol parsing separate from semantic work. Project state owns TypeScript config closure and diagnostics, graph algorithms own deterministic traversal primitives, symbol-impact owns reverse module closure and targeted-test provenance, and type-coupling owns public-signature coupling metrics and cycles. The semantic coordinator shares one indexed symbol traversal across use, trace, and impact without absorbing capability-specific graph algorithms. A failed semantic batch cannot partially mutate Fallow results. Cleanup after a failed refinement may remove only candidates that were created solely for semantic verification.

Deferred scope

These items are out of scope for the current implementation. Each item needs its own product decision and measured evidence before work starts.

  • Program sharing between processes. Each CLI command and each editor session owns its own Program. Separate VS Code or CLI processes do not share a Program. Fallow does not start a global daemon by default. A shared process needs an explicit product and latency decision first.
  • A disk cache for Programs. A Program lives only for one command or one editor session. The companion writes no Program state to disk, so a new run always builds a new Program.
  • LSP sessions for more than one root. The language server binds one semantic session to the first workspace root.
  • Package and workspace rollups for class-method impact. Method impact reports direct consumers, affected files, and targeted tests only.
  • Selectors for computed members, accessors, and individual overloads. The class-method selector abstains for these targets with a stable reason.
  • Framework contracts from decorators, dependency injection containers, manifests, or runtime registration. A contract must name an exact package and heritage symbol. A decorated declaration without such a contract stays an abstention and is never auto-fixable.
  • Idle eviction and a broader memory policy for editor sessions. This work needs measured data from large workspaces first.
  • Cancellation of an in-flight TypeScript checker request. A cancelled owner rejects new requests and does not restart the companion. The protocol has no message that stops a checker request that already runs.

Corpus adjudication and release gate

The local corpus ledger uses schema version 2. Project-level feature buckets are copied into suggested_feature_buckets only as review prompts. A reviewer must record the buckets that genuinely apply to each candidate in adjudicated_feature_buckets; suggestions never contribute to the GO gate.

The corpus uses clean worktrees at exact public refs and does not create or reuse fixture-local node_modules. TypeScript dependency resolution is pinned to the Fallow workspace ancestor, recorded with hashes of its package-lock.json and installed package manifests and type declarations. Any other ancestor dependency root is rejected. The real path of the artifact directory and of each prepared fixture must be inside the Fallow workspace. A symlinked target directory, for example from a build cache wrapper, is rejected: replace it with a real directory before the run. The supplemental Vitest fixture always uses target/type-aware-corpus, so --out-dir does not avoid this check. A workspace without installed dependencies also fails: run npm ci first. Published dependency paths explicitly use the Fallow workspace root as their base. Discovery records the manifest, release binary, full sidecar runtime, TypeScript installation, platform, fixture commits, and dependency provenance. Partial runs must use a separate output directory, so they cannot overwrite canonical publication artifacts.

The evidence phase executes the exact --sidecar-bin artifact recorded during discovery. It does not import semantic implementation modules from the current workspace. The ledger records the sidecar hash, protocol version, and digests of the complete request and normalized response sets. Raw bounded stdout and stderr are retained under the local corpus artifact directory for auditability. Ledger verification recomputes both digests from the pinned requests and stored raw responses. A hash mismatch, malformed or oversized response, missing query result, duplicate query identity, invalid source location, timeout, or non-zero sidecar exit fails the evidence phase.

npm run type-aware:corpus -- prepare
npm run type-aware:corpus -- focused \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- discover \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- measure \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- evidence \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- adjudicate
npm run type-aware:corpus -- verify-ledger
npm run type-aware:corpus -- supplemental \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- capabilities \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- summarize \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs
npm run type-aware:corpus -- verify-publication \
  --sidecar-bin tools/type-aware-sidecar/fallow-type-aware.mjs

No CI job runs the corpus, so the script tests check the age of the tracked evidence instead. benchmarks/type-aware-corpus-freshness.json sets the maximum count of minor releases between the Fallow version that produced benchmarks/type-aware-corpus-evidence.json and the workspace version. A release bump past that lag fails CI. Regenerate the gate, or add a waiver with a tracking issue, a last version (until_version) and a reason. A waiver on fresh evidence also fails, so that it does not stay after the regeneration.

Use --project only with a non-canonical --out-dir for iteration. The publication gate requires the complete manifest, repeated normalized output, complete paired measurements, source-current evidence, checked-in review decisions, independent signoff, and explicit accuracy, abstention, runtime, and memory thresholds. Generate the runtime-bound supplemental and capability artifacts before the summary. The publication verifier rejects drift in the tracked evidence, summary, supplemental smoke, and capability proof. The supplemental command materializes the exact pinned Vitest commit without a fixture-local dependency install, records the approved workspace dependency root and lockfile hash, reruns it twice, and binds its canonical candidate sets to the independently reviewed set. Publication verification repeats that clean supplemental run instead of trusting stored hashes.

The gate reports safe confirmation yield and independently adjudicated truth coverage. It does not claim corpus-wide recall: retained candidates without independent truth remain indeterminate and are excluded from adjudicated accuracy metrics.

The summary reports two yields. confirmation_yield is the share of all candidates that are correctly confirmed as used. The gated checked_confirmation_yield excludes candidates that the sidecar does not resolve because of blocking-diagnostics. Blocking diagnostics have two causes in the corpus:

  • Missing dependencies. The corpus does not install fixture dependencies, so a project that imports a package or type definition outside the Fallow workspace cannot type-check.
  • TypeScript 7 configuration incompatibility, for example moduleResolution=node10 or baseUrl, which TypeScript 7 removed.

These candidates measure the fixture environment, not semantic ability. A ratio that excludes them rises when more projects block, so two more gates apply:

  • maximum_blocking_diagnostics_share bounds the share of blocked candidates.
  • minimum_checked_candidates requires a minimum count of checked candidates.

The summary also lists each blocked project with its program configurations and the share of candidates without type evidence (abstained or unresolved).

Each project role comes from the intent of the project, not from its current output. An accuracy-core project has internal class members where syntactic analysis has known gaps. A zero-control project exposes its class members as public API by design, so it must report no candidates. Vue Core is a zero-control because each package declares "./*": "./*" in exports, which makes every module a public entry under Node subpath rules.

Feature-bucket value requires two separate candidates that are both manually classified as used and correctly confirmed by semantic refinement. The two candidates must provide explicitly adjudicated evidence for distinct buckets. A single candidate tagged with multiple buckets does not satisfy the gate. The current compact result is in benchmarks/type-aware-corpus-summary.md, with source coordinates and provenance in benchmarks/type-aware-corpus-evidence.json. An additional clean Vitest smoke is recorded in benchmarks/type-aware-supplemental-smoke.json.

Reflection, decorators, dependency injection, destructuring, dynamic computed property access, and framework runtime registration can remain invisible to the checker. These cases stay visible as findings. Without --type-aware, the sidecar is never discovered or started and existing output is unchanged.