A helper starts returning seconds instead of milliseconds. Its arguments and return type still look the same. One caller stops requesting a refresh, another keeps sorting correctly, and a CSV column quietly becomes a thousand times too small. A list of references alone cannot tell you which of those outcomes occurred.
Suppose a small application lists sessions, decides when to refresh them, and exports their ages. This is a constructed teaching example; the results below come from running its JavaScript, with fixed inputs and unchanged assertions. Download the complete example to reproduce them.
Pin down the change before listing callers
The download keeps both versions of one helper in elapsed.mjs so the same consumers can run against either version:
export function elapsedBefore(nowMs, startedMs) {
return nowMs - startedMs;
}
export function elapsedAfter(nowMs, startedMs) {
return (nowMs - startedMs) / 1000;
}
Both inputs remain timestamps in milliseconds. The proposed return value is seconds, including fractions: an age of 2_500 milliseconds becomes 2.5, not 2. For nowMs = 60_000 and startedMs = 15_000, the result changes from 45_000 to 45.
That gives the investigation a precise question: which consumers require the old unit, and which only require a property that the conversion preserves? A type check that sees a number in both versions will not answer it.
If the input values are unfamiliar, first follow one value from its source through the codebase. Changing the output unit and changing the input unit are different edits; this example changes only the output.
Use references to build a candidate list
Search for elapsed in your editor, or run:
rg -n 'elapsed' --glob '*.mjs' .
Open consumers.mjs. Two callers deserve a look:
export function needsRefresh(elapsed, nowMs, lastSyncMs) {
return elapsed(nowMs, lastSyncMs) >= 30_000;
}
export function oldestFirst(elapsed, nowMs, sessions) {
return [...sessions]
.sort((a, b) => elapsed(nowMs, b.startedMs) - elapsed(nowMs, a.startedMs))
.map((session) => session.id);
}
needsRefresh compares the value with a fixed threshold. oldestFirst compares two values produced by the same helper. Mark both as candidates, then record what each promises to its caller: refresh once an age reaches thirty seconds; return session IDs from oldest to newest without changing the input array.
Language-server references can help navigate the available project when the language server is installed and configured. Treat their results as navigation leads. Aliases, injected functions, registration, and runtime configuration still need inspection; a references view is not a complete inventory of every consumer that a deployment might select.
Follow the function through the registry
app.mjs passes the helper to something other than the two direct consumers. It chooses a factory using configuration, then gives that factory the helper under another name:
const factory = registry.get(config.exporter);
if (!factory) throw new Error(`Unknown exporter: ${config.exporter}`);
const exporter = factory({ measureAge: elapsed });
Follow register(registry) earlier in the same file. In the before and after modes it calls registerAgeColumn, whose implementation is in age-column.mjs:
export function registerAgeColumn(registry) {
registry.set('age-column', ({ measureAge }) => ({
kind: 'age-column',
rows(nowMs, sessions) {
return [
'id,age_seconds',
...sessions.map(
(session) =>
`${session.id},${measureAge(nowMs, session.startedMs) / 1000}`,
),
].join('\n');
},
}));
}
The CSV header promises seconds. With the old helper, dividing by 1000 supplies them. With the new helper, the division happens twice.
Searching only for elapsed does not show the measureAge(...) call in this file. Expand the search to the alias and registration key after discovering them:
rg -n 'measureAge|age-column|exporter' --glob '*.mjs' .
The test application explicitly selects { exporter: 'age-column' } and checks selectedExporter. That connects the registry entry to the path actually executed in this run. Finding a registered factory without checking the selection would leave its use unresolved. In a server application, the equivalent work may begin with the route and configuration that select a request’s path.
Run the same expectations against both versions
Unzip the download and enter its code-change-impact-analysis folder. It has no third-party dependencies. The recorded run used Node.js 22.22.1; use Node.js 22 or later and run these commands separately in your terminal:
node run.mjs before
node run.mjs after
node run.mjs migrated
after is expected to exit with code 1. Do not join the commands with &&, which would stop before the migrated version. All three modes load the same assertions from tests.mjs.
| Version | Passed | Failed | Exit code |
|---|---|---|---|
before: milliseconds helper, original consumers |
7 | 0 | 0 |
after: seconds helper, original consumers |
4 | 3 | 1, expected failure |
migrated: seconds helper, updated unit-dependent consumers |
7 | 0 | 0 |
The three failing checks in after are the refresh decisions at 30_000 and 31_000 milliseconds, plus the CSV output. The check just below the refresh threshold still passes. Both sorting tests pass, as does rejection of an unknown exporter name.
To inspect the values alongside the assertions, run:
node demo.mjs
For nowMs = 60_000, session alpha starts at 15_000 and beta at 57_500. The printed observations include:
| Observation | before |
after |
migrated |
|---|---|---|---|
| Refresh at age 30 seconds | true |
false |
true |
| Oldest-first IDs | alpha, beta |
alpha, beta |
alpha, beta |
CSV age for alpha |
45 |
0.045 |
45 |
CSV age for beta |
2.5 |
0.0025 |
2.5 |
demo.mjs prints observations; tests.mjs decides whether they satisfy the expected behavior. The default node --test run.mjs runs only before, so a green default run would not validate the proposed change.
Separate a broken consumer from one you tested and cleared
The result is more useful than a count of references:
| Consumer or area | Finding | What supports it |
|---|---|---|
needsRefresh |
Confirmed affected | The unchanged thirty-second threshold tests fail with the seconds helper. |
oldestFirst |
Regression excluded for the tested inputs | Mixed ages, fractional seconds, equal ages, one-millisecond differences, empty and singleton lists retain the expected order; the input remains unchanged. |
Selected age-column exporter |
Confirmed affected for this configuration | The test selects that factory and compares the complete CSV string. Both age values are a thousand times too small after the change. |
| Other deployment configurations or consumers outside the download | Unknown | They have not been supplied or run. |
There is also a reason for the sorting result: its comparison uses the difference between two ages. Dividing both ages by the same positive factor preserves the comparison’s sign for these inputs. It needs relative order, while the refresh threshold and exporter need a particular unit.
That reasoning helps choose the tests. It does not justify claiming that every possible numeric input was checked. These fixtures use small, finite integer millisecond timestamps. Extreme floating-point values, invalid inputs, asynchronous refresh scheduling, and a real downstream CSV reader are outside the run. The download contains one exporter; it makes no claim about a production plugin inventory.
Migrate the unit-dependent consumers
Keep elapsedAfter. In consumers.mjs, the migrated refresh function compares seconds with seconds:
export function needsRefreshSeconds(elapsed, nowMs, lastSyncMs) {
return elapsed(nowMs, lastSyncMs) >= 30;
}
In age-column.mjs, registerAgeColumnSeconds emits measureAge(...) directly, removing the second division. app.mjs selects those two migrated implementations when the mode is migrated. oldestFirst stays unchanged.
The tests continue to demand the same user-visible results: a refresh at thirty seconds, the same sorted IDs, and seconds in the CSV. The final seven passing checks show that those results were restored for this fixture. If your patch also changes expected results, review the assertions alongside the implementation before treating its green run as equivalent.
Use AI to suggest the next file to inspect
While reading, an optional AI question can narrow the search:
Follow the helper passed as
elapsed, including aliases and registered factories. For each candidate consumer, show the source that assumes a unit or only uses ordering. Identify which runtime selection and test input would let me check the consequence. Keep untested callers separate from executed results.
Check the proposed paths in the source, and run the commands yourself.