refactor-tracker
v0.6.0
Published
Run configurable shell detections to track and report technical-refactor progress.
Downloads
1,194
Maintainers
Readme
refactor-tracker
[!NOTE] Beta — pre-1.0. The CLI flags, config schema, and reporter API may change between minor releases. Pin an exact version in CI until 1.0 ships.
A language-agnostic CLI that runs configurable shell detection commands, counts progress for each tracked refactor, and reports deltas to pluggable outputs. Designed to run in CI (on merge) and locally on demand.
The tool is a number collector and reporter. Detection is fully delegated to the shell: a command must print a non-negative integer to stdout — what produces that number (grep, ast-grep, ts-morph, a custom script, …) is up to you.
Install
pnpm add -D refactor-tracker
# or run ad hoc
pnpm dlx refactor-trackerConfiguration
Create .refactor-tracker.yml at your repo root (override with --config <path>):
reporters:
- type: stdout
- type: markdown
output: docs/refactor-progress.md
- type: json
output: .refactor-report.json
refactors:
- id: lazy-routes
name: Importer dynamiquement les routes
description: Lazy-load top-level route views to cut the initial JS bundle
detect:
done:
command: "grep -rl 'React.lazy' frontend/src/views | wc -l | tr -d ' '"
total:
command: "ls frontend/src/views | wc -l | tr -d ' '"
- id: react-hook-form
name: Replace custom useForm with react-hook-form
detect:
done:
command: "grep -rl 'react-hook-form' frontend/src | wc -l | tr -d ' '"
remaining:
command: "grep -rl 'hooks/useForm' frontend/src | wc -l | tr -d ' '"
- id: upgrade-somelib
name: Upgrade somelib to v3
detect:
command: 'node -e "process.exit(require(''./package.json'').dependencies.somelib.startsWith(''3'') ? 0 : 1)"'
binary: trueDetection shapes
Provide any two of done / remaining / total and the third is computed:
| Fields provided | Computed |
| --------------------- | ------------------------------------------------------------ |
| done + total | remaining = total − done |
| done + remaining | total = done + remaining |
| remaining + total | done = total − remaining |
| binary: true | total = 1, done = 1 if the command exits 0, else 0 |
Each command must print a non-negative integer to stdout (binary commands signal via exit code instead).
An optional description field gives a one-line context blurb. It flows through to the JSON output, renders as a subtitle in the HTML reporter, and adds a Description column to the markdown reporter (the column is omitted entirely when no refactor has one). The stdout reporter ignores it.
Listing remaining items
Optionally attach a list command alongside the counts to surface the actual items left to migrate (file paths, symbols, …) — one per line on stdout:
refactors:
- id: lazy-routes
name: Lazy-load top-level routes
detect:
done: { command: "grep -rl 'React.lazy' frontend/src/views | wc -l" }
total: { command: 'ls frontend/src/views | wc -l' }
list:
{
command: "comm -23 <(ls frontend/src/views | sort) <(grep -rl 'React.lazy' frontend/src/views | xargs -n1 basename | sort)",
}The list command only runs when remaining > 0. Markdown and HTML reporters render the items in a collapsible <details> block per refactor; the JSON reporter serializes them as items: string[]; the stdout reporter ignores them. The list is not allowed with binary: true.
Reporter output paths are resolved against the config file's directory, so relative paths Just Work regardless of where the CLI is invoked from. Absolute paths are used as-is.
Reporter config values that are exactly $VAR (e.g. token: $MY_TOKEN) are expanded from the environment at runtime and never stored. A missing variable is a hard error.
CLI
refactor-tracker [options]
-c, --config <path> Path to config file (default: .refactor-tracker.yml)
--dry-run Run detections and print the report as JSON; do not invoke reporters
--fail-on-regression Exit 1 if any task's done count decreased vs the cache
--report-output <path> Write the full Report as JSON to this path, independent of reporters
--reporter <type[:path]> Override configured reporters; repeatable (e.g. --reporter stdout
--reporter markdown:out.md). `custom` reporters are config-only.
--id <id> Filter refactors by id; repeatable, OR semantics. Combines with
--tag via AND.
--no-cache Skip reading and writing the cache file; delta becomes null
--cache-path <path> Override cache location (default: next to the config)--report-output is useful when a downstream tool (CI script, GitHub Action, dashboard) needs the typed Report alongside whatever reporters fire. Reporters in the config still run; the file is written after detections succeed and works with or without --dry-run. The path is resolved against the current working directory.
--reporter replaces any reporters declared in the config for that run. Use stdout on its own, or <type>:<path> for the file-based reporters — handy for a one-off Markdown dump without editing the YAML:
refactor-tracker --reporter markdown:./reports/now.md
refactor-tracker --reporter stdout --reporter json:./reports/now.json--no-cache is convenient in ephemeral environments where caching has no meaning (one-shot CI containers, ad-hoc runs); since delta is always null, --fail-on-regression is effectively a no-op when combined with it. --cache-path is useful when you want the cache somewhere other than next to the config (e.g. under a CI artifact directory). The path is resolved against the current working directory.
GitHub Action
- name: Sync refactor progress
run: pnpm dlx refactor-tracker
# Regression guard on PRs
- name: Check for regressions
run: pnpm dlx refactor-tracker --fail-on-regression --dry-runTagging
Attach tags to any refactor to group reports and filter from the CLI:
refactors:
- id: lazy-routes
name: Lazy-load top-level routes
tags: [frontend, performance]
detect: { ... }Tags are optional and a refactor may carry any number of them. When any refactor has a tag, stdout, markdown, and html reporters render one section per tag (a refactor with N tags appears in N sections). Untagged refactors fall into a trailing Untagged group; the group is omitted when every refactor has at least one tag.
Filter with --tag (repeatable, OR semantics):
refactor-tracker --tag frontend
refactor-tracker --tag frontend --tag performance # any refactor with frontend OR performance
refactor-tracker --tag=frontend # = form also acceptedSkipped refactors keep their cache entries from previous runs, so partial runs don't break --fail-on-regression on the next full run.
Milestones
Each refactor carries two timestamps:
registeredAt— when the tracker first saw the refactor.completedAt— the first run that observed 100% progress. Sticky: it is never cleared, even if the count regresses afterwards.
Milestones live in .refactor-tracker-state.json next to your config. Unlike the cache, this file should be committed so milestones travel with the codebase and are available in CI.
Backfilling registeredAt
For refactors that predate adopting the tracker, set registeredAt in the YAML and it will win over whatever is in the state file:
refactors:
- id: migrate-to-typescript
name: Migrate to TypeScript
registeredAt: 2026-03-12 # ISO date or full timestamp
detect: ...Flags
--show-completed— include refactors that have already reached 100% (hidden by default).--sort-by registered|completed|progress— sort tasks by milestone or progress. Default is config order.registeredis ascending (oldest first),completedis descending (most recent first),progressis ascending (least done first). Refactors with no value for the chosen key sort last.
These are presentation concerns: they apply to stdout, markdown, html, and custom reporters. The json reporter always emits the full unfiltered, unsorted dataset.
Reporters
| Reporter | Output |
| ---------- | ------------------------------------------------------------------------------------------- |
| stdout | Progress table to the terminal (default when no reporters are configured) |
| json | The full report object to a file (output: <path> required) |
| markdown | A progress table to a .md file (output: <path> required) |
| html | A self-contained HTML page with progress bars to a .html file (output: <path> required) |
| custom | Your own module — file path or npm package; extension point for Slack, Linear, Notion, etc. |
Custom reporters
A custom reporter loads an external module and uses it as a Reporter implementation. Reference it by either a relative file path or an npm module specifier:
reporters:
# Local file
- type: custom
path: ./reporters/slack.mjs
# npm package
- type: custom
module: refactor-tracker-notion-reporter
token: $NOTION_TOKEN
databaseId: 1a2b3c4d-...
dataSourceId: 0a1b2c3d-...Exactly one of path or module is required. If the module's default export is a function, it's called as a factory with the reporter config (minus type, module, and path) and must return a Reporter. Otherwise the default export is used directly as the Reporter:
import type { Reporter } from 'refactor-tracker';
// Shape A — default-export an instance:
const reporter: Reporter = {
async report(report) {
// report.tasks: { id, name, done, total, percentage, delta, ... }[]
// report.hasChanges: skip expensive work when nothing changed
},
};
export default reporter;
// Shape B — default-export a factory that receives the config block:
export default function createReporter(config: { token: string }): Reporter {
return {
async report(report) {
/* … */
},
};
}$VAR references in any reporter field are expanded against process.env (missing variables are a hard error).
Notion
To sync each snapshot to a Notion database (donut, per-task progress bars, table on a private team page), install refactor-tracker-notion-reporter and wire it as a custom reporter. See the package README on npm for the one-time Notion setup (integration, database schema, page layout, where to find both IDs).
How it works
Each run compares current counts against .refactor-tracker-cache.json (gitignored) to compute per-task delta and a global hasChanges flag, so reporters can skip noisy or expensive work when nothing moved. The cache is not updated in --dry-run mode.
