From 1f5274249650d9ed169736c37bd241e66ceccaa0 Mon Sep 17 00:00:00 2001 From: Mattia Manzati Date: Fri, 16 Oct 2026 17:14:30 +0200 Subject: [PATCH] perf(apiStabilityLeak): reuse one analysis per file checkApiStabilityLeaks allocated a fresh ApiStabilitySession for every export, and each one built and regrew its maps and work queues. The rule now keeps one session per file and calls Reset before each export. apiStabilityAnalysis.reset clears every per-export map, truncates the settle and publish slices, resets the settle scope and zeroes the budgets and the materialization epoch, keeping the allocated capacity. A shared-cache hit in typeSurface and signatureSurface is recorded in the session map and the settle order without being queued for publication, since publication would skip it anyway. publishTypeSurface and publishSignatureSurface allocate the snapshot's findings map only for the first retained finding. Effect 4.0.5 workload (7f524e25), every rule executing, packages/effect, 5 interleaved profiled runs per side, medians: checkApiStabilityLeaks alloc_space 252 MB -> 140 MB rulerunner.Run alloc_space 754 MB -> 633 MB checkApiStabilityLeaks CPU 0.98 s -> 0.92 s (ranges overlap) Diagnostic equivalence: a dump of every rule-returned diagnostic from a clean build of the whole workload is byte-identical on both sides (1,404 apiStabilityLeak diagnostics on each). Co-Authored-By: Claude Opus 5.5 --- .changeset/reuse-api-stability-session.md | 9 + internal/rules/api_stability_leak.go | 17 +- internal/typeparser/api_stability.go | 82 ++++++-- .../typeparser/api_stability_reset_test.go | 193 ++++++++++++++++++ 4 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 .changeset/reuse-api-stability-session.md create mode 100644 internal/typeparser/api_stability_reset_test.go diff --git a/.changeset/reuse-api-stability-session.md b/.changeset/reuse-api-stability-session.md new file mode 100644 index 00000000..adb4f683 --- /dev/null +++ b/.changeset/reuse-api-stability-session.md @@ -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. diff --git a/internal/rules/api_stability_leak.go b/internal/rules/api_stability_leak.go index 45ddecac..3b1fc32f 100644 --- a/internal/rules/api_stability_leak.go +++ b/internal/rules/api_stability_leak.go @@ -30,13 +30,13 @@ 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 { @@ -44,6 +44,7 @@ func checkApiStabilityLeaks(ctx *rule.Context) []*ast.Diagnostic { } var diagnostics []*ast.Diagnostic + session := ctx.TypeParser.NewApiStabilitySession() for _, exportSymbol := range sortedApiStabilityExports(ctx, moduleSymbol) { if exportSymbol == nil { continue @@ -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...) diff --git a/internal/typeparser/api_stability.go b/internal/typeparser/api_stability.go index b8b53785..47fa707f 100644 --- a/internal/typeparser/api_stability.go +++ b/internal/typeparser/api_stability.go @@ -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 } @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 } } @@ -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 } } diff --git a/internal/typeparser/api_stability_reset_test.go b/internal/typeparser/api_stability_reset_test.go new file mode 100644 index 00000000..2c75ed6a --- /dev/null +++ b/internal/typeparser/api_stability_reset_test.go @@ -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 { value: A; map(f: (a: A) => B): Box } +export interface Node { next: Node; payload: E } +export type Pick2 = { [P in K]: A[P] } +export type IfE = A extends E ? E : U +export declare const make: (a: A) => Box +export declare const boxed: Box +export declare class Service extends Box { run(): IfE } +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 { value: A } +export interface First { box: Box; tagged: Tagged } +export interface Second { box: Box; 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 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 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 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 snapshot") + } +}