Pin the behaviour before you touch it
Before any migration, let the agent write the tests the legacy code never had. Then migrate and rerun them. Every red is a decision, not a surprise.

The previous part sent back the agent's plan that opens with "delete" and replaced it with three task files. This part is the first one, task 000, and it comes before any new code. Even a good plan is tempted to start with the new feature. The right start is the code as it is today.
A photograph, not a judgement
Before any migration step, write the tests the legacy code never had. Not for the new code, for the old one, as it stands.
Start where it is easy: the pure parts. A validation method, a formatter, a service that maps a response to a view model. The legacy transfer component in the demo repo has a validate() method with seven rules in a dozen lines, which you can pin in under half an hour. The agent is good at this, better than at anything else in a migration, on one condition written into the task file: it may not change the code under test. It reads validate(), writes a spec with one case per rule, including the rule that is wrong, and stops.
Those tests record what the old code does, bugs included. That is the point.
They are not a judgement of the code. They are a photograph of it.
Then migrate, then compare
Now run the plan from the previous part: build the new feature beside the old one and flip the route. Then run the same cases against the new code.
Every green is a behaviour preserved without anyone having to remember it. Every red is a decision, made consciously: a bug you now choose to fix, in its own commit, with the test updated to say so, or a regression you caught before a customer did.
Writing tests against code you are about to delete feels like waste. It is not: they are the only record of what the old code really did, and the migration is measured against them. With an agent that record is cheap to write, and the rule stays the same: pin first, migrate second, compare third.
The task file for step zero
Condensed from docs/tasks/000-pin-legacy-transfers.md in the demo repo:
# Task 000: pin the behaviour of libs/legacy/transfers before anything is migrated
## Scope
- Allowed to change: nothing that exists
- New files allowed in: libs/legacy/transfers/src/lib/*.spec.ts
## Out of scope
- Every non-spec file. If validate() looks wrong, write the test that proves what it does today and add `// TODO 000: looks wrong because …` above the test, not in the code
## Done when
- [ ] One it() per branch of TransferComponent.validate(), named for the behaviour it records
- [ ] One it() per public method of TransferService, through the real AccountsStore with an instant FakeBackend
- [ ] Every expectation asserts a hardcoded string or number, never a value recomputed by the code under test
- [ ] npm run test passes
- [ ] git diff --stat main -- ':!*.spec.ts' prints nothing (nothing outside spec files changed)
- [ ] Diff ≤ 300 linesThe hardcoded line matters more than it looks. An agent will happily write expect(error).toBe(blockerMessage['insufficient-funds']): the expected value is read from the code instead of written down, so the test agrees with the code whatever it says. A hardcoded 'This account is closed.' is a photograph. A recomputed value is a mirror.
Read AGENTS.md and docs/tasks/000-pin-legacy-transfers.md. Write characterisation tests for libs/legacy/transfers/src/lib/transfer.component.ts validate() and for TransferService.
Rules: do not modify any file that is not a new *.spec.ts. One it() per rule or branch, named for the behaviour it records. Assert hardcoded strings and numbers. Where the current behaviour looks like a bug, keep the test true to the current behaviour and add // TODO 000: looks wrong because … above it. Run npm run test and report the count.
Read the TODOs it leaves. They are the bug list of your legacy module, written by something that read every branch.
Pin the screen too
Unit tests pin the rules. They do not pin the screen, and the screen is the part you are about to rewrite. So add one test per critical flow that opens a URL, not a component. 's RouterTestingHarness does that in the same test run, no browser needed:
const harness = await RouterTestingHarness.create('/transfers/new/a-current');Fill the fields by their label, read the message on the screen, never a CSS class or a component name. The test only knows the route, so it renders whatever the route points to: the old component today, the new one after the flip. In the demo repo the same spec runs before and after the flip. That is the done-when for the modernised screen. If your team already has end-to-end tests, they do the same job.
What this is not
It is not full coverage. Pin what is cheap and what is scary: the rules, the money, the dates, the things a customer would notice. Template details are not worth unit tests; the URL test covers what matters. An afternoon, not a sprint.
On Monday
- Pick one legacy module. Ask the agent to list its pure functions. Pin those first.
- Before touching a template, write one test that opens its URL and walks the main flow.
- Put "nothing outside
*.spec.tschanged" in the done-when, and check it withgit diff --stat. - Keep the same tests running against the new code until the old code is deleted.
Exercise: module 2, scene 0 in the demo repo. Start: git switch -c m2/you main. Reference: the two legacy specs in libs/legacy/transfers/src/lib/ on origin/track1/m2-ngmodule-solution~1, the state just before the delete.
All of this leans on the compiler and on a test runner that sees every template. The next part is the legacy where that is no longer true: inside an Angular app.
Track 1, part 5 of the free series behind the Fast and safe: AI in your Angular codebase workshop.
Fast and safe: AI in your Angular codebase
Let agents write Angular under rules, modernise legacy code without breaking production, and keep it secure, in three days, on an enterprise-shaped codebase.
See the agenda
The productInDesign panelsAngularJS to Angular migration · Angular | AngularJS | Adobe InDesignThe project 