Convention-aware refactorings for C#, React/TypeScript and fifteen more languages in VS Code.
Status: early but real. 34 refactorings plus a 38-rule code-smell inspection layer across 18 languages, complexity metrics, scan history and churn×complexity hotspots, 532 tests (94 xunit for the C# engine, 406 for the two TypeScript-hosted engines, 32 running inside an actual VS Code). On the Marketplace as
mark-ku.refactory, or build a VSIX yourself with.\pack.ps1. Recent changes: CHANGELOG.md.
What this is — and what it deliberately is not
VS Code already ships extract-function, move-to-new-file, organize-imports and rename. JetBrains ships an official ReSharper extension for generic C# refactoring. Re-implementing any of that would produce a worse copy.
This extension does the four things nobody serves:
- Convention-aware generation — put the file where your repo actually puts
it, with your copyright header, your XML docs, your
this.qualification and your BOM, so the result passes your analyzers instead of flooding review. - Solution-shaped multi-file operations — DI registration, interface ↔ implementation sync, controller version clones.
- React/Next semantics —
'use client'propagation, extract component and extract hook into real files, alias-aware imports. - The everyday structural edits, in the languages that have none. Invert
this
if, merge these two, pull this expression into a variable, turn this concatenation into interpolation. Every JetBrains IDE has had these for fifteen years; VS Code has them for almost no language. Refactory has them for Python, Go, Java, Kotlin, Rust, PHP, C, C++, Objective-C, Dart, Swift and Scala — with no language server, no toolchain, and no project load.
Plus one Refactor This menu that lists the built-in refactorings alongside ours, so "we don't rebuild what exists" is visible rather than merely claimed — and now shows the unavailable ones too, with the reason on the line.
One keystroke, everything applicable
Ctrl+Alt+Shift+T — unbound on every platform, so nothing of yours is stolen.

Ours are grouped under Refactory; VS Code's and the language servers' own actions stay right there under Built-in refactorings. The ones you used most recently float to the top of our group — refactoring is repetitive work, and a rename sweep is the same action forty times.
A third group holds what is unavailable here, with each provider's own reason as the detail line. "Why is Extract Method not offered on this selection" is the question a refactoring menu exists to answer, and an action that silently vanishes answers it with nothing.
The same actions also nest correctly in the native refactor menu (Ctrl+.), in
the groups they belong to — not in a parallel menu of their own:

React: extract a component, keep the conventions
Select JSX, extract. Props are inferred from what the selection captures, key
moves to the call site, and the name is edited in place — type once and the
interface, the function and the props type all update together:

Real recording, not a mockup — scripts/gif.ps1 films the editor doing it.
The new-file variant carries 'use client' across when the source had it, brings
only the imports the markup actually uses, and exports a module-level
constant rather than duplicating it — a copied object literal is a different
object, which quietly breaks reference equality downstream.
If a captured value's type cannot be derived from the file, the refactoring refuses and names it. A plausible-but-wrong type compiles, which makes it exactly the corruption no later gate would catch.
C#: edits that look like their neighbours

Adding an injected dependency means a using in sorted position, a field, its
XML doc, a constructor parameter, a <param> tag at the matching position, and
an assignment. Style is measured from the class being edited — underscore or
not, this. or not, documented fields or not:

Six edits, one undo stop, and every one of them visible before it lands.
Two refusals matter more than the generation itself:
- A manual
new Foo(...)call site means we would have to invent an argument. Injectingnull!to keep it compiling is precisely the corruption this tool exists to prevent. - Whether the type is registered in DI is never implied to have been checked. An unregistered dependency compiles perfectly and throws at application start, invisible to every type-based verifier.
Code smells and the Code Health dashboard
The inspection layer the JetBrains tools ship and VS Code does not: squiggles that say "this works, but it will hurt you", each carrying a quick fix that opens the refactoring menu on the offending range.
- React / Next.js, live while you type: a component defined inside another
component (the state-destroying one),
useEffectwith no dependency array, the map index used as akey, too many hooks / props, oversized effects and components, nested ternaries in JSX, a raw<img>in a Next.js app — plus general TypeScript smells (explicitany, emptycatch,console.log, long files). - C#, on open and save via the Roslyn sidecar: constructor over-injection,
empty catch and catch-all-without-rethrow,
async voidoutside event handlers, long methods, large classes, deep nesting, magic numbers in comparisons, public mutable fields, several top-level types in one file.
The severity budget is deliberate: runtime traps are warnings, structure
worth a look is information, taste is a faded hint — so the noise never
drowns the signal. refactory.smells.react / refactory.smells.csharp turn
each half off, and the smells section of .refactory.json disables
individual rules or moves thresholds per repository. The full catalogue —
every rule id, severity and threshold — is in docs/SMELLS.md.
Refactory: Code Health Dashboard aggregates everything into one themed panel — grouped by file or by rule, sorted by severity or by score, click to jump — with a status bar counter for the active file (✓ when clean). Scan workspace sweeps every React/TS and C# file off disk without opening anything. The dashboard has eight themes: Auto follows your VS Code theme; Nocturne, Abyss, Forest, Orchid, Amber, Ember and Daylight are its own.

The three numbers that say what is worth fixing
A list of smells says what is wrong. It does not say whether you are winning, or which mess actually costs you anything. Three measurements answer that.
Complexity and a health score. Cyclomatic complexity per function, method,
constructor, local function and accessor — counted identically in both engines,
so a mixed repo's numbers are comparable. Anonymous callbacks fold into the
function containing them (a component's complexity includes its inline
handlers); a named nested function is its own unit. Each file gets a score,
100 − (10·warnings + 3·infos + 1·hints + complexity overflow), graded A–F and
shown beside its name, with a Most complex functions list across the
workspace. ts.complexFunction / cs.complexFunction squiggle above the
threshold (default 15, tunable per repo).
Trends. Every completed Scan workspace records a snapshot — totals,
per-rule counts, average and worst score, worst ten files. The dashboard draws
one bar per scan and the per-rule delta since the last one (+3 cs.magicNumber, −2 react.indexAsKey), so "is this getting better" has an
answer. Last 100 scans are kept; Refactory: Code Health: Clear Scan History
starts over.
Hotspots. One git log for the whole repository, joined against the health
score: 100 · ln(1+commits)/ln(1+max) · (100−score)/100. A complex file nobody
touches is not the emergency a complex file touched weekly is, and this is the
section that tells them apart. Not a git repository? The section is simply
absent, never an error. Tune the window with a hotspots block in
.refactory.json — see docs/HOTSPOTS.md.
✦ Review on any file or hotspot writes a briefing — metrics, churn, hotspot
rank, every finding with its rule id and line — into .refactory/reviews/ and
opens your own claude session pointed at it, asking for a prioritised
refactoring assessment that respects .refactory.json. Review top 3 briefs
the three riskiest files in one session, because "which of these first?" cannot
be answered by a session that sees only one. The briefing travels as a file
rather than a command-line argument: one backtick in a diagnostic message is
enough for PowerShell to execute it. The reviews directory self-ignores, so
nothing lands in your commits.
Mechanical smells carry one-key fixes (throw ex; → throw;, remove a
console.log), and Refactory: Clean Up This File applies all of them at
once — one undo reverts the lot.
If the Claude Code CLI is installed, every
smell also offers Fix with Claude Code… — it opens a terminal running your
own claude session with the smell's exact location, rule and message as the
prompt, told to keep the change minimal and obey .refactory.json. You watch
the diff land; nothing is applied behind your back.
Draw it: Mermaid diagrams from your actual code
Every section above answers what is wrong or what is worth fixing. This one answers the question people ask first about code they did not write: what does this do, and what does it talk to.
Select some code — or nothing, for the whole file — and pick Diagram This with
Claude (AI) from the context menu. Refactory writes a briefing, opens your own
claude session pointed at it, and when the diagram comes back it opens
rendered, in the same themes as the dashboard, with zoom, pan, Copy,
Save SVG and Save PNG.
Want just one method, not the whole file? Three ways in: put the cursor
anywhere inside it and pick Diagram This Method (no selection needed —
Refactory resolves the enclosing method itself, for a plain function or a
const foo = () => {} alike); click the "Diagram this method" CodeLens
that sits above every function a language server can see; or select the
method by hand, same as ever. Every diagram — method, selection or whole file
— now arrives with a plain-language explanation alongside it, spelling
out the business logic specifically: what the code guarantees, what it
forbids, which numbers are policy rather than accident. A worked example
lives in demo/diagram-method/.

Every node in that flowchart is a statement in the file open beside it —
including the dashed edge at the bottom, where the button assigns the literal
'next', which is not any post's id. That is the kind of thing a diagram
surfaces and a top-to-bottom read does not.
| Kind | Answers |
|---|---|
| Auto | let Claude read the code and choose |
| Flowchart | branches, guard clauses, early returns, the error path |
| Sequence | who calls whom, in order, with the awaits and the failures |
| Class | types, inheritance, composition, who owns what |
| State | the machine this code implements — including the state with no way out |
| ER | entities, fields, and real cardinality rather than a guess |
| Dependency | the module graph around this file, and any cycle in it |
The briefing carries what Claude cannot cheaply rediscover: the exact selection
(quoted, up to 300 lines), the complexity Refactory already measured — so
the flowchart does not quietly smooth over the branchiest function in the file —
the direct imports it already parsed, and your .refactory.json conventions so
the diagram uses your vocabulary instead of inventing new names.
It also carries the Mermaid rules a model writing from memory gets wrong:
unquoted () in a label, end as a node id, forty nodes nobody will read. Each
of those renders as a red parse error rather than a slightly-wrong picture,
which is why they earn more space in the prompt than the diagram's content does.
The handoff is Deep Review's, plus a return path. The prompt travels as a
file; the command line stays a fixed sentence whose only variable is a filename
this extension sanitised itself; and the one output path both sides agreed on in
advance is watched for the answer. Nothing scrapes the terminal, so nothing
breaks the first time a session wraps a line. .refactory/diagrams/
self-ignores, exactly like the reviews directory.
A ◇ Diagram button sits beside ✦ Review on every file in the Code Health dashboard — the worst file in the workspace is one click from a picture of what it does.
Refactory: Preview Mermaid Diagram renders what you are already looking at —
a .mmd file, a selection, or a fenced ```mermaid block — with no AI
round trip at all.
Mermaid ships inside the VSIX. The panel runs under default-src 'none' and
loads the renderer from media/vendor/, so diagrams work offline, behind a
corporate proxy and in an air-gapped checkout; an integration test asserts the
bundled build contains no eval and no dynamic import, because either would
fail silently inside that CSP. Labels are drawn as real SVG text rather than
<foreignObject> HTML — which is what makes Save PNG work at all, and what
makes a saved SVG look the same wherever it is opened.
The whole contract — what the briefing promises, what Claude must write back, what the panel will render, and how to verify the renderer headlessly — is in docs/DIAGRAMS.md.
Thirteen more languages, one lexer
Open a .py, .go, .rs, .java, .kt, .php, .c, .cpp, .m,
.dart, .swift or .scala file and Ctrl+Alt+Shift+T has answers there too:
| Invert if condition | negate the condition, swap the branches |
| Merge nested if · Split if condition | collapse a pyramid of guards, or open one up to hold an else |
| Add braces · Remove braces | the C-family ones only |
| Introduce variable · Inline variable | the name edited in place, both occurrences linked |
| Convert to an interpolated string | "a " + x → $"a {x}" · "a $x" · "a \(x)", whichever your language writes |
| Apply De Morgan's law | !(a && b) ⟷ !a || !b, both directions |
| Convert to an f-string | Python's "…".format(x) and "…" % x |
Replace if with ?: |
two returns or two assignments become one expression — Python's a if c else b, Rust's and Kotlin's if-expression |
Every one of these is one lexical pass over the buffer. No compiler, no language server, no project load — which is the only reason thirteen languages are affordable at all, and also the hard limit: nothing here answers a question that needs a symbol resolved.
Why a lexer is enough — and where it is not
The engine builds a mask that classifies every character as code, string or comment, then searches only the code. That single decision is the whole safety story:
# if x: pass <- a comment
s = "if y: pass" # <- a string
if z is None: # <- the only `if` here
A refactoring that matched raw text would eventually rewrite the inside of a
string literal. That is not a worse suggestion; it is a corrupted file that
still compiles and passes review. The mask handles Python's triple quotes and
f/r/b prefixes, Go's raw strings, C#'s verbatim @"…""…", Rust's
'lifetime versus 'c', PHP's # comments, Objective-C's @"…", and
JavaScript's template literals — each of which is a case where the naive answer
is silently wrong. Rust and Scala nest block comments, so an inner opener
does not let the first closer end the outer one; treating them like C's leaves a
dangling closer that reads as code and switches the whole file off.
Where the lexer cannot be sure, the refactoring refuses and says so:
- An unterminated string or an unbalanced brace stops everything. Past that point the mask is a guess, and every offset this engine produces comes from the mask.
- Ruby is absent on purpose. Heredocs,
%w[]literals and parenthesis-less calls make its lexical structure genuinely ambiguous without a parser. A mask that is wrong one time in a hundred is worse than no support. if (a < b)inverted toif (a >= b)is reported when the operands look floating-point. Both comparisons are false for NaN, so flipping the operator and swapping the branches sends a NaN the other way. Every market tool flips regardless; this one flips too, and shows you a preview when it matters.- Braces are not removed from a declaration, or where an
elsewould re-bind.if (a) int x = 1;does not compile, andif (a) if (b) x(); else y();compiles as something else entirely. "%d" % valueis not converted to an f-string."%d" % 3.7is"3";f"{3.7}"is"3.7". Only%sand%rsurvive the trip unchanged.
What it does NOT offer, and why
Introduce variable and Convert to an interpolated string are withheld for
TypeScript and JavaScript, because VS Code's own Extract to constant and
Convert to template string are already there and are type-aware in ways a
lexer cannot be. Same rule as everywhere else in this extension: a second,
worse copy of a refactoring the editor already has is a downgrade, not a
feature.
What ships today
React / TypeScript — and JavaScript
Every one of these now serves .js, .jsx, .mjs and .cjs as well as
.ts/.tsx. It is the same parser either way; what changes is that generated
code carries no type annotation, so a .jsx file gets
function Badge({ label }) { and no interface BadgeProps. Emitting one would
not be a degraded result — it would be a file that does not parse.
| Refactoring | Needs |
|---|---|
| Convert import to alias path / to relative path | one file |
| Extract JSX into a component (same file, with in-place naming) | one file |
| Extract JSX into a new component file | one file |
Extract a custom hook (same file, or into your _hooks/ folder) |
one file |
Convert const X: FC<Props> to a function declaration |
one file |
Wrap JSX in a fragment / a conditional / a .map |
one file |
| Inline variable | one file |
'use client' boundary diagnostics + quick fixes (Next.js) |
one file |
| Move a declaration to its own file, repointing every importer | import graph |
| Safe delete a declaration | import graph |
| Rename a component prop, including every JSX call site | import graph |
Python · Go · Java · Kotlin · Rust · PHP · C · C++ · Objective-C · Dart · Swift · Scala
| Refactoring | Where |
|---|---|
| Invert if condition | every language |
| Merge nested if · Split if condition | every language |
| Add braces · Remove braces | the brace languages |
| Introduce variable · Inline variable | every language except TS/JS, where the editor has one |
| Convert to an interpolated string | Python, C#, Kotlin, PHP, Dart, Swift |
| Apply De Morgan's law | every language |
Convert .format() / % to an f-string |
Python |
| Replace if with a conditional expression | everywhere except Go, which has none |
C#
| Refactoring | Needs |
|---|---|
| Add injected dependency (six coordinated edits) | one file |
Add missing ConfigureAwait(false) |
one file |
| Document a member (copying the interface's wording when there is one) | declaration index |
| Implement an interface member in every implementation | declaration index |
| Extract interface into the folder your repo actually uses | declaration index |
| Register a service in the DI container | declaration index |
| Clone a controller into the next API version | declaration index |
Inspection layer (since 0.0.42)
| Feature | Scope |
|---|---|
| 36 code-smell rules — 18 for React/Next.js/TypeScript, 18 for C# (the catalogue) | live for TS, open/save for C# |
| Code Health dashboard — 8 themes, by-file/by-rule grouping, severity filters, Copy-report | whole workspace via Scan workspace |
| One-key mechanical fixes, Clean Up This File, Fix with Claude Code… | quick-fix menu |
Diagrams (since 0.0.45)
| Feature | Scope |
|---|---|
| Diagram This with Claude (AI) — 7 diagram kinds, briefed with the metrics and imports already parsed | selection, or whole file |
| Diagram This Method with Claude (AI) — right-click, or the "Diagram this method" CodeLens above every function | one method, resolved automatically |
| Every diagram ships with a plain-language explanation, business logic called out specifically | any diagram |
| Themed Mermaid panel — zoom, pan, Copy, Save SVG, Save PNG; renderer vendored, no network | any diagram |
Preview Mermaid Diagram — .mmd, a selection, or a fenced block |
no AI needed |
Plus the Refactor This menu, which lists all of the above alongside everything VS Code and the language servers already offer.
Nothing here loads a TypeScript program or a Roslyn solution. The editor already holds one of each; doubling that is how an extension becomes the thing users blame for a slow VS Code. Cross-file questions are answered by two cheap indexes instead:
- a declaration index for C# — a parallel parse of top-level declarations, no
MetadataReference, no MSBuild — which answers "who implements this" in under a second instead of after a 20–60 second solution load; - a lex-only import graph for TypeScript, which answers "which files mention this file" without a type checker.
Within a file the same discipline applies. provideCodeActions fires on every
cursor move and runs every provider we have, so each engine analyses the buffer
once per keystroke, not once per refactoring — a cache keyed by the file's
full text, which means editing simply misses rather than serving an analysis of
text that is no longer there. A nine-provider pass over a 1,400-line .tsx used
to parse it nine times before deciding most of them had nothing to offer, and
the polyglot engine masked a file once per provider for the same reason. Both
are now one, and a test says so for each.
The second of those was found by a performance test rather than by reading the code, which is the argument for having one: the budget it asserts is two orders of magnitude loose, and eight scans of a 100,000-character file still showed up against it.
The limits of that choice are surfaced, not hidden. An implementation that inherits an interface indirectly is not found, and the refusal says so; an import statement whose bindings could not be read cheaply is left alone, and the plan reports how many.
Using it
Install it from the Marketplace (ext install mark-ku.refactory), or build the
VSIX yourself (.\pack.ps1, or code --install-extension refactory-*.vsix).
Reload the window and open a file in any of the eighteen languages. Three ways in:
Ctrl+Alt+Shift+T |
Refactor This — ours grouped under Refactory, the editor's own under Built-in refactorings |
Ctrl+. |
the native menu; our actions nest into the groups they belong to |
Ctrl+Shift+P → Refactory |
commands that operate on a whole file rather than a caret |
Where to put the caret
Nothing is offered "just in case", so position is what makes an action appear. When one is genuinely unavailable it still says why — see When nothing is offered below.
React / TypeScript
| To get | Select / place the caret on |
|---|---|
| Convert an import to alias or relative form | the module specifier string itself ('../../data/posts') — needs paths in the governing tsconfig |
| Convert every import in the file | Command Palette → Refactory: TypeScript: Convert Imports to…. Deliberately absent from the lightbulb: a file-wide rewrite offered on every cursor move is noise |
| Extract JSX into a component, same file or new file | a complete JSX region, selected |
| Extract a custom hook | contiguous statements including at least one hook call |
const X: FC<Props> → function declaration |
the component's declaration |
Wrap JSX in a fragment / conditional / .map |
a complete JSX region, selected |
| Inline variable | a const declaration, or one of its references |
| Move to its own file · Safe delete · Rename prop | the name being declared (for a prop: the property in the props type) |
'use client' fixes |
nothing — the diagnostics appear in Problems, apply with Ctrl+. |
C#
All seven appear together on an explicit invoke, and never on the automatic lightbulb. Whether the caret is inside a class is a question only Roslyn can answer, and spawning a .NET process on every cursor move to ask it would be indefensible — so the offer list is cheap and the real answer, including a readable refusal, comes when you pick one.
Needs a .NET 8 runtime. If none is found, only the C# half switches off; the TypeScript half is unaffected, and a TypeScript-only session never starts the sidecar at all.
Python · Go · Java · Kotlin · Rust · PHP · C · C++ · Objective-C · Dart · Swift · Scala
| To get | Select / place the caret on |
|---|---|
Invert if · Merge nested if · Split if · Add/Remove braces · De Morgan · Replace if with ?: |
the if keyword or its condition — never merely somewhere in the body, or four nested ifs would all offer at once |
| Introduce variable | a selected expression |
| Inline variable | the declaration line of a local |
| Convert to an interpolated string | inside a string literal that is joined to something with + (or . in PHP) |
| Convert to an f-string | inside the string literal of a "…".format(…) or "…" % … |
These need nothing installed: no language server, no SDK, no toolchain. Opening
a .go file in a bare VS Code with no Go extension still gets all of them.
Kotlin is the one exception, and not for a reason of ours: VS Code registers
the language ids for the other fourteen itself, but not kotlin. Until some
Kotlin extension is installed a .kt file opens as plain text, and every
language-scoped feature in every extension — including this one — correctly
declines. Any of them will do; nothing else is needed.
Settings
| Setting | Default | |
|---|---|---|
refactory.preview |
multiFile |
when to route edits through the Refactor Preview panel. A plan carrying warnings forces a preview regardless |
refactory.keymap |
none |
riderStyle adds Ctrl+Alt+M / Ctrl+Alt+V pointing at VS Code's built-in extract refactorings. Off by default because Ctrl+Alt+<letter> is indistinguishable from AltGr+<letter> on non-US layouts, where it types a real character |
refactory.smells.react |
true |
React/Next.js code-smell hints, live while you type |
refactory.smells.csharp |
true |
C# code-smell hints via the Roslyn sidecar, on open and save |
refactory.dotnetPath |
"" |
explicit dotnet for the C# engine |
refactory.disabledLanguages |
[] |
language ids to stay out of entirely, when another extension already covers one the way you want. resource-scoped, so a multi-root workspace can decide per folder |
refactory.developerMode |
false |
exposes the Dev: commands |
Project conventions — .refactory.json
Conventions are properties of the repository, so they live in a committed file at the workspace root; VS Code settings carry personal preferences only. Everything in it is optional — what is absent is detected.
{
"version": 1,
"typescript": {
"components": { "declaration": "exportDefaultFunction", "propsStyle": "interfaceSuffixProps" },
"hooks": {
// First match wins, so put the specific paths above the catch-all.
"location": [
{ "when": "src/components/admin/**", "dir": "src/components/admin/_hooks" },
{ "when": "**", "dir": "src/hooks" }
],
// The one value worth settling by hand: a repo with genuinely mixed hook
// filenames gives the tool nothing to infer from, so it asks every time
// rather than quietly flipping a coin.
"fileNaming": "camelCase"
}
},
"exclude": ["**/node_modules/**", "**/.next/**"],
"smells": {
// Rule ids from docs/SMELLS.md. Thresholds move where the repo has made a
// different honest choice; disable is for rules the team has ruled out.
"disable": ["cs.todoComment"],
"thresholds": { "componentLines": 200, "ctorDependencies": 6 }
}
}
This file is read once, at the workspace root — not walked up per-file the way tsconfig aliases are, because thresholds are a property of the repository, not of any one folder inside it.
Every threshold smells.thresholds can move, and which rule each one drives:
| Key | Default | Language | Drives |
|---|---|---|---|
componentLines |
150 | React | react.longComponent |
propsCount |
8 | React | react.tooManyProps |
hookCount |
8 | React | react.tooManyHooks |
jsxDepth |
6 | React | react.deepJsx |
effectLines |
30 | React | react.largeEffect |
fileLines |
400 | TypeScript | ts.longFile |
methodLines |
60 | C# | cs.longMethod |
parameterCount |
5 | C# | cs.tooManyParameters |
ctorDependencies |
5 | C# | cs.ctorOverInjection |
nestingDepth |
4 | C# | cs.deepNesting |
classMembers |
25 | C# | cs.largeClass |
classLines |
500 | C# | cs.largeClass |
complexity |
15 | both | ts.complexFunction, cs.complexFunction, and the health score |
complexity is the one key with no language behind it, on purpose: the number
also feeds the health score, so a mixed repo needs one shared scale rather than
a C# ruler and a React ruler that happen to use the same word.
hotspots, alongside smells, tunes the churn×complexity ranking
(docs/HOTSPOTS.md):
{
"hotspots": { "windowDays": 90, "maxCommits": 5000, "top": 10 }
}
| Key | Default | Means |
|---|---|---|
windowDays |
90 | how far back git log looks |
maxCommits |
5000 | upper bound on commits examined, for repositories with very long histories |
top |
10 | how many rows the Hotspots list shows |
Import aliases are not configured here — they are read from the tsconfig that governs each file, which in a monorepo is not the same one for every file.
Copy a full example: Next.js ·
layered C#. A .refactorforge.json
left over from before the rename is still honoured when no .refactory.json
is present.
When nothing is offered
-
Set
"refactory.developerMode": true. -
Ctrl+Shift+P→ Refactory: Dev: Why Is Nothing Offered Here? — reports which tsconfig governs the file, which aliases were derived, and what the engine saw at the caret. This is the most common question a refactoring tool has to answer, so it answers it structurally rather than by guesswork.For the polyglot languages it answers the three boring causes first, because they are the real ones far more often than a missing feature is: is the language known (
supported), does the file lex (lexable— one unterminated string earlier in the file disables everything after it), and is the caret in code rather than inside a string or a comment (regionAtCaret). It also lists what was deliberately left to the editor's own refactorings (deferredToEditor). -
Still puzzling: the Refactory output channel has the trace.
Safety
Every refactoring is a pure function producing a serializable plan, which then passes a fixed pipeline before anything touches disk:
- Preconditions — never "offer and hope". Three states only: available, available-with-warnings (preview forced), or unavailable with a reason you can read.
- Optimistic concurrency —
WorkspaceEdithas no version field, so we built one. If a file changed while we were analysing, the edit aborts before touching disk. Stale offsets are never rebased onto changed text. - Post-apply verification — a
WorkspaceEditcontaining file creations is documented as not all-or-nothing, so creations are ordered first and content hashes are re-checked afterwards. - One undo stop — verified in the real editor, including the created file.
The sweep
Example tests encode what someone thought of. For the polyglot engine there is also a property test that encodes what nobody thought of: every caret position and every short selection in a realistic file, in every language, through every offer — and each offer that fires through its execute. Over a hundred thousand offer calls per pass, run three times — LF, CRLF, and a sample written in Traditional Chinese with emoji — asserting that
- nothing throws (an error toast while someone is typing is worse than silence),
- no edit ever begins partway through a string or a comment — a whole literal is a fine thing to hoist into a variable, half of one is a rewritten sentence,
- no two edits in a plan overlap and every span is inside the file,
- and an
availableplan is never empty.
It earned its place on the first run, by finding a real bug: Introduce variable accepted a selection lying inside a string literal, because its "is this an expression" check skipped non-code characters instead of refusing on them. The regression test for that case is written out longhand next to it, because a sweep tells you that something broke and an example tells you what.
See docs/M0-spike-report.md for the editor capabilities this rests on and how each was verified.
Layout
docs/ARCHITECTURE.md has the whole picture: the three engines, the tier system, the offer/execute split, and what each package is forbidden from doing.
| Path | Role |
|---|---|
packages/extension |
VS Code host. The only package allowed to import vscode. |
packages/core-ts |
TS/React engine, on TypeScript's own parser. Must not import vscode or touch the filesystem — enforced by a test, not a convention. |
packages/core-lang |
Polyglot engine: one masked lexer, one profile per language. Must not import vscode, the filesystem, or a parser — all three enforced by tests, because the moment it imports one the next nine languages stop being free. |
server-dotnet |
Roslyn sidecar, JSON-RPC over stdio. Lazy: never spawned for a TypeScript-only session. |
fixtures/ |
Golden fixtures, test workspaces and the screenshot demo project. |
scripts/screenshots.ps1 |
Captures the screenshots above from a real editor. |
scripts/gif.ps1 |
Films the GIFs above from a real editor. |
Adding a language
A language is a profile, not a package: comment delimiters, string rules,
brace-or-indent blocks, how a local is declared, how a condition is negated. See
packages/core-lang/src/profiles.ts — the
Go entry is 20 lines. Two tests then hold you to it: one lexes a hello-world in
every declared language, and one checks that every profile fills in the fields
the refactorings actually read. Nothing appears in the editor until
SUPPORTED_LANGUAGES and the manifest's activationEvents agree.
Development
npm install
npm run build # tsc --noEmit + esbuild bundle
npm run test:unit # vitest, both engines — no editor, ~2 seconds
npm run test:cs # xunit — the C# engine
npm run test:int # @vscode/test-cli — downloads VS Code on first run
npm test # all three
.\pack.ps1 -Bump # version bump + dotnet publish + vsce package + install
F5 launches an Extension Development Host. Three configurations are provided:
an empty workspace, a React repo and a C# solution.
If test:int suddenly reports that no code action appears anywhere, check the
cache before the code: a half-extracted .vscode-test/vscode-*-archive-* leaves
a runnable Code.exe beside an almost-empty resources/app/extensions, so VS
Code starts with five languages instead of seventy-nine, every fixture opens as
plaintext, and every editorLangId guard in this extension correctly
declines. Delete the directory and let it download again.
Developer commands
Set "refactory.developerMode": true to expose them.
- Why Is Nothing Offered Here? — structured diagnosis of the caret position: which tsconfig governs the file, which aliases were detected, what the engine saw. This is the most common question a refactoring tool has to answer.
- Dump Available Code Actions Here — everything every provider offers at the
cursor. Run it in a
.csfile with C# Dev Kit on and in a.tsxfile to keep the "don't rebuild what exists" table honest as the SDKs move. - Run UX Capability Spike — probes the editor capabilities the design depends on and writes a report.
Screenshots and GIFs
.\scripts\screenshots.ps1 # media\screenshots\*.png
.\scripts\screenshots.ps1 -Only 08 # just one scene — the other images stay put
.\scripts\gif.ps1 # media\demo-*.gif (needs ffmpeg)
-Only exists because one new feature does not justify re-capturing seven
images already in the README: a re-shoot of a scene nobody changed is a diff of
PNG noise at best, and a worse composition at worst.
Both drive a real Extension Development Host through marker files rather than
synthetic keystrokes, which never fight for focus and never desynchronise. The
GIF handshake has one extra beat, because the camera has to be rolling before
the refactoring runs: ready-N (posed) → go-N (rolling) → after-N (edit
landed). Scenes invoke the refactoring the way the code action does, so what is
filmed is the extension's own work.
Captures with PrintWindow(PW_RENDERFULLCONTENT), which reads the window's own
content. That is a privacy decision, not a technical one: anything that grabs a
screen rectangle captures whatever is actually on screen if the target window is
not on top. The recorder launches the host with --profile-temp for the same
reason in reverse — a throwaway profile keeps the user's sidebars, chat panels
and extensions out of the frame without editing their real layout to do it.
License
MIT