| title | TypeScript architecture patterns |
|---|---|
| slug | ts |
| summary | SOLID and clean architecture patterns for the mdsmith VS Code extension at editors/vscode/. |
SOLID and clean-architecture rules for
mdsmith's VS Code extension at
editors/vscode/. This page is the
source of truth — the
solid-architecture skill
reads it during design and audit modes
to check TypeScript changes. The layout
below is the target the extension is
converging on. "Current state" callouts
mark where the code differs.
editors/vscode/src/
├── extension.ts (thin entry;
│ delegates to
│ wiring.ts)
├── wiring.ts (compose commands +
│ LSP client)
├── binary.ts (locate mdsmith
│ binary)
└── commands/
├── init.ts
├── kinds.ts
├── fix-workspace.ts
├── merge-driver.ts
├── runner.ts (typed exec of
│ binary subcommands)
└── virtual-doc.ts
Tests live next to source as *.test.ts.
Each commands/<name>.ts holds one
command. Shared steps (subprocess spawn,
output capture, error formatting) live
in commands/runner.ts. We do not copy
those steps into each command; if a
command needs a variant, the variant
goes in runner.ts so every consumer
sees it.
A command past ~200 lines should split. The natural split is three modules: one picks the args, one runs the call, one shows the result.
The target is that adding a command does
not modify extension.ts:
- Create
commands/<name>.tsexporting a registration function. - Wire it from
wiring.ts. - Add
commands/<name>.test.tsalongside. - Add the matching entry to the
contributes.commandssection ofpackage.json.
Commands register in the
registerPaletteCommands helper of the
Wiring class, and new entries belong
in that helper so the wiring stays in
one place. Do not call
vscode.commands.registerCommand from
the activation body.
The contributes section of
package.json is the public surface for
VS Code commands and configuration.
Treat it as part of the contract;
changing the id or arguments of a
shipped command is a breaking change.
Every command takes the same wiring
shape — a typed dependency object or
vscode.ExtensionContext — and returns
Promise<void> or vscode.Disposable.
We do not add optional, command-specific
parameters at the registration site. If
a command needs extra configuration, it
reads it from workspace settings inside
the command.
A command commits to one return shape. "Maybe done, maybe not" violates Liskov; the caller should not need to know which kind it received.
binary.ts exposes a small interface
for locating and invoking the mdsmith
binary. Functions stay narrow:
- "Find the binary path" does not also warm a cache of subcommand outputs.
- "Run
mdsmith kinds --json" does not also runmdsmith fix ..
Add a new function rather than widening an existing one. Consumers import only what they need.
The extension talks to the Go core through three typed boundaries; we never thread raw, unparsed values across them:
- LSP protocol — typed via
vscode-languageclient. Capabilities are the contract; additions are additive, removals are breaking. - Subprocess invocation of the
mdsmithbinary — typed by the JSON schema each subcommand returns, parsed incommands/runner.ts. Todayrunner.tsowns subprocess spawn and returns{stdout, stderr, exitCode}. Target: parse those results into typed domain shapes that commands consume. - File system reads — typed via
narrow shape interfaces (e.g. a
parsed
.mdsmith.ymlshape, not raw YAML).
If a binary subcommand returns a list,
parse it into a typed value in
runner.ts; consumers see the type,
not the raw JSON. The boundary between
strings and types should be exactly one
function deep.
Target shape:
- Activate the extension.
- Construct the wiring object.
- Hand control to
wiring.ts.
extension.ts matches this shape: it
constructs one Wiring and delegates
activate/deactivate to it. The LSP
client, the .mdsmith.yml watcher, and
command registration live in
wiring.ts.
mdsmith follows a four-layer test pyramid. Each layer answers a different question and sits in a different place in the tree:
- Unit — one function or method per test. Lives next to source. No file I/O beyond inline string fixtures. Runs in milliseconds.
- Contract — locks a port-package interface or external surface shape. A contract test must fail loudly when the shape it pins drifts.
- Integration — multiple packages composed together against real Markdown fixtures.
- E2E — the built binary (or the
packaged extension) against a
fixture workspace. Website JS is
covered by
website/e2e/.
The pyramid shape — many unit, fewer contract, fewer integration, fewest e2e — keeps the suite fast and the feedback loop tight.
A new function lands together with
its dedicated unit test by name.
Sub-behaviours of the same function
go in subtests under that parent.
The rule applies to exported and
unexported functions alike — in
production code. Test files
(*_test.go, *.test.ts) and
test-only helpers are out of scope:
the audit walks production sources
only and never asks for "tests for
tests". The audit flags every
production function in the touched
set that lacks a matching test.
The language-specific page binds
this rule to concrete file and
symbol patterns. For Go, that is
TestFunctionName for package
functions and TestReceiver_Method
for methods. For TypeScript, that
is a describe("name") block with
one or more test("case") cases
imported from bun:test.
A production function may skip its dedicated test only if one of these holds:
- It is generated code (file begins
with a
// Code generated…header, matches a generator file pattern such as*_gen.go, is a*.d.tsdeclaration, or is emitted underdist/). The file-level marker is sufficient — no per-function comment is required. - It is a trivial accessor with no
branch — a one-line getter or a
String()-style format method. Add a one-line comment on the function so the audit can distinguish "no test by design" from "no test, forgotten".
A unit test on the same behaviour is faster than the equivalent integration test. It stays focused on one function. It also survives refactors better. The audit pushes back on inverted pyramids:
- An integration test that exercises one function should move down to that function's own package as a unit test.
- An e2e test that exercises behaviour reachable through the integration layer should move down to integration.
Save e2e for the full process boundary. Use it for exit codes. Use it for signals. Use it for subprocess lifecycle. Use it for packaged-artifact tests.
- Unit tests in
xxx.test.tsnext toxxx.ts, importingdescribe,test, andexpectfrombun:test. The dedicated test forfunction foo(or a methodfoo) is adescribe("foo", () => { … })block; one or moretest("case", …)cases enumerate behaviours. Extract pure functions out of command bodies and unit-test those instead of mockingvscode. - Contract tests pin the shape
of the binary boundary — the JSON
envelopes parsed in
commands/runner.ts— thecontributessection ofpackage.json, and the LSP capability set the extension opts into. They live next to the module that owns the contract. - Integration tests drive the
real
mdsmithbinary against fixture workspaces rather than mocking subprocess calls. They live next to the command module they exercise. - E2E tests spin up the VS Code
extension host against a fixture
workspace. Reserve them for
behaviour that cannot be checked
at the lower layers (activation
order, command palette wiring,
onSavehandlers). - Mock the
vscodeAPI only when unavoidable. If a function is hard to test withoutvscode, the function probably contains pure logic that should be extracted. - Place test fixtures under
editors/vscode/test-fixtures/or alongside the test that uses them. Do not import fixture data across command modules.
Severity for missing unit tests:
tax by default. Promote to
blocker if the function sits on
a public surface — an exported
command registration, a
contributes-backed entry point,
or a binary-boundary parser.
These are the mdsmith-specific shapes the audit flags:
- A TypeScript function with no
matching
describe("name", () => { test(…) })block in a sibling*.test.ts. Test debt. Severitytaxby default,blockerif the function is on a public surface (an exported command registration, acontributes-backed entry point, a binary-boundary parser). Test files and test-only helpers are out of scope; see §"Tests / Exemptions". - A
*.test.tsundereditors/vscode/test-fixtures/or an integration-style test that drives a single pure function. Pyramid is inverted; extract the function and push the assertion down to a unit test next to it. - A VS Code extension-host e2e
test added where a unit test on
an extracted pure function would
suffice. E2E is for activation,
command palette wiring, and
onSavelifecycle — not for logic reachable by direct call. - A command that imports another
command. Share state through
wiring.tsinstead. - A command that constructs paths to another command's artifacts. Same fix; go through wiring.
- A type declared in
extension.tsused by commands. Belongs inwiring.tsor its own module. - Raw
child_process.execoutsidebinary.tsorcommands/runner.ts. The subprocess boundary is one place. - A field added to
package.jsoncontributeswithout a corresponding module that owns it. A contract drift bomb. - A test that spins up the full VS Code host to test logic that could be unit-tested out of band.
- A
vscode.commands.registerCommandcall inlined inactivate()or anywhere outsideregisterPaletteCommands(and, after the planned refactor, anywhere outsidewiring.ts). - A
Util,Helpers, orMiscmodule anywhere insrc/. Same smell, same fix.
npm/mdsmith/test/shim.test.ts tests
the binary-launcher shim. The shim is
the only place that knows how to find
and exec the Go binary across
platforms. The extension's binary.ts
should consume that contract, not
reimplement it. If the extension needs
platform-specific behavior the shim
does not provide, the behavior goes
into the shim first.
- A command with three responsibilities
splits into three commands, or one
command plus two helpers in
commands/runner.ts. - A growing
wiring.tsextracts a registry module that commands enroll into;wiring.tsstays a thin call list. - Raw JSON crossing module boundaries parses into a typed shape at the binary boundary; consumers see the shape, not the JSON.
- Scattered
vscode.workspace.getConfigurationcalls centralize into a config-reader module typed to the.mdsmith.ymlandpackage.jsoncontributesschemas.