Nicolas Toulemont
Scaling TeamsGuardrails

From a deprecation policy to code quality, performance, and migration planning for coding agents

How a module-graph check that kept deprecated code out of a ten-year-old frontend grew into plugins that track code quality and performance, and plan migrations for coding agents.

Published
6 min Reading time
On this page

When I joined the company at the end of 2024, the frontend had several implementations of the same UI components: dropdowns, menus, selects. Nothing really said which one to use. I remember wondering whether the date of each implementation’s last commit would tell me, before I gave up and asked colleagues what the recommended practice was.

Some components were marked as deprecated with a DEPRECATED_ prefix, some with @deprecated comments. I was also surprised to keep seeing them in new PRs way too often. Having only just joined, I asked other senior/staff engineers why we were doing this to ourselves. The answer was a mix of three things that had built up over the company’s ten years:

  • There was no design system. Designers created new, incompatible versions of the same components instead of building on a common, stable set of design tokens and a library of reusable components.
  • Code hygiene was an afterthought in the frontend. A lack of technical direction and decisions had left product engineers alone to handle the tradeoff between maintainability and delivery.
  • There weren’t enough engineers to fix it after the fact, by turning two similar but incompatible implementations into one reusable, extendable component.

So I proposed a policy, through an RFC and then an ADR, to stop the bleeding: keep deprecated components out of new code, and document every deprecation so a newcomer could tell which component to use. The policy wouldn’t add engineers, so it had to work with the few we had: keep the problem from growing, and turn the cleanup into work we could plan. Enforcing it meant building a tool that reads the whole codebase, and that tool ended up doing a lot more than deprecations.

The deprecation policy#

The policy#

I wrote the RFC with these goals:

  1. Agreeing that this was something we wanted to do something about.
  2. Enabling decision making by surfacing the problem with actual data about deprecated code: what it is and how much of it we have.
  3. Handling deprecated code as a project we could manage as an engineering organization, not something engineers had to deal with on their own while doing feature work.
  4. Having a single way to mark deprecated code, track it and prevent additional usages of it.
  5. Being in a position to report its evolution over time as part of other codebase health metrics to the engineering leadership if needed.

Deprecated code got a JSDoc tag instead of a prefix, so that IDEs could flag it (in 2024 we still used them heavily), plus some optional contextual metadata for tracking and reporting.

/**
 * @deprecated Use `Button` from the UI kit.
 * @date 2025-03-05
 * @type ui-kit-migration
 */
export function LegacyButton(props: LegacyButtonProps) {
  // ...
}

The enforcement#

We also needed a way to enforce that policy and to generate data that made migrations easier to execute and progress easier to report.

The static analysis tool#

In order to prevent additional usages of deprecated code, I had to be able to resolve all its consumers and track them correctly within the module graph. So a traditional ESLint rule wasn’t going to be enough, since lint rules apply at the file level rather than to the entire module graph.

This requirement led me to build our tool as a module-graph-oriented static analysis tool. It works in two phases:

  1. It builds the entire module graph once.
  2. It then runs every registered plugin against that graph in memory.

The graph itself is a map from absolute file path to module. The resolver turns every import into the same absolute path the map uses as a key, so following an import is a single lookup. Roughly:

type ModuleGraph = Map<FilePath, Module>

type Module = {
  filePath: FilePath
  ast: Program // parsed once, kept for the plugins
  imports: Import[] // each with the resolved path of the file it points to
  exports: Export[]
  dependencies: Set<FilePath> // files this module imports
  dependents: Set<FilePath> // files that import this module
  documentedNodes: { node: Node; comment: Comment }[]
}

The plugin API is deliberately minimal:

  1. Plugins get the ModuleGraph and an AST walking utility.
  2. The reporting API is bare-bones, to leave that concern to the consumer.
export type ModuleGraphPlugin<R = unknown> = {
  name: string
  apply(baseParams: { ModuleGraph: ModuleGraph; walk: /* AST walking utility */ }): R
  report(params: R): void
}

The deprecation plugin#

The deprecation plugin picks the documented nodes whose comment contains @deprecated, finds the names their file exports them under, and walks the dependents sets to build the list of consumers that will require a migration.

fig. 01
How the scanner counts usagesEach file goes through one pass: oxc parses it into an AST once, the scanner resolves its imports to absolute file paths and attaches JSDoc comments to declarations. A linking step then adds, for each file, the files that import it. The resulting module graph stays in memory, keyed by file path, and every registered plugin runs against it. The deprecation plugin finds declarations tagged @deprecated, walks back through their importers, following re-exports from barrel files and aliases, counts one usage per importing file, and writes the counts to the snapshot.

Deprecation plugin

One pass per file

Yes: follow
the new name

No

Parse into an AST once

Resolve imports to
absolute file paths

Attach JSDoc comments
to declarations

Link dependents:
who imports each file

Module graph in memory,
keyed by file path

Find declarations
tagged @deprecated

Walk back through
the files that import them

Re-exported by a
barrel or alias?

Count one usage
per importing file

Other registered plugins

Snapshot: file count
per deprecated export

How the scanner counts usagesEach file goes through one pass: oxc parses it into an AST once, the scanner resolves its imports to absolute file paths and attaches JSDoc comments to declarations. A linking step then adds, for each file, the files that import it. The resulting module graph stays in memory, keyed by file path, and every registered plugin runs against it. The deprecation plugin finds declarations tagged @deprecated, walks back through their importers, following re-exports from barrel files and aliases, counts one usage per importing file, and writes the counts to the snapshot.

Deprecation plugin

One pass per file

Yes: follow
the new name

No

Parse into an AST once

Resolve imports to
absolute file paths

Attach JSDoc comments
to declarations

Link dependents:
who imports each file

Module graph in memory,
keyed by file path

Find declarations
tagged @deprecated

Walk back through
the files that import them

Re-exported by a
barrel or alias?

Count one usage
per importing file

Other registered plugins

Snapshot: file count
per deprecated export

How the scanner counts usages

The static analysis tool lets consumers choose how best to handle each plugin’s results. In our case, CI regenerates the snapshot, compares each deprecated export’s file count with the snapshot committed on the branch, and fails the pull request if a count rose.

fig. 02
How the ratchet decidesCI regenerates the snapshot from the pull request's code and compares each export's file count with the snapshot committed on the branch. If any count rose, CI fails; otherwise it passes. An author who runs the check locally and commits the rewritten snapshot raises the baseline before CI compares.

Yes

No

Rewrites the snapshot,
author commits it

Pull request's code

CI regenerates the snapshot

Snapshot committed
on the branch

Any export's file
count higher?

CI fails

CI passes

Author runs the
check locally

How the ratchet decidesCI regenerates the snapshot from the pull request's code and compares each export's file count with the snapshot committed on the branch. If any count rose, CI fails; otherwise it passes. An author who runs the check locally and commits the rewritten snapshot raises the baseline before CI compares.

Yes

No

Rewrites the snapshot,
author commits it

Pull request's code

CI regenerates the snapshot

Snapshot committed
on the branch

Any export's file
count higher?

CI fails

CI passes

Author runs the
check locally

How the ratchet decides

What happened#

Deprecated code stopped spreading into new code by default. Importing a deprecated component fails CI, and engineers who really need to can override that failure by updating the snapshot. Over time, we kept deprecating code and progressively cleaning it up when we had the bandwidth.

fig. 03
Usages entering and leaving the deprecated count, by month
monthNewly deprecatedRemoved
2025-022170
2025-03928132
2025-047822
2025-05020
2025-061447
2025-0847566
2025-097125
2025-101,11060
2025-1104
2025-1263130
2026-011,569182
2026-0228749
2026-0371648
2026-041,858339
2026-0501,304
2026-060367
2026-07573162
2026-08144253
2026-0912389

The count went from 264 usages in January 2025 to 5,914 at the end of September 2026: 8,191 newly deprecated and 4,099 removed, plus 188 new usages of code already deprecated and 1,370 from scanner changes that counted existing code differently.

So far, 93 deprecated entries, with 2,922 usages between them, were fully cleaned up.

ExportPeakDeprecatedReached zero
Local Button6132025-03-052025-09-04
NotificationStatus4882026-04-272026-07-28
notify4852026-04-272026-07-28
renderWithRouter3412026-04-272026-05-01
useFlags2172025-02-112025-10-27
renderWithStore1372026-04-272026-05-01
useLocalStorage962026-08-052026-08-10

Velocity varies a lot. Tooling deprecations can be cleaned up very quickly once we’ve agreed to do it, since the production impact is null. On the other hand, cleaning up deprecated user-facing components in a ten-year-old codebase takes a bit more care and time.

fig. 04
Usages left after deprecation, by week
weekLocal Buttonnotify and NotificationStatususeFlagsTest render helpers
0100%100%100%97%
1100%82%100%0%
2100%82%100%0%
397%82%100%0%
496%35%94%0%
596%35%94%0%
695%35%94%0%
795%35%88%0%
895%24%88%0%
995%7%90%0%
1095%7%90%0%
1192%4%90%0%
1292%4%90%0%
1390%4%90%0%
1490%0%91%0%
1590%0%91%0%
1690%0%81%0%
1790%0%80%0%
1890%0%80%0%
1990%0%80%0%
2090%0%80%0%
2190%0%80%0%
2287%0%80%0%
2380%0%80%0%
2451%0%80%0%
254%0%80%0%
264%0%79%0%
270%0%79%0%
280%0%75%0%
290%0%70%0%
300%0%24%0%
310%0%24%0%
320%0%24%0%
330%0%24%0%
340%0%24%0%
350%0%21%0%
360%0%17%0%
370%0%0%0%

Most of what we kept deprecating came from the design system effort we started, which led us to replace old, inconsistent, non-WAI-ARIA-compliant UI components with new ones built on top of defined design tokens and open-source WAI-ARIA headless UI primitives.

What the tool became#

I started the scanner at the end of 2024 to organize how people worked together on deprecated code, and to make the codebase enforce decisions we had already made. Back then, the cleanup was going to move at the speed of our engineering capacity.

The scanner still marks deprecations, but it now does two more jobs.

It tracks code quality and performance beyond deprecation. The same module graph now runs 15 more plugins:

  • Type narrowing and component APIs: flagging opaque prop types that should become discriminated unions.
  • Migrations a @deprecated tag can’t mark: legacy API clients, legacy generated clients, hand-written React Query hooks instead of our internal SDKs, code that bypasses internal libraries, and imports of deprecated design system components.
  • Render performance: props that defeat memo, context providers that re-render every reader, selectors that return a new reference on every call, lookups rebuilt on every render, nested list scans, and per-row work in lists. A route-reach plugin counts how many routes reach each file, so findings on hot pages rank first.

It also plans migrations. Before a migration, a coding agent writes a throwaway plugin to take an in-depth snapshot of the code the migration will touch. From that snapshot, the agent splits the work into Linear issues, and cloud coding agents pick them up and execute them.

At the end of 2024, I wondered whether commit dates would tell me which dropdown to use. Today, we and our agents ask the module graph before changing anything.