Skip to content
codefastlabs

Command Palette

Search for a command to run...

@codefast/cliv0.8.0· Decisions

@codefast/cli — design decisions

The choices that shape this package and the reasons behind them. ARCHITECTURE.md describes the resulting layout; README.md describes what the commands do. Each decision below still holds; a decision that stops holding gets replaced here, not annotated.

1. Plain functions, not Explicit Architecture

Context. The CLI has four top-level commands (arrange, audit, mirror, tag), each a short pipeline: read files, run a pure transformation, print or write the result. An earlier revision applied full Explicit / Hexagonal Architecture to it — a port interface per use case, an adapter per port, a DI token per injectable, request objects for one-field inputs, and a shell/ layer wrapping node:fs, node:path, process and Commander behind eleven more ports. Roughly three files in four existed to connect the other one, and the @codefast/di container resolved at runtime what a function call would have checked at compile time.

Decision. Behaviour is wired with plain functions. A command module builds its Commander subcommand and calls prepare* / run* functions directly; domain modules export pure functions; output modules export print* functions. There is no container, no module registry, no token. Node built-ins are called through thin helpers in core/ only where a helper adds something (a Result, a normalised error), never to make them injectable.

Consequences. Dependencies are visible as imports and checked by tsc. A new command is one directory with command.ts, run*.ts, domain/ and output.ts. The cost is that swapping an implementation for a test means passing a real path or spying on logger, not binding a mock — accepted, because every infrastructure call here is cheap to exercise for real. This is the worked example the @codefast/di "explicit architecture" samples cite when they say the pattern must be earned by the domain: a CLI of this size does not earn it.

2. An interface needs a second implementation

Context. Under the previous layout every interface had exactly one implementation, so the interface documented nothing and guaranteed nothing; it existed because the architecture required a port.

Decision. An interface or abstract type is introduced only when at least two concrete implementations exist, or when a module genuinely needs a test double that a real temp path or a vi.spyOn cannot provide. Pure domain code needs none.

Consequences. The core/filesystem/node.ts helpers are functions, not an adapter behind a port; telemetry or timing, if ever wanted, wraps a function instead of hooking a container activation.

3. Commander is the command model

Context. Commander already gives a declarative, composable tree of commands, options and actions. The earlier CommandTree / CommandRouteWire JSON that was translated into Commander added a layer with no extra capability.

Decision. Each <command>/command.ts exports create<Name>Command(): Command and uses the Commander API directly; cli.ts is the composition root that registers the four commands and global options.

Consequences. Adding a flag is one .option() call next to the action that reads it. Argv validation stays a Zod schema per command (cli-schema.ts) parsed through parseWithSchema, so shape errors are reported as usage errors, not as stack traces.

4. Result<T, AppError> for recoverable failures

Context. A CLI's failures are mostly expected: a missing config, an unparsable file, a path outside the workspace. Throwing for those turns every caller into a try/catch and loses the error code the exit code depends on.

Decision. Fallible operations return Result<T, AppError> (core/result.ts, core/errors.ts). AppError is a plain object with a code, not an Error subclass, so building one costs no stack capture. One boundary, consumeCliAppError / runCliResultAsync in core/cli/result-handle.ts, turns an err into a formatted message and the matching exit code (core/exit-codes.ts); unexpected exceptions still propagate.

Consequences. Domain and orchestration code never touch process.exitCode; only the command boundary does. Error text is produced in one place (formatAppError), so --json and human output stay consistent.

5. Presenters are output functions

Context. Printing a report is a side effect at the edge; it never needs to be abstracted over.

Decision. Each command directory has an output.ts (or a small *-reporter.ts) whose exported functions write through core/logger.ts. logger is a plain object so a test can vi.spyOn(logger, "out").

Consequences. Output changes never touch orchestration; a command's --json output lives beside its human output.

6. Parse TypeScript with oxc-parser

Context. arrange and tag read and rewrite TypeScript source. The classic typescript compiler API was the only consumer of that runtime in the repository once the build moved to native TypeScript 7.

Decision. AST work (arrange/domain/ast/, tag) uses oxc-parser; edits are applied as text ranges (core/source-text-edit.ts) rather than by printing a transformed AST, so untouched code keeps its formatting byte for byte.

Consequences. Nothing in the repository depends on the classic typescript runtime. The trade is that the CLI reads syntax only — it never type-checks — which is all four commands need.

7. Audits are read-only and mechanical

Context. audit rtl, audit links, audit comments and audit react gate CI. A gate that needs judgment to interpret, or that can only be fixed by hand, is ignored under time pressure.

Decision. Every audit reports a location and a one-line reason, exits non-zero on any finding, and where the fix is mechanical offers --fix (comment dividers) so a red run is one command from green. Allowlists live in codefast.config.js, never in the audited files.

Consequences. Audits stay cheap to keep on; a new rule has to come with a precise detector or it does not ship.

8. Naming

Context. Suffixes such as .port.ts, .adapter.ts, .domain-service.ts, .value-object.ts and .coordination.ts described the pattern a file played in the old architecture, not what the file contained.

Decision. One concept per file, named for the concept: grouping.ts, analyze.ts, exports.ts. The only reserved suffix is .test.ts; Zod schemas are named for what they parse (cli-schema.ts, core/config/schema.ts). Directory names are the four commands plus core/, and a command's pure logic lives under its domain/.

Consequences. A filename says what a module does; the directory says which command it belongs to.

9. Tests

Context. With no container, a test calls the function it targets.

Decision. Unit tests live under tests/unit/, mirroring src/, and run in Node. Filesystem-touching code is tested against fixture inputs under tests/unit/support/; output through spies on logger. There is no mocking layer for infrastructure.

Consequences. Adding a test means importing a function and asserting on a Result. Coverage is enforced by the workspace's test:coverage gate, not by this document.

License

Released under the MIT License.