Skip to content
Tsumugu

Workspace layout and dependency direction#

Status#

This document describes the repository as it exists today. Tsumugu is pre-alpha, and the workspace graph is deliberately smaller than the component list in docs/designs/architecture/index.mdx: the scanner, document model, routing, navigation, shell, serializer and server all live inside tsumugu-core until a boundary has been demonstrated by working software.

Workspaces are added when a boundary has been demonstrated by working software, not in anticipation of one. Creating a workspace for every conceptual component before the first vertical slice would fix boundaries that the implementation has not yet validated.

Layout#

tsumugu/
├── packages/                  publishable-intent packages
│   ├── core/                  tsumugu-core
│   ├── cli/                   tsumugu
│   ├── renderer-markdown/     tsumugu-renderer-markdown
│   ├── renderer-html/         tsumugu-renderer-html
│   ├── renderer-mdx/          tsumugu-renderer-mdx (opt-in, executing)
│   ├── theme-default/         tsumugu-theme-default
│   ├── transformer-highlight/ tsumugu-transformer-highlight
│   ├── preset/                tsumugu-preset
│   └── build/                 tsumugu-build
├── internal/          internal-only workspaces, never published
│   └── tsconfig/      @tsumugu/internal-tsconfig
└── tests/             repository-level tests
    └── helpers/       shared test support code, not collected as suites

Unit tests are colocated with the code they cover, under packages/*/src/**/*.test.ts. See docs/designs/testing.md.

pnpm-workspace.yaml maps exactly to those two roots: packages/* and internal/*.

Dependency direction#

Dependencies point towards core. Core never points back.

Workspace dependency directiontsumugu depends on tsumugu-preset, and, only under the trust declaration, on tsumugu-renderer-mdx. The preset depends on renderer-markdown, renderer-html, theme-default and transformer-highlight. renderer-mdx depends on renderer-markdown and renderer-html. All four of those depend on tsumugu-core, which depends on none of them.tsumugutsumugu-presetrenderer-mdxonly under --trustrenderer-markdownrenderer-htmltheme-defaulttransformer-highlighttsumugu-core

Every arrow points downward, which is the rule rather than a drawing convention: a package that had to be drawn pointing up would be a package that broke it. The dashed box and the dashed arrow are the one edge that is made at runtime rather than always.

tsumugu-preset is the composition root: it is the one package that decides which renderers, which transformers and which theme an ordinary project gets. The CLI parses a command line and prints to a terminal; core composes what it is handed and chooses nothing. That is what keeps a different set of choices possible without changing either of them. See docs/designs/composition.md.

The one edge that skips the preset is tsumugu-renderer-mdx, and it is deliberate. The preset still decides whether an executing MDX renderer is registered — it accepts one only alongside trust, and puts it ahead of the Markdown renderer — but it does not import the package, because doing so would put a compiler, a bundler and a JSX runtime into the dependency graph of every project that composes the defaults, including the ones that never pass --trust. So the CLI, which is where the declaration is made, constructs the renderer and hands it over. A library consumer does the same thing in one line, or does not, and pays nothing for the choice.

Each renderer holds every type of its own parser: mdast never leaves tsumugu-renderer-markdown, hast never leaves tsumugu-renderer-html. Core and themes see only the Semantic AST, so either parser can be replaced without changing them. HTML therefore remains a first-class input instead of a second format bolted on.

The rules the repository commits to, taken from docs/designs/architecture/index.mdx and docs/designs/principles.md:

  • tsumugu-core must not depend on the CLI, themes, renderers, the build adapter, search, or AI packages. Those are consumers of core, not parts of it.

  • The CLI composes public packages. Nothing depends on the CLI.

  • Workspace dependency cycles are forbidden.

  • Internal workspaces may be used as development tooling, but a publishable package must never require one at runtime. An internal workspace is never published, so a runtime dependency on it would break every consumer.

  • Workspace-to-workspace dependencies use the workspace: protocol.

Further renderer and theme packages join packages/ as the vertical slice in Milestone 1 requires them.

What enforces these rules#

pnpm check:boundaries

Two layers, because a boundary can be broken in two different places.

The manifests. tests/workspace.test.ts reads every package.json and checks the declared graph: privacy, explicit exports, the workspace: protocol, the absence of cycles, and core's forbidden targets. This is where a mistake becomes permanent. Once a package is published, a dependency edge cannot be withdrawn without a breaking change.

The imports. tests/boundaries.test.ts reads every source file, because an import can bypass the manifest entirely. Five rules, each reported separately with the file, the line, the source and target packages, and what was violated:

| Rule | Catches | | -------------------------- | ------------------------------------------------------------------ | | deep-import | tsumugu-core/src/…, a path that does not exist after publication | | escaping-relative-import | ../../core/src/…, the same thing spelled relatively | | internal-dependency | a publishable package importing an internal/ workspace | | forbidden-edge | core importing the CLI, a theme, a renderer, build, search or AI | | undeclared-dependency | an import that resolves only because pnpm installed it nearby |

Specifiers are extracted with TypeScript's own preProcessFile rather than a regular expression, so import type, export … from and dynamic import() are all covered while an import-shaped string inside a comment is not.

The same file also pins the public export surface of each publishable package. Adding a runtime export fails the test until the new name is listed deliberately, which is what docs/designs/principles.md means by a public API being earned rather than accumulated.

Type-only dependencies#

A type-only import is still a dependency. import type { Foo } from "tsumugu-x" must be declared exactly like a value import, and the boundary rules treat the two identically.

Where it may be declared depends on whether the type escapes:

  • If the type appears in the package's emitted .d.ts, it belongs in dependencies. A consumer cannot type-check against a package it was not given.

  • If it is used only internally and never appears in the emitted declarations, devDependencies is enough.

An internal/ workspace may never be imported from a publishable package, not even type-only. Its declarations do not exist for consumers at all.

Publication policy#

Every workspace, including the two under packages/, is currently marked "private": true. Nothing is published to npm during pre-alpha. Release and versioning configuration is tracked in issue #49.

The packages under packages/ still carry full publication metadata: exports, files, license, repository, engines, and "type": "module". This keeps the public surface explicit and testable before the first release rather than assembled at release time.

The internal/ naming convention is doubly encoded: the directory and the @tsumugu/internal- package-name prefix. Both are asserted by the workspace test, so an internal workspace cannot quietly become publishable.

Toolchain#

| Tool | Version | Note | | ---------- | ---------- | ------------------------------------------------- | | Node.js | >=24.0.0 | Node.js 24 "Krypton" is the Active LTS line | | pnpm | 11.10.0 | pinned through packageManager | | TypeScript | ^6.0.3 | ESM-only, strict, project references | | Vitest | ^4.1.9 | vitest.config.ts; see docs/designs/testing.md |

These are the versions the repository requires today. The supported operating systems, module format, upgrade policy, and position on Bun and Deno are in docs/designs/compatibility.md, with the reasoning recorded in ADR 0001.

TypeScript 7, the native compiler, was evaluated and not adopted for this foundation: at the time of writing its first stable release was eighteen days old. A compiler is the single highest-blast-radius dependency in a TypeScript monorepo, so the mature 6.x line was chosen instead. Adopting TypeScript 7 is a decision worth its own evaluation once it has soaked.

Supply-chain settings#

pnpm-workspace.yaml sets two deliberate constraints:

  • minimumReleaseAge: 30240 (21 days) delays adoption of freshly published versions, so a compromised release has a window to be detected before it can enter the lockfile.

  • onlyBuiltDependencies: [] states explicitly that no dependency may run install scripts. pnpm blocks them by default; declaring the empty allowlist makes any future exception appear in a diff.

Build model#

TypeScript project references drive the build. The root tsconfig.json is a solution file containing no sources of its own; it only orders the referenced projects. tsc --build therefore resolves cross-package types and build order without a task orchestrator.

A task orchestrator such as Turborepo is not used. With three workspaces and one dependency edge it would add configuration and a dependency without improving build correctness or speed. This should be revisited if the graph grows enough that build ordering or caching becomes a real cost.

Test files are excluded from the package build projects so they cannot reach dist/. They are type-checked instead by tsconfig.test.json, a single non-composite project covering tests/, the colocated tests under packages/*/src, and vitest.config.ts. One project for all tests is simpler than a parallel test project per package, and it cannot drift out of step with the build projects.

That layout is also why eslint.config.js lists its TypeScript projects explicitly instead of using typescript-eslint's project service: the service resolves each file through the nearest tsconfig.json, which for a colocated test is the package build project that deliberately excludes it.

Commands#

All commands run from the repository root.

| Command | Behaviour | | ----------------------- | ---------------------------------------------------- | | pnpm install | installs the workspace, using the committed lockfile | | pnpm format | formats every supported file with Prettier | | pnpm format:check | reports unformatted files without changing them | | pnpm lint | runs type-aware ESLint over the workspace | | pnpm lint:fix | applies the fixes ESLint can make safely | | pnpm build | tsc --build, emits dist/ for each package | | pnpm typecheck | builds the packages, then type-checks tests/ | | pnpm test | builds, then runs Vitest | | pnpm test:watch | re-runs affected tests as files change | | pnpm test:coverage | builds, then runs the suite with coverage | | pnpm check:boundaries | builds, then checks the dependency and export rules | | pnpm check | formatting, linting, types and tests; the local gate | | pnpm clean | removes build output and TypeScript build info |

pnpm test builds first on purpose. tests/cli.test.ts executes the emitted packages/cli/dist/bin.js in a child process, so it can only pass against real build output. tsc --build is incremental, so repeat runs are cheap.

pnpm check composes only non-mutating steps, so it is safe to run before committing and is the command CI should use.

Each package also exposes build, typecheck and lint. At package level typecheck runs tsc --build, which is the same command as build: with project references, type checking and emit are a single pass, and tsc --build --noEmit is rejected outright because a referenced project may not disable emit.

Code quality tooling#

Prettier formats. ESLint checks correctness. The split is strict, and it is what keeps the two tools from fighting.

| Tool | Owns | Configuration | | ------------ | ------------------------------------------------------------ | --------------------------------------- | | Prettier | all formatting: TypeScript, JavaScript, JSON, Markdown, YAML | prettier.config.js, .prettierignore | | ESLint | correctness only, with type information | eslint.config.js | | EditorConfig | editor defaults before any tool runs | .editorconfig |

eslint-config-prettier is deliberately not a dependency. ESLint's formatting rules are no longer part of the recommended sets, and no stylistic rule is enabled here, so there is no overlap to disable.

ESLint uses recommendedTypeChecked from typescript-eslint. Floating promises, misused promises, and unsafe any flow are invisible without type information, so the non-type-checked preset would not be enough. Import ordering is not enforced: it is a stylistic concern that Prettier does not touch, and adding a plugin for it would spend a dependency on diff aesthetics rather than correctness. Unused code is caught twice over, by noUnusedLocals/noUnusedParameters in TypeScript and by @typescript-eslint/no-unused-vars.

Biome was evaluated as a single-tool replacement for both. It was rejected on a measured fact rather than a preference: version 2.5.2 does not format Markdown or YAML, reporting "No files were processed" for both. Tsumugu is a documentation project whose repository is mostly Markdown, so a formatter that cannot format Markdown does not cover the repository. Its noFloatingPromises rule is also still in the nursery group. Pairing Biome with Prettier would mean running two formatting systems, which is worse than running one.