Skip to content
5 changes: 5 additions & 0 deletions .changeset/humble-gifts-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stack-effect": patch
---

add ModuleChild schema for nested module selection
5 changes: 5 additions & 0 deletions .changeset/tough-sloths-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stack-effect": patch
---

add Think toolkit as default toolkit for chat service
18 changes: 17 additions & 1 deletion .docs/architecture/catalog-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ Defines modules organized by category:
- **Infrastructure modules**: ai, ai-sample-toolkit, ai-chat-service, presence
(supported on specific package identities)

Modules may declare `children` for same-target parent-child relationships used
in nested selection UI. Children are either `required` (auto-selected with
parent) or `optional` (user-toggleable). Modules listed as children are excluded
from top-level selection lists.

### Content Templates

Template files in `content/` use a token substitution system:
Expand Down Expand Up @@ -110,6 +115,11 @@ erDiagram
string description
}

ModuleChild {
ModuleId moduleId
string requirement "required | optional"
}

SupportedOn {
string _tag "kind | identity"
TargetKind kind "optional"
Expand All @@ -135,6 +145,7 @@ erDiagram
ModuleDefinition ||--o{ SupportedOn : "supportedOn"
ModuleDefinition ||--o{ ModuleDefinition : "dependencies"
ModuleDefinition ||--o{ ModuleImplication : "implies"
ModuleDefinition ||--o{ ModuleChild : "children"
```

## Catalog Graph Structure
Expand Down Expand Up @@ -175,6 +186,7 @@ graph TB
- `supportedOn`: Module can attach to target
- `requiredModule`: Module depends on another module
- `implies`: Module implies another module on a different target
- `childOf`: Module is a child of another module (same-target parent-child)

## Dependency Chains

Expand Down Expand Up @@ -205,9 +217,13 @@ flowchart LR
E -->|requires| B
E -->|requires| C
C -->|requires| B
C -->|requires| D
C -.->|optional child| D
```

Note: `ai-sample-toolkit` is an **optional child** of `ai-chat-service`, shown
in nested selection UI when the parent is selected. This differs from
dependencies which are always resolved by BlueprintService.

## Integration Points

The catalog is consumed by:
Expand Down
23 changes: 22 additions & 1 deletion .docs/domain-lexicon.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,15 +184,36 @@ Invariants:
- Identity is `id: ModuleId`.
- Compatibility is declared via `SupportedOn` rules.
- Dependencies can require a target, a module attachment, or both.
- Children declare same-target parent-child relationships for nested selection UI.

Connected terms:

- `ModuleId`, `SupportedOn`, `DesiredContributions`, `ModuleImplication`
- `ModuleId`, `SupportedOn`, `DesiredContributions`, `ModuleImplication`, `ModuleChild`

In code:

- `ModuleDefinition`

### ModuleChild

Definition:
A parent-child relationship between modules on the same target, used to organize nested selection in interactive CLI flows.

Invariants:

- Children must share at least one `SupportedOn` rule with their parent (same-target constraint).
- Requirement is either `"required"` (auto-selected when parent selected, not user-toggleable) or `"optional"` (user can toggle).
- A module listed as a child is excluded from top-level selection lists (inferred from parent relationship).
- Children are a UI/selection concept only; they do not affect Blueprint dependency resolution.

Connected terms:

- `ModuleDefinition`, `ModuleId`, `Visibility`

In code:

- `ModuleChild`

## Resolution-Space Terms

### BlueprintTargetNode
Expand Down
32 changes: 31 additions & 1 deletion .docs/how-to/add-new-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,37 @@ different target kind, add the `implies` field:
This means: when `http-api-client` is selected on a client target, the
blueprint will also include `http-api-server` on any server target.

## Step 7: Verify
## Step 7: Add Children (Optional)

If this module should have sub-modules that appear nested in the selection UI,
add the `children` field. Children must be on the **same target** as the parent.

```typescript
{
id: ModuleId.make("ai-chat-service"),
// ... other fields
children: [
{ moduleId: ModuleId.make("ai-sample-toolkit"), requirement: "optional" },
{ moduleId: ModuleId.make("ai-weather-toolkit"), requirement: "optional" },
],
}
```

**Requirement types:**

| Value | Behavior |
| ---------- | ----------------------------------------------------- |
| `required` | Auto-selected when parent selected, not user-toggleable |
| `optional` | User can toggle on/off when parent is selected |

**Key points:**

- Children are a **UI concept only** - they don't affect Blueprint resolution
- Modules listed as children are **excluded from top-level** selection lists
- Children must share at least one `supportedOn` rule with their parent
- For cross-target relationships, use `dependencies` or `implies` instead

## Step 8: Verify

Run the validation suite:

Expand Down
17 changes: 17 additions & 0 deletions .docs/ubiquitous-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ See also:

- `TargetDefinition`, `ModuleDefinition`

## ModuleChild

Say:

- "ModuleChild declares a parent-child relationship between modules on the same target for nested selection."
- "Required children are auto-selected when the parent is selected; optional children are user-toggleable."
- "Children are inferred from parent relationships and excluded from top-level selection."

Avoid:

- Confusing children with dependencies (children are UI-only, dependencies affect Blueprint resolution)
- Using children for cross-target relationships (use dependencies or implications instead)

See also:

- `ModuleDefinition`, `Visibility`

## Ambiguities We Explicitly Resolve

- `Selection` means user intent; `Blueprint` means resolved implication.
Expand Down
144 changes: 142 additions & 2 deletions apps/cli/e2e/matrix.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, layer } from "@effect/vitest";
import { CatalogService } from "@repo/catalog";
import { TargetKind } from "@repo/domain/Catalog";
import { ModuleId, TargetKind } from "@repo/domain/Catalog";
import { Effect } from "effect";
import { CLI } from "./harness";

Expand All @@ -19,6 +19,20 @@ interface MatrixEntry {
readonly label: string; // human-readable test name
}

/**
* Entry for testing modules with their optional children.
* Tracks parent module and all optional children to add separately.
*/
interface ChildrenMatrixEntry {
readonly target: string; // "kind/name" for --target
readonly parentModule: string; // parent module ID
readonly optionalChildren: ReadonlyArray<{
readonly moduleId: string;
readonly childTarget: string; // target for the child module
}>;
readonly label: string; // human-readable test name
}

/**
* Canonical target names per kind. Identity-based modules override this
* with their specific name (e.g., "package/domain", "package/ai").
Expand Down Expand Up @@ -74,7 +88,7 @@ const buildMatrix = Effect.gen(function* () {
// For each non-init target kind, get supported modules and group by identity
for (const kind of catalog.getTargetKinds({ visibility: "public" })) {
const modules = yield* catalog.getSupportedModules(kind);
const grouped = groupModulesByTarget(modules as any);
const grouped = groupModulesByTarget(modules);

for (const [target, moduleIds] of grouped) {
// Individual module tests
Expand Down Expand Up @@ -111,6 +125,76 @@ const singleTargetEntries = matrix.filter(
!e.target.startsWith("client-foldkit"),
);

// ---------------------------------------------------------------------------
// Build the optional children matrix - tests modules with all optional children
// ---------------------------------------------------------------------------

/**
* Get the target string for a module based on its supportedOn configuration.
*/
function getModuleTarget(mod: {
supportedOn: ReadonlyArray<
| { _tag: "kind"; kind: string }
| { _tag: "identity"; identity: { kind: string; name: string } }
>;
}): string {
const s = mod.supportedOn[0];
if (!s) return "unknown/unknown";
return s._tag === "identity"
? `${s.identity.kind}/${s.identity.name}`
: `${s.kind}/${defaultTargetNames.get(s.kind) ?? s.kind}`;
}

const buildChildrenMatrix = Effect.gen(function* () {
const catalog = yield* CatalogService;
const entries: Array<ChildrenMatrixEntry> = [];

// Get all modules and find those with optional children
const allModules = catalog.getModules();

// Build a lookup map for quick module access
const moduleMap = new Map(allModules.map((m) => [m.id, m]));

for (const mod of allModules) {
const children = mod.children as
| Array<{ moduleId: string; requirement: "required" | "optional" }>
| undefined;

if (!children || children.length === 0) continue;

// Filter to only optional children
const optionalChildren = children.filter(
(c) => c.requirement === "optional",
);
if (optionalChildren.length === 0) continue;

// Get target for parent module
const parentTarget = getModuleTarget(mod);

// Get targets for each optional child
const childrenWithTargets = optionalChildren.map((child) => {
const childMod = moduleMap.get(ModuleId.make(child.moduleId));
return {
moduleId: child.moduleId,
childTarget: childMod ? getModuleTarget(childMod) : parentTarget,
};
});

entries.push({
target: parentTarget,
parentModule: mod.id,
optionalChildren: childrenWithTargets,
label: `${parentTarget} + ${mod.id} (with optional: ${optionalChildren.map((c) => c.moduleId).join(", ")})`,
});
}

return entries;
});

const childrenMatrix = Effect.runSync(
buildChildrenMatrix.pipe(Effect.provide(CatalogService.layer)),
);

// ---------------------------------------------------------------------------
// Full-stack combos: pair each client combo with its required server modules
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -325,4 +409,60 @@ describe("matrix", () => {
);
}
});

layer(CLI.layer)("modules with optional children (maximal)", (it) => {
for (const entry of childrenMatrix) {
it.effect(
entry.label,
() =>
Effect.gen(function* () {
const cli = yield* CLI;
const sluggedModules = [
entry.parentModule,
...entry.optionalChildren.map((c) => c.moduleId),
].join("-");
const name = `matrix-children-${entry.target.replace("/", "-")}-${sluggedModules}`;
const root = `${cli.workdir}/${name}`;

// Init project
yield* cli.run("init", name, "--yes", "--root", cli.workdir);
yield* cli.expectExitCode(0);

// Add parent module to its target
yield* cli.run(
"add",
"--yes",
"--root",
root,
"--target",
entry.target,
"--modules",
entry.parentModule,
);
yield* cli.expectExitCode(0);

// Add each optional child to its respective target
for (const child of entry.optionalChildren) {
yield* cli.run(
"add",
"--yes",
"--root",
root,
"--target",
child.childTarget,
"--modules",
child.moduleId,
);
yield* cli.expectExitCode(0);
}

// Validate
yield* cli.withinProject(name, function* (project) {
yield* project.expectTypeCheckPasses();
});
}).pipe(Effect.provide(CLI.layer)),
{ timeout: 180_000 },
);
}
});
});
Loading
Loading