From 21725fb304366d6c3dbed5313d2f811b6a580568 Mon Sep 17 00:00:00 2001 From: = <=> Date: Sat, 28 Mar 2026 17:09:50 +0000 Subject: [PATCH 1/6] feat(role-enforcement): add roleDefinitionsDir, role definitions, and skill prompt Add roleDefinitionsDir as a required config field with filesystem validation (directory exists, index.md exists, one .md per role). Create 13 role definition files for the riviere-cli pilot plus the role-enforcement skill prompt that agents read to classify and annotate code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/role-enforcement-skill-bootstrap.md | 137 +++++++ .../role-definitions/aggregate-repository.md | 47 +++ .../riviere-cli/role-definitions/aggregate.md | 56 +++ .../role-definitions/cli-entrypoint.md | 44 ++ .../role-definitions/cli-output-formatter.md | 34 ++ .../role-definitions/command-input-factory.md | 41 ++ .../command-use-case-input.md | 44 ++ .../command-use-case-result.md | 38 ++ .../role-definitions/command-use-case.md | 56 +++ .../role-definitions/domain-service.md | 46 +++ .../role-definitions/external-client-error.md | 39 ++ .../role-definitions/external-client-model.md | 34 ++ .../external-client-service.md | 46 +++ .../riviere-cli/role-definitions/index.md | 28 ++ .../role-definitions/value-object.md | 43 ++ .../riviere-cli/role-enforcement.config.json | 1 + .../role-enforcement.schema.json | 6 +- .../skills/BATTLE-TEST-LOG.md | 5 + .../skills/role-enforcement.md | 202 ++++++++++ .../src/cli/create-oxlint-config.spec.ts | 2 + .../cli/run-role-enforcement.error.spec.ts | 7 + .../src/cli/run-role-enforcement.spec.ts | 58 +-- .../load-role-enforcement-config.spec.ts | 378 +++++++++--------- .../config/load-role-enforcement-config.ts | 43 +- .../src/config/role-enforcement-config.ts | 1 + 25 files changed, 1227 insertions(+), 209 deletions(-) create mode 100644 docs/project/specs/role-enforcement-skill-bootstrap.md create mode 100644 packages/riviere-cli/role-definitions/aggregate-repository.md create mode 100644 packages/riviere-cli/role-definitions/aggregate.md create mode 100644 packages/riviere-cli/role-definitions/cli-entrypoint.md create mode 100644 packages/riviere-cli/role-definitions/cli-output-formatter.md create mode 100644 packages/riviere-cli/role-definitions/command-input-factory.md create mode 100644 packages/riviere-cli/role-definitions/command-use-case-input.md create mode 100644 packages/riviere-cli/role-definitions/command-use-case-result.md create mode 100644 packages/riviere-cli/role-definitions/command-use-case.md create mode 100644 packages/riviere-cli/role-definitions/domain-service.md create mode 100644 packages/riviere-cli/role-definitions/external-client-error.md create mode 100644 packages/riviere-cli/role-definitions/external-client-model.md create mode 100644 packages/riviere-cli/role-definitions/external-client-service.md create mode 100644 packages/riviere-cli/role-definitions/index.md create mode 100644 packages/riviere-cli/role-definitions/value-object.md create mode 100644 packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md create mode 100644 packages/riviere-role-enforcement/skills/role-enforcement.md diff --git a/docs/project/specs/role-enforcement-skill-bootstrap.md b/docs/project/specs/role-enforcement-skill-bootstrap.md new file mode 100644 index 00000000..4382b1d9 --- /dev/null +++ b/docs/project/specs/role-enforcement-skill-bootstrap.md @@ -0,0 +1,137 @@ +# Spec: Role Enforcement Skill Bootstrap + +## Context + +PR #277 introduced role enforcement for the `extract` feature in `riviere-cli` — 18 files annotated with `@riviere-role` comments, validated by an Oxlint-based tool in `packages/riviere-role-enforcement`. + +Now we need to roll this out across the entire codebase. But "just annotate everything" doesn't work — applying roles often requires refactoring code that mixes responsibilities. To make this scalable, we're building a skill prompt (`packages/riviere-role-enforcement/skills/role-enforcement.md`) that agents read to apply role enforcement. + +The key insight: role definition files (one per role) contain the behavioral contracts, patterns, and anti-patterns that agents need to classify code correctly. The config owns structural constraints (targets, layers, paths); the definitions own semantic knowledge (what the role *means*). + +## Progress + +### Phase 1: Foundation + +- [ ] 1A. Add `roleDefinitionsDir` to schema, types, config loader, tests +- [ ] 1B. Create role definition files (13 roles + index.md) +- [ ] 1C. Create skill prompt file +- [ ] 1D. Commit and push foundation + +### Phase 2: Rollout (agents use the skill) + +- [ ] 2A. Apply to features/builder/ +- [ ] 2B. Apply to features/query/ +- [ ] 2C. Apply to platform/infra/cli-presentation/ +- [ ] 2D. Apply to remaining platform/ areas +- [ ] 2E. Apply to shell/ +- [ ] 2F. Expand include to src/**/*.ts, verify 100% coverage + +## Deliverables + +### 1. Role Definition File System + +#### 1A. Schema Changes + +**File**: `packages/riviere-role-enforcement/role-enforcement.schema.json` + +Add `roleDefinitionsDir` as a required string property. Add to root `required` array. + +#### 1B. Type Changes + +**File**: `packages/riviere-role-enforcement/src/config/role-enforcement-config.ts` + +Add `roleDefinitionsDir: string` to `RoleEnforcementConfig`. + +#### 1C. Config Loader Validation + +**File**: `packages/riviere-role-enforcement/src/config/load-role-enforcement-config.ts` + +After existing schema + semantic validation, add filesystem validation: +1. Resolve `roleDefinitionsDir` relative to `configDir` +2. Verify the directory exists +3. Verify `index.md` exists in the directory +4. For each role in `config.roles`, verify `{role-name}.md` exists +5. Collect all missing files into a single error message + +Add `roleDefinitionsDir: string` (absolute resolved path) to `LoadedRoleEnforcementConfig`. + +#### 1D. Config Update + +**File**: `packages/riviere-cli/role-enforcement.config.json` + +Add: `"roleDefinitionsDir": "role-definitions"` + +### 2. Role Definition Files + +**Location**: `packages/riviere-cli/role-definitions/` + +Template structure (must NOT duplicate what config already expresses): + +```markdown +# {Role Name} + +## Purpose +One sentence: what this role represents and why it exists. + +## Behavioral Contract +What code with this role DOES at runtime. + +## Examples +### Canonical Example +### Edge Cases + +## Anti-Patterns +### Common Misclassifications +### Mixed Responsibility Signals + +## Decision Guidance +Criteria for choosing between this role and similar roles. + +## References +``` + +Files to create: +- `index.md` (project context, links to architecture resources) +- `cli-entrypoint.md`, `command-use-case.md`, `command-use-case-input.md`, `command-use-case-result.md`, `command-input-factory.md`, `cli-output-formatter.md`, `external-client-service.md`, `external-client-model.md`, `external-client-error.md`, `aggregate.md`, `aggregate-repository.md`, `value-object.md`, `domain-service.md` + +### 3. Skill Prompt + +**File**: `packages/riviere-role-enforcement/skills/role-enforcement.md` + +Three workflows: +- **analyze** — Read-only classification report +- **add** — Analyze → plan → highlight decisions → execute + refactor +- **configure** — Setup for new packages (deferred instructions) + +Key principles: +- Generic roles over specific +- Fewer roles = more consistency +- Split over force-fit +- Config owns structure, definitions own semantics +- Never silently introduce new roles +- Document all decisions in battle test log + +## Battle Test Log + +**File**: `packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md` + +Each agent documents: +- Area analyzed +- Classifications made (with confidence levels) +- Decisions that were non-obvious +- Where the skill was helpful vs. confusing +- Missing role definitions or unclear guidance +- New roles proposed +- Refactoring performed +- What should be improved in the skill + +## End State + +- `role-enforcement.config.json` includes `src/**/*.ts` +- Every exported class, function, interface, and type-alias in riviere-cli has a `@riviere-role` annotation +- All enforcement checks pass +- Battle test log captures full process for skill improvement + +## Assumptions & Questions (to review with user at the end) + +*(Populated during execution)* diff --git a/packages/riviere-cli/role-definitions/aggregate-repository.md b/packages/riviere-cli/role-definitions/aggregate-repository.md new file mode 100644 index 00000000..b7cb632c --- /dev/null +++ b/packages/riviere-cli/role-definitions/aggregate-repository.md @@ -0,0 +1,47 @@ +# aggregate-repository + +## Purpose +A class that handles loading and saving an aggregate — the boundary between domain and persistence infrastructure. + +## Behavioral Contract +1. **Load** — assemble the aggregate from persisted state (files, database, APIs) and return it +2. **Save** (optional) — persist the aggregate's current state +3. The repository MUST return an aggregate, not raw data or partial state +4. May use external-client-services internally to access storage, parsers, or other tools + +## Examples + +### Canonical Example +```typescript +/** @riviere-role aggregate-repository */ +export class ExtractionProjectRepository { + load(input: LoadInput): ExtractionProject { + const project = createConfiguredProject(input.configPath) + const contexts = this.buildModuleContexts(project) + return new ExtractionProject(project, contexts) + } + + private buildModuleContexts(project: Project): ModuleContext[] { + // internal assembly logic + } +} +``` + +### Edge Cases +- A repository may have multiple load methods for different access patterns (e.g., loadFromProject vs loadFromPersistedState) +- Private helper methods inside the repository are implementation details, not separate roles + +## Anti-Patterns + +### Common Misclassifications +- **Not an external-client-service**: repositories assemble aggregates from multiple sources. External client services provide single technical capabilities. +- **Not a command-use-case**: repositories only handle loading/saving, not orchestration of domain behavior. + +### Mixed Responsibility Signals +- If the repository contains business logic or makes domain decisions — that belongs on the aggregate or a domain-service +- If the repository returns raw data instead of an aggregate — it may be an external-client-service instead +- If the repository calls other repositories — potential aggregate boundary issue + +## Decision Guidance +- **vs external-client-service**: Does it return an aggregate? → aggregate-repository. Does it return raw data or library-specific types? → external-client-service +- **vs command-use-case**: Does it only load/save? → aggregate-repository. Does it orchestrate load→invoke→save? → command-use-case diff --git a/packages/riviere-cli/role-definitions/aggregate.md b/packages/riviere-cli/role-definitions/aggregate.md new file mode 100644 index 00000000..fff9ae8e --- /dev/null +++ b/packages/riviere-cli/role-definitions/aggregate.md @@ -0,0 +1,56 @@ +# aggregate + +## Purpose +A class or type that represents the central domain entity in a feature — it owns state and enforces business invariants. + +## Behavioral Contract +An aggregate: +1. **Owns state** — holds the data that represents the current state of a domain concept +2. **Enforces invariants** — business rules are methods on the aggregate, not external functions +3. **Exposes behavior** — public methods represent domain operations that may modify state +4. **Is loaded/saved through a repository** — never created ad-hoc in commands or services + +## Examples + +### Canonical Example +```typescript +/** @riviere-role aggregate */ +export class ExtractionProject { + constructor( + private readonly project: Project, + private readonly moduleContexts: ModuleContext[], + ) {} + + extractDraftComponents(options: ExtractionOptions): ExtractDraftComponentsResult { + // domain logic that operates on internal state + } + + enrichComponents(options: EnrichmentOptions): EnrichDraftComponentsResult { + // domain logic that operates on internal state + } +} +``` + +### Edge Cases +- An interface defining the aggregate shape is also role `aggregate` +- A type alias for the aggregate is also role `aggregate` +- An aggregate may be immutable (returning new instances from methods) + +## Anti-Patterns + +### Common Misclassifications +- **Not a value-object**: aggregates own behavior and enforce invariants. Value objects are data-only with equality semantics. +- **Not an external-client-model**: aggregates are domain concepts, not external data shapes. +- **Not a command-use-case-result**: results are output contracts, not domain entities. + +### Mixed Responsibility Signals +- If the class makes direct calls to external libraries (fs, git, HTTP) — infrastructure leaking in, extract to external-client-service +- If the class loads its own state from disk/database — repository responsibility leaking in +- If the class formats output for display — cli-output-formatter responsibility leaking in + +## Decision Guidance +- **vs value-object**: Does it enforce invariants and own behavior? → aggregate. Is it a simple data structure with no behavior? → value-object +- **vs domain-service**: Is the behavior tied to specific state? → aggregate method. Is the behavior operating on data passed in without owning it? → domain-service + +## References +- [Tactical DDD: Aggregates](https://www.domainlanguage.com/ddd/) — Aggregate design patterns diff --git a/packages/riviere-cli/role-definitions/cli-entrypoint.md b/packages/riviere-cli/role-definitions/cli-entrypoint.md new file mode 100644 index 00000000..4b8d8a25 --- /dev/null +++ b/packages/riviere-cli/role-definitions/cli-entrypoint.md @@ -0,0 +1,44 @@ +# cli-entrypoint + +## Purpose +A function that wires a CLI command: registers it with the CLI framework, translates user input, invokes a command-use-case, and formats output. + +## Behavioral Contract +1. **Register** — define the CLI command, its flags, and description using the CLI framework (e.g., Commander) +2. **Translate** — convert CLI options into a command-use-case-input (often via a command-input-factory) +3. **Invoke** — call the command-use-case with the typed input +4. **Format** — pass the result to a cli-output-formatter for display + +This is the thinnest possible layer between the external world and the domain. + +## Examples + +### Canonical Example +```typescript +/** @riviere-role cli-entrypoint */ +export function createExtractCommand(): Command { + return new Command('extract') + .description('Extract components from source code') + .option('--config ', 'Config file path') + .action(async (options) => { + const input = createExtractDraftComponentsInput(options) + const result = extractDraftComponents(input) + presentExtractionResult(result) + }) +} +``` + +## Anti-Patterns + +### Common Misclassifications +- **Not a command-use-case**: entrypoints do not load aggregates or contain domain logic +- **Not a cli-output-formatter**: entrypoints call formatters but do not contain formatting logic + +### Mixed Responsibility Signals +- If the entrypoint constructs complex objects or does validation beyond basic CLI parsing — factory or command logic leaking in +- If the entrypoint calls multiple command use cases in sequence — composition should be in a single command or separate CLI commands +- If the entrypoint directly accesses repositories or datastores — command-use-case responsibility leaking in + +## Decision Guidance +- **vs command-use-case**: Does it know about the CLI framework? → cli-entrypoint. Does it accept typed input and orchestrate domain behavior? → command-use-case +- **vs command-input-factory**: Does it register CLI commands? → cli-entrypoint. Does it only transform options into input? → command-input-factory diff --git a/packages/riviere-cli/role-definitions/cli-output-formatter.md b/packages/riviere-cli/role-definitions/cli-output-formatter.md new file mode 100644 index 00000000..d5fc4b33 --- /dev/null +++ b/packages/riviere-cli/role-definitions/cli-output-formatter.md @@ -0,0 +1,34 @@ +# cli-output-formatter + +## Purpose +A function that transforms a command-use-case-result into user-facing CLI output (tables, JSON, markdown, colored text). + +## Behavioral Contract +1. Accept a typed result (command-use-case-result or domain type) +2. Transform into a presentation format suitable for terminal output +3. Write to stdout/stderr or return formatted strings + +## Examples + +### Canonical Example +```typescript +/** @riviere-role cli-output-formatter */ +export function presentExtractionResult(result: ExtractDraftComponentsResult): void { + console.log(formatTable(result.components)) + console.log(`Extraction: ${result.extractionOutcome}`) +} +``` + +## Anti-Patterns + +### Common Misclassifications +- **Not a command-use-case**: formatters do not load state or invoke domain behavior +- **Not a domain-service**: formatters are infrastructure concerns, not domain logic + +### Mixed Responsibility Signals +- If the formatter makes decisions about WHAT to show based on business rules — domain logic leaking in +- If the formatter loads additional data to enrich the output — command or repository responsibility leaking in +- If the formatter accepts raw CLI options to decide formatting — should accept a result type instead + +## Decision Guidance +- **vs external-client-service**: Is it formatting output for the user? → cli-output-formatter. Is it wrapping an external library? → external-client-service diff --git a/packages/riviere-cli/role-definitions/command-input-factory.md b/packages/riviere-cli/role-definitions/command-input-factory.md new file mode 100644 index 00000000..9fa41431 --- /dev/null +++ b/packages/riviere-cli/role-definitions/command-input-factory.md @@ -0,0 +1,41 @@ +# command-input-factory + +## Purpose +A function that translates raw external input (CLI options, HTTP request body) into a typed command-use-case-input. + +## Behavioral Contract +1. Accept raw/untyped external data (CLI options object, parsed request body) +2. Validate, transform, and assemble into a typed command-use-case-input +3. Return the typed input — never invoke the command itself + +## Examples + +### Canonical Example +```typescript +/** @riviere-role command-input-factory */ +export function createExtractDraftComponentsInput( + options: CliExtractOptions, + resolvedConfig: ResolvedConfig, +): ExtractDraftComponentsInput { + return { + configPath: resolvedConfig.configPath, + sourceMode: options.pullRequest ? 'pull-request' : 'full-project', + allowIncomplete: options.allowIncomplete ?? false, + includeConnections: options.includeConnections ?? true, + baseBranch: options.baseBranch, + } +} +``` + +## Anti-Patterns + +### Common Misclassifications +- **Not a command-use-case**: factories do not load aggregates or invoke domain behavior +- **Not a cli-entrypoint**: entrypoints wire up the full CLI command (register with Commander, call factory, call command, format output). Factories only build the input. + +### Mixed Responsibility Signals +- If the factory also calls the command use case — that's entrypoint behavior leaking in +- If the factory does complex domain logic to build the input — some logic may belong in a domain-service + +## Decision Guidance +- **vs cli-entrypoint**: Does it register CLI commands or call the use case? → cli-entrypoint. Does it only build the input object? → command-input-factory diff --git a/packages/riviere-cli/role-definitions/command-use-case-input.md b/packages/riviere-cli/role-definitions/command-use-case-input.md new file mode 100644 index 00000000..4cc888d8 --- /dev/null +++ b/packages/riviere-cli/role-definitions/command-use-case-input.md @@ -0,0 +1,44 @@ +# command-use-case-input + +## Purpose +A type that defines the single input contract for a command-use-case function. + +## Behavioral Contract +This is a data structure, not behavior. It: +1. Defines all parameters a command-use-case needs to execute +2. Is the ONLY parameter type accepted by its corresponding command-use-case +3. Contains domain-relevant data, NOT raw external input (no CLI option objects, no HTTP request bodies) + +## Examples + +### Canonical Example +```typescript +/** @riviere-role command-use-case-input */ +export interface ExtractDraftComponentsInput { + configPath: string + sourceMode: 'full-project' | 'pull-request' + allowIncomplete: boolean + includeConnections: boolean + baseBranch?: string +} +``` + +### Edge Cases +- A type alias is valid: `export type FooInput = { ... }` +- Can contain optional fields for optional behavior variations +- Can reference value objects or other domain types as field types + +## Anti-Patterns + +### Common Misclassifications +- **Not a value-object**: value objects represent domain concepts with equality semantics. Inputs are structural contracts for a specific command. +- **Not an external-client-model**: external client models represent data shapes from third-party APIs. + +### Mixed Responsibility Signals +- If the input contains fields that only make sense for output formatting — those belong in a separate concern +- If the input directly mirrors CLI flags with their raw types — a command-input-factory should translate +- If the input contains nested objects that are themselves inputs for sub-commands — the command may need splitting + +## Decision Guidance +- **vs value-object**: Is it specifically the parameter for a command-use-case function? → command-use-case-input. Is it a reusable domain concept? → value-object +- **vs external-client-model**: Does it define what a command needs? → command-use-case-input. Does it define what an external service returns? → external-client-model diff --git a/packages/riviere-cli/role-definitions/command-use-case-result.md b/packages/riviere-cli/role-definitions/command-use-case-result.md new file mode 100644 index 00000000..f64629bb --- /dev/null +++ b/packages/riviere-cli/role-definitions/command-use-case-result.md @@ -0,0 +1,38 @@ +# command-use-case-result + +## Purpose +A type that defines the single return contract for a command-use-case function. + +## Behavioral Contract +This is a data structure. It: +1. Defines what a command-use-case returns after execution +2. Contains domain-meaningful results, not raw infrastructure output +3. Is consumed by cli-output-formatters or other downstream code + +## Examples + +### Canonical Example +```typescript +/** @riviere-role command-use-case-result */ +export interface ExtractDraftComponentsResult { + components: DraftComponent[] + extractionOutcome: ExtractionOutcome +} +``` + +### Edge Cases +- Can be a discriminated union for success/failure: `type Result = SuccessResult | FailureResult` +- Can reference domain types (aggregates, value objects) in its fields + +## Anti-Patterns + +### Common Misclassifications +- **Not an external-client-model**: client models represent external data shapes, not command outcomes +- **Not a value-object**: value objects are reusable domain concepts; results are specific to one command + +### Mixed Responsibility Signals +- If the result contains presentation-specific fields (formatted strings, colors, table layouts) — that's cli-output-formatter responsibility +- If the result directly mirrors what an external API returns — the command may not be adding domain value + +## Decision Guidance +- **vs value-object**: Is it the specific return type of a command-use-case? → command-use-case-result. Is it a reusable concept used across multiple contexts? → value-object diff --git a/packages/riviere-cli/role-definitions/command-use-case.md b/packages/riviere-cli/role-definitions/command-use-case.md new file mode 100644 index 00000000..61cc4348 --- /dev/null +++ b/packages/riviere-cli/role-definitions/command-use-case.md @@ -0,0 +1,56 @@ +# command-use-case + +## Purpose +A function that orchestrates a write-side workflow: loading state, invoking domain behavior, and returning a result. + +## Behavioral Contract +A command use case follows this sequence: +1. **Load** — instantiate repository, load the aggregate from persisted state +2. **Invoke** — call a method on the aggregate to perform domain behavior +3. **Return** — return a typed result (command-use-case-result) + +Optionally, between invoke and return: +- **Save** — persist the modified aggregate back through the repository + +The function accepts exactly one parameter typed as a `command-use-case-input`. + +## Examples + +### Canonical Example +```typescript +/** @riviere-role command-use-case */ +export function extractDraftComponents( + input: ExtractDraftComponentsInput, +): ExtractDraftComponentsResult { + const repository = new ExtractionProjectRepository() + const project = repository.load(input) + return project.extractDraftComponents(input.options) +} +``` + +### Edge Cases +- A command that loads but does not save (read-then-transform workflows) is still a command-use-case if it orchestrates domain behavior +- A command that delegates to multiple aggregate methods in sequence is valid if those methods are on the same aggregate + +## Anti-Patterns + +### Common Misclassifications +- **Not a domain-service**: domain services contain pure business logic with no loading/saving. If it instantiates a repository or loads state, it is a command-use-case. +- **Not a cli-entrypoint**: entrypoints translate external input (CLI flags) into a command-use-case-input and call the command. They do not load aggregates. +- **Not a command-input-factory**: factories construct the input object from raw external data. They do not invoke domain behavior. + +### Mixed Responsibility Signals +- If-else branches that decide WHAT operation to perform (not just how) — likely multiple command use cases merged into one +- Direct calls to external libraries (ts-morph, fs, git) instead of going through a repository — infrastructure leaking into the command +- Formatting or presenting results for output — cli-output-formatter responsibility leaking in +- Constructing the input object from CLI flags — command-input-factory responsibility leaking in +- Multiple unrelated aggregates being loaded and orchestrated — likely needs splitting into separate commands + +## Decision Guidance +- **vs domain-service**: Does it load/save through a repository? → command-use-case. Pure logic operating on passed-in data? → domain-service +- **vs cli-entrypoint**: Does it know about CLI frameworks (Commander, yargs)? → cli-entrypoint. Does it accept a typed input and return a typed result? → command-use-case +- **vs aggregate-repository**: Does it coordinate load→invoke→save? → command-use-case. Does it only handle loading/saving? → aggregate-repository + +## References +- [CQRS Pattern](https://martinfowler.com/bliki/CQRS.html) — Commands vs queries separation +- Separation of Concerns Skill Q3: "Orchestrates write operations?" → commands/ diff --git a/packages/riviere-cli/role-definitions/domain-service.md b/packages/riviere-cli/role-definitions/domain-service.md new file mode 100644 index 00000000..157a3f36 --- /dev/null +++ b/packages/riviere-cli/role-definitions/domain-service.md @@ -0,0 +1,46 @@ +# domain-service + +## Purpose +A function that contains domain business logic that doesn't naturally belong on a single aggregate. + +## Behavioral Contract +1. Accepts domain objects (aggregates, value objects) as parameters +2. Performs business logic — transformations, calculations, validations +3. Returns domain objects or primitive results +4. Has NO side effects — does not load from or save to repositories, does not call external services +5. Is pure: same inputs always produce same outputs + +## Examples + +### Canonical Example +```typescript +/** @riviere-role domain-service */ +export function detectConnections( + components: DraftComponent[], + sourceFiles: SourceFile[], +): Connection[] { + // pure domain logic analyzing components and source files +} +``` + +### Edge Cases +- Validation functions that check domain rules +- Transformation functions that map between domain types +- Calculation functions that derive new domain values + +## Anti-Patterns + +### Common Misclassifications +- **Not a command-use-case**: if it loads from a repository or saves results, it's orchestrating — that's a command +- **Not an external-client-service**: if it calls external libraries, it belongs in infrastructure +- **Not a command-input-factory**: if it transforms CLI options, it's a factory + +### Mixed Responsibility Signals +- If the function accesses the file system, database, or network — infrastructure leaking in +- If the function creates repositories or loads state — command-use-case behavior leaking in +- If the function has side effects — either move side effects out or reclassify + +## Decision Guidance +- **vs command-use-case**: Does it load/save state? → command-use-case. Is it pure logic? → domain-service +- **vs aggregate method**: Does the logic naturally belong to one aggregate's state? → aggregate method. Does it operate across multiple domain objects? → domain-service +- **vs external-client-service**: Does it call external libraries? → external-client-service. Does it only operate on domain types? → domain-service diff --git a/packages/riviere-cli/role-definitions/external-client-error.md b/packages/riviere-cli/role-definitions/external-client-error.md new file mode 100644 index 00000000..ca251bcd --- /dev/null +++ b/packages/riviere-cli/role-definitions/external-client-error.md @@ -0,0 +1,39 @@ +# external-client-error + +## Purpose +An error class that represents failures from external services, providing structured error information for infrastructure boundaries. + +## Behavioral Contract +1. Extend Error (or a base error class) +2. Capture external-service-specific failure details +3. Provide enough context for callers to handle or report the failure + +## Examples + +### Canonical Example +```typescript +/** @riviere-role external-client-error */ +export class GitOperationError extends Error { + constructor( + public readonly operation: string, + public readonly exitCode: number, + message: string, + ) { + super(`Git ${operation} failed (exit ${exitCode}): ${message}`) + this.name = 'GitOperationError' + } +} +``` + +## Anti-Patterns + +### Common Misclassifications +- **Not a value-object**: error classes represent failure modes, not domain concepts +- **Not a domain-service error**: if the error originates from an external tool or library, it belongs here + +### Mixed Responsibility Signals +- If the error class contains retry logic or recovery behavior — that belongs in the service or a higher layer + +## Decision Guidance +- Is the error caused by an external tool/library failure? → external-client-error +- Is the error caused by a domain rule violation? → should be a domain error (may need a new role) diff --git a/packages/riviere-cli/role-definitions/external-client-model.md b/packages/riviere-cli/role-definitions/external-client-model.md new file mode 100644 index 00000000..3208af44 --- /dev/null +++ b/packages/riviere-cli/role-definitions/external-client-model.md @@ -0,0 +1,34 @@ +# external-client-model + +## Purpose +A type that represents data structures from or for external services — the shapes that external-client-services accept or return. + +## Behavioral Contract +This is a data structure. It: +1. Defines the contract between the codebase and an external service +2. May mirror external API shapes or represent configuration for external tools +3. Lives in the infrastructure layer alongside external-client-services + +## Examples + +### Canonical Example +```typescript +/** @riviere-role external-client-model */ +export interface GitRepositoryInfo { + repositoryRoot: string + currentBranch: string + remoteName: string +} +``` + +## Anti-Patterns + +### Common Misclassifications +- **Not a value-object**: value objects are domain concepts. External client models are infrastructure types. +- **Not a command-use-case-input**: inputs are for commands. Models are for external service boundaries. + +### Mixed Responsibility Signals +- If the type contains both external service fields AND domain behavior fields — split into external model + domain type + +## Decision Guidance +- **vs value-object**: Does it represent an external service's data shape? → external-client-model. Does it represent a domain concept? → value-object diff --git a/packages/riviere-cli/role-definitions/external-client-service.md b/packages/riviere-cli/role-definitions/external-client-service.md new file mode 100644 index 00000000..4e1a0f2a --- /dev/null +++ b/packages/riviere-cli/role-definitions/external-client-service.md @@ -0,0 +1,46 @@ +# external-client-service + +## Purpose +A function that wraps a third-party library or external tool behind a project-controlled boundary. + +## Behavioral Contract +1. Accept domain-meaningful parameters (not raw library types) +2. Call the external library/tool +3. Return domain-meaningful results or library-specific types that are used by repositories or other infrastructure + +These functions isolate external dependencies so that: +- The rest of the codebase does not depend directly on external APIs +- External libraries can be swapped without changing domain code + +## Examples + +### Canonical Example +```typescript +/** @riviere-role external-client-service */ +export function createConfiguredProject(projectPath: string): Project { + return new Project({ + tsConfigFilePath: path.join(projectPath, 'tsconfig.json'), + skipAddingFilesFromTsConfig: false, + }) +} +``` + +### Edge Cases +- Functions wrapping git CLI operations +- Functions wrapping file system operations that use specific external libraries +- Functions that configure and return instances of external libraries + +## Anti-Patterns + +### Common Misclassifications +- **Not an aggregate-repository**: repositories orchestrate loading/saving of aggregates. External client services are lower-level technical helpers. +- **Not a domain-service**: domain services contain business logic. External client services contain integration code. + +### Mixed Responsibility Signals +- If the function does domain logic WITH external calls — split into domain-service + external-client-service +- If the function assembles an aggregate from multiple sources — that's aggregate-repository behavior +- If the function contains business rules about what to load — domain logic leaking into infrastructure + +## Decision Guidance +- **vs aggregate-repository**: Does it assemble a full aggregate? → aggregate-repository. Does it provide a single technical capability? → external-client-service +- **vs domain-service**: Does it call external libraries? → external-client-service. Does it only operate on domain types? → domain-service diff --git a/packages/riviere-cli/role-definitions/index.md b/packages/riviere-cli/role-definitions/index.md new file mode 100644 index 00000000..ecceca1f --- /dev/null +++ b/packages/riviere-cli/role-definitions/index.md @@ -0,0 +1,28 @@ +# Role Definitions + +## Architecture Resources + +These resources inform how roles are classified and where code should live: + +- [Separation of Concerns Skill](https://github.com/NTCoding/claude-skillz/blob/main/separation-of-concerns/SKILL.md) — Code placement decision tree (Q1-Q7): wiring → entrypoint → commands → queries → domain → infra +- [Tactical DDD Skill](https://github.com/NTCoding/claude-skillz/blob/main/tactical-ddd/SKILL.md) — Aggregate design, value objects, domain services, repositories +- [ADR-002: Allowed Folder Structures](../../docs/architecture/adr/ADR-002-allowed-folder-structures.md) — Canonical directory layout +- [Software Design Conventions](../../docs/conventions/software-design.md) — SD-001 through SD-023 + +## Dependency Rules + +Dependencies point inward: +- `entrypoint/` → commands, queries, own feature infra, platform infra +- `commands/` → domain, platform infra, platform domain, own feature infra +- `domain/` → platform domain ONLY (no infra) +- `infra/` → external libraries, platform infra + +## Classification Decision Tree + +When classifying a declaration: +1. What layer does the file path map to? Check allowed roles for that layer. +2. Does the declaration name match a `nameMatches` pattern? (e.g., `.*Input$` → command-use-case-input) +3. What is the declaration type (function, class, interface)? Filter to roles allowing that target. +4. Read the behavioral contract in the matching role definition file. +5. If ambiguous, check Decision Guidance sections for tie-breaking criteria. +6. If no existing role fits, flag for human review before proposing a new role. diff --git a/packages/riviere-cli/role-definitions/value-object.md b/packages/riviere-cli/role-definitions/value-object.md new file mode 100644 index 00000000..0ca46b0b --- /dev/null +++ b/packages/riviere-cli/role-definitions/value-object.md @@ -0,0 +1,43 @@ +# value-object + +## Purpose +A type or class that represents a domain concept defined by its attributes rather than identity — it carries meaning but no behavior that modifies external state. + +## Behavioral Contract +1. Defined by its values, not by an identity +2. Typically immutable +3. May have derived/computed properties but no side effects +4. Used as building blocks within aggregates, inputs, and results + +## Examples + +### Canonical Example +```typescript +/** @riviere-role value-object */ +export interface ModuleContext { + moduleName: string + sourceFiles: SourceFile[] + tsConfigPath: string +} +``` + +### Edge Cases +- Discriminated unions are value objects: `type Outcome = 'success' | 'partial' | 'failure'` +- Enum-like const objects can be value objects +- A class with only getters and no mutation methods is a value object + +## Anti-Patterns + +### Common Misclassifications +- **Not an aggregate**: if it owns behavior that enforces invariants and is loaded through a repository, it's an aggregate +- **Not a command-use-case-input**: if it's specifically the parameter type for a command, use that more specific role +- **Not an external-client-model**: if it represents an external service's data shape rather than a domain concept + +### Mixed Responsibility Signals +- If the type contains methods that call external services — likely an aggregate or misplaced infrastructure +- If the type is only used as a function parameter for one command — consider command-use-case-input instead + +## Decision Guidance +- **vs aggregate**: Does it own behavior and enforce invariants? → aggregate. Is it a data structure? → value-object +- **vs command-use-case-input**: Is it the specific input for one command? → command-use-case-input. Is it reused across multiple contexts? → value-object +- **vs external-client-model**: Does it represent a domain concept? → value-object. Does it represent an external API shape? → external-client-model diff --git a/packages/riviere-cli/role-enforcement.config.json b/packages/riviere-cli/role-enforcement.config.json index 635d81c5..0f8812cb 100644 --- a/packages/riviere-cli/role-enforcement.config.json +++ b/packages/riviere-cli/role-enforcement.config.json @@ -1,5 +1,6 @@ { "include": ["src/features/extract/**/*.ts", "src/platform/infra/git/**/*.ts"], + "roleDefinitionsDir": "role-definitions", "ignorePatterns": ["**/*.spec.ts", "**/__fixtures__/**"], "layers": { "entrypoint": { diff --git a/packages/riviere-role-enforcement/role-enforcement.schema.json b/packages/riviere-role-enforcement/role-enforcement.schema.json index 564dda09..f8070bbb 100644 --- a/packages/riviere-role-enforcement/role-enforcement.schema.json +++ b/packages/riviere-role-enforcement/role-enforcement.schema.json @@ -3,8 +3,12 @@ "$id": "https://living-architecture.dev/schemas/role-enforcement.schema.json", "type": "object", "additionalProperties": false, - "required": ["include", "ignorePatterns", "layers", "roles"], + "required": ["include", "ignorePatterns", "layers", "roles", "roleDefinitionsDir"], "properties": { + "roleDefinitionsDir": { + "type": "string", + "minLength": 1 + }, "include": { "type": "array", "minItems": 1, diff --git a/packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md b/packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md new file mode 100644 index 00000000..f9574f3f --- /dev/null +++ b/packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md @@ -0,0 +1,5 @@ +# Role Enforcement Skill — Battle Test Log + +This log captures how the role enforcement skill performed when applied by agents. It documents decisions, challenges, skill gaps, and improvement suggestions. + +Each section represents one area of the codebase that was analyzed and annotated. diff --git a/packages/riviere-role-enforcement/skills/role-enforcement.md b/packages/riviere-role-enforcement/skills/role-enforcement.md new file mode 100644 index 00000000..bd4ccd82 --- /dev/null +++ b/packages/riviere-role-enforcement/skills/role-enforcement.md @@ -0,0 +1,202 @@ +# Role Enforcement Skill + +Apply, analyze, and manage role annotations across a TypeScript codebase. + +## Usage + +```text +Read this file and follow the workflow for the requested action: +- analyze — Read-only classification report +- add — Full workflow: analyze → plan → execute +- configure — Set up role enforcement for a new package +``` + +## Core Principles + +1. **Generic roles over specific** — Always try to fit existing roles before proposing new ones. The fewer roles, the more consistent the codebase. +2. **Split over force-fit** — If code mixes responsibilities, recommend splitting rather than assigning a weak role. +3. **Human approval for new roles** — Never silently introduce a new role. Always propose and wait for approval. +4. **Config owns structure, definitions own semantics** — The config file defines targets, layers, paths. The definition files describe behavioral contracts and patterns. +5. **Document everything** — Every decision, challenge, and insight must be captured in the battle test log. + +## Step 1: Load Context + +Before classifying any code: + +1. **Read the config file**: Find `role-enforcement.config.json` in the target package. Parse it to understand: + - `include` / `ignorePatterns` — which files are in scope + - `layers` — which roles are allowed at which paths + - `roles` — the full role catalog with targets, naming patterns, and constraints + - `roleDefinitionsDir` — path to role definition files + +2. **Read index.md**: Read `{roleDefinitionsDir}/index.md` to discover project-level architecture resources. Follow the links to read referenced documents (separation of concerns, tactical DDD, ADRs, conventions). + +3. **Read role definitions**: For each role in the config, read `{roleDefinitionsDir}/{role-name}.md`. These contain: + - Behavioral contracts (what the code DOES) + - Examples (canonical + edge cases) + - Anti-patterns (misclassifications, mixed responsibility signals) + - Decision guidance (choosing between similar roles) + +## Step 2: Determine Scope + +Ask about the scope of work: +- **Full package** — all files matched by include patterns +- **One feature** — a specific feature directory (e.g., `src/features/extract/`) +- **One layer** — a specific layer across features (e.g., all `domain/` directories) +- **Specific files** — user-provided file paths + +Recommend working in small increments (one feature or layer at a time) for safety. + +## Step 3: Discover Files + +1. List all `.ts` files in scope matching the config's `include` patterns +2. Exclude files matching `ignorePatterns` (specs, fixtures, etc.) +3. Categorize each file: + - **Already annotated** — has `/** @riviere-role ... */` comments on all exports + - **Partially annotated** — some exports have annotations, some don't + - **Unannotated** — no role annotations + +## Step 4: Classify Unannotated Declarations + +For each unannotated exported declaration (function, class, interface, type-alias): + +### Classification Decision Process + +1. **Layer constraint**: What layer does the file path map to? What roles are allowed in that layer? +2. **Name matching**: Does the declaration name match any role's `nameMatches` pattern? +3. **Target matching**: What is the declaration type? Filter to roles allowing that target. +4. **Behavioral analysis**: Read the role definition files for candidate roles. Which behavioral contract best matches what this code actually does? + +### Confidence Levels + +- **HIGH** — Single clear match. Layer + target + behavior all point to one role. +- **MEDIUM** — Two candidates, one stronger. Or behavior matches but the file is in an unexpected layer. +- **LOW** — Ambiguous. Possible mixed responsibility. Multiple roles could apply. + +### Mixed Responsibility Detection + +Watch for these signals (from the role definition anti-patterns): +- A function that loads state AND contains business logic (command + domain mixed) +- A function that calls external libraries AND orchestrates domain behavior (infra + command mixed) +- A type that serves as both a command input AND a domain concept (input + value-object mixed) +- A function that formats output AND makes domain decisions (formatter + domain mixed) + +When mixed responsibilities are detected: +1. Identify which roles are mixed +2. Propose how to split the code into separate files/functions +3. Classify the split pieces with their correct roles + +## Step 5: Produce Classification Report + +Output a structured report: + +```markdown +## Classification Report: {package}/{scope} + +### Summary +- Files in scope: N +- Already annotated: N +- Unannotated: N +- High confidence: N +- Needs review: N + +### Classifications + +| File | Declaration | Type | Proposed Role | Confidence | Notes | +|------|-------------|------|---------------|------------|-------| + +### Mixed Responsibility Files + +| File | Roles Mixed | Recommended Action | +|------|-------------|--------------------| + +### Proposed New Roles (if any) + +| Name | Justification | Example Files | +|------|---------------|---------------| + +### Decisions Log + +For each non-obvious classification, explain: +1. What candidates were considered +2. Why the chosen role won +3. What the runner-up was and why it lost +``` + +## Step 6: Execute (add workflow only) + +After presenting the classification report: + +1. **Annotate**: Add `/** @riviere-role {role-name} */` before each exported declaration + - Place the annotation on the line immediately before the `export` keyword + - One annotation per declaration + - Use the exact role name from the config + +2. **Refactor** (when mixed responsibilities detected): + - Split the file into separate files, one per role + - Move each piece to the correct layer directory + - Update imports in all affected files + - Annotate the split pieces + +3. **Update config** (if needed): + - Add new include patterns if the scope expanded + - Add new layer paths if files moved to new locations + - Add new roles if approved by user + +4. **Verify**: Run the enforcement tool: + ```bash + pnpm exec tsx packages/riviere-role-enforcement/src/bin.ts packages/riviere-cli/role-enforcement.config.json + ``` + Fix any violations until the tool passes. + +5. **Run tests**: Ensure existing tests still pass: + ```bash + pnpm nx test riviere-cli + ``` + +## Step 7: Document in Battle Test Log + +After completing work on each area, append to `packages/riviere-role-enforcement/skills/BATTLE-TEST-LOG.md`: + +```markdown +## {Area Name} — {Date} + +### Scope +- Files analyzed: N +- Files annotated: N +- Files refactored: N + +### Classifications +| File | Role | Confidence | Notes | +|------|------|------------|-------| + +### Key Decisions +- {Decision 1}: Chose X over Y because... +- {Decision 2}: ... + +### Skill Gaps +- {What was missing or unclear in the skill/definitions} + +### New Roles Proposed +- {Role name}: {Justification} (approved/pending) + +### Refactoring Performed +- {File}: Split into {file1} ({role1}) + {file2} ({role2}) + +### What Worked Well +- {Positive feedback about the skill} + +### What Should Be Improved +- {Suggestions for skill improvement} +``` + +## Configure Workflow + +If no `role-enforcement.config.json` exists in the target package: + +1. Analyze the package's directory structure +2. Map directories to layers based on naming conventions +3. Start with the standard role catalog (the 13 roles defined above) +4. Create the config file with appropriate include/ignore patterns +5. Create the `roleDefinitionsDir` with index.md and role definition files +6. Run enforcement and iteratively fix until compliance is achieved diff --git a/packages/riviere-role-enforcement/src/cli/create-oxlint-config.spec.ts b/packages/riviere-role-enforcement/src/cli/create-oxlint-config.spec.ts index 8eedc353..d03e583e 100644 --- a/packages/riviere-role-enforcement/src/cli/create-oxlint-config.spec.ts +++ b/packages/riviere-role-enforcement/src/cli/create-oxlint-config.spec.ts @@ -15,6 +15,7 @@ describe('createOxlintConfig', () => { paths: ['src/**/commands'], }, }, + roleDefinitionsDir: 'role-definitions', roles: [], }, '/repo/packages/riviere-cli', @@ -47,6 +48,7 @@ describe('createOxlintConfig', () => { paths: ['src/**/entrypoint'], }, }, + roleDefinitionsDir: 'role-definitions', roles: [], }, '/repo/packages/riviere-role-enforcement', diff --git a/packages/riviere-role-enforcement/src/cli/run-role-enforcement.error.spec.ts b/packages/riviere-role-enforcement/src/cli/run-role-enforcement.error.spec.ts index 83b86868..65bca5be 100644 --- a/packages/riviere-role-enforcement/src/cli/run-role-enforcement.error.spec.ts +++ b/packages/riviere-role-enforcement/src/cli/run-role-enforcement.error.spec.ts @@ -12,10 +12,16 @@ import { function createFixtureWorkspace(): string { const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'role-enforcement-error-')) fs.mkdirSync(path.join(workspaceDir, 'src', 'entrypoint'), { recursive: true }) + fs.mkdirSync(path.join(workspaceDir, 'role-definitions'), { recursive: true }) fs.writeFileSync( path.join(workspaceDir, 'src', 'entrypoint', 'cli.ts'), '/** @riviere-role cli-entrypoint */\nexport function createCli(): void {}\n', ) + fs.writeFileSync(path.join(workspaceDir, 'role-definitions', 'index.md'), '# Role Definitions') + fs.writeFileSync( + path.join(workspaceDir, 'role-definitions', 'cli-entrypoint.md'), + '# cli-entrypoint', + ) fs.writeFileSync( path.join(workspaceDir, 'role-enforcement.config.json'), JSON.stringify( @@ -28,6 +34,7 @@ function createFixtureWorkspace(): string { paths: ['src/entrypoint'], }, }, + roleDefinitionsDir: 'role-definitions', roles: [ { allowedNames: ['createCli'], diff --git a/packages/riviere-role-enforcement/src/cli/run-role-enforcement.spec.ts b/packages/riviere-role-enforcement/src/cli/run-role-enforcement.spec.ts index 2257b3fb..bea5454e 100644 --- a/packages/riviere-role-enforcement/src/cli/run-role-enforcement.spec.ts +++ b/packages/riviere-role-enforcement/src/cli/run-role-enforcement.spec.ts @@ -12,6 +12,7 @@ function createFixtureWorkspace(): string { const workspaceDir = mkdtempSync(path.join(tmpdir(), 'role-enforcement-workspace-')) mkdirSync(path.join(workspaceDir, 'src', 'commands'), { recursive: true }) mkdirSync(path.join(workspaceDir, 'src', 'entrypoint'), { recursive: true }) + mkdirSync(path.join(workspaceDir, 'role-definitions'), { recursive: true }) writeFileSync( path.join(workspaceDir, 'src', 'commands', 'runThingInput.ts'), @@ -49,6 +50,37 @@ export function createCli(): void {} `, ) + const roles = [ + { + allowedInputs: ['command-use-case-input'], + allowedNames: ['runThing'], + allowedOutputs: ['command-use-case-result'], + name: 'command-use-case', + targets: ['function'], + }, + { + allowedNames: ['RunThingInput'], + name: 'command-use-case-input', + targets: ['interface'], + }, + { + allowedNames: ['RunThingResult'], + name: 'command-use-case-result', + targets: ['interface'], + }, + { + allowedNames: ['createCli'], + name: 'cli-entrypoint', + targets: ['function'], + }, + ] + + const roleDefsDir = path.join(workspaceDir, 'role-definitions') + writeFileSync(path.join(roleDefsDir, 'index.md'), '# Role Definitions') + for (const role of roles) { + writeFileSync(path.join(roleDefsDir, `${role.name}.md`), `# ${role.name}`) + } + writeFileSync( path.join(workspaceDir, 'role-enforcement.config.json'), JSON.stringify( @@ -65,30 +97,8 @@ export function createCli(): void {} paths: ['src/entrypoint'], }, }, - roles: [ - { - allowedInputs: ['command-use-case-input'], - allowedNames: ['runThing'], - allowedOutputs: ['command-use-case-result'], - name: 'command-use-case', - targets: ['function'], - }, - { - allowedNames: ['RunThingInput'], - name: 'command-use-case-input', - targets: ['interface'], - }, - { - allowedNames: ['RunThingResult'], - name: 'command-use-case-result', - targets: ['interface'], - }, - { - allowedNames: ['createCli'], - name: 'cli-entrypoint', - targets: ['function'], - }, - ], + roleDefinitionsDir: 'role-definitions', + roles, }, null, 2, diff --git a/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.spec.ts b/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.spec.ts index e2ada88f..9280209f 100644 --- a/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.spec.ts +++ b/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.spec.ts @@ -1,5 +1,5 @@ import { - mkdtempSync, rmSync, writeFileSync + mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -9,97 +9,106 @@ import { import { loadRoleEnforcementConfig } from './load-role-enforcement-config' import { RoleEnforcementConfigError } from './role-enforcement-config-error' -function createTempConfigFile(contents: string): string { - const tempDir = mkdtempSync(path.join(tmpdir(), 'role-enforcement-config-')) - const configPath = path.join(tempDir, 'role-enforcement.config.json') - writeFileSync(configPath, contents) +interface RoleFixture { + name: string + targets: string[] +} + +function createTempDir(): string { + return mkdtempSync(path.join(tmpdir(), 'role-enforcement-config-')) +} + +function writeConfig(dir: string, config: unknown): string { + const configPath = path.join(dir, 'role-enforcement.config.json') + writeFileSync(configPath, JSON.stringify(config)) return configPath } +function createRoleDefsDir(dir: string, roles: RoleFixture[], withIndex = true): void { + const roleDefsDir = path.join(dir, 'role-definitions') + mkdirSync(roleDefsDir) + if (withIndex) { + writeFileSync(path.join(roleDefsDir, 'index.md'), '# Role Definitions') + } + for (const role of roles) { + writeFileSync(path.join(roleDefsDir, `${role.name}.md`), `# ${role.name}`) + } +} + +function cleanupDir(dir: string): void { + rmSync(dir, { + force: true, + recursive: true, + }) +} + +const commandRole: RoleFixture = { + name: 'command-use-case', + targets: ['function'], +} + +const baseConfig = { + ignorePatterns: [], + include: ['src/**/*.ts'], + layers: { + commands: { + allowedRoles: ['command-use-case'], + paths: ['src/**/commands'], + }, + }, + roleDefinitionsDir: 'role-definitions', + roles: [commandRole], +} + it('loads a valid config file', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: ['**/*.spec.ts'], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roles: [ + { + allowedNames: ['runThing'], + name: 'command-use-case', + targets: ['function'], }, - roles: [ - { - allowedNames: ['runThing'], - name: 'command-use-case', - targets: ['function'], - }, - ], - }), - ) + ], + }) + createRoleDefsDir(tempDir, [commandRole]) const loadedConfig = loadRoleEnforcementConfig(configPath) expect(loadedConfig.config.roles).toHaveLength(1) expect(loadedConfig.config.layers).toHaveProperty('commands') - expect(loadedConfig.configDir).toBe(path.dirname(configPath)) + expect(loadedConfig.configDir).toBe(tempDir) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('allows roles without allowedNames or nameMatches', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, - }, - roles: [ - { - name: 'command-use-case', - targets: ['function'], - }, - ], - }), - ) + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, baseConfig) + createRoleDefsDir(tempDir, [commandRole]) const loadedConfig = loadRoleEnforcementConfig(configPath) expect(loadedConfig.config.roles[0]?.name).toBe('command-use-case') - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('rejects roles declaring both allowedNames and nameMatches', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roles: [ + { + allowedNames: ['runThing'], + name: 'command-use-case', + nameMatches: '^run[A-Z].+$', + targets: ['function'], }, - roles: [ - { - allowedNames: ['runThing'], - name: 'command-use-case', - nameMatches: '^run[A-Z].+$', - targets: ['function'], - }, - ], - }), - ) + ], + }) + createRoleDefsDir(tempDir, [commandRole]) expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( new RoleEnforcementConfigError( @@ -107,32 +116,22 @@ it('rejects roles declaring both allowedNames and nameMatches', () => { ), ) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('rejects invalid regular expressions in nameMatches', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roles: [ + { + name: 'command-use-case', + nameMatches: '[', + targets: ['function'], }, - roles: [ - { - name: 'command-use-case', - nameMatches: '[', - targets: ['function'], - }, - ], - }), - ) + ], + }) + createRoleDefsDir(tempDir, [commandRole]) expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( new RoleEnforcementConfigError( @@ -140,63 +139,43 @@ it('rejects invalid regular expressions in nameMatches', () => { ), ) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('accepts forbiddenDependencies referencing defined roles', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roles: [ + { + forbiddenDependencies: ['command-use-case'], + name: 'command-use-case', + targets: ['function'], }, - roles: [ - { - forbiddenDependencies: ['command-use-case'], - name: 'command-use-case', - targets: ['function'], - }, - ], - }), - ) + ], + }) + createRoleDefsDir(tempDir, [commandRole]) const loadedConfig = loadRoleEnforcementConfig(configPath) expect(loadedConfig.config.roles[0]?.forbiddenDependencies).toStrictEqual(['command-use-case']) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('rejects forbiddenDependencies referencing undefined roles', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roles: [ + { + forbiddenDependencies: ['nonexistent-role'], + name: 'command-use-case', + targets: ['function'], }, - roles: [ - { - forbiddenDependencies: ['nonexistent-role'], - name: 'command-use-case', - targets: ['function'], - }, - ], - }), - ) + ], + }) + createRoleDefsDir(tempDir, [commandRole]) expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( new RoleEnforcementConfigError( @@ -204,31 +183,21 @@ it('rejects forbiddenDependencies referencing undefined roles', () => { ), ) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('rejects layer allowedRoles referencing undefined roles', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['nonexistent-role'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + layers: { + commands: { + allowedRoles: ['nonexistent-role'], + paths: ['src/**/commands'], }, - roles: [ - { - name: 'command-use-case', - targets: ['function'], - }, - ], - }), - ) + }, + }) + createRoleDefsDir(tempDir, [commandRole]) expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( new RoleEnforcementConfigError( @@ -236,47 +205,92 @@ it('rejects layer allowedRoles referencing undefined roles', () => { ), ) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('rejects malformed json files', () => { - const configPath = createTempConfigFile('{') + const tempDir = createTempDir() + const configPath = path.join(tempDir, 'role-enforcement.config.json') + writeFileSync(configPath, '{') expect(() => loadRoleEnforcementConfig(configPath)).toThrowError(RoleEnforcementConfigError) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, - }) + cleanupDir(tempDir) }) it('reports root-level schema violations', () => { - const configPath = createTempConfigFile( - JSON.stringify({ - extra: true, - ignorePatterns: [], - include: ['src/**/*.ts'], - layers: { - commands: { - allowedRoles: ['command-use-case'], - paths: ['src/**/commands'], - }, + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + extra: true, + ignorePatterns: [], + include: ['src/**/*.ts'], + layers: { + commands: { + allowedRoles: ['command-use-case'], + paths: ['src/**/commands'], }, - roles: [], - }), - ) + }, + roles: [], + }) expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( new RoleEnforcementConfigError( - 'Invalid role enforcement config: $: must NOT have additional properties; roles: must NOT have fewer than 1 items', + "Invalid role enforcement config: $: must have required property 'roleDefinitionsDir'; $: must NOT have additional properties; roles: must NOT have fewer than 1 items", ), ) - rmSync(path.dirname(configPath), { - force: true, - recursive: true, + cleanupDir(tempDir) +}) + +it('rejects config when roleDefinitionsDir directory does not exist', () => { + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, { + ...baseConfig, + roleDefinitionsDir: 'nonexistent-dir', }) + + expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( + new RoleEnforcementConfigError( + 'roleDefinitionsDir: missing files: index.md, command-use-case.md', + ), + ) + + cleanupDir(tempDir) +}) + +it('rejects config when index.md is missing from roleDefinitionsDir', () => { + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, baseConfig) + createRoleDefsDir(tempDir, [commandRole], false) + + expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( + new RoleEnforcementConfigError('roleDefinitionsDir: missing files: index.md'), + ) + + cleanupDir(tempDir) +}) + +it('rejects config when role definition files are missing', () => { + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, baseConfig) + createRoleDefsDir(tempDir, [], true) + + expect(() => loadRoleEnforcementConfig(configPath)).toThrowError( + new RoleEnforcementConfigError('roleDefinitionsDir: missing files: command-use-case.md'), + ) + + cleanupDir(tempDir) +}) + +it('includes roleDefinitionsDir absolute path in loaded config', () => { + const tempDir = createTempDir() + const configPath = writeConfig(tempDir, baseConfig) + createRoleDefsDir(tempDir, [commandRole]) + + const loadedConfig = loadRoleEnforcementConfig(configPath) + const expectedDir = path.resolve(tempDir, 'role-definitions') + + expect(loadedConfig.roleDefinitionsDir).toBe(expectedDir) + + cleanupDir(tempDir) }) diff --git a/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.ts b/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.ts index 97b218c9..38a745be 100644 --- a/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.ts +++ b/packages/riviere-role-enforcement/src/config/load-role-enforcement-config.ts @@ -1,4 +1,6 @@ -import { readFileSync } from 'node:fs' +import { + existsSync, readFileSync +} from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import Ajv2020 from 'ajv/dist/2020' @@ -22,17 +24,54 @@ export interface LoadedRoleEnforcementConfig { config: roleConfig.RoleEnforcementConfig configDir: string configPath: string + roleDefinitionsDir: string } export function loadRoleEnforcementConfig(configPath: string): LoadedRoleEnforcementConfig { const absolutePath = path.resolve(configPath) const rawConfig = readConfigJson(absolutePath) const validatedConfig = validateRoleEnforcementConfig(rawConfig) + const configDir = path.dirname(absolutePath) + validateRoleDefinitionsDir(configDir, validatedConfig) + const roleDefinitionsDir = path.resolve(configDir, validatedConfig.roleDefinitionsDir) return { config: validatedConfig, - configDir: path.dirname(absolutePath), + configDir, configPath: absolutePath, + roleDefinitionsDir, + } +} + +function validateRoleDefinitionsDir( + configDir: string, + config: roleConfig.RoleEnforcementConfig, +): void { + const resolvedDir = path.resolve(configDir, config.roleDefinitionsDir) + const missingFiles: string[] = [] + + if (!existsSync(resolvedDir)) { + const allFiles = ['index.md', ...config.roles.map((r) => `${r.name}.md`)] + throw new RoleEnforcementConfigError( + `roleDefinitionsDir: missing files: ${allFiles.join(', ')}`, + ) + } + + if (!existsSync(path.join(resolvedDir, 'index.md'))) { + missingFiles.push('index.md') + } + + for (const role of config.roles) { + const roleFile = `${role.name}.md` + if (!existsSync(path.join(resolvedDir, roleFile))) { + missingFiles.push(roleFile) + } + } + + if (missingFiles.length > 0) { + throw new RoleEnforcementConfigError( + `roleDefinitionsDir: missing files: ${missingFiles.join(', ')}`, + ) } } diff --git a/packages/riviere-role-enforcement/src/config/role-enforcement-config.ts b/packages/riviere-role-enforcement/src/config/role-enforcement-config.ts index 3f3bffee..b03823f9 100644 --- a/packages/riviere-role-enforcement/src/config/role-enforcement-config.ts +++ b/packages/riviere-role-enforcement/src/config/role-enforcement-config.ts @@ -19,5 +19,6 @@ export interface RoleEnforcementConfig { ignorePatterns: string[] include: string[] layers: Record + roleDefinitionsDir: string roles: RoleDefinition[] } From b29e8a858289c59219955850118ee8407cffb520 Mon Sep 17 00:00:00 2001 From: = <=> Date: Sat, 28 Mar 2026 17:26:03 +0000 Subject: [PATCH 2/6] feat(role-enforcement): annotate builder + query features, refactor add-component Apply role enforcement to features/builder (16 files) and features/query (6 files). Refactor addComponent command to return typed result instead of mixing output concerns. Add queries layer to config. Document Promise tool limitation in battle test log. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../riviere-cli/role-enforcement.config.json | 11 +- .../builder/commands/add-component-result.ts | 13 ++ .../builder/commands/add-component.spec.ts | 63 ++++----- .../builder/commands/add-component.ts | 125 ++++++++---------- .../builder/entrypoint/add-component.ts | 21 ++- .../features/builder/entrypoint/add-domain.ts | 1 + .../features/builder/entrypoint/add-source.ts | 1 + .../builder/entrypoint/check-consistency.ts | 1 + .../builder/entrypoint/component-checklist.ts | 1 + .../builder/entrypoint/component-summary.ts | 1 + .../builder/entrypoint/define-custom-type.ts | 1 + .../src/features/builder/entrypoint/enrich.ts | 1 + .../features/builder/entrypoint/finalize.ts | 1 + .../src/features/builder/entrypoint/init.ts | 1 + .../builder/entrypoint/link-external.ts | 1 + .../features/builder/entrypoint/link-http.ts | 1 + .../src/features/builder/entrypoint/link.ts | 1 + .../features/builder/entrypoint/validate.ts | 1 + .../builder/queries/api-component-queries.ts | 3 + .../features/query/entrypoint/components.ts | 1 + .../src/features/query/entrypoint/domains.ts | 1 + .../features/query/entrypoint/entry-points.ts | 1 + .../src/features/query/entrypoint/orphans.ts | 1 + .../src/features/query/entrypoint/search.ts | 1 + .../src/features/query/entrypoint/trace.ts | 1 + .../cli-presentation/add-component-hints.ts | 8 ++ .../add-component-mapper.spec.ts | 1 - .../component-mapping/add-component-mapper.ts | 2 +- .../skills/BATTLE-TEST-LOG.md | 57 ++++++++ 29 files changed, 215 insertions(+), 108 deletions(-) create mode 100644 packages/riviere-cli/src/features/builder/commands/add-component-result.ts create mode 100644 packages/riviere-cli/src/platform/infra/cli-presentation/add-component-hints.ts diff --git a/packages/riviere-cli/role-enforcement.config.json b/packages/riviere-cli/role-enforcement.config.json index 0f8812cb..22c20d06 100644 --- a/packages/riviere-cli/role-enforcement.config.json +++ b/packages/riviere-cli/role-enforcement.config.json @@ -1,16 +1,20 @@ { - "include": ["src/features/extract/**/*.ts", "src/platform/infra/git/**/*.ts"], + "include": ["src/features/extract/**/*.ts", "src/platform/infra/git/**/*.ts", "src/features/builder/**/*.ts", "src/features/query/**/*.ts"], "roleDefinitionsDir": "role-definitions", "ignorePatterns": ["**/*.spec.ts", "**/__fixtures__/**"], "layers": { "entrypoint": { - "paths": ["src/features/entrypoint"], + "paths": ["src/features/entrypoint", "src/features/builder/entrypoint", "src/features/query/entrypoint"], "allowedRoles": ["cli-entrypoint"] }, "commands": { - "paths": ["src/features/commands"], + "paths": ["src/features/commands", "src/features/builder/commands"], "allowedRoles": ["command-use-case", "command-use-case-input", "command-use-case-result", "command-input-factory"] }, + "queries": { + "paths": ["src/features/builder/queries"], + "allowedRoles": ["domain-service", "value-object"] + }, "domain": { "paths": ["src/features,platform/domain"], "allowedRoles": ["aggregate", "value-object", "domain-service"] @@ -37,7 +41,6 @@ "name": "command-use-case", "targets": ["function"], "allowedInputs": ["command-use-case-input"], - "allowedOutputs": ["command-use-case-result"], "forbiddenDependencies": ["command-use-case"] }, { diff --git a/packages/riviere-cli/src/features/builder/commands/add-component-result.ts b/packages/riviere-cli/src/features/builder/commands/add-component-result.ts new file mode 100644 index 00000000..a7feb776 --- /dev/null +++ b/packages/riviere-cli/src/features/builder/commands/add-component-result.ts @@ -0,0 +1,13 @@ +import { CliErrorCode } from '../../../platform/infra/cli-presentation/error-codes' + +/** @riviere-role command-use-case-result */ +export type AddComponentResult = + | { + success: true + componentId: string + } + | { + success: false + code: CliErrorCode + message: string + } diff --git a/packages/riviere-cli/src/features/builder/commands/add-component.spec.ts b/packages/riviere-cli/src/features/builder/commands/add-component.spec.ts index 2f0f79dd..9e43588c 100644 --- a/packages/riviere-cli/src/features/builder/commands/add-component.spec.ts +++ b/packages/riviere-cli/src/features/builder/commands/add-component.spec.ts @@ -11,9 +11,6 @@ import { type TestContext, createTestContext, setupCommandTest, - parseErrorOutput, - parseSuccessOutput, - hasSuccessOutputStructure, createGraphWithDomain, } from '../../../platform/__fixtures__/command-test-fixtures' @@ -28,7 +25,6 @@ describe('addComponent command', () => { module: 'test-module', repository: 'test-repo', filePath: '/path/to/file.ts', - outputJson: true, } function inputWithGraphPath(overrides: Partial = {}) { @@ -48,14 +44,16 @@ describe('addComponent command', () => { ['special chars', 'UI