Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/reuse-api-stability-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@effect/tsgo": patch
---

Speed up `apiStabilityLeak` by reusing one stability analysis per file.

The rule used to allocate a fresh analysis, with its maps and work queues, for every export of a file. It now keeps one analysis per file and resets it before each export. The reset analysis behaves exactly like a fresh one, so the reported diagnostics are unchanged. A published surface that a later export reads from the shared cache is no longer queued for publication again, and a published surface without retained findings no longer allocates a findings map.

On `packages/effect` of effect 4.0.5 with every rule enabled, the rule allocates 252 MB → 140 MB.
17 changes: 9 additions & 8 deletions internal/rules/api_stability_leak.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,21 @@ var ApiStabilityLeak = rule.Rule{
}

// checkApiStabilityLeaks compares each export's declared stability ceiling with
// the actual stability of its public surface. Every export is inspected in its
// own ApiStabilitySession, so the export's type and signatures share one
// traversal and unpublishable results stay local to it. Declared stability
// lookups persist per checker, and a complete, settled, context-free concrete
// type or signature surface is reused by later exports, files and TypeParsers
// over the same checker. The ceiling and the diagnostic location are per
// export.
// the actual stability of its public surface. Every export is inspected in the
// file's ApiStabilitySession, reset before each export, so the export's type
// and signatures share one traversal and unpublishable results stay local to
// it. Declared stability lookups persist per checker, and a complete, settled,
// context-free concrete type or signature surface is reused by later exports,
// files and TypeParsers over the same checker. The ceiling and the diagnostic
// location are per export.
func checkApiStabilityLeaks(ctx *rule.Context) []*ast.Diagnostic {
moduleSymbol := checker.Checker_getSymbolOfDeclaration(ctx.Checker, ctx.SourceFile.AsNode())
if moduleSymbol == nil {
return nil
}

var diagnostics []*ast.Diagnostic
session := ctx.TypeParser.NewApiStabilitySession()
for _, exportSymbol := range sortedApiStabilityExports(ctx, moduleSymbol) {
if exportSymbol == nil {
continue
Expand All @@ -64,7 +65,7 @@ func checkApiStabilityLeaks(ctx *rule.Context) []*ast.Diagnostic {
continue
}

session := ctx.TypeParser.NewApiStabilitySession()
session.Reset()
walker := newApiStabilityWalker(ctx, session, exportSymbol, target, location, ceiling)
walker.inspectExport()
diagnostics = append(diagnostics, walker.diagnostics...)
Expand Down
82 changes: 69 additions & 13 deletions internal/typeparser/api_stability.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,15 +660,15 @@ func ApiStabilityImmediateAliasedSymbol(c *checker.Checker, symbol *ast.Symbol)
}

// ApiStabilitySession is the analysis-local memo for the computed stability of
// one export. The rule creates one session per export and queries the export's
// symbol (and any namespace member) through it, so repeated components are
// computed once within the export. After settling, the session publishes every
// complete, context-free concrete type and signature surface to the
// per-checker shared caches, so a later export, file or TypeParser over the
// same checker composes that snapshot instead of recomputing; the session's
// symbol and carrier memos, and every incomplete or blocked result, are
// discarded with the session. Declared stability is read from the per-checker
// declared caches and never needs a session.
// one export. The rule keeps one session per file, resets it before each export
// and queries the export's symbol (and any namespace member) through it, so
// repeated components are computed once within the export. After settling, the
// session publishes every complete, context-free concrete type and signature
// surface to the per-checker shared caches, so a later export, file or
// TypeParser over the same checker composes that snapshot instead of
// recomputing; the session's symbol and carrier memos, and every incomplete or
// blocked result, are discarded with the session. Declared stability is read
// from the per-checker declared caches and never needs a session.
type ApiStabilitySession struct {
analysis *apiStabilityAnalysis
}
Expand All @@ -684,6 +684,16 @@ func (tp *TypeParser) NewApiStabilitySession() *ApiStabilitySession {
return &ApiStabilitySession{analysis: newApiStabilityAnalysis(tp)}
}

// Reset discards every analysis-local result so the session analyzes the next
// export exactly like a new session would, while keeping its allocated tables.
// The published shared snapshots are unaffected.
func (s *ApiStabilitySession) Reset() {
if s == nil || s.analysis == nil {
return
}
s.analysis.reset()
}

// UsedBySymbol computes the stability the children of a symbol's public
// surface expose inside this session and settles cycle cuts: the root symbol's
// own declared tag is excluded from the returned dependencies and minimum. The
Expand Down Expand Up @@ -1245,6 +1255,40 @@ func newApiStabilityAnalysis(tp *TypeParser) *apiStabilityAnalysis {
}
}

// reset returns the analysis to the state newApiStabilityAnalysis produces,
// keeping map and slice capacity. Every per-export field must be cleared here.
func (a *apiStabilityAnalysis) reset() {
clear(a.sessionSymbols)
clear(a.sessionTypes)
clear(a.sessionComponents)
clear(a.sessionSignatures)
clear(a.typeFrames)
clear(a.signatureFrames)
clear(a.carriers)
a.settleSymbols = a.settleSymbols[:0]
a.settleTypes = a.settleTypes[:0]
a.settleComponents = a.settleComponents[:0]
a.settleSignatures = a.settleSignatures[:0]
a.scope.reset()
a.publishTypes = a.publishTypes[:0]
a.publishSignatures = a.publishSignatures[:0]
clear(a.activeSymbols)
clear(a.activeTypes)
clear(a.activeComponents)
clear(a.activeSignatures)
clear(a.activeAliases)
clear(a.activeConditionals)
clear(a.signatureArgs)
clear(a.signatureParameterCache)
clear(a.safetySafeMemo)
clear(a.typeNameSymbols)
clear(a.optionalTaggedSymbols)
a.work = 0
a.safetyWork = 0
clear(a.materializationReads)
a.materializationEpoch = 0
}

func (a *apiStabilityAnalysis) consumeWork() bool {
a.work++
return a.work <= apiStabilityMaxWork
Expand Down Expand Up @@ -1469,12 +1513,15 @@ func (a *apiStabilityAnalysis) publishTypeSurface(t *checker.Type, inspect apiSt
return
}
own := a.typeOwnTag(t)
entry := apiStabilitySharedSurface{findings: make(map[apiStabilityFindingKey]apiStabilityFinding, len(surface.findings))}
var entry apiStabilitySharedSurface
for findingKey, finding := range surface.findings {
if own.excludes(findingKey, finding) {
entry.removed = append(entry.removed, findingKey)
continue
}
if entry.findings == nil {
entry.findings = make(map[apiStabilityFindingKey]apiStabilityFinding, len(surface.findings))
}
entry.findings[findingKey] = finding
}
*store.Get(key) = entry
Expand All @@ -1492,12 +1539,15 @@ func (a *apiStabilityAnalysis) publishSignatureSurface(signature *checker.Signat
return
}
own := a.signatureOwnTag(signature)
entry := apiStabilitySharedSurface{findings: make(map[apiStabilityFindingKey]apiStabilityFinding, len(surface.findings))}
var entry apiStabilitySharedSurface
for findingKey, finding := range surface.findings {
if own.excludes(findingKey, finding) {
entry.removed = append(entry.removed, findingKey)
continue
}
if entry.findings == nil {
entry.findings = make(map[apiStabilityFindingKey]apiStabilityFinding, len(surface.findings))
}
entry.findings[findingKey] = finding
}
*store.Get(signature) = entry
Expand Down Expand Up @@ -2253,7 +2303,10 @@ func (a *apiStabilityAnalysis) typeSurface(t *checker.Type, inspect apiStability
}
if key.carrier == nil {
if shared, ok := a.sharedTypeSurface(t, inspect); ok {
a.storeTypeSurface(key, shared)
// Already published, so it is recorded without being queued for
// publication again.
a.settleTypes = append(a.settleTypes, key)
a.sessionTypes[key] = shared
return shared
}
}
Expand Down Expand Up @@ -3591,7 +3644,10 @@ func (a *apiStabilityAnalysis) signatureSurface(signature *checker.Signature, su
}
if key.carrier == nil {
if shared, ok := a.sharedSignatureSurface(signature); ok {
a.storeSignatureSurface(key, shared)
// Already published, so it is recorded without being queued for
// publication again.
a.settleSignatures = append(a.settleSignatures, key)
a.sessionSignatures[key] = shared
return shared
}
}
Expand Down
193 changes: 193 additions & 0 deletions internal/typeparser/api_stability_reset_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package typeparser

import (
"reflect"
"slices"
"testing"

"github.com/microsoft/TypeScript/tsc/shim/ast"
)

// apiStabilityAnalysisDiff names the fields of two analyses that differ,
// treating a nil and an empty map or slice as equal. Only one side may hold a
// non-empty collection, so it compares a used analysis against a fresh one. A
// field of a kind it does not know fails the test, so a new field forces a
// decision about reset.
func apiStabilityAnalysisDiff(t *testing.T, got, want *apiStabilityAnalysis) []string {
t.Helper()
var diff []string
gotValue, wantValue := reflect.ValueOf(got).Elem(), reflect.ValueOf(want).Elem()
for i := range gotValue.NumField() {
name := gotValue.Type().Field(i).Name
if !apiStabilityValuesEqual(t, name, gotValue.Field(i), wantValue.Field(i)) {
diff = append(diff, name)
}
}
return diff
}

func apiStabilityValuesEqual(t *testing.T, path string, got, want reflect.Value) bool {
t.Helper()
switch got.Kind() {
case reflect.Map, reflect.Slice:
if got.Len() != 0 && want.Len() != 0 {
t.Fatalf("%s: both sides are non-empty", path)
}
return got.Len() == 0 && want.Len() == 0
case reflect.Struct:
for i := range got.NumField() {
if !apiStabilityValuesEqual(t, path+"."+got.Type().Field(i).Name, got.Field(i), want.Field(i)) {
return false
}
}
return true
case reflect.Pointer:
return got.Pointer() == want.Pointer()
case reflect.Int:
return got.Int() == want.Int()
default:
t.Fatalf("%s: unsupported kind %s", path, got.Kind())
return false
}
}

const apiStabilityResetSource = `/** @stability experimental */
interface E { value: string }
/** @stability unstable */
interface U { value: string }
export interface Box<A> { value: A; map<B>(f: (a: A) => B): Box<B> }
export interface Node { next: Node; payload: E }
export type Pick2<A, K extends keyof A> = { [P in K]: A[P] }
export type IfE<A> = A extends E ? E : U
export declare const make: <A>(a: A) => Box<A>
export declare const boxed: Box<E>
export declare class Service extends Box<U> { run(): IfE<string> }
export interface Leaky { e: E }
export interface Other { u: U }
/** @stability unstable */
interface Reportable {
/** @stability experimental */ test?: string }
declare global { interface Error extends Reportable {} }
export function augmented(e: Error) {}
`

// TestApiStabilityResetMatchesFreshAnalysis proves a reset analysis is
// indistinguishable from a new one after a query that fills its tables.
func TestApiStabilityResetMatchesFreshAnalysis(t *testing.T) {
t.Parallel()

c, tp, sf, done := compileAndGetCheckerAndSourceFileInternal(t, apiStabilityResetSource)
defer done()
primeApiStabilityChecker(t, c)

session := tp.NewApiStabilitySession()
for _, name := range []string{"Box", "Node", "Pick2", "IfE", "make", "boxed", "Service", "augmented"} {
session.UsedBySymbol(apiStabilityTestExport(t, c, sf, name))
}

dirty := apiStabilityAnalysisDiff(t, session.analysis, newApiStabilityAnalysis(tp))
for _, field := range []string{
"sessionSymbols", "sessionTypes", "sessionComponents", "sessionSignatures",
"typeFrames", "signatureFrames", "carriers",
"settleSymbols", "settleTypes", "settleComponents", "settleSignatures",
"signatureArgs", "signatureParameterCache", "safetySafeMemo", "typeNameSymbols",
"optionalTaggedSymbols", "work", "safetyWork", "materializationReads", "materializationEpoch",
} {
if !slices.Contains(dirty, field) {
t.Errorf("the queries left %s untouched, so the reset of it is not exercised (dirty: %v)", field, dirty)
}
}

session.Reset()
if diff := apiStabilityAnalysisDiff(t, session.analysis, newApiStabilityAnalysis(tp)); len(diff) != 0 {
t.Fatalf("reset analysis differs from a fresh one in %v", diff)
}
}

// TestApiStabilityResetHidesPreviousExport proves no state of one export
// reaches the next one: an exhausted budget and a stale result stored for the
// next root are both discarded by Reset.
func TestApiStabilityResetHidesPreviousExport(t *testing.T) {
t.Parallel()

c, tp, sf, done := compileAndGetCheckerAndSourceFileInternal(t, apiStabilityResetSource)
defer done()
primeApiStabilityChecker(t, c)

leaky := apiStabilityTestExport(t, c, sf, "Leaky")
other := apiStabilityTestExport(t, c, sf, "Other")
session := tp.NewApiStabilitySession()
if got := apiStabilityDependencyNames(session.UsedBySymbol(leaky)); !slices.Equal(got, []string{"E"}) {
t.Fatalf("Leaky = %v, want [E]", got)
}

session.analysis.work = apiStabilityMaxWork
session.analysis.safetyWork = apiStabilitySafetyMaxWork + 1
session.analysis.materializationEpoch = 99
session.analysis.sessionSymbols[other] = newApiStabilitySurface()

session.Reset()
used := session.UsedBySymbol(other)
if got := apiStabilityDependencyNames(used); used.incomplete || !slices.Equal(got, []string{"U"}) {
t.Fatalf("Other after reset = %v (incomplete %v), want complete [U]", got, used.incomplete)
}
if _, ok := session.analysis.sessionSymbols[leaky]; ok {
t.Fatal("the previous export's symbol result survived the reset")
}
}

// TestApiStabilityResetSharedHitIsNotRepublished proves a later export reuses
// a published surface after a reset: the composed result is the same, the hit
// is recorded for settlement, it is not queued for publication again, and the
// snapshot keeps the findings it was published with.
func TestApiStabilityResetSharedHitIsNotRepublished(t *testing.T) {
t.Parallel()

c, tp, sf, done := compileAndGetCheckerAndSourceFileInternal(t, `/** @stability experimental */
interface E { value: string }
/** @stability experimental */
export interface Tagged { value: string }
export interface Box<A> { value: A }
export interface First { box: Box<E>; tagged: Tagged }
export interface Second { box: Box<E>; tagged: Tagged }
`)
defer done()
primeApiStabilityChecker(t, c)

first := apiStabilityTestExport(t, c, sf, "First")
box := c.GetTypeOfSymbol(ast.TSGetSymbolMembers(first)["box"])
tagged := c.GetDeclaredTypeOfSymbol(apiStabilityTestExport(t, c, sf, "Tagged"))
session := tp.NewApiStabilitySession()
if got := apiStabilityDependencyNames(session.UsedBySymbol(first)); !slices.Equal(got, []string{"E", "Tagged"}) {
t.Fatalf("First = %v, want [E Tagged]", got)
}
boxEntry := apiStabilitySharedTypeEntry(tp, box, apiStabilityShallow)
if boxEntry == nil || len(boxEntry.findings) != 1 {
t.Fatalf("Box<E> snapshot = %v, want one retained finding", boxEntry)
}
taggedEntry := apiStabilitySharedTypeEntry(tp, tagged, apiStabilityShallow)
if taggedEntry == nil || taggedEntry.findings != nil || len(taggedEntry.removed) != 1 {
t.Fatalf("Tagged snapshot = %+v, want only its own tag, removed", taggedEntry)
}

session.Reset()
boxKey := apiStabilityTypeKey{t: box, inspect: apiStabilityShallow}
surface := session.analysis.typeSurface(box, apiStabilityShallow, apiStabilitySubstitution{})
if len(surface.findings) != 1 || session.analysis.work != 0 {
t.Fatalf("Box<E> hit = %d findings after %d work, want 1 finding and no work", len(surface.findings), session.analysis.work)
}
if !slices.Equal(session.analysis.settleTypes, []apiStabilityTypeKey{boxKey}) {
t.Fatalf("settleTypes = %v, want the Box<E> hit", session.analysis.settleTypes)
}
if len(session.analysis.publishTypes) != 0 {
t.Fatalf("the hit was queued for publication: %v", session.analysis.publishTypes)
}

session.Reset()
if got := apiStabilityDependencyNames(session.UsedBySymbol(apiStabilityTestExport(t, c, sf, "Second"))); !slices.Equal(got, []string{"E", "Tagged"}) {
t.Fatalf("Second after reset = %v, want [E Tagged]", got)
}
if after := apiStabilitySharedTypeEntry(tp, box, apiStabilityShallow); after != boxEntry || len(after.findings) != 1 {
t.Fatal("reuse replaced or mutated the Box<E> snapshot")
}
}
Loading