Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

If We Ship This, What Does the Tree Look Like?

The previous eight articles have argued that the intention/extension framing is, conditionally, worth taking seriously. This article asks: if the conditional is met (and Part 10 is the test of whether it is), what would the resulting package tree actually look like?

The tree below is tentative. It is not a roadmap commitment. It is a working sketch that exists to be argued with, especially by me when I run the diagnostic in the next article. If the diagnostic kills the hypothesis, none of these packages should exist. If the diagnostic survives, the tree is a first proposal that will need revision once the first real package boundary gets drawn.

The proposed tree

packages/
  intentions/                       # kernel: Intention<T>, registry, lifecycle
    src/
      intention.ts                  # the Intention<T> brand + registry
      refinement.ts                 # @Refines, morphisms in I
      style.ts                      # IntentionStyle (lifecycle workflow)
      scope.ts                      # Scope.Module | Scope.Application
      ports.ts                      # IntentionRegistry, IntentionRepository

  extensions/                       # umbrella + shared kernel
    extensions-shared-kernel/       # tier-0: the projection contract
      src/
        extension.ts                # Extension<I, Kind> base
        projection.ts               # the projects-from(Intention) pointer
        manifest.ts                 # cross-kind manifest schema
        ports.ts                    # ExtensionScanner, ExtensionManifest

    extensions-type/                # Part 5
    extensions-class/               # Part 6
    extensions-method/              # Part 6
    extensions-function/            # Part 6
    extensions-module/              # Part 7
    extensions-application/         # Part 7
    extensions-type-system/         # Part 8

    extensions-requirement/         # the existing requirements-* kernel,
                                    # repositioned as one extension kind

  requirements/                     # CLI binary — unchanged consumer surface
  requirements-*/                   # legacy packages — adapter or rename

The tree has three concentric layers:

  1. intentions/ — the kernel. One package. Defines Intention<T>, the registry, the refinement morphisms, the pluggable lifecycle style. No projection logic lives here.
  2. extensions/extensions-shared-kernel/ — the projection contract. One package. Defines Extension<I, Kind>, the projects-from pointer, the manifest schema, the scanner port. No kind-specific logic lives here.
  3. extensions/extensions-<kind>/ — one package per extension kind, including the repositioned extensions-requirement/. Each implements the kind-specific decorators, analyzer rules, and (where needed, per Part 8) codegen step.

The legacy requirements-* family is the migration question, which gets its own section below.

What the kernel looks like in code

A sketch (type-check-only, illustrative):

// packages/intentions/src/intention.ts
export const IntentionBrand: unique symbol = Symbol.for('intention');
export type IntentionId = string & { readonly [IntentionBrand]: 'IntentionId' };

export interface Intention<TName extends string = string> {
  readonly id: IntentionId;
  readonly name: TName;
  readonly purpose: string;
  readonly scope: Scope;
  readonly refines?: ReadonlyArray<Intention>;
  readonly style?: IntentionStyle;
}

export function defineIntention<TName extends string>(
  spec: Omit<Intention<TName>, 'id'>,
): Intention<TName> { /* ... */ }
// packages/extensions/extensions-shared-kernel/src/extension.ts
export interface Extension<I extends Intention, K extends ExtensionKind> {
  readonly intention: I;
  readonly kind: K;
  readonly site: ExtensionSite;
}

export type ExtensionKind =
  | 'type' | 'class' | 'method' | 'function'
  | 'module' | 'application' | 'type-system' | 'requirement';
// packages/extensions/extensions-method/src/decorator.ts
export function MethodExtension<I extends Intention>(intention: I) {
  return function <T>(
    target: object,
    propertyKey: string | symbol,
    descriptor: TypedPropertyDescriptor<T>,
  ) {
    /* register {intention, kind: 'method', site: {target, propertyKey}} */
  };
}

The kernel surface is small. Each kind-specific package adds its own decorator (or generator, in the type-system case) and consumes the shared kernel's registry. The unification is concretely visible in code: every extension shares the same Extension<I, K> shape, varying only in the kind tag and the site shape.

The migration question

The existing @frenchexdev/requirements-* family is seventeen packages, dog-fooded by this site, with a published CLI binary (npx requirements). Migrating it under the framing has two viable shapes.

Shape A: rename. The entire requirements-* family becomes extensions-requirement-*. The CLI binary becomes npx extensions-requirement or stays npx requirements as an alias. Every import path in every consumer changes. The existing API surface is preserved but lives at a new path.

This is the architecturally clean option. It signals the repositioning clearly. It places the requirements kernel in its new conceptual home. The cost is the migration friction across every consumer of the package family — most importantly, the CV site itself, which has dozens of decorations across its own codebase. A name change is not a refactor; it is a search-and-replace across hundreds of files plus a major version bump plus a migration guide.

Shape B: thin adapter. The existing requirements-* family stays at its current names. A new extensions-requirement package is published as a thin adapter that re-exports the existing decorators under the new shape. Consumers that want the new framing can import from extensions-requirement; consumers that already use requirements-* are unchanged. The two surfaces coexist; eventually, optionally, the old surface deprecates.

This is the migration-friendly option. It minimises consumer disruption. It places the framing on top of existing packages rather than asking them to move. The cost is two surfaces for one thing — for the lifetime of the deprecation window, both @frenchexdev/requirements and @frenchexdev/extensions-requirement exist, and consumers have to know which to use. The risk is the lifetime never ends and the duplication becomes permanent.

I genuinely do not know which shape is correct. The clean option has higher up-front cost; the friendly option has higher ongoing cost. Both are coherent. The decision is partly political — how much disruption is the existing consumer base willing to absorb — and that question cannot be answered before there is more than one consumer. Today the CV site is essentially the only consumer; the political weight is mine.

If the diagnostic in Part 10 survives and the hypothesis is alive, I would probably start with Shape B (thin adapter) and move toward Shape A over a long deprecation window. The two-surface period would be the time to validate that the framing actually pays off, before paying the rename cost.

The objection: package proliferation for its own sake

This is the strongest objection in the series and I am going to give it the most honest hearing of any objection so far.

The objection runs: the proposed tree adds at least ten new packages — intentions/, extensions-shared-kernel/, and seven kind-specific extension packages, plus the repositioned extensions-requirement/. The existing monorepo already has seventeen requirements packages. The total reaches at least twenty-seven packages, before any of them have a single concrete consumer outside the CV site. This is package proliferation for its own sake. Every additional package adds a maintenance burden, a versioning burden, a documentation burden, a release-cycle burden. The framing might be conceptually clean, but the operational cost of representing it as a tree of packages is enormous, and the operational cost falls on a one-person maintainer.

I want to engage this in five steps because the objection has multiple components.

Step one: concede the maintenance arithmetic. The arithmetic is correct. Ten new packages plus seventeen legacy packages plus all the cross-package dependency graphs is a lot of operational surface for one person to maintain. Every release is a coordinated multi-package release. Every refactor crosses package boundaries. Every bug report has to be triaged across the tree. The existing seventeen-package family is already at the edge of what one person can maintain comfortably; adding ten more pushes past that edge.

Step two: question the necessity of the per-kind packaging. Not every conceptual distinction needs to be a package. The eight extension kinds could conceivably all live in a single @frenchexdev/extensions package, with each kind exported from its own sub-path (@frenchexdev/extensions/method, @frenchexdev/extensions/type, etc.). This collapses the operational surface from ten new packages to two new packages (intentions/ and extensions/), at the cost of less granular dependency management — consumers who only want method extensions still install the whole extensions package.

This is a real trade-off and I lean toward the collapsed version. The granular packaging in @frenchexdev/requirements-* was justified by the hexagonal-architecture concerns of the requirements family (kernel / analyzers / scaffolders / lib / CLI as distinct tiers, with each tier having its own dependency rules). The extension kinds do not have that hexagonal stratification — they are all decorators-plus-analyzers — so the granular packaging is not architecturally necessary. The collapse is a reasonable response to the proliferation objection.

Step three: question the necessity of distinguishing extensions-shared-kernel from intentions. Are these really two packages or one? The kernel of intentions and the kernel of extensions share most of their dependency surface (both depend on the registry, both depend on the lifecycle style, both depend on nothing else). They could be a single @frenchexdev/intentions package with the extensions kernel as a sub-module. This collapses further: one new package total, plus the kind-specific implementations as sub-paths.

I think this is too aggressive. The intention/extension distinction is the load-bearing distinction of the whole framing. Collapsing them into one package risks obscuring the distinction. But it is a live option and I want to mark it explicitly: the operational case for "one package, not two" is stronger than the architectural case for "two packages." Architecture might lose this trade-off.

Step four: appeal to the type-system extension case as a justification for codegen-driven packaging. The strongest case for the framing is the type-system extension case (Part 8), which structurally requires a codegen step. A codegen-driven package family has different operational concerns than a pure-decorator family — generators have their own versioning, their own emission contracts, their own template management. If the type-system extension case is the case the framing exists for, the codegen infrastructure is part of the deal and the operational cost is the price of the capability. This makes the proliferation argument less decisive for that specific case. It does not make it less decisive for the other cases.

Step five: accept that the objection wins, partially. The honest conclusion is that the proposed tree is too large for one maintainer and should be collapsed. The collapsed version is one or two new packages, not ten. The kind-specific implementations live as sub-paths within the umbrella package. The legacy requirements-* family stays at its current packaging until and unless a real second consumer emerges and forces a re-evaluation.

The architectural sketch in this article was the expanded version. The operational version is much smaller. I want to be clear about which version is being proposed. The expanded version exists to show the conceptual structure; the operational version is what would actually ship.

A revised, operational tree

packages/
  intentions/                       # kernel + extensions shared kernel
    src/
      intention.ts                  # Intention<T>, registry
      extension.ts                  # Extension<I, K>, manifest
      refinement.ts                 # @Refines morphisms
      style.ts                      # IntentionStyle, ExtensionStyle
      scope.ts                      # Scope.Module | Scope.Application

  extensions/                       # umbrella for all kinds
    src/
      type.ts                       # @TypeExtension
      class.ts                      # @ClassExtension
      method.ts                     # @MethodExtension
      function.ts                   # @FunctionExtension
      module.ts                     # @ModuleExtension
      application.ts                # @ApplicationExtension
      type-system.ts                # @TypeSystemExtension + codegen

  requirements-*/                   # legacy — thin adapter to extensions
                                    # later: deprecate then remove

This is two new packages instead of ten. The CLI binary stays at npx requirements. The existing requirements-* family gradually re-exports through the new umbrella. The operational cost is real but bounded.

What stays open

I do not know whether even this collapsed tree is too large. I do not know whether the intentions and extensions packages should be one package instead of two. I do not know whether the legacy requirements-* family should be deprecated or kept as a peer indefinitely. I do not know whether the codegen requirement for type-system extensions justifies pulling ts-codegen-pipeline into the dependency graph of the intentions kernel, or whether the codegen should live in a separate optional package.

The biggest question, which Part 10 is designed to answer, is whether any of these packages should exist. The diagnostic is binary: if it surfaces value, the smallest viable subset of the operational tree gets built. If it does not, none of them do.

⬇ Download