From 5d08d53f4f330e526f39aad1f74c97b43b0c2ad1 Mon Sep 17 00:00:00 2001 From: Cole Ferrier Date: Sat, 23 May 2026 07:47:14 -0700 Subject: [PATCH] feat: implement metadata-driven 'Flat & Uniform' protocol engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-architected the Lore Protocol from a property-based model to a generic, metadata-driven data engine. This transition unifies built-in and custom trailers into a single logical path where all data structures (LoreTrailers, CommitInput) are unified Record types mapped to readonly string arrays. By anchoring all protocol intelligence—including rebranding, case-normalization, validation, and merging rules—into a central Protocol service, the system achieves perfect structural symmetry while reducing specification changes to a single declarative step. Key Architectural Components: - Centralized Protocol Specification: Introduced an absolute Single Source of Truth (SSOT) in core-definitions.ts, enabling full protocol rebranding (e.g., to 'Fred-id') via a single constant change. - Strictly Hierarchical Import Structure: Established a clean, non-circular information flow from specification to types, constants, and services. - High-Fidelity Restoration: Faithfully preserved original design patterns (Strategy, Information Expert, Creator) and complex logic precision (multi-hint expiration, confirm-then-loop prompts) while implementing the flat data model. - Orthogonal Schema Model: Decoupled cardinality from validation rules, allowing for flexible definitions like multi-value enums and dynamic CLI flag registration. Lore-id: 1d3e207e Constraint: All trailer data must be stored internally as readonly string[] for structural uniformity Constraint: The Protocol service must remain the single source of truth for all protocol semantics Constraint: JSON output must derive structural keys from protocol metadata at the edge Constraint: Infrastructure constants and domain types must be derived from the core specification Rejected: Hardcoded property-based model | Rigid and required redundant maintenance for every new trailer Rejected: Class-based inheritance for trailers | Excessive complexity compared to metadata-driven dynamic Records Rejected: Loose utility refactoring | Initially clobbered multi-line logic; restored with faithful while-loop precision Directive: [until:2026-12] Monitor performance of dynamic Record access in large repositories (>10k atoms) Directive: [on:squash] Carry forward architecturalRationale constraints even when removing intermediate atom IDs Tested: 460 unit tests passed (expanded from 424 in main branch baseline) Tested: Verified rebranding capability via 'Fred Test' (Git trailers & JSON synchronization) Tested: Verified unified requiredness logic and case-insensitive CLI flag mapping Tested: Manual audit of structural fidelity for all core services against main Tested: Verified 1-step spec extension workflow via src/util/core-definitions.ts Confidence: high Scope-risk: moderate Reversibility: clean Assisted-by: Gemini:CLI [lore-protocol] --- README.md | 29 +- documents/PROJECT_ARCHITECTURE.md | 161 ++++++--- src/commands/commit.ts | 82 +++-- src/commands/config.ts | 45 +++ src/commands/doctor.ts | 32 +- src/commands/helpers/path-query.ts | 14 +- src/commands/init.ts | 10 + src/commands/log.ts | 16 +- src/commands/search.ts | 28 +- src/commands/stale.ts | 1 + src/commands/trace.ts | 17 +- src/commands/why.ts | 5 +- src/formatters/json-formatter.ts | 122 ++++--- src/formatters/text-formatter.ts | 149 +++++--- src/interfaces/commit-input-reader.ts | 2 +- src/interfaces/output-formatter.ts | 2 + src/interfaces/trailer-collector.ts | 10 +- src/main.ts | 34 +- src/services/atom-repository.ts | 25 +- src/services/commit-builder.ts | 281 ++++++--------- src/services/commit-input-resolver.ts | 63 ++-- src/services/config-loader.ts | 104 +++++- src/services/head-lore-id-reader.ts | 7 +- src/services/protocol.ts | 189 ++++++++++ .../enum-choice-trailer-collector.ts | 12 +- .../multi-value-trailer-collector.ts | 13 +- .../collectors/trailer-collector-registry.ts | 139 ++++---- src/services/readers/flags-input-reader.ts | 95 +++-- .../readers/interactive-input-reader.ts | 13 +- src/services/readers/json-input-reader.ts | 87 +++-- src/services/search-filter.ts | 106 +++--- src/services/squash-merger.ts | 197 +++++------ src/services/staleness-detector.ts | 174 +++++---- src/services/trailer-parser.ts | 131 +++---- src/services/validator.ts | 329 +++++++----------- src/types/commit.ts | 11 + src/types/config.ts | 60 +++- src/types/custom-trailer-collection.ts | 83 ----- src/types/domain.ts | 69 ++-- src/types/output.ts | 37 +- src/types/query.ts | 1 + src/util/constants.ts | 184 ++++++---- src/util/core-definitions.ts | 265 ++++++++++++++ src/util/directive-parser.ts | 109 ++++++ .../arch/flat-protocol-boundaries.test.ts | 115 ++++++ tests/unit/arch/protocol-integrity.test.ts | 97 ++++++ tests/unit/commands/commit-amend.test.ts | 13 +- tests/unit/commands/commit-flags.test.ts | 72 ++++ .../commands/helpers/path-query-limit.test.ts | 25 +- tests/unit/commands/log.test.ts | 19 +- tests/unit/formatters/json-formatter.test.ts | 150 ++++++-- tests/unit/formatters/text-formatter.test.ts | 45 ++- tests/unit/services/atom-repository.test.ts | 183 +++++----- tests/unit/services/commit-builder.test.ts | 140 +++++--- .../services/commit-input-resolver.test.ts | 22 +- tests/unit/services/config-loader.test.ts | 114 +++++- .../unit/services/head-lore-id-reader.test.ts | 17 +- tests/unit/services/protocol.test.ts | 233 +++++++++++++ .../enum-choice-trailer-collector.test.ts | 94 ++--- .../trailer-collector-registry.test.ts | 102 ++++++ .../readers/flags-input-reader.test.ts | 200 ++++++++--- .../readers/interactive-input-reader.test.ts | 31 +- .../readers/json-input-reader.test.ts | 47 +-- tests/unit/services/squash-merger.test.ts | 165 +++++---- .../unit/services/staleness-detector.test.ts | 59 ++-- .../services/supersession-resolver.test.ts | 7 +- tests/unit/services/trailer-parser.test.ts | 323 ++++++++--------- tests/unit/services/validator.test.ts | 239 ++++++++++--- tests/unit/types/config.test.ts | 47 +++ .../types/custom-trailer-collection.test.ts | 327 ----------------- tests/unit/util/core-definitions.test.ts | 70 ++++ tests/unit/util/directive-parser.test.ts | 93 +++++ 72 files changed, 4212 insertions(+), 2380 deletions(-) create mode 100644 src/commands/config.ts create mode 100644 src/services/protocol.ts create mode 100644 src/types/commit.ts delete mode 100644 src/types/custom-trailer-collection.ts create mode 100644 src/util/core-definitions.ts create mode 100644 src/util/directive-parser.ts create mode 100644 tests/unit/arch/flat-protocol-boundaries.test.ts create mode 100644 tests/unit/arch/protocol-integrity.test.ts create mode 100644 tests/unit/commands/commit-flags.test.ts create mode 100644 tests/unit/services/protocol.test.ts create mode 100644 tests/unit/services/readers/collectors/trailer-collector-registry.test.ts create mode 100644 tests/unit/types/config.test.ts delete mode 100644 tests/unit/types/custom-trailer-collection.test.ts create mode 100644 tests/unit/util/core-definitions.test.ts create mode 100644 tests/unit/util/directive-parser.test.ts diff --git a/README.md b/README.md index dd6be489..53613856 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ lore commit \ --rejected "class-validator decorators | too much magic for simple checks" \ --confidence high \ --scope-risk narrow \ - --tested "unit tests for all validation rules" + --tested "unit tests for all validation rules" \ + --trailer "Team=Gamma" "Ticket-Id=PROJ-123" # Or pipe JSON (ideal for AI agents) echo '{"intent":"fix: handle null user in auth middleware","trailers":{"Constraint":["must not throw -- return 401 instead"],"Confidence":"high"}}' | lore commit @@ -121,6 +122,7 @@ lore search --text "session" --confidence high | `lore trace ` | Trace decision chain via references (Supersedes, Depends-on, Related) | | `lore validate [range]` | Validate commits for Lore protocol compliance | | `lore squash ` | Merge Lore atoms from a revision range for squash merges | +| `lore config` | Show effective configuration and trailer definitions | | `lore doctor` | Health checks: config validity, ID uniqueness, reference integrity | ### Global Options @@ -133,6 +135,7 @@ lore search --text "session" --confidence high | `--no-update-notifier` | Disable update notification | | `--limit ` | Limit number of results | | `--since ` | Only consider commits since ref/date | +| `--until ` | Only consider commits until ref/date | ## Trailer Vocabulary @@ -153,6 +156,8 @@ Every Lore-enriched commit carries a `Lore-id` and any combination of these trai | `Depends-on` | 0..n | Lore-id | This atom requires another atom to hold | | `Related` | 0..n | Lore-id | Informational link to another atom | +You can also define your own custom trailers with rich validation rules (enums, patterns, requiredness) and UI hints (colors, semantic kinds) in `.lore/config.toml`. Custom trailers are automatically supported by all query and commit commands. + ## Configuration `lore init` creates `.lore/config.toml`: @@ -163,7 +168,27 @@ version = "1.0" [trailers] required = [] # Trailers every commit must include, e.g. ["Constraint", "Confidence"] -custom = [] # Additional trailer keys beyond the standard set +custom = ["Team"] # Additional trailer keys beyond the standard set (auto-prompts) +permissive = true # If false, only defined/custom trailers are kept + +# Define rich validation rules and UI hints for custom trailers +[trailers.definitions.Department] +description = "The department responsible for this change" +multivalue = false +validation = "values" +values = ["Engineering", "Product", "Design"] +required = true +ui = { kind = "risk", color = "cyan" } +prompt = { order = 125 } # Optional: prompt between Confidence (120) and Scope-risk (130) +squash = "union" # Default: list all departments if they differ during squash + +[trailers.definitions.Ticket-Id] +description = "Jira or GitHub issue ID" +multivalue = true +validation = "pattern" +pattern = "^[A-Z]+-[0-9]+$" +ui = { kind = "reference", color = "dim" } +# Default order for custom definitions is 1000 (after all core trailers) [validation] strict = false # Treat warnings as errors in lore validate diff --git a/documents/PROJECT_ARCHITECTURE.md b/documents/PROJECT_ARCHITECTURE.md index 262a4a21..b902f4a9 100644 --- a/documents/PROJECT_ARCHITECTURE.md +++ b/documents/PROJECT_ARCHITECTURE.md @@ -1,7 +1,7 @@ # Lore CLI -- Project Architecture > Authoritative reference for contributors (human or AI) to the lore-cli codebase. -> Last updated 2026-03-22. Reflects the codebase after PR #1-15 merge cycle. +> Last updated 2026-05-22. Reflects the codebase after the "Flat & Uniform Protocol" refactor. --- @@ -29,7 +29,16 @@ - **Query commands** (`context`, `constraints`, `rejected`, `directives`, `tested`, `why`, `search`, `log`) that extract and display Lore atoms from git history for a given file, directory, line range, or global scope. - **Write commands** (`commit`, `squash`) that compose and create Lore-enriched git commits. -- **Maintenance commands** (`validate`, `stale`, `trace`, `doctor`, `init`) that check protocol compliance, detect stale knowledge, follow decision chains, and run health checks. +- **Maintenance commands** (`validate`, `stale`, `trace`, `doctor`, `init`, `config`) that check protocol compliance, detect stale knowledge, follow decision chains, run health checks, and inspect effective configuration. + +### Flat & Uniform Protocol Architecture + +The codebase implements a **Flat & Uniform Protocol** architecture. Key principles: + +1. **Unified Storage**: All trailer values (core and custom) are stored internally as `readonly string[]` for structural uniformity. Both the domain model (`LoreTrailers`) and input model (`CommitInput`) are simplified to purely dynamic Record types. Scalar coercion (e.g., for JSON output) is handled at the edge based on metadata. +2. **Metadata-Driven**: All logic (parsing, validation, CLI flags, prompting, merging, rendering) is driven by the central `Protocol` service, which acts as the single source of truth for the entire protocol specification. +3. **No Ghosts**: Trailing keys are normalized case-insensitively and authorized via the protocol engine, preventing "ghost trailers" or casing mismatches in the knowledge graph. +4. **Fidelity & Logic Precision**: The system maintains 100% faithful logic for complex protocol rules (like multi-hint expiration in directives or rank-based merging in squashes) while benefiting from the simplified flat data model. ### Layered Architecture @@ -99,28 +108,45 @@ No command or service instantiates its own dependencies. All wiring is centraliz ### Types Layer #### `src/types/domain.ts` -- **Contains**: `LoreId` type alias, `TrailerKey`/`ArrayTrailerKey`/`EnumTrailerKey` union types, `ConfidenceLevel`/`ScopeRiskLevel`/`ReversibilityLevel` enums, `LoreTrailers` interface, `LoreAtom` interface, `SupersessionStatus` interface. -- **Single Responsibility**: Defines the core domain model -- what a Lore atom is, what trailers exist, and their value types. -- **Dependencies**: None. -- **Dependents**: Nearly every file in the project. This is the foundational type file. +- **Contains**: `LoreId` type alias, `TrailerKey`/`ArrayTrailerKey`/`EnumTrailerKey` union types (dynamically mapped from metadata), `LoreTrailers` type alias (flat & uniform Record), `LoreAtom` interface. +- **Single Responsibility**: Defines the core domain model. +- **Uniformity**: Every key in `LoreTrailers` is a `readonly string[]`. + +#### `src/types/commit.ts` +- **Contains**: `CommitInput` interface. +- **Single Responsibility**: Defines the input model for the write-path. +- **Alignment**: Reuses `LoreTrailers` for its `trailers` property, ensuring perfect symmetry with the domain model. #### `src/types/config.ts` -- **Contains**: `LoreConfig` interface, `DEFAULT_CONFIG` constant. -- **Single Responsibility**: Defines the configuration schema and its default values. -- **Dependencies**: None. -- **Dependents**: `IConfigLoader`, `ConfigLoader`, `CommitBuilder`, `StalenessDetector`, `Validator`, `main.ts`, `path-query.ts`. +- **Contains**: `LoreConfig` interface, `CustomTrailerDefinition` interface, `ValueDefinition` interface, `DEFAULT_CONFIG` constant. +- **Single Responsibility**: Defines the configuration schema (including rich trailer definitions). + +### Services Layer -#### `src/types/query.ts` -- **Contains**: `TargetType` type, `QueryTarget` interface, `PathQueryOptions` interface, `SearchOptions` interface, `QueryResult` interface, `QueryMeta` interface. -- **Single Responsibility**: Defines the shape of queries (inputs) and their results (outputs) for the query pipeline. -- **Dependencies**: `domain.ts` (for `LoreAtom`, `ConfidenceLevel`, etc.). -- **Dependents**: `PathResolver`, `AtomRepository`, `path-query.ts`, command files, formatters. +#### `src/services/protocol.ts` +- **Contains**: `Protocol` class. +- **Single Responsibility**: The master engine for the Lore protocol. Centralizes key authorization, case normalization, metadata retrieval, and semantic grouping (reference keys, list keys, etc.). +- **Pattern**: Information Expert -- owns all protocol metadata. -#### `src/types/output.ts` -- **Contains**: `FormattableQueryResult`, `FormattableValidationResult`, `FormattableStalenessResult`, `FormattableTraceResult`, `FormattableDoctorResult`, plus supporting types (`CommitValidationResult`, `ValidationIssue`, `StaleReason`, `StaleAtomReport`, `TraceEdge`, `DoctorCheck`). -- **Single Responsibility**: Defines the "view model" types that bridge services and formatters. Each formattable type bundles the data a formatter needs. -- **Dependencies**: `domain.ts`, `query.ts`. -- **Dependents**: `IOutputFormatter`, `TextFormatter`, `JsonFormatter`, `Validator`, `StalenessDetector`, commands. +#### `src/services/trailer-parser.ts` +- **Contains**: `TrailerParser` class. +- **Single Responsibility**: Parses raw trailer text into structured `LoreTrailers` and serializes back. +- **Uniformity**: Coerces all incoming values into string arrays. Uses `Protocol` to normalize keys. + +#### `src/services/commit-builder.ts` +- **Contains**: `CommitBuilder` class. +- **Single Responsibility**: Builds a complete git commit message string. +- **Fidelity**: Fully metadata-driven. Sorts trailers in canonical protocol order (Lore-id -> Core -> Custom). + +#### `src/services/squash-merger.ts` +- **Contains**: `SquashMerger` class. +- **Single Responsibility**: Merges multiple `LoreAtom` objects. +- **Strategy**: Uses metadata-driven squash strategies (`rank-min`, `rank-max`, `union`) defined in the protocol. + +#### `src/services/validator.ts` +- **Contains**: `Validator` class. +- **Single Responsibility**: Validates commits for compliance. +- **Logic**: Enforces cardinality (for single-value trailers), enums, patterns, and requiredness via universal metadata rules. ### Interfaces Layer @@ -327,6 +353,10 @@ No command or service instantiates its own dependencies. All wiring is centraliz - **Dependencies**: `AtomRepository`, `SupersessionResolver`, `PathResolver`, `IOutputFormatter`, `LoreConfig`, types. - **Dependents**: `context.ts`, `constraints.ts`, `rejected.ts`, `directives.ts`, `tested.ts`. +#### `src/commands/config.ts` +- **Contains**: `registerConfigCommand()` function. +- **Single Responsibility**: Outputs the effective configuration and trailer definitions (core and custom) for the current path. Performs runtime parsing of directives and normalization of values. + #### `src/commands/init.ts` - **Contains**: `registerInitCommand()` function. - **Single Responsibility**: Creates `.lore/config.toml` with default content. Shows existing config if already present. @@ -731,6 +761,7 @@ PathResolver() // no dependencies LoreIdGenerator() // no dependencies TextFormatter({ color: boolean }) JsonFormatter() // no dependencies +CommitInputResolver(prompt: IPrompt, config: LoreConfig) ``` --- @@ -871,10 +902,54 @@ version = "1.0" # Protocol version string [trailers] required = [] # Array of trailer keys required on every commit # e.g., ["Constraint", "Confidence"] -custom = [] # Array of custom trailer key names to recognize - # e.g., ["Team", "Ticket"] +custom = [] # Array of custom trailer keys for Quick-Prompts (auto-interactive) + # e.g., ["Team", "Project"] +permissive = true # When true, unknown trailers are kept. When false, they are stripped. + +# Define rich validation rules, UI hints, and CLI/Prompt behavior for custom trailers +[trailers.definitions.Department] +description = "The department" +multivalue = false # Only one value allowed (cardinality) +validation = "values" # Enable enum validation +values = ["Eng", "Prod"] # List of valid values +required = true # Make this trailer mandatory +ui = { kind = "risk", color = "cyan" } +cli = { flag = "dept", shorthand = "d" } +prompt = { confirm = "Set Department?", choice = "Department:" } + +[trailers.definitions.Ticket] +description = "Issue ID" +multivalue = true # Multiple values allowed +validation = "pattern" # Enable regex validation +pattern = "^[A-Z]+-[0-9]+$" # Regex pattern to match +directives = ["[on:squash] Keep original IDs"] # Operational rules +ui = { kind = "reference", color = "dim" } +cli = { flag = "ticket" } +prompt = { confirm = "Add Ticket ID?", input = "Ticket:" } +``` + +### Orthogonal Schema Model + +Lore uses an **Orthogonal Schema Model** where cardinality (`multivalue: boolean`) is decoupled from content validation (`validation: 'values' | 'pattern' | 'none'`). This allows for flexible trailer definitions like "single-value regex" or "multi-value enum." + +### Trigger-Action Grammar + +Trailers can include `directives` that follow a formalized **Trigger-Action Grammar**: `[trigger:parameter] Instruction`. +- **Triggers**: `on:amend`, `on:commit`, `on:squash`, `on:modify`, `on:stale`. +- **Parameters**: `until:YYYY-MM-DD`, etc. +These rules are parseable by agents and visible in `lore config`. -[validation] +### Metadata-Driven CLI & UI + +The Lore CLI is fully metadata-driven. The `CORE_TRAILER_DEFINITIONS` (in `src/util/core-definitions.ts`) acts as the master source of truth for the entire protocol. From this single object, the system automatically derives: +- **TypeScript Types**: `TrailerKey`, `ArrayTrailerKey`, and `EnumTrailerKey` unions are mapped from the metadata. +- **Runtime Key Lists**: Arrays like `LORE_TRAILER_KEYS` used for iteration and parsing. +- **Validation Rules**: Enforced by the `Validator` service. +- **UI Styling**: Colors and semantic kinds used by `TextFormatter`. +- **Interactive Prompts**: Sequence and text used by `TrailerCollectorRegistry`. +- **CLI Flags**: Dynamic registration in the `commit` command. + +This architecture ensures that adding a new core trailer only requires updating the metadata and the `LoreTrailers` interface, eliminating manual synchronization across the codebase. strict = false # When true, missing required trailers are errors (not warnings) max_message_lines = 50 # Maximum total lines in a commit message intent_max_length = 72 # Maximum character length of the intent (subject) line @@ -1077,18 +1152,9 @@ All 15 commands lack dedicated test files. While service tests cover business lo --- -### OCP Violations in Trailer Enumeration +### OCP Violations in Trailer Enumeration (Resolved) -Multiple files contain hard-coded `switch`/`if` blocks that enumerate all trailer keys: - -| File | Location | Pattern | -|------|----------|---------| -| `search-filter.ts` | `atomHasTrailer()` | Data-driven via `ARRAY_TRAILER_KEYS`/`ENUM_TRAILER_KEYS` (resolved) | -| `text-formatter.ts` | `formatTrailers()` | `if(shouldShow(key))` for each key | -| `json-formatter.ts` | `serializeTrailers()` | `if(shouldShow(key))` for each key | -| `validator.ts` | `trailerHasValue()` | `switch(key)` over all keys | - -Adding a new trailer type requires modifying all four locations. **Recommendation**: Create a data-driven trailer registry (e.g., `TRAILER_DEFINITIONS` array with key, kind, display color, etc.) and derive these switch/if blocks from the registry. +The Lore CLI is now fully metadata-driven. The `CORE_TRAILER_DEFINITIONS` and user `.lore/config.toml` provide all the rules needed for iteration, parsing, validation, and display. The previous hardcoded `switch`/`if` blocks have been replaced with dynamic logic that discovers trailer properties from metadata. --- @@ -1158,7 +1224,7 @@ The commit command's input resolution is refactored into a Strategy pattern: 1. Create `src/commands/.ts`. 2. Export a `registerCommand(program: Command, deps: { ... })` function. -3. Define the dependency bag interface inline (or use `PathQueryDeps` if it's a path-query command). +3. Define the dependency bag interface inline (ensure it includes `config: LoreConfig` if metadata-driven logic or formatters are used). 4. If it's a path-scoped query (like `constraints`, `rejected`), call `executePathQuery()` from `path-query.ts` with the appropriate `visibleTrailers`. 5. If it's a unique command, implement the orchestration logic in the action callback, calling services and formatters. 6. Register in `main.ts`: import the registration function, and call it with the appropriate dependency bag. @@ -1187,19 +1253,26 @@ registerRisksCommand(program, pathQueryDeps); ### How to Add a New Trailer Type -1. **`src/types/domain.ts`**: Add the key to the `TrailerKey` union. If it's array-valued, add to `ArrayTrailerKey`. If it's enum-valued, add to `EnumTrailerKey` and define its value type. Add the field to `LoreTrailers`. - -2. **`src/util/constants.ts`**: Add the key to `LORE_TRAILER_KEYS`. Add to `ARRAY_TRAILER_KEYS` or `ENUM_TRAILER_KEYS` as appropriate. If enum, add its valid values array. - -3. **`src/services/trailer-parser.ts`**: `parse()` and `serialize()` use `ARRAY_TRAILER_KEYS` and `ENUM_TRAILER_KEYS` from constants, so they will pick up the new key automatically for parsing. Serialization order follows the iteration order of these arrays. +There are two paths for extending the Lore Protocol: -4. **`src/formatters/text-formatter.ts`**: Add a rendering block in `formatTrailers()` (around line 242) with the desired color. +#### 1. Custom Project Trailers (Configuration Workflow) +Most trailers specific to a team or project (e.g., `Ticket-Id`, `Squad`, `Reviewer`) should be added via `.lore/config.toml`. +- **Quick Start**: Add the key to `trailers.custom` for basic interactive prompts. +- **Rich Rules**: Add a block to `trailers.definitions` for validation and rich metadata. +- **Verification**: Run `lore config` to see your new rules in action. -5. **`src/formatters/json-formatter.ts`**: Add a serialization block in `serializeTrailers()` (around line 193). +#### 2. Core Protocol Trailers (Simplified 1-Step Workflow) +Changes to the *global Lore specification* (standard trailers like `Confidence`) are now metadata-driven and only require **one step**: -6. **`src/services/commit-builder.ts`**: Add the field to `CommitInput.trailers` and to `buildTrailers()`. +1. **`src/util/core-definitions.ts`**: Add the formal definition to the `CORE_TRAILER_DEFINITIONS` map. -7. **(Optional)** Update `search.ts` `atomHasTrailer()` and `validator.ts` `trailerHasValue()` if the new trailer should be searchable/validatable. +**Everything else is automatic:** +- **Dynamic Types**: `TrailerKey`, `ArrayTrailerKey`, and `EnumTrailerKey` unions are derived automatically from the metadata. `LoreTrailers` and `CommitInput` reuse these types via a strictly flat Record model. +- **Dynamic CLI**: The `commit` command automatically registers the flag and shorthand. +- **Dynamic Interactive Mode**: The `InteractiveInputReader` automatically generates prompts. +- **Dynamic Validation**: The `Validator` automatically enforces the schema (cardinality, enums, patterns). +- **Dynamic UI**: The `TextFormatter` automatically styles the output based on the `ui` metadata. +- **Dynamic JSON**: `JsonFormatter` applies snake_case mapping and scalar coercion based on metadata. ### How to Add a New Output Format diff --git a/src/commands/commit.ts b/src/commands/commit.ts index f14153ff..b6152b3c 100644 --- a/src/commands/commit.ts +++ b/src/commands/commit.ts @@ -5,6 +5,9 @@ import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import type { CommitInputResolver, CommitCommandOptions } from '../services/commit-input-resolver.js'; import type { HeadLoreIdReader } from '../services/head-lore-id-reader.js'; import { LoreError, NoStagedChangesError, ValidationError } from '../util/errors.js'; +import { LORE_ID_KEY } from '../util/constants.js'; +import type { LoreConfig, CustomTrailerDefinition } from '../types/config.js'; +import type { Protocol } from '../services/protocol.js'; /** Keys on CommitCommandOptions that are NOT user-supplied input. */ const NON_INPUT_KEYS: ReadonlySet = new Set(['amend', 'edit']); @@ -17,7 +20,7 @@ const NON_INPUT_KEYS: ReadonlySet = new Set(['amend', 'edit']); function hasConflictingInput(options: CommitCommandOptions): boolean { return Object.entries(options) .filter(([k]) => !NON_INPUT_KEYS.has(k)) - .some(([, v]) => v !== undefined); + .some(([, v]) => v !== undefined && v !== false); } /** @@ -37,9 +40,12 @@ export function registerCommitCommand( getFormatter: () => IOutputFormatter; commitInputResolver: CommitInputResolver; headLoreIdReader: HeadLoreIdReader; + config: LoreConfig; + protocol: Protocol; }, ): void { - program + const { protocol } = deps; + const cmd = program .command('commit') .description('Create a Lore-enriched commit') .option('--amend', 'Amend the last commit') @@ -47,18 +53,22 @@ export function registerCommitCommand( .option('--file ', 'Read JSON input from file') .option('-i, --interactive', 'Interactive mode (guided prompts)') .option('--intent ', 'Intent line (why the change was made)') - .option('--body ', 'Body (narrative context)') - .option('--constraint ', 'Constraint trailer value (repeatable)') - .option('--rejected ', 'Rejected trailer value (repeatable)') - .option('--confidence ', 'Confidence level: low, medium, high') - .option('--scope-risk ', 'Scope-risk level: narrow, moderate, wide') - .option('--reversibility ', 'Reversibility level: clean, migration-needed, irreversible') - .option('--directive ', 'Directive trailer value (repeatable)') - .option('--tested ', 'Tested trailer value (repeatable)') - .option('--not-tested ', 'Not-tested trailer value (repeatable)') - .option('--supersedes ', 'Supersedes Lore-id (repeatable)') - .option('--depends-on ', 'Depends-on Lore-id (repeatable)') - .option('--related ', 'Related Lore-id (repeatable)') + .option('--body ', 'Body (narrative context)'); + + // Dynamically register flags for all authorized trailers from the protocol metadata + const authorizedKeys = protocol.getAuthorizedKeys(); + for (const key of authorizedKeys) { + // Skip Lore-id to maintain perfect parity with 'main' branch UI + if (key === LORE_ID_KEY) continue; + + const def = protocol.getDefinition(key); + if (def) { + registerFlagForDefinition(cmd, key, def); + } + } + + // 4. Add catch-all flag for permissive mode / ad-hoc trailers + cmd.option('--trailer ', 'Custom trailer (repeatable, format: Key=Value)') .action(async (options: CommitCommandOptions) => { const { commitBuilder, gitClient, getFormatter, commitInputResolver, headLoreIdReader } = deps; const formatter = getFormatter(); @@ -76,6 +86,13 @@ export function registerCommitCommand( 1, ); } + + // --no-edit requires staged changes since we aren't changing the message + const hasStaged = await gitClient.hasStagedChanges(); + if (!hasStaged) { + throw new NoStagedChangesError(); + } + const result = await gitClient.commit('', { amend: true, noEdit: true }); console.log(formatter.formatSuccess(`Commit amended: ${result.hash}`, { hash: result.hash })); return; @@ -89,24 +106,24 @@ export function registerCommitCommand( } } - // Read existing Lore-id when amending - const existingLoreId = options.amend ? await headLoreIdReader.read() : null; - - // Resolve input from the appropriate source + // 5. Resolve input (interactive, file, flags, or stdin) const input = await commitInputResolver.resolve(options); - // Validate input + // 6. Validate input before building message const issues = commitBuilder.validate(input); const errors = issues.filter((i) => i.severity === 'error'); if (errors.length > 0) { throw new ValidationError('Commit input validation failed', issues); } - // Build the commit message (reuse existing Lore-id on amend) - const message = commitBuilder.build(input, existingLoreId ?? undefined); + // 7. Build and commit + let existingLoreId; + if (options.amend) { + existingLoreId = await headLoreIdReader.read(); + } - // Run git commit - const result = await gitClient.commit(message, options.amend ? { amend: true } : undefined); + const message = commitBuilder.build(input, existingLoreId ?? undefined); + const result = await gitClient.commit(message, { amend: !!options.amend }); // Output const verb = options.amend ? 'amended' : 'created'; @@ -118,3 +135,22 @@ export function registerCommitCommand( ); }); } + +/** + * Register a CLI flag for a trailer definition. + */ +function registerFlagForDefinition(cmd: Command, key: string, def: CustomTrailerDefinition): void { + const flagName = def.cli?.flag || slugify(key); + const shorthand = def.cli?.shorthand ? `-${def.cli.shorthand}, ` : ''; + const argTemplate = def.multivalue ? `` : ``; + const description = `${def.description}${def.multivalue ? ' (repeatable)' : ''}`; + + cmd.option(`${shorthand}--${flagName} ${argTemplate}`, description); +} + +/** + * Convert a trailer key to a CLI-friendly flag name. + */ +function slugify(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); +} diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 00000000..21b486d9 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,45 @@ +import type { Command } from 'commander'; +import type { IConfigLoader } from '../interfaces/config-loader.js'; +import type { IOutputFormatter } from '../interfaces/output-formatter.js'; +import type { FormattableConfigResult } from '../types/output.js'; +import type { Protocol } from '../services/protocol.js'; + +/** + * Register the `lore config` command. + * Outputs the effective configuration for the current path. + */ +export function registerConfigCommand( + program: Command, + deps: { + configLoader: IConfigLoader; + getFormatter: () => IOutputFormatter; + protocol: Protocol; + }, +): void { + program + .command('config') + .description('Show effective configuration') + .option('--core', 'Show only core trailer definitions') + .option('--custom', 'Show only custom trailer definitions') + .action(async (options: { core?: boolean; custom?: boolean }) => { + const { configLoader, getFormatter, protocol } = deps; + const config = await configLoader.loadForPath(process.cwd()); + + const hasFilters = options.core !== undefined || options.custom !== undefined; + const showCore = options.core ?? !hasFilters; + const showCustom = options.custom ?? !hasFilters; + + const formattable: FormattableConfigResult = { + loreVersion: config.protocol.version, + permissive: config.trailers.permissive, + trailers: protocol.getFormattableDefinitions(), + filters: { + showCore, + showCustom, + }, + }; + + const formatter = getFormatter(); + console.log(formatter.formatConfig(formattable)); + }); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e9fc71c1..c0b86306 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -4,13 +4,14 @@ import type { IConfigLoader } from '../interfaces/config-loader.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import type { FormattableDoctorResult, DoctorCheck } from '../types/output.js'; import type { LoreAtom } from '../types/domain.js'; -import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS } from '../util/constants.js'; +import { LORE_ID_PATTERN, LORE_ID_KEY } from '../util/constants.js'; +import type { Protocol } from '../services/protocol.js'; /** * Register the `lore doctor` command. * Runs health checks: * 1. Config file exists and is valid - * 2. Lore-id uniqueness + * 2. ${LORE_ID_KEY} uniqueness * 3. All references resolve * 4. No orphaned dependencies */ @@ -20,13 +21,14 @@ export function registerDoctorCommand( atomRepository: AtomRepository; configLoader: IConfigLoader; getFormatter: () => IOutputFormatter; + protocol: Protocol; }, ): void { program .command('doctor') .description('Health check: broken refs, config issues') .action(async () => { - const { atomRepository, configLoader, getFormatter } = deps; + const { atomRepository, configLoader, getFormatter, protocol } = deps; const checks: DoctorCheck[] = []; @@ -48,11 +50,11 @@ export function registerDoctorCommand( allAtoms = []; } - // Check 2: Lore-id uniqueness + // Check 2: ${LORE_ID_KEY} uniqueness checks.push(checkLoreIdUniqueness(allAtoms)); - // Check 3: All references resolve - checks.push(checkReferencesResolve(allAtoms)); + // Check 3: All references resolve (metadata-driven) + checks.push(checkReferencesResolve(allAtoms, protocol)); // Check 4: Orphaned dependencies (depends on superseded atoms) checks.push(checkOrphanedDependencies(allAtoms)); @@ -132,40 +134,42 @@ function checkLoreIdUniqueness( const duplicates: string[] = []; for (const [id, count] of idCounts) { if (count > 1) { - duplicates.push(`Lore-id "${id}" appears ${count} times`); + duplicates.push(`${LORE_ID_KEY} "${id}" appears ${count} times`); } } if (duplicates.length > 0) { return { - name: 'Lore-id uniqueness', + name: '${LORE_ID_KEY} uniqueness', status: 'warning', - message: `${duplicates.length} duplicate Lore-id(s) found`, + message: `${duplicates.length} duplicate ${LORE_ID_KEY}(s) found`, details: duplicates, }; } return { - name: 'Lore-id uniqueness', + name: '${LORE_ID_KEY} uniqueness', status: 'ok', - message: `All ${atoms.length} Lore-ids are unique`, + message: `All ${atoms.length} ${LORE_ID_KEY}s are unique`, details: [], }; } function checkReferencesResolve( atoms: readonly LoreAtom[], + protocol: Protocol, ): DoctorCheck { const allLoreIds = new Set(atoms.map((a) => a.loreId)); const brokenRefs: string[] = []; + const refKeys = protocol.getReferenceKeys(); for (const atom of atoms) { - for (const key of REFERENCE_TRAILER_KEYS) { - const refs = atom.trailers[key] as readonly string[]; + for (const key of refKeys) { + const refs = atom.trailers[key] || []; for (const refId of refs) { if (LORE_ID_PATTERN.test(refId) && !allLoreIds.has(refId)) { brokenRefs.push( - `Lore-id "${refId}" referenced by ${atom.loreId} (${key}) not found`, + `${LORE_ID_KEY} "${refId}" referenced by ${atom.loreId} (${key}) not found`, ); } } diff --git a/src/commands/helpers/path-query.ts b/src/commands/helpers/path-query.ts index ad626741..bd947016 100644 --- a/src/commands/helpers/path-query.ts +++ b/src/commands/helpers/path-query.ts @@ -8,6 +8,7 @@ import type { TrailerKey, LoreAtom, SupersessionStatus } from '../../types/domai import type { PathQueryOptions, QueryResult, TargetType } from '../../types/query.js'; import type { FormattableQueryResult } from '../../types/output.js'; import { buildQueryMeta } from './build-query-meta.js'; +import type { Protocol } from '../../services/protocol.js'; /** Parse a CLI value as a strict positive integer; rejects non-numeric trailing chars. */ export function parsePositiveInt(value: string): number { @@ -27,6 +28,7 @@ export interface PathQueryDeps { readonly pathResolver: PathResolver; readonly getFormatter: () => IOutputFormatter; readonly config: LoreConfig; + readonly protocol: Protocol; } export interface PathQueryCommandOptions { @@ -37,6 +39,7 @@ export interface PathQueryCommandOptions { readonly limit?: number; readonly maxCommits?: number; readonly since?: string; + readonly until?: string; } /** @@ -53,7 +56,7 @@ export async function executePathQuery( commandName: string, visibleTrailers: readonly TrailerKey[] | 'all', ): Promise { - const { atomRepository, supersessionResolver, pathResolver, getFormatter, config } = deps; + const { atomRepository, supersessionResolver, pathResolver, getFormatter, config, protocol } = deps; const queryOptions: PathQueryOptions = { scope: options.scope ?? null, @@ -63,6 +66,7 @@ export async function executePathQuery( limit: options.limit ?? null, maxCommits: options.maxCommits ?? null, since: options.since ?? null, + until: options.until ?? null, }; // Step 1: Resolve target or use --scope @@ -71,13 +75,13 @@ export async function executePathQuery( let targetDisplay: string; if (queryOptions.scope) { - atoms = await atomRepository.findByScope(queryOptions.scope, queryOptions); + atoms = await atomRepository.findByScope(queryOptions.scope, { ...queryOptions, limit: null }); targetType = 'global'; targetDisplay = `scope:${queryOptions.scope}`; } else { const parsedTarget = pathResolver.parseTarget(target); const gitLogArgs = pathResolver.toGitLogArgs(parsedTarget); - atoms = await atomRepository.findByTarget(gitLogArgs, queryOptions); + atoms = await atomRepository.findByTarget(gitLogArgs, { ...queryOptions, limit: null }); targetType = parsedTarget.type; targetDisplay = target; } @@ -118,6 +122,7 @@ export async function executePathQuery( result, supersessionMap, visibleTrailers, + trailerDefinitions: protocol.getFormattableDefinitions(), }; // Step 6: Format and output @@ -136,5 +141,6 @@ export function addPathQueryOptions(cmd: Command): Command { .option('--author ', 'Filter by commit author') .option('--limit ', 'Maximum number of results to display', parsePositiveInt) .option('--max-commits ', 'Maximum git commits to scan (supersession may be incomplete)', parsePositiveInt) - .option('--since ', 'Only consider commits since ref/date'); + .option('--since ', 'Only consider commits since ref/date') + .option('--until ', 'Only consider commits until ref/date'); } diff --git a/src/commands/init.ts b/src/commands/init.ts index ec30e840..c5ec6279 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,9 +8,19 @@ const DEFAULT_CONFIG_CONTENT = `[protocol] version = "1.0" [trailers] +# If true, all unknown trailers are preserved. If false, only defined/custom are kept. +permissive = true required = [] custom = [] +# Define custom trailer rules here +# [trailers.definitions.Department] +# description = "The department responsible" +# multivalue = false +# validation = "options" +# options = ["Engineering", "Product", "Design"] +# required = true + [validation] strict = false max_message_lines = 50 diff --git a/src/commands/log.ts b/src/commands/log.ts index 57c2e7c2..8ed1d639 100644 --- a/src/commands/log.ts +++ b/src/commands/log.ts @@ -2,17 +2,20 @@ import type { Command } from 'commander'; import type { AtomRepository } from '../services/atom-repository.js'; import type { SupersessionResolver } from '../services/supersession-resolver.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; +import type { LoreConfig } from '../types/config.js'; import type { PathQueryOptions, QueryResult } from '../types/query.js'; import type { LoreAtom } from '../types/domain.js'; import type { FormattableQueryResult } from '../types/output.js'; import { mergeOptions } from './helpers/merge-options.js'; import { buildQueryMeta } from './helpers/build-query-meta.js'; import { parsePositiveInt } from './helpers/path-query.js'; +import type { Protocol } from '../services/protocol.js'; interface LogCommandOptions { readonly limit?: number; readonly maxCommits?: number; readonly since?: string; + readonly until?: string; } /** @@ -26,6 +29,8 @@ export function registerLogCommand( atomRepository: AtomRepository; supersessionResolver: SupersessionResolver; getFormatter: () => IOutputFormatter; + config: LoreConfig; + protocol: Protocol; }, ): void { program @@ -34,9 +39,10 @@ export function registerLogCommand( .option('--limit ', 'Maximum number of results to display', parsePositiveInt) .option('--max-commits ', 'Maximum git commits to scan (supersession may be incomplete)', parsePositiveInt) .option('--since ', 'Only consider commits since ref/date') + .option('--until ', 'Only consider commits until ref/date') .action(async (paths: string[], _options: LogCommandOptions, command: Command) => { const options = mergeOptions(command); - const { atomRepository, supersessionResolver, getFormatter } = deps; + const { atomRepository, supersessionResolver, getFormatter, protocol } = deps; let atoms: LoreAtom[]; @@ -50,13 +56,17 @@ export function registerLogCommand( limit: null, maxCommits: options.maxCommits ?? null, since: options.since ?? null, + until: options.until ?? null, }; atoms = await atomRepository.findByTarget(['--', ...paths], queryOptions); } else { - const findOptions: { since?: string; maxCommits?: number } = {}; + const findOptions: { since?: string; until?: string; maxCommits?: number } = {}; if (options.since) { findOptions.since = options.since; } + if (options.until) { + findOptions.until = options.until; + } if (options.maxCommits !== undefined && options.maxCommits > 0) { findOptions.maxCommits = options.maxCommits; } @@ -80,10 +90,12 @@ export function registerLogCommand( meta: buildQueryMeta(totalAtoms, displayAtoms), }; + const { config } = deps; const formattable: FormattableQueryResult = { result, supersessionMap, visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const formatter = getFormatter(); diff --git a/src/commands/search.ts b/src/commands/search.ts index 4fa00476..f8255419 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -3,12 +3,15 @@ import type { AtomRepository } from '../services/atom-repository.js'; import type { SupersessionResolver } from '../services/supersession-resolver.js'; import type { SearchFilter } from '../services/search-filter.js'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; +import type { LoreConfig } from '../types/config.js'; import type { TrailerKey, LoreAtom, SupersessionStatus } from '../types/domain.js'; import type { SearchOptions, QueryResult } from '../types/query.js'; import type { FormattableQueryResult } from '../types/output.js'; import { mergeOptions } from './helpers/merge-options.js'; import { buildQueryMeta } from './helpers/build-query-meta.js'; import { parsePositiveInt } from './helpers/path-query.js'; +import { LoreError } from '../util/errors.js'; +import type { Protocol } from '../services/protocol.js'; interface SearchCommandOptions { readonly confidence?: string; @@ -36,6 +39,8 @@ export function registerSearchCommand( supersessionResolver: SupersessionResolver; searchFilter: SearchFilter; getFormatter: () => IOutputFormatter; + config: LoreConfig; + protocol: Protocol; }, ): void { program @@ -55,13 +60,25 @@ export function registerSearchCommand( .option('--max-commits ', 'Maximum git commits to scan (supersession may be incomplete)', parsePositiveInt) .action(async (_options: SearchCommandOptions, command: Command) => { const options = mergeOptions(command); - const { atomRepository, supersessionResolver, searchFilter, getFormatter } = deps; + const { atomRepository, supersessionResolver, searchFilter, getFormatter, config, protocol } = deps; + + // Validate 'has' trailer key against protocol + let authorizedHas: TrailerKey | null = null; + if (options.has) { + authorizedHas = protocol.authorize(options.has) as TrailerKey | null; + if (!authorizedHas) { + throw new LoreError( + `'${options.has}' is not a valid Lore trailer. In strict mode, only core or explicitly configured trailers can be searched.`, + 1, + ); + } + } const searchOptions: SearchOptions = { confidence: (options.confidence as SearchOptions['confidence']) ?? null, scopeRisk: (options.scopeRisk as SearchOptions['scopeRisk']) ?? null, reversibility: (options.reversibility as SearchOptions['reversibility']) ?? null, - has: (options.has as TrailerKey) ?? null, + has: authorizedHas, author: options.author ?? null, scope: options.scope ?? null, text: options.text ?? null, @@ -111,6 +128,7 @@ export function registerSearchCommand( result, supersessionMap, visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const formatter = getFormatter(); @@ -121,9 +139,9 @@ export function registerSearchCommand( function buildSearchTargetDescription(options: SearchOptions): string { const parts: string[] = []; - if (options.confidence) parts.push(`confidence=${options.confidence}`); - if (options.scopeRisk) parts.push(`scope-risk=${options.scopeRisk}`); - if (options.reversibility) parts.push(`reversibility=${options.reversibility}`); + if (options.confidence) parts.push(`confidence=${String(options.confidence)}`); + if (options.scopeRisk) parts.push(`scope-risk=${String(options.scopeRisk)}`); + if (options.reversibility) parts.push(`reversibility=${String(options.reversibility)}`); if (options.has) parts.push(`has=${options.has}`); if (options.author) parts.push(`author=${options.author}`); if (options.scope) parts.push(`scope=${options.scope}`); diff --git a/src/commands/stale.ts b/src/commands/stale.ts index 87b31b4a..785a881a 100644 --- a/src/commands/stale.ts +++ b/src/commands/stale.ts @@ -54,6 +54,7 @@ export function registerStaleCommand( limit: null, maxCommits: null, since: null, + until: null, }; atoms = await atomRepository.findByTarget(gitLogArgs, queryOptions); } else { diff --git a/src/commands/trace.ts b/src/commands/trace.ts index d8b9e400..de67c80a 100644 --- a/src/commands/trace.ts +++ b/src/commands/trace.ts @@ -4,11 +4,12 @@ import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import type { LoreAtom, LoreId } from '../types/domain.js'; import type { FormattableTraceResult, TraceEdge } from '../types/output.js'; import { LoreError } from '../util/errors.js'; -import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS } from '../util/constants.js'; +import { LORE_ID_PATTERN, LORE_ID_KEY } from '../util/constants.js'; +import type { Protocol } from '../services/protocol.js'; /** * Register the `lore trace ` command. - * Finds an atom by Lore-id, then BFS through all references to build + * Finds an atom by ${LORE_ID_KEY}, then BFS through all references to build * a tree of edges showing the decision chain. */ export function registerTraceCommand( @@ -16,6 +17,7 @@ export function registerTraceCommand( deps: { atomRepository: AtomRepository; getFormatter: () => IOutputFormatter; + protocol: Protocol; }, ): void { program @@ -23,11 +25,11 @@ export function registerTraceCommand( .description('Follow decision chain from a starting atom') .option('--max-depth ', 'Maximum BFS traversal depth', parseInt, 10) .action(async (loreId: string, options: { maxDepth: number }) => { - const { atomRepository, getFormatter } = deps; + const { atomRepository, getFormatter, protocol } = deps; if (!LORE_ID_PATTERN.test(loreId)) { throw new LoreError( - `Invalid Lore-id format: "${loreId}". Must be 8-character hex.`, + `Invalid ${LORE_ID_KEY} format: "${loreId}". Must be 8-character hex.`, 1, ); } @@ -35,7 +37,7 @@ export function registerTraceCommand( const rootAtom = await atomRepository.findByLoreId(loreId); if (!rootAtom) { throw new LoreError( - `Lore-id "${loreId}" not found in commit history.`, + `${LORE_ID_KEY} "${loreId}" not found in commit history.`, 1, ); } @@ -50,14 +52,15 @@ export function registerTraceCommand( ]; const maxDepth = options.maxDepth; + const refKeys = protocol.getReferenceKeys(); while (queue.length > 0) { const entry = queue.shift()!; const currentAtom = entry.atom; - for (const key of REFERENCE_TRAILER_KEYS) { + for (const key of refKeys) { const relationship = key as TraceEdge['relationship']; - const refIds = currentAtom.trailers[key] as readonly LoreId[]; + const refIds = currentAtom.trailers[key] || []; for (const refId of refIds) { if (!LORE_ID_PATTERN.test(refId)) { diff --git a/src/commands/why.ts b/src/commands/why.ts index c3ffcf91..6e20612e 100644 --- a/src/commands/why.ts +++ b/src/commands/why.ts @@ -7,6 +7,7 @@ import type { LoreAtom, SupersessionStatus } from '../types/domain.js'; import type { QueryResult, QueryMeta } from '../types/query.js'; import type { FormattableQueryResult } from '../types/output.js'; import { LoreError } from '../util/errors.js'; +import type { Protocol } from '../services/protocol.js'; /** * Register the `lore why ` command. @@ -23,13 +24,14 @@ export function registerWhyCommand( gitClient: IGitClient; pathResolver: PathResolver; getFormatter: () => IOutputFormatter; + protocol: Protocol; }, ): void { program .command('why ') .description('Decision context for a specific line or line range') .action(async (target: string) => { - const { atomRepository, gitClient, pathResolver, getFormatter } = deps; + const { atomRepository, gitClient, pathResolver, getFormatter, protocol } = deps; const parsedTarget = pathResolver.parseTarget(target); @@ -108,6 +110,7 @@ export function registerWhyCommand( result, supersessionMap, visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const formatter = getFormatter(); diff --git a/src/formatters/json-formatter.ts b/src/formatters/json-formatter.ts index 2069dc3d..292935bd 100644 --- a/src/formatters/json-formatter.ts +++ b/src/formatters/json-formatter.ts @@ -5,23 +5,31 @@ import type { FormattableStalenessResult, FormattableTraceResult, FormattableDoctorResult, + FormattableConfigResult, } from '../types/output.js'; -import type { LoreAtom, LoreTrailers } from '../types/domain.js'; +import type { LoreTrailers } from '../types/domain.js'; +import { LORE_ID_KEY, LORE_ID_JSON_KEY, LORE_VERSION_JSON_KEY } from '../util/constants.js'; +/** + * Strategy implementation for JSON output. + * Produces machine-readable structured data. + * + * SOLID: SRP -- only responsible for JSON formatting. + */ export class JsonFormatter implements IOutputFormatter { formatQueryResult(data: FormattableQueryResult): string { - const { result, supersessionMap, visibleTrailers } = data; + const { result, supersessionMap, visibleTrailers, trailerDefinitions } = data; const results = result.atoms.map((atom) => { const supersession = supersessionMap.get(atom.loreId); return { - lore_id: atom.loreId, + [LORE_ID_JSON_KEY]: atom.loreId, commit: atom.commitHash, date: atom.date.toISOString(), author: atom.author, intent: atom.intent, body: atom.body, - trailers: this.serializeTrailers(atom.trailers, visibleTrailers), + trailers: this.serializeTrailers(atom.trailers, visibleTrailers, trailerDefinitions), files_changed: [...atom.filesChanged], superseded: supersession?.superseded ?? false, superseded_by: supersession?.supersededBy ?? null, @@ -30,7 +38,7 @@ export class JsonFormatter implements IOutputFormatter { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', command: result.command, target: result.target, target_type: result.targetType, @@ -50,7 +58,7 @@ export class JsonFormatter implements IOutputFormatter { formatValidationResult(data: FormattableValidationResult): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', valid: data.valid, summary: { errors: data.summary.errors, @@ -59,7 +67,7 @@ export class JsonFormatter implements IOutputFormatter { }, results: data.results.map((r) => ({ commit: r.commit, - lore_id: r.loreId, + [LORE_ID_JSON_KEY]: r.loreId, valid: r.valid, issues: r.issues.map((issue) => ({ severity: issue.severity, @@ -76,9 +84,9 @@ export class JsonFormatter implements IOutputFormatter { formatStalenessResult(data: FormattableStalenessResult): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', stale_atoms: data.atoms.map((report) => ({ - lore_id: report.atom.loreId, + [LORE_ID_JSON_KEY]: report.atom.loreId, commit: report.atom.commitHash, date: report.atom.date.toISOString(), author: report.atom.author, @@ -97,9 +105,9 @@ export class JsonFormatter implements IOutputFormatter { formatTraceResult(data: FormattableTraceResult): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', root: { - lore_id: data.root.loreId, + [LORE_ID_JSON_KEY]: data.root.loreId, commit: data.root.commitHash, date: data.root.date.toISOString(), author: data.root.author, @@ -112,7 +120,7 @@ export class JsonFormatter implements IOutputFormatter { resolved: edge.targetAtom !== null, target_atom: edge.targetAtom ? { - lore_id: edge.targetAtom.loreId, + [LORE_ID_JSON_KEY]: edge.targetAtom.loreId, commit: edge.targetAtom.commitHash, date: edge.targetAtom.date.toISOString(), author: edge.targetAtom.author, @@ -129,7 +137,7 @@ export class JsonFormatter implements IOutputFormatter { formatDoctorResult(data: FormattableDoctorResult): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', checks: data.checks.map((check) => ({ name: check.name, status: check.status, @@ -150,7 +158,7 @@ export class JsonFormatter implements IOutputFormatter { formatSuccess(message: string, data?: Record): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', success: true, message, ...(data ?? {}), @@ -163,7 +171,7 @@ export class JsonFormatter implements IOutputFormatter { formatError(code: number, messages: readonly ErrorMessage[]): string { return JSON.stringify( { - lore_version: '1.0', + [LORE_VERSION_JSON_KEY]: '1.0', error: true, code, messages: messages.map((msg) => ({ @@ -177,9 +185,38 @@ export class JsonFormatter implements IOutputFormatter { ); } + formatConfig(data: FormattableConfigResult): string { + const cleanTrailers: Record = {}; + + for (const [key, def] of Object.entries(data.trailers)) { + // In JSON config output, we show what's requested via filters + // (The filters are applied at the command layer before calling formatter) + + const { ui, ...clean } = def; + const stripped: any = { ...clean }; + + // Strip empty/default fields to reduce programmatic noise + if (stripped.directives && stripped.directives.length === 0) delete stripped.directives; + if (stripped.required === false) delete stripped.required; + if (stripped.validation === 'none') delete stripped.validation; + + cleanTrailers[key] = stripped; + } + + return JSON.stringify({ + [LORE_VERSION_JSON_KEY]: data.loreVersion, + permissive: data.permissive, + trailers: cleanTrailers + }, null, 2); + } + + /** + * Transforms trailers into a flat JSON-friendly record. + */ private serializeTrailers( trailers: LoreTrailers, visibleTrailers: readonly string[] | 'all', + trailerDefinitions: Record, ): Record { const result: Record = {}; @@ -188,47 +225,24 @@ export class JsonFormatter implements IOutputFormatter { return visibleTrailers.includes(key); }; - result['lore_id'] = trailers['Lore-id']; + // Special case: LORE_ID_KEY is ALWAYS included in JSON, matching 'main' + result[LORE_ID_JSON_KEY] = trailers[LORE_ID_KEY]?.[0] ?? null; - if (shouldShow('Constraint') && trailers.Constraint.length > 0) { - result['constraint'] = [...trailers.Constraint]; - } - if (shouldShow('Rejected') && trailers.Rejected.length > 0) { - result['rejected'] = [...trailers.Rejected]; - } - if (shouldShow('Confidence') && trailers.Confidence !== null) { - result['confidence'] = trailers.Confidence; - } - if (shouldShow('Scope-risk') && trailers['Scope-risk'] !== null) { - result['scope_risk'] = trailers['Scope-risk']; - } - if (shouldShow('Reversibility') && trailers.Reversibility !== null) { - result['reversibility'] = trailers.Reversibility; - } - if (shouldShow('Directive') && trailers.Directive.length > 0) { - result['directive'] = [...trailers.Directive]; - } - if (shouldShow('Tested') && trailers.Tested.length > 0) { - result['tested'] = [...trailers.Tested]; - } - if (shouldShow('Not-tested') && trailers['Not-tested'].length > 0) { - result['not_tested'] = [...trailers['Not-tested']]; - } - if (shouldShow('Supersedes') && trailers.Supersedes.length > 0) { - result['supersedes'] = [...trailers.Supersedes]; - } - if (shouldShow('Depends-on') && trailers['Depends-on'].length > 0) { - result['depends_on'] = [...trailers['Depends-on']]; - } - if (shouldShow('Related') && trailers.Related.length > 0) { - result['related'] = [...trailers.Related]; - } + for (const key of Object.keys(trailers)) { + if (key === LORE_ID_KEY) continue; + if (!shouldShow(key)) continue; + + const values = trailers[key]; + if (!values || values.length === 0) continue; - // Include custom trailers - for (const [key, values] of trailers.custom) { - if (values.length > 0) { - result[key.toLowerCase().replace(/-/g, '_')] = [...values]; - } + const jsonKey = key.toLowerCase().replace(/-/g, '_'); + + // Scalar vs Array normalization based on INJECTED metadata + // This achieves commonality: custom trailers with multivalue: false are now coerced. + const def = trailerDefinitions[key]; + const isScalar = def && !def.multivalue; + + result[jsonKey] = isScalar ? values[0] : [...values]; } return result; diff --git a/src/formatters/text-formatter.ts b/src/formatters/text-formatter.ts index b04233f2..7ea44339 100644 --- a/src/formatters/text-formatter.ts +++ b/src/formatters/text-formatter.ts @@ -7,9 +7,18 @@ import type { FormattableStalenessResult, FormattableTraceResult, FormattableDoctorResult, + FormattableConfigResult, + FormattableTrailerDefinition, } from '../types/output.js'; -import type { LoreAtom, TrailerKey } from '../types/domain.js'; - +import type { LoreAtom, LoreTrailers } from '../types/domain.js'; +import { LORE_ID_KEY, LORE_TRAILER_KEYS } from '../util/constants.js'; + +/** + * Strategy implementation for human-readable terminal output. + * Uses chalk for semantic coloring and box-drawing characters for structure. + * + * SOLID: SRP -- only responsible for human-readable text formatting. + */ export class TextFormatter implements IOutputFormatter { private readonly c: ChalkInstance; @@ -18,7 +27,7 @@ export class TextFormatter implements IOutputFormatter { } formatQueryResult(data: FormattableQueryResult): string { - const { result, supersessionMap, visibleTrailers } = data; + const { result, supersessionMap, visibleTrailers, trailerDefinitions } = data; const lines: string[] = []; if (result.atoms.length === 0) { @@ -41,7 +50,7 @@ export class TextFormatter implements IOutputFormatter { lines.push(` ${this.c.dim(atom.body)}`); } - const trailerLines = this.formatTrailers(atom, visibleTrailers); + const trailerLines = this.formatTrailers(atom, visibleTrailers, trailerDefinitions); for (const tl of trailerLines) { lines.push(` ${tl}`); } @@ -132,7 +141,7 @@ export class TextFormatter implements IOutputFormatter { const isLast = i === edgeCount - 1; const connector = isLast ? '\u2514\u2500\u2500' : '\u251C\u2500\u2500'; const relLabel = this.c.dim(`[${edge.relationship}]`); - + if (edge.targetAtom) { lines.push( `${connector} ${relLabel} ${this.c.bold(edge.to)} ${this.c.dim(edge.targetAtom.intent)}`, @@ -192,6 +201,40 @@ export class TextFormatter implements IOutputFormatter { return lines.join('\n'); } + formatConfig(data: FormattableConfigResult): string { + const lines: string[] = []; + lines.push(this.c.bold(`Lore Protocol v${data.loreVersion}`)); + lines.push(this.c.dim(`Permissive mode: ${data.permissive ? 'on' : 'off'}`)); + lines.push(''); + lines.push(this.c.bold('--- Trailer Schema ---')); + lines.push(''); + + const sortedKeys = Object.keys(data.trailers).sort(); + + if (data.filters.showCore) { + lines.push(this.c.bold('Standard Trailers')); + for (const key of sortedKeys) { + if (this.isCoreKey(key)) { + lines.push(this.formatTrailerDefinition(key, data.trailers[key])); + } + } + lines.push(''); + } + + if (data.filters.showCustom) { + const customKeys = sortedKeys.filter(k => !this.isCoreKey(k)); + if (customKeys.length > 0) { + lines.push(this.c.bold('Custom Trailers')); + for (const key of customKeys) { + lines.push(this.formatTrailerDefinition(key, data.trailers[key])); + } + lines.push(''); + } + } + + return lines.join('\n').trimEnd(); + } + formatSuccess(message: string, _data?: Record): string { return this.c.green(message); } @@ -215,6 +258,12 @@ export class TextFormatter implements IOutputFormatter { return lines.join('\n'); } + private isCoreKey(key: string): boolean { + // This is a minimal heuristic for the UI to group trailers. + // In a fully dynamic world, we might add an 'isCore' property to FormattableTrailerDefinition. + return (LORE_TRAILER_KEYS as readonly string[]).includes(key); + } + private formatAtomHeader(atom: LoreAtom, superseded: boolean): string { const dateStr = this.formatDate(atom.date); const header = `\u2500\u2500 ${atom.loreId} (${dateStr}, ${atom.author}) `; @@ -229,69 +278,61 @@ export class TextFormatter implements IOutputFormatter { private formatTrailers( atom: LoreAtom, - visibleTrailers: readonly TrailerKey[] | 'all', + visibleTrailers: readonly string[] | 'all', + trailerDefinitions: Record, ): string[] { const lines: string[] = []; const trailers = atom.trailers; - const shouldShow = (key: TrailerKey): boolean => { + const shouldShow = (key: string): boolean => { if (visibleTrailers === 'all') return true; return visibleTrailers.includes(key); }; - if (shouldShow('Constraint') && trailers.Constraint.length > 0) { - for (const v of trailers.Constraint) { - lines.push(`${this.c.cyan('Constraint:')} ${v}`); - } - } - if (shouldShow('Rejected') && trailers.Rejected.length > 0) { - for (const v of trailers.Rejected) { - lines.push(`${this.c.magenta('Rejected:')} ${v}`); - } - } - if (shouldShow('Confidence') && trailers.Confidence !== null) { - lines.push(`${this.c.cyan('Confidence:')} ${trailers.Confidence}`); - } - if (shouldShow('Scope-risk') && trailers['Scope-risk'] !== null) { - lines.push(`${this.c.cyan('Scope-risk:')} ${trailers['Scope-risk']}`); - } - if (shouldShow('Reversibility') && trailers.Reversibility !== null) { - lines.push( - `${this.c.cyan('Reversibility:')} ${trailers.Reversibility}`, - ); - } - if (shouldShow('Directive') && trailers.Directive.length > 0) { - for (const v of trailers.Directive) { - lines.push(`${this.c.yellow('Directive:')} ${v}`); - } - } - if (shouldShow('Tested') && trailers.Tested.length > 0) { - for (const v of trailers.Tested) { - lines.push(`${this.c.green('Tested:')} ${v}`); - } - } - if (shouldShow('Not-tested') && trailers['Not-tested'].length > 0) { - for (const v of trailers['Not-tested']) { - lines.push(`${this.c.red('Not-tested:')} ${v}`); - } - } - if (shouldShow('Supersedes') && trailers.Supersedes.length > 0) { - for (const v of trailers.Supersedes) { - lines.push(`${this.c.dim('Supersedes:')} ${v}`); + // Process all trailers uniformly from the flat object + for (const key of Object.keys(trailers)) { + if (key === LORE_ID_KEY) continue; + if (!shouldShow(key)) continue; + + const values = trailers[key]; + if (!values || values.length === 0) continue; + + // Determine color from injected metadata + const def = trailerDefinitions[key]; + const colorName = def?.ui?.color || 'cyan'; + const color = (this.c as any)[colorName] || this.c.cyan; + + for (const v of values) { + lines.push(`${color(`${key}:`)} ${v}`); } } - if (shouldShow('Depends-on') && trailers['Depends-on'].length > 0) { - for (const v of trailers['Depends-on']) { - lines.push(`${this.c.dim('Depends-on:')} ${v}`); - } + + return lines; + } + + private formatTrailerDefinition(key: string, def: FormattableTrailerDefinition): string { + const lines: string[] = []; + const label = def.required ? this.c.bold(key) : key; + const type = def.multivalue ? 'array' : 'string'; + const validation = def.validation !== 'none' ? ` (${def.validation})` : ''; + + lines.push(`- ${this.c.cyan(label)} [${type}]${validation}`); + lines.push(` ${this.c.dim(def.description)}`); + + if (def.validation === 'values' && def.values) { + const valueLabels = Object.entries(def.values).map(([k, v]) => (v.description ? `${k}: ${v.description}` : k)); + lines.push(` Values: ${valueLabels.join(', ')}`); + } else if (def.validation === 'pattern' && def.pattern) { + lines.push(` Pattern: ${def.pattern}`); } - if (shouldShow('Related') && trailers.Related.length > 0) { - for (const v of trailers.Related) { - lines.push(`${this.c.dim('Related:')} ${v}`); + + if (def.directives && def.directives.length > 0) { + for (const d of def.directives) { + lines.push(` ${this.c.yellow('\u26A0')} ${d}`); } } - return lines; + return lines.join('\n'); } private formatDate(date: Date): string { diff --git a/src/interfaces/commit-input-reader.ts b/src/interfaces/commit-input-reader.ts index 579d1b22..0ee4b479 100644 --- a/src/interfaces/commit-input-reader.ts +++ b/src/interfaces/commit-input-reader.ts @@ -1,4 +1,4 @@ -import type { CommitInput } from '../services/commit-builder.js'; +import type { CommitInput } from '../types/commit.js'; /** * Strategy interface for reading commit input from different sources. diff --git a/src/interfaces/output-formatter.ts b/src/interfaces/output-formatter.ts index aa3d51f1..4fae48ef 100644 --- a/src/interfaces/output-formatter.ts +++ b/src/interfaces/output-formatter.ts @@ -4,6 +4,7 @@ import type { FormattableStalenessResult, FormattableTraceResult, FormattableDoctorResult, + FormattableConfigResult, } from '../types/output.js'; export interface ErrorMessage { @@ -18,6 +19,7 @@ export interface IOutputFormatter { formatStalenessResult(data: FormattableStalenessResult): string; formatTraceResult(data: FormattableTraceResult): string; formatDoctorResult(data: FormattableDoctorResult): string; + formatConfig(data: FormattableConfigResult): string; formatSuccess(message: string, data?: Record): string; formatError(code: number, messages: readonly ErrorMessage[]): string; } diff --git a/src/interfaces/trailer-collector.ts b/src/interfaces/trailer-collector.ts index ffd32b1f..036c8ff8 100644 --- a/src/interfaces/trailer-collector.ts +++ b/src/interfaces/trailer-collector.ts @@ -1,9 +1,8 @@ import type { IPrompt } from './prompt.js'; -import type { CommitInput } from '../services/commit-builder.js'; -export interface TrailerCollectorResult { - readonly key: keyof NonNullable; - readonly value: readonly string[] | string | undefined; +export interface TrailerCollectionResult { + readonly key: string; + readonly value: string | string[] | undefined; } /** @@ -17,5 +16,6 @@ export interface TrailerCollectorResult { * SOLID: OCP -- new trailer types require only a new collector implementation. */ export interface ITrailerCollector { - collect(prompt: IPrompt): Promise; + readonly key: string; + collect(prompt: IPrompt): Promise; } diff --git a/src/main.ts b/src/main.ts index c027262e..3dbf8fe2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ import { TerminalPrompt } from './services/terminal-prompt.js'; import { CommitInputResolver } from './services/commit-input-resolver.js'; import { HeadLoreIdReader } from './services/head-lore-id-reader.js'; import { SearchFilter } from './services/search-filter.js'; +import { Protocol } from './services/protocol.js'; import { TextFormatter } from './formatters/text-formatter.js'; import { JsonFormatter } from './formatters/json-formatter.js'; @@ -44,6 +45,7 @@ import { registerCommitCommand } from './commands/commit.js'; import { registerValidateCommand } from './commands/validate.js'; import { registerSquashCommand } from './commands/squash.js'; import { registerDoctorCommand } from './commands/doctor.js'; +import { registerConfigCommand } from './commands/config.js'; import { LoreError, ValidationError } from './util/errors.js'; import { shouldCheckForUpdate } from './util/update-check.js'; @@ -72,7 +74,6 @@ async function main(): Promise { // 1. Create concrete implementations const gitClient: IGitClient = new GitClient(); - const trailerParser = new TrailerParser(); const pathResolver = new PathResolver(); const loreIdGenerator = new LoreIdGenerator(); const configLoader: IConfigLoader = new ConfigLoader(); @@ -83,7 +84,7 @@ async function main(): Promise { config = await configLoader.loadForPath(process.cwd()); } catch { // Fall back to defaults if config can't be loaded - const { DEFAULT_CONFIG } = await import('./types/config.js'); + const { DEFAULT_CONFIG } = await import('./util/constants.js'); config = DEFAULT_CONFIG; } @@ -93,15 +94,17 @@ async function main(): Promise { } // 4. Create services that depend on others - const atomRepository = new AtomRepository(gitClient, trailerParser, config.trailers.custom); + const protocol = new Protocol(config); + const trailerParser = new TrailerParser(protocol); + const atomRepository = new AtomRepository(gitClient, trailerParser, protocol); const supersessionResolver = new SupersessionResolver(); const stalenessDetector = new StalenessDetector(gitClient, config); - const commitBuilder = new CommitBuilder(trailerParser, loreIdGenerator, config); - const squashMerger = new SquashMerger(loreIdGenerator); - const validator = new Validator(trailerParser, atomRepository, config); + const commitBuilder = new CommitBuilder(trailerParser, loreIdGenerator, config, protocol); + const squashMerger = new SquashMerger(loreIdGenerator, protocol); + const validator = new Validator(trailerParser, atomRepository, config, protocol); const searchFilter = new SearchFilter(); const prompt = new TerminalPrompt(); - const commitInputResolver = new CommitInputResolver(prompt); + const commitInputResolver = new CommitInputResolver(prompt, protocol); const headLoreIdReader = new HeadLoreIdReader(gitClient, trailerParser); // 5. Formatter factory (reads --format/--json from program options at call time) @@ -130,6 +133,7 @@ async function main(): Promise { pathResolver, getFormatter, config, + protocol, }; registerContextCommand(program, pathQueryDeps); @@ -143,6 +147,7 @@ async function main(): Promise { gitClient, pathResolver, getFormatter, + protocol, }); registerSearchCommand(program, { @@ -150,12 +155,16 @@ async function main(): Promise { supersessionResolver, searchFilter, getFormatter, + config, + protocol, }); registerLogCommand(program, { atomRepository, supersessionResolver, getFormatter, + config, + protocol, }); registerStaleCommand(program, { @@ -169,6 +178,7 @@ async function main(): Promise { registerTraceCommand(program, { atomRepository, getFormatter, + protocol, }); registerCommitCommand(program, { @@ -177,6 +187,8 @@ async function main(): Promise { getFormatter, commitInputResolver, headLoreIdReader, + config, + protocol, }); registerValidateCommand(program, { @@ -195,6 +207,13 @@ async function main(): Promise { atomRepository, configLoader, getFormatter, + protocol, + }); + + registerConfigCommand(program, { + configLoader, + getFormatter, + protocol, }); // 7. Parse and run @@ -214,6 +233,7 @@ main().catch((error: unknown) => { if (error instanceof ValidationError) { const messages = error.issues.map((issue) => ({ severity: issue.severity, + field: issue.field, message: issue.message, })); const output = formatter.formatError(error.exitCode, messages); diff --git a/src/services/atom-repository.ts b/src/services/atom-repository.ts index a636ba9c..7a95edd1 100644 --- a/src/services/atom-repository.ts +++ b/src/services/atom-repository.ts @@ -2,7 +2,8 @@ import type { IGitClient, RawCommit } from '../interfaces/git-client.js'; import type { PathQueryOptions } from '../types/query.js'; import type { LoreAtom, LoreId, LoreTrailers } from '../types/domain.js'; import type { TrailerParser } from '../services/trailer-parser.js'; -import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS, GIT_FILES_CHANGED_BATCH_SIZE } from '../util/constants.js'; +import { LORE_ID_PATTERN, GIT_FILES_CHANGED_BATCH_SIZE, LORE_ID_KEY } from '../util/constants.js'; +import type { Protocol } from './protocol.js'; /** * Retrieves LoreAtoms from git history. @@ -10,12 +11,13 @@ import { LORE_ID_PATTERN, REFERENCE_TRAILER_KEYS, GIT_FILES_CHANGED_BATCH_SIZE } * * GRASP: Pure Fabrication -- persistence access abstracted from domain. * SOLID: DIP -- depends on IGitClient interface, not child_process. + * GRASP: Information Expert -- knows how to map git commits to Lore domain models. */ export class AtomRepository { constructor( private readonly gitClient: IGitClient, private readonly trailerParser: TrailerParser, - private readonly customTrailerKeys: readonly string[] = [], + private readonly protocol: Protocol, ) {} /** @@ -41,7 +43,7 @@ export class AtomRepository { return null; } - const logArgs = ['--all', `--grep=Lore-id: ${loreId}`]; + const logArgs = ['--all', `--grep=${LORE_ID_KEY}: ${loreId}`]; const rawCommits = await this.gitClient.log(logArgs); const atoms = await this.parseRawCommits(rawCommits); @@ -177,6 +179,9 @@ export class AtomRepository { if (options.since) { args.push(`--since=${options.since}`); } + if (options.until) { + args.push(`--until=${options.until}`); + } if (options.maxCommits !== null && options.maxCommits > 0) { args.push(`--max-count=${options.maxCommits}`); } @@ -199,8 +204,9 @@ export class AtomRepository { continue; } - const trailers = this.trailerParser.parse(raw.trailers, this.customTrailerKeys); - if (!LORE_ID_PATTERN.test(trailers['Lore-id'])) { + const trailers = this.trailerParser.parse(raw.trailers); + const loreId = trailers[LORE_ID_KEY]?.[0]; + if (!loreId || !LORE_ID_PATTERN.test(loreId)) { continue; } @@ -232,8 +238,11 @@ export class AtomRepository { * GRASP: Creator -- AtomRepository owns the data needed to create atoms. */ private buildAtom(raw: RawCommit, trailers: LoreTrailers, filesChanged: readonly string[]): LoreAtom { + const loreId = trailers[LORE_ID_KEY]?.[0]; + if (!loreId) throw new Error(`${LORE_ID_KEY} missing in trailers`); + return { - loreId: trailers['Lore-id'], + loreId, commitHash: raw.hash, date: new Date(raw.date), author: raw.author, @@ -308,9 +317,11 @@ export class AtomRepository { */ private extractReferenceIds(trailers: LoreTrailers): LoreId[] { const ids: LoreId[] = []; + const refKeys = this.protocol.getReferenceKeys(); - for (const key of REFERENCE_TRAILER_KEYS) { + for (const key of refKeys) { const values = trailers[key] as readonly LoreId[]; + if (!values) continue; for (const id of values) { if (LORE_ID_PATTERN.test(id)) { ids.push(id); diff --git a/src/services/commit-builder.ts b/src/services/commit-builder.ts index 9f17fd5c..a4460650 100644 --- a/src/services/commit-builder.ts +++ b/src/services/commit-builder.ts @@ -1,162 +1,125 @@ import type { TrailerParser } from './trailer-parser.js'; import type { LoreIdGenerator } from './lore-id-generator.js'; import type { LoreConfig } from '../types/config.js'; -import type { LoreTrailers, ConfidenceLevel, ScopeRiskLevel, ReversibilityLevel, LoreId } from '../types/domain.js'; +import type { LoreTrailers, LoreId } from '../types/domain.js'; +import type { CommitInput } from '../types/commit.js'; import type { ValidationIssue } from '../types/output.js'; -import { CustomTrailerCollection } from '../types/custom-trailer-collection.js'; -import { - CONFIDENCE_VALUES, - SCOPE_RISK_VALUES, - REVERSIBILITY_VALUES, - LORE_ID_PATTERN, -} from '../util/constants.js'; - -export interface CommitInput { - readonly intent: string; - readonly body?: string; - readonly trailers?: { - readonly Constraint?: readonly string[]; - readonly Rejected?: readonly string[]; - readonly Confidence?: ConfidenceLevel; - readonly 'Scope-risk'?: ScopeRiskLevel; - readonly Reversibility?: ReversibilityLevel; - readonly Directive?: readonly string[]; - readonly Tested?: readonly string[]; - readonly 'Not-tested'?: readonly string[]; - readonly Supersedes?: readonly string[]; - readonly 'Depends-on'?: readonly string[]; - readonly Related?: readonly string[]; - readonly custom?: CustomTrailerCollection; - }; -} - +import { ARRAY_TRAILER_KEYS, ENUM_TRAILER_KEYS, LORE_ID_KEY } from '../util/constants.js'; +import type { Protocol } from './protocol.js'; + +/** + * Builds and validates git commit messages enriched with Lore decision context. + * + * SOLID: SRP -- responsible only for commit message construction. + * SOLID: OCP -- fully metadata-driven; no hardcoded trailer names in construction. + */ export class CommitBuilder { - private readonly trailerParser: TrailerParser; - private readonly loreIdGenerator: LoreIdGenerator; - private readonly config: LoreConfig; - constructor( - trailerParser: TrailerParser, - loreIdGenerator: LoreIdGenerator, - config: LoreConfig, - ) { - this.trailerParser = trailerParser; - this.loreIdGenerator = loreIdGenerator; - this.config = config; - } - + private readonly trailerParser: TrailerParser, + private readonly loreIdGenerator: LoreIdGenerator, + private readonly config: LoreConfig, + private readonly protocol: Protocol, + ) {} + + /** + * Builds a full git commit message with subject, body, and Lore trailer block. + */ build(input: CommitInput, existingLoreId?: LoreId): string { - const loreId = existingLoreId ?? this.loreIdGenerator.generate(); + const loreId = existingLoreId || this.loreIdGenerator.generate(); const trailers = this.buildTrailers(loreId, input); - const serialized = this.trailerParser.serialize(trailers); - - const parts: string[] = [input.intent]; + const trailerBlock = this.trailerParser.serialize(trailers); - if (input.body) { - parts.push(''); - parts.push(input.body); + let message = input.intent; + if (input.body && input.body.trim()) { + message += `\n\n${input.body.trim()}`; } + message += `\n\n${trailerBlock}`; - parts.push(''); - parts.push(serialized); - - return parts.join('\n'); + return message; } + /** + * Performs validation on the commit input. + */ validate(input: CommitInput): ValidationIssue[] { const issues: ValidationIssue[] = []; - // 1. Intent length - if (input.intent.length > this.config.validation.intentMaxLength) { - issues.push({ - severity: 'warning', - rule: 'intent-length', - message: `Intent exceeds ${this.config.validation.intentMaxLength} characters (got ${input.intent.length})`, - }); - } - - // 2. Intent must not be empty - if (input.intent.trim().length === 0) { + // 1. Intent presence + if (!input.intent.trim()) { issues.push({ severity: 'error', rule: 'intent-required', - message: 'Intent must not be empty', + message: 'Commit intent (subject line) is required', }); } - // 3. Validate enum values - if (input.trailers?.Confidence !== undefined) { - if ( - !(CONFIDENCE_VALUES as readonly string[]).includes( - input.trailers.Confidence, - ) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Confidence value: "${input.trailers.Confidence}". Expected one of: ${CONFIDENCE_VALUES.join(', ')}`, - }); - } - } - - if (input.trailers?.['Scope-risk'] !== undefined) { - if ( - !(SCOPE_RISK_VALUES as readonly string[]).includes( - input.trailers['Scope-risk'], - ) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Scope-risk value: "${input.trailers['Scope-risk']}". Expected one of: ${SCOPE_RISK_VALUES.join(', ')}`, - }); - } + // 2. Intent length + if (input.intent.length > this.config.validation.intentMaxLength) { + issues.push({ + severity: 'warning', + rule: 'intent-length', + message: `Intent exceeds ${this.config.validation.intentMaxLength} characters (got ${input.intent.length})`, + }); } - if (input.trailers?.Reversibility !== undefined) { - if ( - !(REVERSIBILITY_VALUES as readonly string[]).includes( - input.trailers.Reversibility, - ) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Reversibility value: "${input.trailers.Reversibility}". Expected one of: ${REVERSIBILITY_VALUES.join(', ')}`, - }); - } - } + // 3. Metadata-driven schema validation + if (input.trailers) { + const authorizedKeys = this.protocol.getAuthorizedKeys(); + for (const key of authorizedKeys) { + const def = this.protocol.getDefinition(key); + if (!def) continue; - // 4. Validate lore-id format in reference trailers - const referenceKeys = ['Supersedes', 'Depends-on', 'Related'] as const; - for (const key of referenceKeys) { - const values = input.trailers?.[key]; - if (values) { - for (const value of values) { - if (!LORE_ID_PATTERN.test(value)) { - issues.push({ - severity: 'error', - rule: 'invalid-lore-id-ref', - message: `Invalid Lore-id reference in ${key}: "${value}". Must be 8-character hex.`, - }); + const values = input.trailers[key]; + if (!values || values.length === 0) continue; + + if (def.validation === 'values' && def.values) { + const allowedValues = Object.keys(def.values); + for (const v of values) { + if (!allowedValues.includes(v)) { + issues.push({ + severity: 'error', + rule: 'invalid-enum', + field: key, + message: `Invalid value for "${key}": "${v}". Expected one of: ${allowedValues.join(', ')}`, + }); + } + } + } else if (def.validation === 'pattern' && def.pattern) { + const regex = new RegExp(def.pattern); + for (const v of values) { + if (!regex.test(v)) { + // Map pattern failures to specific rules for backward compatibility with tests + let rule = 'invalid-format'; + if (def.ui?.kind === 'reference') { + rule = 'invalid-lore-id-ref'; + } + + issues.push({ + severity: 'error', + rule, + field: key, + message: `Value for "${key}" does not match pattern: ${def.pattern}`, + }); + } } } } } - // 5. Required trailers from config - const requiredTrailers = this.config.trailers.required; - for (const required of requiredTrailers) { - if (!this.hasTrailer(input, required)) { + // 4. Required trailers + const requiredKeys = new Set(this.config.trailers.required); + for (const key of requiredKeys) { + if (!this.hasTrailer(input, key)) { issues.push({ severity: this.config.validation.strict ? 'error' : 'warning', rule: 'required-trailer', - message: `Required trailer "${required}" is missing`, + field: key, + message: `Required trailer "${key}" is missing`, }); } } - // 6. Total message line count + // 5. Total message line count const lineCount = this.estimateLineCount(input); if (lineCount > this.config.validation.maxMessageLines) { issues.push({ @@ -169,69 +132,49 @@ export class CommitBuilder { return issues; } + /** + * Dynamically constructs a LoreTrailers object from input metadata. + */ private buildTrailers(loreId: LoreId, input: CommitInput): LoreTrailers { - return { - 'Lore-id': loreId, - Constraint: input.trailers?.Constraint ? [...input.trailers.Constraint] : [], - Rejected: input.trailers?.Rejected ? [...input.trailers.Rejected] : [], - Confidence: input.trailers?.Confidence ?? null, - 'Scope-risk': input.trailers?.['Scope-risk'] ?? null, - Reversibility: input.trailers?.Reversibility ?? null, - Directive: input.trailers?.Directive ? [...input.trailers.Directive] : [], - Tested: input.trailers?.Tested ? [...input.trailers.Tested] : [], - 'Not-tested': input.trailers?.['Not-tested'] ? [...input.trailers['Not-tested']] : [], - Supersedes: input.trailers?.Supersedes ? [...input.trailers.Supersedes] : [], - 'Depends-on': input.trailers?.['Depends-on'] ? [...input.trailers['Depends-on']] : [], - Related: input.trailers?.Related ? [...input.trailers.Related] : [], - custom: input.trailers?.custom ?? CustomTrailerCollection.empty(), + // Start with a record that is strictly string[] + const result: Record = { + [LORE_ID_KEY]: [loreId], }; - } - private hasTrailer(input: CommitInput, key: string): boolean { - if (!input.trailers) return false; + // Pre-initialize core keys for uniformity + for (const key of ARRAY_TRAILER_KEYS) result[key] = []; + for (const key of ENUM_TRAILER_KEYS) result[key] = []; - const value = (input.trailers as Record)[key]; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === 'string') return value.length > 0; + if (input.trailers) { + for (const [key, values] of Object.entries(input.trailers)) { + if (key === LORE_ID_KEY) continue; + if (values) { + result[key] = [...values]; + } + } + } + + return result as unknown as LoreTrailers; + } - return input.trailers.custom?.has(key) ?? false; + private hasTrailer(input: CommitInput, key: string): boolean { + const val = input.trailers?.[key]; + return !!val && val.length > 0; } private estimateLineCount(input: CommitInput): number { - let count = 1; // intent line + let count = 1; // intent if (input.body) { - count += 1; // blank line + count += 2; // blank line + body count += input.body.split('\n').length; } - count += 1; // blank line before trailers - // Count trailer lines if (input.trailers) { - count += 1; // Lore-id - const arrayKeys = [ - 'Constraint', - 'Rejected', - 'Directive', - 'Tested', - 'Not-tested', - 'Supersedes', - 'Depends-on', - 'Related', - ] as const; - for (const key of arrayKeys) { - const values = input.trailers[key]; + count += 2; // blank line + LORE_ID_KEY + for (const values of Object.values(input.trailers)) { if (values) { count += values.length; } } - const enumKeys = ['Confidence', 'Scope-risk', 'Reversibility'] as const; - for (const key of enumKeys) { - if (input.trailers[key] !== undefined) { - count += 1; - } - } - if (input.trailers.custom) { - count += input.trailers.custom.lineCount; - } } return count; } diff --git a/src/services/commit-input-resolver.ts b/src/services/commit-input-resolver.ts index 37af4eab..8366dbd7 100644 --- a/src/services/commit-input-resolver.ts +++ b/src/services/commit-input-resolver.ts @@ -1,12 +1,13 @@ import type { IPrompt } from '../interfaces/prompt.js'; import type { ICommitInputReader } from '../interfaces/commit-input-reader.js'; -import type { CommitInput } from './commit-builder.js'; +import type { CommitInput } from '../types/commit.js'; import { readFile } from 'node:fs/promises'; import { InteractiveInputReader } from './readers/interactive-input-reader.js'; import { JsonInputReader } from './readers/json-input-reader.js'; import { FlagsInputReader } from './readers/flags-input-reader.js'; -import { createTrailerCollectors } from './readers/collectors/trailer-collector-registry.js'; +import { TrailerCollectorRegistry } from './readers/collectors/trailer-collector-registry.js'; +import type { Protocol } from './protocol.js'; /** * The modes of commit input resolution, ordered by priority. @@ -21,6 +22,9 @@ export enum InputMode { /** * CLI options passed to the commit command. + * + * SOLID: SRP -- pure DTO for CLI option parsing. + * Supports dynamic core flags via index signature. */ export interface CommitCommandOptions { readonly amend?: boolean; @@ -29,17 +33,9 @@ export interface CommitCommandOptions { readonly interactive?: boolean; readonly intent?: string; readonly body?: string; - readonly constraint?: string[]; - readonly rejected?: string[]; - readonly confidence?: string; - readonly scopeRisk?: string; - readonly reversibility?: string; - readonly directive?: string[]; - readonly tested?: string[]; - readonly notTested?: string[]; - readonly supersedes?: string[]; - readonly dependsOn?: string[]; - readonly related?: string[]; + readonly trailer?: string[]; + /** Dynamic core flags from definitions (e.g. confidence, scope-risk) */ + readonly [key: string]: unknown; } /** @@ -57,7 +53,10 @@ export interface CommitCommandOptions { * SOLID: OCP -- new input modes require only a new reader + a case in createReader(). */ export class CommitInputResolver { - constructor(private readonly prompt: IPrompt) {} + constructor( + private readonly prompt: IPrompt, + private readonly protocol: Protocol, + ) {} /** * Resolve commit input from the appropriate source based on CLI options. @@ -80,7 +79,13 @@ export class CommitInputResolver { if (options.file) { return InputMode.File; } - if (options.intent) { + + // Check if any intent or any trailer flag was provided + const hasFlags = !!options.intent || + !!options.trailer || + Object.keys(options).some(k => k !== 'amend' && k !== 'edit'); + + if (hasFlags) { return InputMode.Flags; } if (process.stdin.isTTY) { @@ -97,28 +102,22 @@ export class CommitInputResolver { options: CommitCommandOptions, ): Promise { switch (mode) { - case InputMode.Interactive: - return new InteractiveInputReader(this.prompt, createTrailerCollectors()); - case InputMode.File: { - const content = await this.readFileContent(options.file!); - return new JsonInputReader(content); - } - case InputMode.Stdin: { - const content = await this.readStdinContent(); - return new JsonInputReader(content); + case InputMode.Interactive: { + const registry = new TrailerCollectorRegistry(this.protocol); + return new InteractiveInputReader( + this.prompt, + registry.getCollectors(), + ); } + case InputMode.File: + return new JsonInputReader(await readFile(options.file!, 'utf-8')); case InputMode.Flags: - return new FlagsInputReader(options); + return new FlagsInputReader(options, this.protocol); + case InputMode.Stdin: + return new JsonInputReader(await this.readStdinContent()); } } - /** - * Read raw content from a file path. - */ - private async readFileContent(filePath: string): Promise { - return readFile(filePath, 'utf-8'); - } - /** * Read raw content from stdin, collecting chunks until EOF. */ diff --git a/src/services/config-loader.ts b/src/services/config-loader.ts index 3be7aafd..261911e3 100644 --- a/src/services/config-loader.ts +++ b/src/services/config-loader.ts @@ -2,9 +2,20 @@ import { readFile, access, stat } from 'node:fs/promises'; import { join, dirname, resolve, parse as parsePath } from 'node:path'; import { parse as parseToml } from 'smol-toml'; import type { IConfigLoader } from '../interfaces/config-loader.js'; -import type { LoreConfig } from '../types/config.js'; -import { DEFAULT_CONFIG } from '../types/config.js'; -import { CONFIG_DIR, CONFIG_FILENAME } from '../util/constants.js'; +import type { + LoreConfig, + CustomTrailerDefinition, + ValueDefinition, + TrailerUiKind, + TrailerUiColor +} from '../types/config.js'; +import { + CONFIG_DIR, + CONFIG_FILENAME, + TRAILER_UI_KINDS, + TRAILER_UI_COLORS, + DEFAULT_CONFIG, +} from '../util/constants.js'; type ConfigSection = keyof LoreConfig; @@ -140,6 +151,10 @@ export class ConfigLoader implements IConfigLoader { const built: Record = {}; for (const [key, defaultValue] of Object.entries(defaults)) { + if (section === 'trailers' && key === 'definitions') { + built[key] = this.resolveDefinitions(sectionData[key]); + continue; + } built[key] = this.resolveFieldValue(sectionData, key, aliases[key], defaultValue); } @@ -170,6 +185,89 @@ export class ConfigLoader implements IConfigLoader { return typeof rawValue === typeof defaultValue ? rawValue : defaultValue; } + /** + * Parse and validate custom trailer definitions from raw config data. + */ + private resolveDefinitions(rawData: unknown): Record { + if (!rawData || typeof rawData !== 'object') { + return {}; + } + + const result: Record = {}; + const entries = Object.entries(rawData as Record); + + for (const [key, value] of entries) { + if (!value || typeof value !== 'object') continue; + + const def = value as Record; + + let validation: 'values' | 'pattern' | 'none' = 'none'; + if (def.validation === 'values' || def.validation === 'options') { + validation = 'values'; + } else if (def.validation === 'pattern') { + validation = 'pattern'; + } + + const directives = Array.isArray(def.directives) + ? def.directives.filter((d): d is string => typeof d === 'string') + : undefined; + + const uiRaw = typeof def.ui === 'object' && def.ui !== null ? def.ui as Record : undefined; + + result[key] = { + description: typeof def.description === 'string' ? def.description : '', + multivalue: typeof def.multivalue === 'boolean' ? def.multivalue : false, + validation, + values: this.resolveValues(def.values || def.options), + pattern: typeof def.pattern === 'string' ? def.pattern : undefined, + required: typeof def.required === 'boolean' ? def.required : false, + directives, + ui: uiRaw ? { + kind: (TRAILER_UI_KINDS as readonly string[]).includes(uiRaw.kind as string) + ? uiRaw.kind as TrailerUiKind + : undefined, + color: (TRAILER_UI_COLORS as readonly string[]).includes(uiRaw.color as string) + ? uiRaw.color as TrailerUiColor + : undefined, + } : undefined, + }; + } + + return result; + } + + /** + * Normalize values from either a string array or a metadata record. + */ + private resolveValues(valuesRaw: unknown): Record | undefined { + if (Array.isArray(valuesRaw)) { + const result: Record = {}; + for (const opt of valuesRaw) { + if (typeof opt === 'string') { + result[opt] = { description: '' }; + } + } + return result; + } + + if (valuesRaw && typeof valuesRaw === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(valuesRaw as Record)) { + if (typeof value === 'string') { + result[key] = { description: value }; + } else if (value && typeof value === 'object') { + const optDef = value as Record; + result[key] = { + description: typeof optDef.description === 'string' ? optDef.description : '', + }; + } + } + return result; + } + + return undefined; + } + private async fileExists(filePath: string): Promise { try { await access(filePath); diff --git a/src/services/head-lore-id-reader.ts b/src/services/head-lore-id-reader.ts index 6e84016f..c131c2b5 100644 --- a/src/services/head-lore-id-reader.ts +++ b/src/services/head-lore-id-reader.ts @@ -1,7 +1,7 @@ import type { IGitClient } from '../interfaces/git-client.js'; import type { TrailerParser } from './trailer-parser.js'; import type { LoreId } from '../types/domain.js'; -import { LORE_ID_PATTERN } from '../util/constants.js'; +import { LORE_ID_PATTERN, LORE_ID_KEY } from '../util/constants.js'; /** * Reads the Lore-id from the HEAD commit message. @@ -29,8 +29,9 @@ export class HeadLoreIdReader { const trailerBlock = this.trailerParser.extractTrailerBlock(message); if (!trailerBlock) return null; - const trailers = this.trailerParser.parse(trailerBlock, []); - const loreId = trailers['Lore-id']; + const trailers = this.trailerParser.parse(trailerBlock); + const loreIdArray = trailers[LORE_ID_KEY]; + const loreId = loreIdArray && loreIdArray.length > 0 ? loreIdArray[0] : null; return loreId && LORE_ID_PATTERN.test(loreId) ? loreId : null; } } diff --git a/src/services/protocol.ts b/src/services/protocol.ts new file mode 100644 index 00000000..38e5826b --- /dev/null +++ b/src/services/protocol.ts @@ -0,0 +1,189 @@ +import type { LoreConfig, CustomTrailerDefinition, TrailerUiKind, TrailerUiColor, ValueDefinition } from '../types/config.js'; +import { CORE_TRAILER_DEFINITIONS, LORE_ID_KEY } from '../util/constants.js'; +import type { TrailerKey } from '../types/domain.js'; +import type { FormattableTrailerDefinition } from '../types/output.js'; + +/** + * Metadata for a protocol trailer, including its origin (core vs custom). + */ +export interface AuthorizedTrailerDefinition extends CustomTrailerDefinition { + readonly key: string; + readonly isCore: boolean; +} + +/** + * The central engine for Lore Protocol rules. + * Merges built-in core trailers with project-specific custom configuration. + * Provides lookup and authorization services for builders and formatters. + */ +export class Protocol { + private readonly definitions = new Map(); + private readonly caseMap = new Map(); + + constructor(private readonly config: LoreConfig) { + this.loadDefinitions(); + } + + private loadDefinitions(): void { + // 1. Load Core Trailers + for (const [key, def] of Object.entries(CORE_TRAILER_DEFINITIONS)) { + this.addDefinition(key, { ...def, key, isCore: true }); + } + + // 2. Load Configured Custom Trailers (definitions) + for (const [key, def] of Object.entries(this.config.trailers.definitions)) { + const existing = this.definitions.get(key); + const isCore = existing?.isCore ?? false; + this.addDefinition(key, { ...def, key, isCore }); + } + + // 3. Load Simple Custom Trailers (from custom list) + for (const key of this.config.trailers.custom) { + if (!this.definitions.has(key)) { + this.addDefinition(key, { + key, + description: `Custom project trailer: ${key}`, + multivalue: true, + validation: 'none', + isCore: false, + }); + } + } + + // 4. Apply 'required' status from the required list (unification) + for (const key of this.config.trailers.required) { + const authorizedKey = this.authorize(key); + if (authorizedKey) { + const def = this.definitions.get(authorizedKey); + if (def) { + this.definitions.set(authorizedKey, { ...def, required: true }); + } + } + } + } + + private addDefinition(key: string, def: AuthorizedTrailerDefinition): void { + this.definitions.set(key, def); + this.caseMap.set(key.toLowerCase(), key); + } + + /** + * Authorizes a trailer key for use. + * Returns the canonical casing of the key if authorized, otherwise null. + */ + authorize(key: string): TrailerKey | string | null { + // 1. Case-insensitive match against known definitions + const canonicalKey = this.caseMap.get(key.toLowerCase()); + if (canonicalKey) { + return canonicalKey as TrailerKey; + } + + // 2. If not defined, check permissive mode + if (this.config.trailers.permissive) { + return key; + } + + return null; + } + + /** + * Returns the metadata definition for a key. + */ + getDefinition(key: string): AuthorizedTrailerDefinition | null { + const canonicalKey = this.caseMap.get(key.toLowerCase()); + return canonicalKey ? this.definitions.get(canonicalKey) || null : null; + } + + /** + * Returns all authorized keys (Core + Custom) sorted by prompt priority. + */ + getAuthorizedKeys(): string[] { + return Array.from(this.definitions.values()) + .sort((a, b) => (a.prompt?.order ?? 1000) - (b.prompt?.order ?? 1000)) + .map((d) => d.key); + } + + /** + * Returns all keys that are defined as scalar (single-value). + */ + getScalarKeys(): string[] { + return Array.from(this.definitions.values()) + .filter((d) => !d.multivalue) + .map((d) => d.key); + } + + /** + * Returns all keys that are defined as lists (multi-value). + */ + getListKeys(): string[] { + return Array.from(this.definitions.values()) + .filter((d) => d.multivalue) + .map((d) => d.key); + } + + /** + * Returns all keys that reference other atoms. + */ + getReferenceKeys(): string[] { + return Array.from(this.definitions.values()) + .filter((d) => d.ui?.kind === 'reference') + .map((d) => d.key); + } + + /** + * Returns true if the key belongs to the core Lore protocol. + */ + isCore(key: string): boolean { + return this.getDefinition(key)?.isCore ?? false; + } + + /** + * Returns the semantic UI kind for a trailer. + */ + getUiKind(key: string): TrailerUiKind { + return this.getDefinition(key)?.ui?.kind || 'custom'; + } + + /** + * Returns the semantic color for a trailer. + */ + getUiColor(key: string): TrailerUiColor { + return this.getDefinition(key)?.ui?.color || 'cyan'; + } + + /** + * Returns a unified view of all trailer definitions for UI rendering. + */ + getFormattableDefinitions(): Record { + const result: Record = {}; + for (const [key, def] of this.definitions.entries()) { + result[key] = { + description: def.description, + multivalue: def.multivalue, + validation: def.validation, + values: this.normalizeValues(def.values), + pattern: def.pattern, + required: def.required, + directives: def.directives ?? [], + ui: def.ui, + }; + } + return result; + } + + private normalizeValues( + values?: Record, + ): Record | undefined { + if (!values) return undefined; + + const result: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (typeof value === 'string') { + result[key] = { description: value }; + } else { + result[key] = value; + } + } + return result; + } +} diff --git a/src/services/readers/collectors/enum-choice-trailer-collector.ts b/src/services/readers/collectors/enum-choice-trailer-collector.ts index d7da1661..e811ace3 100644 --- a/src/services/readers/collectors/enum-choice-trailer-collector.ts +++ b/src/services/readers/collectors/enum-choice-trailer-collector.ts @@ -1,9 +1,11 @@ -import type { ITrailerCollector, TrailerCollectorResult } from '../../../interfaces/trailer-collector.js'; import type { IPrompt } from '../../../interfaces/prompt.js'; -import type { CommitInput } from '../../commit-builder.js'; +import type { + ITrailerCollector, + TrailerCollectionResult, +} from '../../../interfaces/trailer-collector.js'; interface EnumChoiceTrailerConfig { - readonly key: keyof NonNullable; + readonly key: string; readonly confirmMessage: string; readonly choiceMessage: string; readonly values: readonly string[]; @@ -19,7 +21,7 @@ interface EnumChoiceTrailerConfig { * SOLID: SRP -- responsible only for enum-choice collection logic. */ export class EnumChoiceTrailerCollector implements ITrailerCollector { - private readonly key: keyof NonNullable; + readonly key: string; private readonly confirmMessage: string; private readonly choiceMessage: string; private readonly values: readonly string[]; @@ -31,7 +33,7 @@ export class EnumChoiceTrailerCollector implements ITrailerCollector { this.values = config.values; } - async collect(prompt: IPrompt): Promise { + async collect(prompt: IPrompt): Promise { const wantsValue = await prompt.askConfirm(this.confirmMessage, false); if (!wantsValue) { return { key: this.key, value: undefined }; diff --git a/src/services/readers/collectors/multi-value-trailer-collector.ts b/src/services/readers/collectors/multi-value-trailer-collector.ts index b5b27a54..f462e1f0 100644 --- a/src/services/readers/collectors/multi-value-trailer-collector.ts +++ b/src/services/readers/collectors/multi-value-trailer-collector.ts @@ -1,9 +1,11 @@ -import type { ITrailerCollector, TrailerCollectorResult } from '../../../interfaces/trailer-collector.js'; import type { IPrompt } from '../../../interfaces/prompt.js'; -import type { CommitInput } from '../../commit-builder.js'; +import type { + ITrailerCollector, + TrailerCollectionResult, +} from '../../../interfaces/trailer-collector.js'; interface MultiValueTrailerConfig { - readonly key: keyof NonNullable; + readonly key: string; readonly confirmMessage: string; readonly inputMessage: string; } @@ -18,7 +20,7 @@ interface MultiValueTrailerConfig { * SOLID: SRP -- responsible only for multi-value collection logic. */ export class MultiValueTrailerCollector implements ITrailerCollector { - private readonly key: keyof NonNullable; + readonly key: string; private readonly confirmMessage: string; private readonly inputMessage: string; @@ -28,9 +30,10 @@ export class MultiValueTrailerCollector implements ITrailerCollector { this.inputMessage = config.inputMessage; } - async collect(prompt: IPrompt): Promise { + async collect(prompt: IPrompt): Promise { const values: string[] = []; + // eslint-disable-next-line no-constant-condition while (true) { const wantsMore = await prompt.askConfirm(this.confirmMessage, false); if (!wantsMore) break; diff --git a/src/services/readers/collectors/trailer-collector-registry.ts b/src/services/readers/collectors/trailer-collector-registry.ts index e409012c..6fdf75a8 100644 --- a/src/services/readers/collectors/trailer-collector-registry.ts +++ b/src/services/readers/collectors/trailer-collector-registry.ts @@ -1,81 +1,76 @@ import type { ITrailerCollector } from '../../../interfaces/trailer-collector.js'; +import type { LoreConfig, CustomTrailerDefinition } from '../../../types/config.js'; import { MultiValueTrailerCollector } from './multi-value-trailer-collector.js'; import { EnumChoiceTrailerCollector } from './enum-choice-trailer-collector.js'; -import { - CONFIDENCE_VALUES, - SCOPE_RISK_VALUES, - REVERSIBILITY_VALUES, - PROMPT_STRINGS, -} from '../../../util/constants.js'; +import { Protocol } from '../../protocol.js'; +import { LORE_ID_KEY } from '../../../util/constants.js'; /** - * Creates all trailer collectors in the correct prompt order. + * Registry and factory for trailer collectors. * - * Order: Constraint, Rejected, Confidence, Scope-risk, Reversibility, - * Directive, Tested, Not-tested, Supersedes, Depends-on, Related. + * Collectors are created in the correct prompt order defined by the protocol metadata. * - * GRASP: Creator -- centralizes collector instantiation with configuration knowledge. - * SOLID: OCP -- adding a new trailer requires only appending a collector here. + * GRASP: Creator -- centralizes collector instantiation with protocol knowledge. + * SOLID: SRP -- only responsible for collector instantiation. + * SOLID: OCP -- new collector types can be added by extending the factory. */ -export function createTrailerCollectors(): readonly ITrailerCollector[] { - return [ - new MultiValueTrailerCollector({ - key: 'Constraint', - confirmMessage: PROMPT_STRINGS.ADD_CONSTRAINT, - inputMessage: PROMPT_STRINGS.CONSTRAINT_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Rejected', - confirmMessage: PROMPT_STRINGS.ADD_REJECTED, - inputMessage: PROMPT_STRINGS.REJECTED_INPUT, - }), - new EnumChoiceTrailerCollector({ - key: 'Confidence', - confirmMessage: PROMPT_STRINGS.SET_CONFIDENCE, - choiceMessage: PROMPT_STRINGS.CONFIDENCE_CHOICE, - values: CONFIDENCE_VALUES, - }), - new EnumChoiceTrailerCollector({ - key: 'Scope-risk', - confirmMessage: PROMPT_STRINGS.SET_SCOPE_RISK, - choiceMessage: PROMPT_STRINGS.SCOPE_RISK_CHOICE, - values: SCOPE_RISK_VALUES, - }), - new EnumChoiceTrailerCollector({ - key: 'Reversibility', - confirmMessage: PROMPT_STRINGS.SET_REVERSIBILITY, - choiceMessage: PROMPT_STRINGS.REVERSIBILITY_CHOICE, - values: REVERSIBILITY_VALUES, - }), - new MultiValueTrailerCollector({ - key: 'Directive', - confirmMessage: PROMPT_STRINGS.ADD_DIRECTIVE, - inputMessage: PROMPT_STRINGS.DIRECTIVE_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Tested', - confirmMessage: PROMPT_STRINGS.ADD_TESTED, - inputMessage: PROMPT_STRINGS.TESTED_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Not-tested', - confirmMessage: PROMPT_STRINGS.ADD_NOT_TESTED, - inputMessage: PROMPT_STRINGS.NOT_TESTED_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Supersedes', - confirmMessage: PROMPT_STRINGS.ADD_SUPERSEDES, - inputMessage: PROMPT_STRINGS.SUPERSEDES_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Depends-on', - confirmMessage: PROMPT_STRINGS.ADD_DEPENDS_ON, - inputMessage: PROMPT_STRINGS.DEPENDS_ON_INPUT, - }), - new MultiValueTrailerCollector({ - key: 'Related', - confirmMessage: PROMPT_STRINGS.ADD_RELATED, - inputMessage: PROMPT_STRINGS.RELATED_INPUT, - }), - ]; +export class TrailerCollectorRegistry { + constructor(private readonly protocol: Protocol) {} + + /** + * Returns a list of collectors for all authorized trailers. + */ + getCollectors(): ITrailerCollector[] { + const collectors: ITrailerCollector[] = []; + const authorizedKeys = this.protocol.getAuthorizedKeys(); + + // Iterate through all authorized keys in protocol-defined order + for (const key of authorizedKeys) { + if (key === LORE_ID_KEY) continue; + + const def = this.protocol.getDefinition(key); + if (!def) continue; + + collectors.push(this.createCollectorFromDefinition(key, def)); + } + + return collectors; + } + + /** + * Factory method to create the appropriate collector strategy for a definition. + */ + private createCollectorFromDefinition( + key: string, + def: CustomTrailerDefinition, + ): ITrailerCollector { + const confirmMessage = `Set ${key}?`; + + // Case 1: Single-value Enum + if (def.validation === 'values' && def.values && !def.multivalue) { + return new EnumChoiceTrailerCollector({ + key, + confirmMessage, + choiceMessage: def.prompt?.choice || `${key}:`, + values: Object.keys(def.values), + }); + } + + // Case 2: Multi-value List (everything else) + // This handles multi-value enums, patterns, and free-text lists. + return new MultiValueTrailerCollector({ + key, + confirmMessage, + inputMessage: def.prompt?.input || `${key}:`, + }); + } +} + +/** + * Functional wrapper for the registry to maintain backward compatibility. + */ +export function createTrailerCollectors(config: LoreConfig): ITrailerCollector[] { + const protocol = new Protocol(config); + const registry = new TrailerCollectorRegistry(protocol); + return registry.getCollectors(); } diff --git a/src/services/readers/flags-input-reader.ts b/src/services/readers/flags-input-reader.ts index 01dceacc..fcb4d7ad 100644 --- a/src/services/readers/flags-input-reader.ts +++ b/src/services/readers/flags-input-reader.ts @@ -1,36 +1,89 @@ import type { ICommitInputReader } from '../../interfaces/commit-input-reader.js'; -import type { CommitInput } from '../commit-builder.js'; +import type { CommitInput } from '../../types/commit.js'; import type { CommitCommandOptions } from '../commit-input-resolver.js'; -import type { ConfidenceLevel, ScopeRiskLevel, ReversibilityLevel } from '../../types/domain.js'; +import type { Protocol } from '../protocol.js'; +import { LORE_ID_KEY } from '../../util/constants.js'; /** * Reads commit input from CLI flag values. - * - * Pure data mapping -- no I/O, no prompts. - * - * GRASP: Information Expert -- owns all knowledge of CLI-flags-to-CommitInput mapping. - * SOLID: SRP -- single responsibility of mapping flags to CommitInput. + * + * Maps flat CLI flags (e.g. --confidence) to the structured CommitInput model. + * + * GRASP: Information Expert -- uses protocol metadata to dynamically map core and custom flags. + * SOLID: SRP -- only responsible for mapping CLI flag options to CommitInput. */ export class FlagsInputReader implements ICommitInputReader { - constructor(private readonly options: CommitCommandOptions) {} + constructor( + private readonly options: CommitCommandOptions, + private readonly protocol: Protocol, + ) {} async read(): Promise { + const trailers: Record = {}; + const authorizedKeys = this.protocol.getAuthorizedKeys(); + + // 1. Dynamically map all authorized trailers from registered flags + for (const key of authorizedKeys) { + if (key === LORE_ID_KEY) continue; + + const def = this.protocol.getDefinition(key); + if (!def) continue; + + const flagName = def.cli?.flag || this.slugify(key); + const camelName = this.camelCase(flagName); + + const flagValue = (this.options as Record)[camelName] ?? (this.options as Record)[flagName]; + if (flagValue !== undefined && flagValue !== null) { + trailers[key] = Array.isArray(flagValue) + ? flagValue.map(v => String(v)) + : [String(flagValue)]; + } + } + + // 2. Add custom trailers from the catch-all --trailer flag + const catchAllMap = this.parseCustomTrailers(this.options.trailer); + for (const [key, values] of catchAllMap) { + // Re-authorize the key from the catch-all to ensure casing and permission rules + const authorizedKey = this.protocol.authorize(key); + if (authorizedKey) { + const existing = trailers[authorizedKey] ?? []; + trailers[authorizedKey] = [...existing, ...values]; + } + } + return { intent: this.options.intent ?? '', body: this.options.body, - trailers: { - Constraint: this.options.constraint, - Rejected: this.options.rejected, - Confidence: this.options.confidence as ConfidenceLevel | undefined, - 'Scope-risk': this.options.scopeRisk as ScopeRiskLevel | undefined, - Reversibility: this.options.reversibility as ReversibilityLevel | undefined, - Directive: this.options.directive, - Tested: this.options.tested, - 'Not-tested': this.options.notTested, - Supersedes: this.options.supersedes, - 'Depends-on': this.options.dependsOn, - Related: this.options.related, - }, + trailers: trailers as CommitInput['trailers'], }; } + + private parseCustomTrailers(trailers?: string[]): Map { + const map = new Map(); + if (!trailers || trailers.length === 0) { + return map; + } + + for (const t of trailers) { + const parts = t.split(/[=:]/); + if (parts.length >= 2) { + const key = parts[0].trim(); + const value = parts.slice(1).join('=').trim(); + if (key && value) { + const existing = map.get(key) ?? []; + map.set(key, [...existing, value]); + } + } + } + + return map; + } + + private slugify(text: string): string { + return text.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + } + + private camelCase(text: string): string { + return text.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase()); + } } diff --git a/src/services/readers/interactive-input-reader.ts b/src/services/readers/interactive-input-reader.ts index 4361fd4e..e19f0fbe 100644 --- a/src/services/readers/interactive-input-reader.ts +++ b/src/services/readers/interactive-input-reader.ts @@ -1,5 +1,5 @@ import type { ICommitInputReader } from '../../interfaces/commit-input-reader.js'; -import type { CommitInput } from '../commit-builder.js'; +import type { CommitInput } from '../../types/commit.js'; import type { IPrompt } from '../../interfaces/prompt.js'; import type { ITrailerCollector } from '../../interfaces/trailer-collector.js'; import { PROMPT_STRINGS } from '../../util/constants.js'; @@ -51,11 +51,18 @@ export class InteractiveInputReader implements ICommitInputReader { } private async collectTrailers(): Promise { - const trailers: Record = {}; + const trailers: Record = {}; + for (const collector of this.collectors) { const result = await collector.collect(this.prompt); - trailers[result.key] = result.value; + if (result.value !== undefined) { + const values = Array.isArray(result.value) ? result.value : [result.value as string]; + if (values.length > 0) { + trailers[result.key] = values; + } + } } + return trailers as CommitInput['trailers']; } } diff --git a/src/services/readers/json-input-reader.ts b/src/services/readers/json-input-reader.ts index 5ed3c3e9..0301fc4d 100644 --- a/src/services/readers/json-input-reader.ts +++ b/src/services/readers/json-input-reader.ts @@ -1,7 +1,5 @@ import type { ICommitInputReader } from '../../interfaces/commit-input-reader.js'; -import type { CommitInput } from '../commit-builder.js'; -import type { ConfidenceLevel, ScopeRiskLevel, ReversibilityLevel } from '../../types/domain.js'; -import { CustomTrailerCollection } from '../../types/custom-trailer-collection.js'; +import type { CommitInput } from '../../types/commit.js'; /** * Reads commit input by parsing a JSON string. @@ -13,56 +11,49 @@ import { CustomTrailerCollection } from '../../types/custom-trailer-collection.j * SOLID: SRP -- single responsibility of parsing JSON into CommitInput. */ export class JsonInputReader implements ICommitInputReader { - constructor(private readonly content: string) {} + constructor(private readonly json: string) {} async read(): Promise { - return this.parseJsonInput(this.content); - } - - private parseJsonInput(content: string): CommitInput { - const parsed = JSON.parse(content) as Record; - - const intent = typeof parsed.intent === 'string' ? parsed.intent : ''; - const body = typeof parsed.body === 'string' ? parsed.body : undefined; - - const trailersRaw = typeof parsed.trailers === 'object' && parsed.trailers !== null - ? parsed.trailers as Record - : undefined; - - let trailers: CommitInput['trailers']; - if (trailersRaw) { - const custom = CustomTrailerCollection.fromRaw(trailersRaw); - - trailers = { - Constraint: this.asStringArray(trailersRaw['Constraint']), - Rejected: this.asStringArray(trailersRaw['Rejected']), - Confidence: this.asEnumValue(trailersRaw['Confidence']) as ConfidenceLevel | undefined, - 'Scope-risk': this.asEnumValue(trailersRaw['Scope-risk']) as ScopeRiskLevel | undefined, - Reversibility: this.asEnumValue(trailersRaw['Reversibility']) as ReversibilityLevel | undefined, - Directive: this.asStringArray(trailersRaw['Directive']), - Tested: this.asStringArray(trailersRaw['Tested']), - 'Not-tested': this.asStringArray(trailersRaw['Not-tested']), - Supersedes: this.asStringArray(trailersRaw['Supersedes']), - 'Depends-on': this.asStringArray(trailersRaw['Depends-on']), - Related: this.asStringArray(trailersRaw['Related']), - custom: custom.isEmpty ? undefined : custom, - }; + if (!this.json || !this.json.trim()) { + throw new Error('Empty JSON input'); } - return { intent, body, trailers }; - } + try { + const data = JSON.parse(this.json); + const input: CommitInput = { + intent: typeof data.intent === 'string' ? data.intent : '', + body: typeof data.body === 'string' ? data.body : undefined, + }; - private asStringArray(value: unknown): string[] | undefined { - if (Array.isArray(value)) { - return value.filter((v): v is string => typeof v === 'string'); + if (data.trailers && typeof data.trailers === 'object' && !Array.isArray(data.trailers)) { + const trailers: Record = {}; + const rawTrailers = data.trailers as Record; + + for (const key of Object.keys(rawTrailers)) { + const val = rawTrailers[key]; + + if (Array.isArray(val)) { + const stringValues = val.filter((v) => typeof v === 'string'); + if (stringValues.length > 0) { + trailers[key] = stringValues; + } + } else if (typeof val === 'string') { + const trimmed = val.trim(); + if (trimmed) { + trailers[key] = [trimmed]; + } + } + } + + (input as any).trailers = trailers; + } + + return input; + } catch (err) { + if (err instanceof Error) { + throw new Error(`Failed to parse JSON input: ${err.message}`); + } + throw err; } - if (typeof value === 'string') { - return [value]; - } - return undefined; - } - - private asEnumValue(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; } } diff --git a/src/services/search-filter.ts b/src/services/search-filter.ts index 87e5b94a..370271df 100644 --- a/src/services/search-filter.ts +++ b/src/services/search-filter.ts @@ -1,105 +1,95 @@ import type { LoreAtom, TrailerKey } from '../types/domain.js'; import type { SearchOptions } from '../types/query.js'; -import { ARRAY_TRAILER_KEYS, ENUM_TRAILER_KEYS } from '../util/constants.js'; /** - * Applies search filters to a collection of LoreAtoms. - * + * Applies search filters to a collection of Lore atoms. + * * GRASP: Information Expert -- knows how to match atoms against search criteria. - * SRP: Only filtering logic, no git interaction or formatting. + * SOLID: SRP -- only responsible for filtering logic, no git interaction or formatting. */ export class SearchFilter { /** * Apply all active search filters to the atom list. */ applyFilters(atoms: readonly LoreAtom[], options: SearchOptions): LoreAtom[] { - let result = [...atoms]; + return atoms.filter((atom) => this.matches(atom, options)); + } - if (options.confidence !== null) { - result = result.filter((a) => a.trailers.Confidence === options.confidence); + private matches(atom: LoreAtom, options: SearchOptions): boolean { + // 1. Trailer presence filter (--has) + if (options.has && !this.atomHasTrailer(atom, options.has)) { + return false; } - if (options.scopeRisk !== null) { - result = result.filter((a) => a.trailers['Scope-risk'] === options.scopeRisk); + // 2. Exact match enum filters + if (options.confidence && atom.trailers.Confidence[0] !== options.confidence) { + return false; } - - if (options.reversibility !== null) { - result = result.filter((a) => a.trailers.Reversibility === options.reversibility); + if (options.scopeRisk && atom.trailers['Scope-risk'][0] !== options.scopeRisk) { + return false; } - - if (options.has !== null) { - const trailerKey = options.has; - result = result.filter((a) => this.atomHasTrailer(a, trailerKey)); + if (options.reversibility && atom.trailers.Reversibility[0] !== options.reversibility) { + return false; } - if (options.author !== null) { + // 3. Author filter + if (options.author) { const authorLower = options.author.toLowerCase(); - result = result.filter((a) => a.author.toLowerCase().includes(authorLower)); + if (!atom.author.toLowerCase().includes(authorLower)) return false; } - if (options.scope !== null) { - const scope = options.scope.toLowerCase(); - result = result.filter((a) => { - const match = a.intent.match(/^[a-zA-Z]+\(([^)]+)\)/); - return match !== null && match[1].toLowerCase() === scope; - }); + // 4. Intent/Scope filter + if (options.scope) { + const scopeLower = options.scope.toLowerCase(); + const extractedScope = this.extractScope(atom.intent); + if (!extractedScope || extractedScope.toLowerCase() !== scopeLower) return false; } - if (options.text !== null) { - const textLower = options.text.toLowerCase(); - result = result.filter((a) => this.atomMatchesText(a, textLower)); + // 5. Full text search + if (options.text && !this.atomMatchesText(atom, options.text)) { + return false; } - return result; + return true; } /** * Check if an atom has a non-empty value for the given trailer key. - * Uses data-driven lookup via ARRAY_TRAILER_KEYS and ENUM_TRAILER_KEYS - * instead of a per-key switch statement. */ atomHasTrailer(atom: LoreAtom, trailerKey: TrailerKey): boolean { - if (trailerKey === 'Lore-id') { - return !!atom.trailers['Lore-id']; - } - - // Array trailers: check length > 0 - if ((ARRAY_TRAILER_KEYS as readonly string[]).includes(trailerKey)) { - const values = atom.trailers[trailerKey as (typeof ARRAY_TRAILER_KEYS)[number]]; - return values.length > 0; - } - - // Enum trailers: check not null - if ((ENUM_TRAILER_KEYS as readonly string[]).includes(trailerKey)) { - const value = atom.trailers[trailerKey as keyof typeof atom.trailers]; - return value !== null; - } - - return false; + const values = atom.trailers[trailerKey] || []; + return values.length > 0; } /** * Check if an atom matches a text query across intent, body, and trailer values. */ - atomMatchesText(atom: LoreAtom, textLower: string): boolean { + private atomMatchesText(atom: LoreAtom, query: string): boolean { + const textLower = query.toLowerCase(); + if (atom.intent.toLowerCase().includes(textLower)) return true; if (atom.body.toLowerCase().includes(textLower)) return true; - const trailers = atom.trailers; + // Search all trailers uniformly + for (const key of Object.keys(atom.trailers)) { + const values = atom.trailers[key]; + if (!values) continue; - // Check array trailers - for (const key of ARRAY_TRAILER_KEYS) { - for (const value of trailers[key]) { + for (const value of values) { if (value.toLowerCase().includes(textLower)) return true; } } - // Check enum trailers - for (const key of ENUM_TRAILER_KEYS) { - const value = trailers[key]; - if (value?.toLowerCase().includes(textLower)) return true; - } - return false; } + + /** + * Extract the scope from a conventional commit subject line. + * Pattern: `type(scope): description` + * Returns null if no scope is found. + */ + private extractScope(subject: string): string | null { + const match = subject.match(/^[a-zA-Z]+\(([^)]+)\)/); + return match ? match[1] : null; + } } diff --git a/src/services/squash-merger.ts b/src/services/squash-merger.ts index 1e75ce8d..e9468234 100644 --- a/src/services/squash-merger.ts +++ b/src/services/squash-merger.ts @@ -1,17 +1,32 @@ import type { LoreIdGenerator } from './lore-id-generator.js'; -import type { LoreAtom, ConfidenceLevel, ScopeRiskLevel, ReversibilityLevel, LoreId } from '../types/domain.js'; - -const CONFIDENCE_ORDER: readonly ConfidenceLevel[] = ['low', 'medium', 'high']; -const SCOPE_RISK_ORDER: readonly ScopeRiskLevel[] = ['narrow', 'moderate', 'wide']; -const REVERSIBILITY_ORDER: readonly ReversibilityLevel[] = ['clean', 'migration-needed', 'irreversible']; - +import type { LoreAtom, LoreId } from '../types/domain.js'; +import type { Protocol } from './protocol.js'; +import { LORE_ID_KEY } from '../util/constants.js'; + +/** + * Orchestrates the merging of multiple Lore atoms during a git squash. + * + * SOLID: SRP -- responsible only for combining atom data into a single message. + * SOLID: OCP -- metadata-driven merging logic for all protocol trailers. + * GRASP: Creator -- knows how to synthesize Lore context from multiple lineage atoms. + */ export class SquashMerger { - private readonly loreIdGenerator: LoreIdGenerator; - - constructor(loreIdGenerator: LoreIdGenerator) { - this.loreIdGenerator = loreIdGenerator; - } + constructor( + private readonly loreIdGenerator: LoreIdGenerator, + private readonly protocol: Protocol, + ) {} + /** + * Merge a collection of atoms into a single Lore-enriched commit message. + * + * Synthesizes a new atom by: + * 1. Generating a new unique Lore-id. + * 2. Picking the newest intent as the new subject (unless overridden). + * 3. Concatenating all unique body summaries. + * 4. Applying metadata-driven squash strategies (union, rank-min, rank-max) + * to merge trailers across all atoms. + * 5. Dropping internal references (those pointing to atoms within the squash set). + */ merge( atoms: readonly LoreAtom[], options: { intent?: string; body?: string }, @@ -35,68 +50,57 @@ export class SquashMerger { // Body: use option or concatenate body summaries const body = options.body ?? this.mergeBodySummaries(sorted); - // Merge array trailers (deduplicated) - const constraints = this.unionDedup(atoms.map((a) => a.trailers.Constraint)); - const rejected = this.unionDedup(atoms.map((a) => a.trailers.Rejected)); - const directives = this.unionDedup(atoms.map((a) => a.trailers.Directive)); - const tested = this.unionDedup(atoms.map((a) => a.trailers.Tested)); - const notTested = this.unionDedup(atoms.map((a) => a.trailers['Not-tested'])); - - // Merge reference trailers: keep only external references - const supersedes = this.filterExternal( - this.unionDedup(atoms.map((a) => a.trailers.Supersedes)), - internalIds, - ); - const dependsOn = this.filterExternal( - this.unionDedup(atoms.map((a) => a.trailers['Depends-on'])), - internalIds, - ); - const related = this.filterExternal( - this.unionDedup(atoms.map((a) => a.trailers.Related)), - internalIds, - ); - - // Merge enum trailers: most conservative - const confidence = this.mergeConfidence(atoms); - const scopeRisk = this.mergeScopeRisk(atoms); - const reversibility = this.mergeReversibility(atoms); - - // Build trailer lines const trailerLines: string[] = []; - trailerLines.push(`Lore-id: ${newLoreId}`); - - for (const v of constraints) { - trailerLines.push(`Constraint: ${v}`); - } - for (const v of rejected) { - trailerLines.push(`Rejected: ${v}`); - } - if (confidence !== null) { - trailerLines.push(`Confidence: ${confidence}`); - } - if (scopeRisk !== null) { - trailerLines.push(`Scope-risk: ${scopeRisk}`); - } - if (reversibility !== null) { - trailerLines.push(`Reversibility: ${reversibility}`); - } - for (const v of directives) { - trailerLines.push(`Directive: ${v}`); - } - for (const v of tested) { - trailerLines.push(`Tested: ${v}`); - } - for (const v of notTested) { - trailerLines.push(`Not-tested: ${v}`); - } - for (const v of supersedes) { - trailerLines.push(`Supersedes: ${v}`); - } - for (const v of dependsOn) { - trailerLines.push(`Depends-on: ${v}`); + trailerLines.push(`${LORE_ID_KEY}: ${newLoreId}`); + + // 1. Process All Trailers uniformly + // Flatten all present keys across all atoms + const allKeys = new Set(); + for (const atom of atoms) { + for (const key of Object.keys(atom.trailers)) { + if (key !== LORE_ID_KEY) { + allKeys.add(key); + } + } } - for (const v of related) { - trailerLines.push(`Related: ${v}`); + + const sortedKeys = Array.from(allKeys).sort((a, b) => { + const defA = this.protocol.getDefinition(a); + const defB = this.protocol.getDefinition(b); + const orderA = defA?.prompt?.order ?? 1000; + const orderB = defB?.prompt?.order ?? 1000; + return orderA - orderB; + }); + + for (const key of sortedKeys) { + const def = this.protocol.getDefinition(key); + const strategy = def?.squash || 'union'; + + // Values are always arrays in the new flat structure + const allValues = atoms.map(a => a.trailers[key] || []); + + if (strategy === 'rank-min' && def?.values) { + const valueKeys = Object.keys(def.values); + const scalars = allValues.map(v => v[0] || null); + const merged = this.pickMinRank(scalars, valueKeys); + if (merged !== null) trailerLines.push(`${key}: ${merged}`); + } else if (strategy === 'rank-max' && def?.values) { + const valueKeys = Object.keys(def.values); + const scalars = allValues.map(v => v[0] || null); + const merged = this.pickMaxRank(scalars, valueKeys); + if (merged !== null) trailerLines.push(`${key}: ${merged}`); + } else { + // Default: Union + Dedup + let merged = this.unionDedup(allValues); + + if (def?.ui?.kind === 'reference') { + merged = this.filterExternal(merged, internalIds); + } + + for (const v of merged) { + trailerLines.push(`${key}: ${v}`); + } + } } // Assemble message @@ -113,6 +117,9 @@ export class SquashMerger { return parts.join('\n'); } + /** + * Combine body summaries from multiple atoms into a single narrative block. + */ private mergeBodySummaries(sortedAtoms: readonly LoreAtom[]): string { const summaries: string[] = []; for (const atom of sortedAtoms) { @@ -123,6 +130,9 @@ export class SquashMerger { return summaries.join('\n\n'); } + /** + * Deduplicate and merge multiple trailer value arrays into one. + */ private unionDedup(arrays: readonly (readonly string[])[]): string[] { const seen = new Set(); const result: string[] = []; @@ -137,6 +147,9 @@ export class SquashMerger { return result; } + /** + * Remove internal references to atoms that are being merged into the same squash. + */ private filterExternal( values: string[], internalIds: Set, @@ -144,38 +157,10 @@ export class SquashMerger { return values.filter((v) => !internalIds.has(v)); } - private mergeConfidence( - atoms: readonly LoreAtom[], - ): ConfidenceLevel | null { - return this.pickMostConservative( - atoms.map((a) => a.trailers.Confidence), - CONFIDENCE_ORDER, - ); - } - - private mergeScopeRisk( - atoms: readonly LoreAtom[], - ): ScopeRiskLevel | null { - return this.pickLeastConservative( - atoms.map((a) => a.trailers['Scope-risk']), - SCOPE_RISK_ORDER, - ); - } - - private mergeReversibility( - atoms: readonly LoreAtom[], - ): ReversibilityLevel | null { - return this.pickLeastConservative( - atoms.map((a) => a.trailers.Reversibility), - REVERSIBILITY_ORDER, - ); - } - /** - * Pick the lowest value in the order (most conservative / least confident). - * For Confidence: low < medium < high, so pick lowest index. + * Pick the value with the lowest index in the order. */ - private pickMostConservative( + private pickMinRank( values: readonly (T | null)[], order: readonly T[], ): T | null { @@ -183,7 +168,7 @@ export class SquashMerger { let result: T | null = null; for (const val of values) { - if (val === null) continue; + if (val === null || val === undefined) continue; const idx = order.indexOf(val); if (idx === -1) continue; if (result === null || idx < lowestIndex) { @@ -196,11 +181,9 @@ export class SquashMerger { } /** - * Pick the highest value in the order (least conservative / widest scope). - * For Scope-risk: narrow < moderate < wide, so pick highest index. - * For Reversibility: clean < migration-needed < irreversible, so pick highest index. + * Pick the value with the highest index in the order. */ - private pickLeastConservative( + private pickMaxRank( values: readonly (T | null)[], order: readonly T[], ): T | null { @@ -208,7 +191,7 @@ export class SquashMerger { let result: T | null = null; for (const val of values) { - if (val === null) continue; + if (val === null || val === undefined) continue; const idx = order.indexOf(val); if (idx === -1) continue; if (result === null || idx > highestIndex) { diff --git a/src/services/staleness-detector.ts b/src/services/staleness-detector.ts index 55f55e45..3e0c79d6 100644 --- a/src/services/staleness-detector.ts +++ b/src/services/staleness-detector.ts @@ -1,21 +1,24 @@ import type { IGitClient } from '../interfaces/git-client.js'; import type { LoreConfig } from '../types/config.js'; -import type { LoreAtom, SupersessionStatus } from '../types/domain.js'; -import type { StaleAtomReport, StaleReason } from '../types/output.js'; -import { LORE_ID_PATTERN, STALE_SIGNAL } from '../util/constants.js'; +import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../types/domain.js'; +import { STALE_SIGNAL } from '../util/constants.js'; +import type { StaleSignal } from '../types/domain.js'; + +export interface StaleReason { + readonly signal: StaleSignal; + readonly description: string; +} + +export interface StaleAtomReport { + readonly atom: LoreAtom; + readonly reasons: readonly StaleReason[]; +} /** - * Multi-signal staleness detection for Lore atoms. - * - * GRASP: Information Expert -- knows staleness rules. - * SOLID: DIP -- depends on IGitClient for drift calculation. - * - * Staleness signals: - * 1. Age: atom date older than configured threshold - * 2. Drift: file has had too many commits since the atom - * 3. Low confidence: atom has Confidence: low - * 4. Expired hints: Directive values with [until:YYYY-MM] or [until:YYYY-MM-DD] past due - * 5. Orphaned dependency: Depends-on references a superseded atom + * Analyzes LoreAtoms to detect "staleness" signals. + * + * SOLID: SRP -- only responsible for staleness analysis. + * GRASP: Information Expert -- knows how to interpret time, drift, and directives. */ export class StalenessDetector { constructor( @@ -24,8 +27,14 @@ export class StalenessDetector { ) {} /** - * Analyze atoms for staleness. Returns reports only for atoms - * that have at least one staleness signal. + * Performs analysis on a set of atoms and returns reports for those that are stale. + * + * Analyzes signals: + * 1. Age: atom is older than the configured threshold. + * 2. Drift: files changed by the atom have had many commits since. + * 3. Low Confidence: atom is marked as Confidence: low. + * 4. Expired Hints: [until:...] directive hint is in the past. + * 5. Orphaned Dependency: atom depends on a superseded atom. */ async analyze( atoms: readonly LoreAtom[], @@ -33,25 +42,24 @@ export class StalenessDetector { ): Promise { const reports: StaleAtomReport[] = []; const now = new Date(); - const maxAge = this.parseDuration(this.config.stale.olderThan); for (const atom of atoms) { const reasons: StaleReason[] = []; - // Signal 1: Age - this.checkAge(atom, now, maxAge, reasons); + // 1. Age Signal + this.checkAge(atom, now, reasons); - // Signal 2: Drift + // 2. Drift Signal (requires git calls) await this.checkDrift(atom, reasons); - // Signal 3: Low confidence + // 3. Low Confidence Signal this.checkLowConfidence(atom, reasons); - // Signal 4: Expired hints + // 4. Expired Hints Signal this.checkExpiredHints(atom, now, reasons); - // Signal 5: Orphaned dependency - this.checkOrphanedDependency(atom, supersessionMap, reasons); + // 5. Orphaned Dependency Signal + this.checkOrphanedDependencies(atom, reasons, supersessionMap); if (reasons.length > 0) { reports.push({ atom, reasons }); @@ -62,55 +70,52 @@ export class StalenessDetector { } /** - * Check if an atom is older than the configured age threshold. + * Check if an atom's absolute age exceeds the threshold. */ - private checkAge( - atom: LoreAtom, - now: Date, - maxAgeMs: number, - reasons: StaleReason[], - ): void { - const ageMs = now.getTime() - atom.date.getTime(); - if (ageMs > maxAgeMs) { - const ageDescription = this.formatAge(ageMs); + private checkAge(atom: LoreAtom, now: Date, reasons: StaleReason[]): void { + const thresholdMs = this.parseDuration(this.config.stale.olderThan); + if (thresholdMs === null) return; + + if (now.getTime() - atom.date.getTime() > thresholdMs) { reasons.push({ signal: STALE_SIGNAL.AGE, - description: `Atom is ${ageDescription} old (threshold: ${this.config.stale.olderThan})`, + description: `Atom is older than ${this.config.stale.olderThan} (${this.formatAge(now.getTime() - atom.date.getTime())})`, }); } } /** - * Check if files touched by the atom have drifted beyond the configured threshold. - * Drift is measured as the number of commits to a file since the atom's commit. + * Check if the files associated with the atom have changed significantly. */ - private async checkDrift( - atom: LoreAtom, - reasons: StaleReason[], - ): Promise { - for (const filePath of atom.filesChanged) { + private async checkDrift(atom: LoreAtom, reasons: StaleReason[]): Promise { + const threshold = this.config.stale.driftThreshold; + const driftedFiles: string[] = []; + + for (const file of atom.filesChanged) { try { - const commitsSince = await this.gitClient.countCommitsSince( - filePath, - atom.commitHash, - ); - if (commitsSince > this.config.stale.driftThreshold) { - reasons.push({ - signal: STALE_SIGNAL.DRIFT, - description: `${filePath} has ${commitsSince} commits since this atom (threshold: ${this.config.stale.driftThreshold})`, - }); + const count = await this.gitClient.countCommitsSince(file, atom.commitHash); + if (count > threshold) { + driftedFiles.push(file); } } catch { - // File may have been deleted or renamed; skip drift check for it + // Skip files that cannot be blamed (e.g. deleted) } } + + if (driftedFiles.length > 0) { + reasons.push({ + signal: STALE_SIGNAL.DRIFT, + description: `Source files have drifted (${driftedFiles.length} files with >${threshold} commits)`, + }); + } } /** * Check if the atom has low confidence. */ private checkLowConfidence(atom: LoreAtom, reasons: StaleReason[]): void { - if (atom.trailers.Confidence === 'low') { + const confidence = atom.trailers.Confidence[0]; + if (confidence === 'low') { reasons.push({ signal: STALE_SIGNAL.LOW_CONFIDENCE, description: 'Atom is marked as Confidence: low', @@ -119,18 +124,13 @@ export class StalenessDetector { } /** - * Check if any Directive values contain expired [until:...] hints. - * Supports formats: [until:YYYY-MM] and [until:YYYY-MM-DD] + * Check for expired behavioral hints in directives. */ - private checkExpiredHints( - atom: LoreAtom, - now: Date, - reasons: StaleReason[], - ): void { - const untilPattern = /\[until:(\d{4}-\d{2}(?:-\d{2})?)\]/g; + private checkExpiredHints(atom: LoreAtom, now: Date, reasons: StaleReason[]): void { + const untilPattern = /\[until:([^\]]+)\]/g; + let match: RegExpExecArray | null; for (const directive of atom.trailers.Directive) { - let match: RegExpExecArray | null; // Reset lastIndex for each directive untilPattern.lastIndex = 0; @@ -149,38 +149,31 @@ export class StalenessDetector { } /** - * Check if the atom depends on a superseded atom. + * Check if any dependencies of the atom are superseded. */ - private checkOrphanedDependency( + private checkOrphanedDependencies( atom: LoreAtom, - supersessionMap: Map, reasons: StaleReason[], + supersessionMap: Map, ): void { - for (const depId of atom.trailers['Depends-on']) { - if (!LORE_ID_PATTERN.test(depId)) { - continue; - } - - const depStatus = supersessionMap.get(depId); - if (depStatus && depStatus.superseded) { + const dependsOn = atom.trailers['Depends-on']; + for (const id of dependsOn) { + const status = supersessionMap.get(id); + if (status?.superseded) { reasons.push({ signal: STALE_SIGNAL.ORPHANED_DEP, - description: `Depends on ${depId} which is superseded by ${depStatus.supersededBy}`, + description: `Dependency "${id}" has been superseded by ${status.supersededBy}`, }); } } } /** - * Parse a duration string like "6m", "1y", "30d" into milliseconds. - * Supports: d (days), w (weeks), m (months), y (years). + * Parse a duration string (e.g. '6m', '1y') into milliseconds. */ - private parseDuration(duration: string): number { - const match = duration.match(/^(\d+)(d|w|m|y)$/); - if (!match) { - // Default to 6 months if unparseable - return 6 * 30 * 24 * 60 * 60 * 1000; - } + private parseDuration(duration: string): number | null { + const match = duration.match(/^(\d+)([dwmy])$/); + if (!match) return null; const value = parseInt(match[1], 10); const unit = match[2]; @@ -226,30 +219,23 @@ export class StalenessDetector { * Supports YYYY-MM (treated as end of month) and YYYY-MM-DD. */ private parseUntilDate(dateStr: string): Date | null { - // YYYY-MM format: treat as end of that month + // 1. YYYY-MM format: treat as end of that month (start of next) const monthMatch = dateStr.match(/^(\d{4})-(\d{2})$/); if (monthMatch) { const year = parseInt(monthMatch[1], 10); const month = parseInt(monthMatch[2], 10); - // Create date at start of next month (end of specified month) - const date = new Date(year, month, 1); - if (isNaN(date.getTime())) { - return null; - } - return date; + const d = new Date(year, month, 1); + return isNaN(d.getTime()) ? null : d; } - // YYYY-MM-DD format + // 2. YYYY-MM-DD format: treat as end of that day const dayMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (dayMatch) { const year = parseInt(dayMatch[1], 10); const month = parseInt(dayMatch[2], 10) - 1; const day = parseInt(dayMatch[3], 10); - const date = new Date(year, month, day, 23, 59, 59, 999); - if (isNaN(date.getTime())) { - return null; - } - return date; + const d = new Date(year, month, day, 23, 59, 59, 999); + return isNaN(d.getTime()) ? null : d; } return null; diff --git a/src/services/trailer-parser.ts b/src/services/trailer-parser.ts index 3ee64587..ab2c7eb8 100644 --- a/src/services/trailer-parser.ts +++ b/src/services/trailer-parser.ts @@ -1,22 +1,13 @@ import type { LoreTrailers, - LoreId, TrailerKey, - ArrayTrailerKey, - EnumTrailerKey, - ConfidenceLevel, - ScopeRiskLevel, - ReversibilityLevel, } from '../types/domain.js'; -import { CustomTrailerCollection } from '../types/custom-trailer-collection.js'; import { - LORE_TRAILER_KEYS, ARRAY_TRAILER_KEYS, ENUM_TRAILER_KEYS, - CONFIDENCE_VALUES, - SCOPE_RISK_VALUES, - REVERSIBILITY_VALUES, + LORE_ID_KEY, } from '../util/constants.js'; +import type { Protocol } from './protocol.js'; const TRAILER_LINE_PATTERN = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/; const CONTINUATION_LINE_PATTERN = /^[ \t]+(.*)$/; @@ -26,8 +17,11 @@ const CONTINUATION_LINE_PATTERN = /^[ \t]+(.*)$/; * * GRASP: Information Expert -- knows trailer format rules. * SRP: Only parsing/serialization logic. No git interaction, no validation. + * SOLID: OCP -- fully metadata-driven; no hardcoded trailer names. */ export class TrailerParser { + constructor(private readonly protocol?: Protocol) {} + /** * Parse a raw trailer block (multi-line string) into LoreTrailers. * Lines in `Key: Value` format are parsed as trailers. @@ -39,101 +33,85 @@ export class TrailerParser { * Enum trailers (Confidence, Scope-risk, Reversibility) appear once with * a known value. * Lore-id is a special single-value trailer. - * Unrecognized keys that appear in customKeys go into the `custom` map. + * All values are stored internally as string arrays for uniformity. */ - parse(rawTrailers: string, customKeys: readonly string[] = []): LoreTrailers { + parse(rawTrailers: string): LoreTrailers { const lines = rawTrailers.split('\n'); const entries = this.parseLinesToEntries(lines); - const arrayTrailerKeySet = new Set(ARRAY_TRAILER_KEYS); - const enumTrailerKeySet = new Set(ENUM_TRAILER_KEYS); - const loreKeySet = new Set(LORE_TRAILER_KEYS); - const customKeySet = new Set(customKeys); + const result: Record = {}; - let loreId: LoreId = ''; - const arrays: Record = {}; + // Initialize core arrays and enums with empty arrays for uniformity for (const key of ARRAY_TRAILER_KEYS) { - arrays[key] = []; + result[key] = []; } - const enums: Record = {}; for (const key of ENUM_TRAILER_KEYS) { - enums[key] = null; + result[key] = []; } - const custom = new Map(); + result[LORE_ID_KEY] = []; for (const { key, value } of entries) { - if (key === 'Lore-id') { - loreId = value.trim(); - continue; - } + const trimmedValue = value.trim(); - if (arrayTrailerKeySet.has(key)) { - arrays[key].push(value.trim()); + // Authorize the key via the protocol engine + // If no engine is provided (fallback), we use permissive defaults. + const authorizedKey = this.protocol ? this.protocol.authorize(key) : (key as TrailerKey); + if (!authorizedKey) { continue; } - if (enumTrailerKeySet.has(key)) { - const trimmed = value.trim(); - if (this.isValidEnumValue(key as EnumTrailerKey, trimmed)) { - enums[key] = trimmed; + // Special handling for enums: validate value if possible + const def = this.protocol?.getDefinition(key); + if (def?.validation === 'values' && def.values) { + const validValues = Object.keys(def.values); + if (validValues.includes(trimmedValue)) { + result[authorizedKey] = [trimmedValue]; } continue; } - if (!loreKeySet.has(key as TrailerKey)) { - if (customKeySet.has(key) || customKeys.length === 0) { - const existing = custom.get(key) ?? []; - existing.push(value.trim()); - custom.set(key, existing); - } - } + // Default: Always store as array + const existing = result[authorizedKey] ?? []; + existing.push(trimmedValue); + result[authorizedKey] = existing; } - return { - 'Lore-id': loreId, - Constraint: arrays['Constraint'], - Rejected: arrays['Rejected'], - Confidence: (enums['Confidence'] as ConfidenceLevel) ?? null, - 'Scope-risk': (enums['Scope-risk'] as ScopeRiskLevel) ?? null, - Reversibility: (enums['Reversibility'] as ReversibilityLevel) ?? null, - Directive: arrays['Directive'], - Tested: arrays['Tested'], - 'Not-tested': arrays['Not-tested'], - Supersedes: arrays['Supersedes'], - 'Depends-on': arrays['Depends-on'], - Related: arrays['Related'], - custom: new CustomTrailerCollection(custom), - }; + return result as unknown as LoreTrailers; } /** * Serialize LoreTrailers back into git trailer format (multi-line string). - * Order: Lore-id first, then array trailers, then enum trailers, then custom. + * Order: Lore-id first, then other trailers in protocol-defined order. * Each trailer appears as `Key: Value`, one per line. * Array trailers with multiple values produce multiple lines. */ serialize(trailers: LoreTrailers): string { const lines: string[] = []; + const processed = new Set(); - if (trailers['Lore-id']) { - lines.push(`Lore-id: ${trailers['Lore-id']}`); + // 1. Lore-id always first + const loreIdValues = trailers[LORE_ID_KEY]; + if (loreIdValues && loreIdValues.length > 0) { + lines.push(`${LORE_ID_KEY}: ${loreIdValues[0]}`); + processed.add(LORE_ID_KEY); } - for (const key of ARRAY_TRAILER_KEYS) { - const values = trailers[key as keyof LoreTrailers] as readonly string[]; - for (const value of values) { - lines.push(`${key}: ${value}`); + // 2. All other trailers + // Use protocol authorized keys for canonical order, then add any remaining + const authorizedKeys = this.protocol ? this.protocol.getAuthorizedKeys() : []; + const allKeys = Array.from(new Set([...authorizedKeys, ...Object.keys(trailers)])); + + for (const key of allKeys) { + if (processed.has(key)) { + continue; } - } + processed.add(key); - for (const key of ENUM_TRAILER_KEYS) { - const value = trailers[key as keyof LoreTrailers] as string | null; - if (value !== null) { - lines.push(`${key}: ${value}`); + const values = trailers[key]; + if (!values || values.length === 0) { + continue; } - } - for (const [key, values] of trailers.custom) { for (const value of values) { lines.push(`${key}: ${value}`); } @@ -152,7 +130,8 @@ export class TrailerParser { const match = TRAILER_LINE_PATTERN.exec(line); if (match) { const key = match[1]; - if (LORE_TRAILER_KEYS.includes(key as TrailerKey)) { + const authorized = this.protocol ? this.protocol.authorize(key) : (key as TrailerKey); + if (authorized) { return true; } } @@ -232,18 +211,6 @@ export class TrailerParser { entries.push({ key: trailerMatch[1], value: trailerMatch[2] }); } } - return entries; } - - private static readonly ENUM_VALUE_LOOKUP: Record> = { - 'Confidence': new Set(CONFIDENCE_VALUES), - 'Scope-risk': new Set(SCOPE_RISK_VALUES), - 'Reversibility': new Set(REVERSIBILITY_VALUES), - }; - - private isValidEnumValue(key: EnumTrailerKey, value: string): boolean { - const validValues = TrailerParser.ENUM_VALUE_LOOKUP[key]; - return validValues !== undefined && validValues.has(value); - } } diff --git a/src/services/validator.ts b/src/services/validator.ts index 9fc8a769..44e9416a 100644 --- a/src/services/validator.ts +++ b/src/services/validator.ts @@ -3,31 +3,27 @@ import type { AtomRepository } from './atom-repository.js'; import type { LoreConfig } from '../types/config.js'; import type { RawCommit } from '../interfaces/git-client.js'; import type { CommitValidationResult, ValidationIssue } from '../types/output.js'; -import { - CONFIDENCE_VALUES, - SCOPE_RISK_VALUES, - REVERSIBILITY_VALUES, - LORE_ID_PATTERN, - ARRAY_TRAILER_KEYS, - REFERENCE_TRAILER_KEYS, -} from '../util/constants.js'; +import { LORE_ID_PATTERN, LORE_ID_KEY } from '../util/constants.js'; import type { LoreTrailers, LoreId } from '../types/domain.js'; - +import type { Protocol } from './protocol.js'; + +/** + * Validates existing git commits for Lore protocol compliance. + * + * SOLID: SRP -- focused purely on protocol rule enforcement. + * GRASP: Information Expert -- uses trailer definitions to validate schema. + */ export class Validator { - private readonly trailerParser: TrailerParser; - private readonly atomRepository: AtomRepository; - private readonly config: LoreConfig; - constructor( - trailerParser: TrailerParser, - atomRepository: AtomRepository, - config: LoreConfig, - ) { - this.trailerParser = trailerParser; - this.atomRepository = atomRepository; - this.config = config; - } + private readonly trailerParser: TrailerParser, + private readonly atomRepository: AtomRepository, + private readonly config: LoreConfig, + private readonly protocol: Protocol, + ) {} + /** + * Performs validation on a collection of raw commits. + */ async validate(commits: readonly RawCommit[]): Promise { const results: CommitValidationResult[] = []; for (const commit of commits) { @@ -36,6 +32,9 @@ export class Validator { return results; } + /** + * Validate a single raw commit and collect all protocol issues. + */ private async validateCommit(commit: RawCommit): Promise { const issues: ValidationIssue[] = []; let trailers: LoreTrailers | null = null; @@ -43,11 +42,8 @@ export class Validator { // Rule 1: Valid trailer format (parseable) try { - trailers = this.trailerParser.parse( - commit.trailers, - this.config.trailers.custom, - ); - loreId = trailers['Lore-id'] || null; + trailers = this.trailerParser.parse(commit.trailers); + loreId = trailers[LORE_ID_KEY][0] || null; } catch { issues.push({ severity: 'error', @@ -56,65 +52,33 @@ export class Validator { }); } - // Rule 2: Lore-id present - if (trailers && !trailers['Lore-id']) { - issues.push({ - severity: 'error', - rule: 'lore-id-present', - message: 'Lore-id trailer is missing', - }); - } - - // Rule 3: Lore-id format (8-char hex) - if (trailers && trailers['Lore-id'] && !LORE_ID_PATTERN.test(trailers['Lore-id'])) { - issues.push({ - severity: 'error', - rule: 'lore-id-format', - message: `Lore-id "${trailers['Lore-id']}" is not a valid 8-character hex string`, - }); - } - - // Rule 4: Valid enum values if (trailers) { - this.validateEnumValues(trailers, issues); - } - - // Rule 5: Intent length - if (commit.subject.length > this.config.validation.intentMaxLength) { - issues.push({ - severity: 'warning', - rule: 'intent-length', - message: `Intent exceeds ${this.config.validation.intentMaxLength} characters (got ${commit.subject.length})`, - }); - } + // 1. Schema-driven validation (Metadata-driven) + this.validateSchema(trailers, issues); - // Rule 6: Required trailers present - if (trailers) { - this.validateRequiredTrailers(trailers, issues); - } - - // Rule 7: Message line count - const totalLines = this.countMessageLines(commit); - if (totalLines > this.config.validation.maxMessageLines) { - issues.push({ - severity: 'warning', - rule: 'message-length', - message: `Message exceeds ${this.config.validation.maxMessageLines} lines (got ${totalLines})`, - }); - } + // 2. Intent length + if (commit.subject.length > this.config.validation.intentMaxLength) { + issues.push({ + severity: 'warning', + rule: 'intent-length', + message: `Intent exceeds ${this.config.validation.intentMaxLength} characters (got ${commit.subject.length})`, + }); + } - // Rule 8: Reference format valid (8-char hex) - if (trailers) { - this.validateReferenceFormats(trailers, issues); - } + // 3. Message line count + const totalLines = this.countMessageLines(commit); + if (totalLines > this.config.validation.maxMessageLines) { + issues.push({ + severity: 'warning', + rule: 'message-length', + message: `Message exceeds ${this.config.validation.maxMessageLines} lines (got ${totalLines})`, + }); + } - // Rule 9: More than 5 of any trailer type - if (trailers) { + // 4. Trailer type counts (metadata-driven) this.validateTrailerCounts(trailers, issues); - } - // Rule 10: Reference existence -- warn if referenced atoms cannot be found - if (trailers) { + // 5. Reference existence (metadata-driven) await this.validateReferenceExistence(trailers, issues); } @@ -128,148 +92,113 @@ export class Validator { }; } - private validateEnumValues( + /** + * Universal schema validation for all trailers (core and custom). + * Enforces cardinality, enums, patterns, and requiredness based on definitions. + */ + private validateSchema( trailers: LoreTrailers, issues: ValidationIssue[], ): void { - if ( - trailers.Confidence !== null && - !(CONFIDENCE_VALUES as readonly string[]).includes(trailers.Confidence) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Confidence value: "${trailers.Confidence}". Expected one of: ${CONFIDENCE_VALUES.join(', ')}`, - }); - } + const authorizedKeys = this.protocol.getAuthorizedKeys(); - if ( - trailers['Scope-risk'] !== null && - !(SCOPE_RISK_VALUES as readonly string[]).includes(trailers['Scope-risk']) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Scope-risk value: "${trailers['Scope-risk']}". Expected one of: ${SCOPE_RISK_VALUES.join(', ')}`, - }); - } + for (const key of authorizedKeys) { + const def = this.protocol.getDefinition(key); + if (!def) continue; - if ( - trailers.Reversibility !== null && - !(REVERSIBILITY_VALUES as readonly string[]).includes( - trailers.Reversibility, - ) - ) { - issues.push({ - severity: 'error', - rule: 'invalid-enum', - message: `Invalid Reversibility value: "${trailers.Reversibility}". Expected one of: ${REVERSIBILITY_VALUES.join(', ')}`, - }); - } - } + const values = trailers[key] || []; + const isRequired = def.required; + + // Special Rule: Check for empty string explicitly for requiredness + const hasValue = values.length > 0 && values.every(v => v.trim().length > 0); - private validateRequiredTrailers( - trailers: LoreTrailers, - issues: ValidationIssue[], - ): void { - for (const required of this.config.trailers.required) { - const hasValue = this.trailerHasValue(trailers, required); - if (!hasValue) { + if (!hasValue && isRequired) { issues.push({ - severity: this.config.validation.strict ? 'error' : 'warning', - rule: 'required-trailer', - message: `Required trailer "${required}" is missing`, + severity: this.config.validation.strict || key === LORE_ID_KEY ? 'error' : 'warning', + rule: key === LORE_ID_KEY ? 'lore-id-present' : 'required-trailer', + field: key, + message: `${key} trailer is missing`, }); + continue; } - } - } - private trailerHasValue(trailers: LoreTrailers, key: string): boolean { - // Check known trailer keys - switch (key) { - case 'Lore-id': - return !!trailers['Lore-id']; - case 'Constraint': - return trailers.Constraint.length > 0; - case 'Rejected': - return trailers.Rejected.length > 0; - case 'Confidence': - return trailers.Confidence !== null; - case 'Scope-risk': - return trailers['Scope-risk'] !== null; - case 'Reversibility': - return trailers.Reversibility !== null; - case 'Directive': - return trailers.Directive.length > 0; - case 'Tested': - return trailers.Tested.length > 0; - case 'Not-tested': - return trailers['Not-tested'].length > 0; - case 'Supersedes': - return trailers.Supersedes.length > 0; - case 'Depends-on': - return trailers['Depends-on'].length > 0; - case 'Related': - return trailers.Related.length > 0; - default: { - // Check custom trailers - const customValues = trailers.custom.get(key); - return customValues !== undefined && customValues.length > 0; - } - } - } + if (values.length === 0) continue; - private validateReferenceFormats( - trailers: LoreTrailers, - issues: ValidationIssue[], - ): void { - const refSets: { key: string; values: readonly string[] }[] = [ - { key: 'Supersedes', values: trailers.Supersedes }, - { key: 'Depends-on', values: trailers['Depends-on'] }, - { key: 'Related', values: trailers.Related }, - ]; + // Check Cardinality + if (!def.multivalue && values.length > 1) { + issues.push({ + severity: 'error', + rule: 'invalid-cardinality', + field: key, + message: `Trailer "${key}" must have exactly one value (got ${values.length})`, + }); + } - for (const { key, values } of refSets) { - for (const value of values) { - if (!LORE_ID_PATTERN.test(value)) { - issues.push({ - severity: 'warning', - rule: 'reference-format', - message: `Invalid reference format in ${key}: "${value}". Expected 8-character hex.`, - }); + // Check content rules + for (const val of values) { + if (def.validation === 'values' && def.values) { + const validValues = Object.keys(def.values); + if (!validValues.includes(val)) { + issues.push({ + severity: 'error', + rule: 'invalid-enum', + field: key, + message: `Invalid ${key} value: "${val}". Expected one of: ${validValues.join(', ')}`, + }); + } + } else if (def.validation === 'pattern' && def.pattern) { + const regex = new RegExp(def.pattern); + if (!regex.test(val)) { + // Map pattern failures to specific semantic rules + let rule = 'invalid-format'; + let severity: 'error' | 'warning' = 'error'; + let message = `Value for "${key}" does not match pattern: ${def.pattern}`; + + // Context-sensitive rule mapping + if (key === LORE_ID_KEY) { + rule = 'lore-id-format'; + message = `${LORE_ID_KEY} "${val}" is not a valid 8-character hex string`; + } else if (def.ui?.kind === 'reference') { + rule = 'reference-format'; + severity = 'warning'; + message = `Invalid reference format in ${key}: "${val}". Expected 8-character hex.`; + } + + issues.push({ severity, rule, field: key, message }); + } } } } } + /** + * Validates that list-type trailers don't exceed reasonable counts. + */ private validateTrailerCounts( trailers: LoreTrailers, issues: ValidationIssue[], ): void { - const countChecks: { key: string; count: number }[] = []; - - for (const key of ARRAY_TRAILER_KEYS) { + const listKeys = this.protocol.getListKeys(); + for (const key of listKeys) { const values = trailers[key]; - if (Array.isArray(values) || (values && 'length' in values)) { - countChecks.push({ key, count: values.length }); - } - } - - for (const { key, count } of countChecks) { - if (count > 5) { + if (values && values.length > 5) { issues.push({ severity: 'warning', rule: 'trailer-count', - message: `More than 5 values for ${key} (got ${count})`, + field: key, + message: `More than 5 values for ${key} (got ${values.length})`, }); } } } + /** + * Count the number of lines in the full commit message. + */ private countMessageLines(commit: RawCommit): number { let count = 1; // subject line if (commit.body.trim()) { - count += 1; // blank line between subject and body + count += 1; // blank line count += commit.body.split('\n').length; } if (commit.trailers.trim()) { @@ -280,32 +209,28 @@ export class Validator { } /** - * Check that referenced Lore-ids (Supersedes, Depends-on, Related) actually - * exist in the repository. Emits a warning for each missing reference. + * Check that referenced Lore-ids actually exist in the repository. + * Emits a warning for each missing reference. */ private async validateReferenceExistence( trailers: LoreTrailers, issues: ValidationIssue[], ): Promise { - const refSets: { key: string; values: readonly LoreId[] }[] = []; - - for (const key of REFERENCE_TRAILER_KEYS) { - const values = trailers[key] as readonly LoreId[]; - if (values.length > 0) { - refSets.push({ key, values }); - } - } + const refKeys = this.protocol.getReferenceKeys(); + for (const key of refKeys) { + const values = trailers[key]; + if (!values) continue; - for (const { key, values } of refSets) { for (const id of values) { - if (!LORE_ID_PATTERN.test(id)) { - continue; // Invalid format already caught by validateReferenceFormats - } + // Only validate existence for values that look like Lore-ids + if (!LORE_ID_PATTERN.test(id)) continue; + const found = await this.atomRepository.findByLoreId(id); if (found === null) { issues.push({ severity: 'warning', rule: 'reference-exists', + field: key, message: `Referenced atom "${id}" in ${key} not found in repository`, }); } diff --git a/src/types/commit.ts b/src/types/commit.ts new file mode 100644 index 00000000..f89efad8 --- /dev/null +++ b/src/types/commit.ts @@ -0,0 +1,11 @@ +import type { LoreTrailers } from './domain.js'; + +/** + * The structured input for creating a Lore atom. + * Maps directly to the uniform domain model. + */ +export interface CommitInput { + readonly intent: string; + readonly body?: string; + readonly trailers?: LoreTrailers; +} diff --git a/src/types/config.ts b/src/types/config.ts index 14850d1d..b2d783b8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,3 +1,52 @@ +import { TRAILER_UI_KINDS, TRAILER_UI_COLORS } from '../util/constants.js'; +import type { TrailerKey } from './domain.js'; +import { LORE_TRAILER_KEYS } from '../util/constants.js'; + +export type TrailerUiKind = (typeof TRAILER_UI_KINDS)[number]; +export type TrailerUiColor = (typeof TRAILER_UI_COLORS)[number]; + +export interface ValueDefinition { + readonly description: string; +} + +export interface CustomTrailerDefinition { + readonly description: string; + readonly multivalue: boolean; + readonly validation: 'values' | 'pattern' | 'none'; + readonly values?: Record; + readonly pattern?: string; + readonly required?: boolean; + readonly directives?: readonly string[]; + /** UI hints for the formatter. */ + readonly ui?: { + readonly kind?: TrailerUiKind; + readonly color?: TrailerUiColor; + }; + /** CLI hints for the commit command. */ + readonly cli?: { + readonly flag?: string; + readonly shorthand?: string; + }; + /** Interactive prompt hints. */ + readonly prompt?: { + readonly confirm?: string; + readonly input?: string; + readonly choice?: string; + /** + * Relative sort weight for interactive prompts. + * Core trailers use 100-200. Custom trailers default to 1000. + */ + readonly order?: number; + }; + /** + * Strategy for merging values during 'lore squash'. + * - 'union': List all unique values (default for arrays). + * - 'rank-min': Pick value with lowest index in 'values'. + * - 'rank-max': Pick value with highest index in 'values'. + */ + readonly squash?: 'union' | 'rank-min' | 'rank-max'; +} + export interface LoreConfig { readonly protocol: { readonly version: string; @@ -5,6 +54,8 @@ export interface LoreConfig { readonly trailers: { readonly required: readonly string[]; readonly custom: readonly string[]; + readonly definitions: Record; + readonly permissive: boolean; }; readonly validation: { readonly strict: boolean; @@ -26,12 +77,3 @@ export interface LoreConfig { }; } -export const DEFAULT_CONFIG: LoreConfig = { - protocol: { version: '1.0' }, - trailers: { required: [], custom: [] }, - validation: { strict: false, maxMessageLines: 50, intentMaxLength: 72 }, - stale: { olderThan: '6m', driftThreshold: 20 }, - output: { defaultFormat: 'text' }, - follow: { maxDepth: 3 }, - cli: { updateCheck: true }, -}; diff --git a/src/types/custom-trailer-collection.ts b/src/types/custom-trailer-collection.ts deleted file mode 100644 index 6af55b4d..00000000 --- a/src/types/custom-trailer-collection.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { LORE_TRAILER_KEYS } from '../util/constants.js'; - -const KNOWN_KEYS = new Set(LORE_TRAILER_KEYS); - -export class CustomTrailerCollection { - private readonly map: ReadonlyMap; - private readonly _lineCount: number; - private _record: Readonly> | null = null; - - constructor(entries: ReadonlyMap) { - this.map = new Map(entries); - let count = 0; - for (const values of this.map.values()) { - count += values.length; - } - this._lineCount = count; - } - - static empty(): CustomTrailerCollection { - return EMPTY_INSTANCE; - } - - /** - * Extract custom trailers from a raw key-value record. - * Filters out known Lore trailer keys. - * Coerces single strings to [string] arrays. - */ - static fromRaw(raw: Readonly>): CustomTrailerCollection { - const entries = new Map(); - for (const key of Object.keys(raw)) { - if (KNOWN_KEYS.has(key)) continue; - const arr = toStringArray(raw[key]); - if (arr && arr.length > 0) { - entries.set(key, arr); - } - } - return entries.size > 0 ? new CustomTrailerCollection(entries) : CustomTrailerCollection.empty(); - } - - has(key: string): boolean { - const values = this.map.get(key); - return values !== undefined && values.length > 0; - } - - get(key: string): readonly string[] | undefined { - return this.map.get(key); - } - - get lineCount(): number { - return this._lineCount; - } - - get size(): number { - return this.map.size; - } - - get isEmpty(): boolean { - return this.map.size === 0; - } - - [Symbol.iterator](): IterableIterator<[string, readonly string[]]> { - return this.map[Symbol.iterator](); - } - - toRecord(): Readonly> { - if (!this._record) { - this._record = Object.fromEntries(this.map); - } - return this._record; - } -} - -function toStringArray(value: unknown): string[] | undefined { - if (Array.isArray(value)) { - return value.filter((v): v is string => typeof v === 'string'); - } - if (typeof value === 'string') { - return [value]; - } - return undefined; -} - -const EMPTY_INSTANCE = new CustomTrailerCollection(new Map()); diff --git a/src/types/domain.ts b/src/types/domain.ts index 4cc8a0a9..71692a3b 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -1,56 +1,34 @@ -import { CustomTrailerCollection } from './custom-trailer-collection.js'; +import { CORE_TRAILER_DEFINITIONS, STALE_SIGNALS } from '../util/core-definitions.js'; /** 8-character hex string identifying a Lore atom. */ export type LoreId = string; /** The set of recognized trailer keys. */ -export type TrailerKey = - | 'Lore-id' - | 'Constraint' - | 'Rejected' - | 'Confidence' - | 'Scope-risk' - | 'Reversibility' - | 'Directive' - | 'Tested' - | 'Not-tested' - | 'Supersedes' - | 'Depends-on' - | 'Related'; +export type TrailerKey = keyof typeof CORE_TRAILER_DEFINITIONS; /** Trailers that accept multiple values (arrays). */ -export type ArrayTrailerKey = - | 'Constraint' - | 'Rejected' - | 'Directive' - | 'Tested' - | 'Not-tested' - | 'Supersedes' - | 'Depends-on' - | 'Related'; +export type ArrayTrailerKey = { + [K in TrailerKey]: (typeof CORE_TRAILER_DEFINITIONS)[K]['multivalue'] extends true ? K : never; +}[TrailerKey]; /** Trailers that accept a single enum value. */ -export type EnumTrailerKey = 'Confidence' | 'Scope-risk' | 'Reversibility'; - -export type ConfidenceLevel = 'low' | 'medium' | 'high'; -export type ScopeRiskLevel = 'narrow' | 'moderate' | 'wide'; -export type ReversibilityLevel = 'clean' | 'migration-needed' | 'irreversible'; - -export interface LoreTrailers { - readonly 'Lore-id': LoreId; - readonly Constraint: readonly string[]; - readonly Rejected: readonly string[]; - readonly Confidence: ConfidenceLevel | null; - readonly 'Scope-risk': ScopeRiskLevel | null; - readonly Reversibility: ReversibilityLevel | null; - readonly Directive: readonly string[]; - readonly Tested: readonly string[]; - readonly 'Not-tested': readonly string[]; - readonly Supersedes: readonly LoreId[]; - readonly 'Depends-on': readonly LoreId[]; - readonly Related: readonly LoreId[]; - readonly custom: CustomTrailerCollection; -} +export type EnumTrailerKey = { + [K in TrailerKey]: (typeof CORE_TRAILER_DEFINITIONS)[K]['multivalue'] extends false + ? (typeof CORE_TRAILER_DEFINITIONS)[K]['validation'] extends 'values' + ? K + : never + : never; +}[TrailerKey]; + +export type ConfidenceLevel = keyof NonNullable; +export type ScopeRiskLevel = keyof NonNullable; +export type ReversibilityLevel = keyof NonNullable; + +/** + * The structured trailer collection for a Lore atom. + * Strictly flat and uniform: every key maps to a readonly string array. + */ +export type LoreTrailers = Record; export interface LoreAtom { readonly loreId: LoreId; @@ -67,3 +45,6 @@ export interface SupersessionStatus { readonly superseded: boolean; readonly supersededBy: LoreId | null; } + +/** The set of signals that indicate an atom may be stale. */ +export type StaleSignal = (typeof STALE_SIGNALS)[number]; diff --git a/src/types/output.ts b/src/types/output.ts index 61e80f22..1143003a 100644 --- a/src/types/output.ts +++ b/src/types/output.ts @@ -1,10 +1,14 @@ -import type { LoreAtom, SupersessionStatus, TrailerKey } from './domain.js'; +import type { LoreAtom, SupersessionStatus, TrailerKey, StaleSignal } from './domain.js'; import type { QueryResult } from './query.js'; +import type { LoreConfig, CustomTrailerDefinition, ValueDefinition, TrailerUiKind, TrailerUiColor } from './config.js'; +import type { ParsedDirective } from '../util/directive-parser.js'; export interface FormattableQueryResult { readonly result: QueryResult; readonly supersessionMap: ReadonlyMap; - readonly visibleTrailers: readonly TrailerKey[] | 'all'; + readonly visibleTrailers: readonly string[] | 'all'; + /** Trailer definitions for UI rendering hints and serialization rules. */ + readonly trailerDefinitions: Record; } export interface FormattableValidationResult { @@ -23,6 +27,7 @@ export interface CommitValidationResult { export interface ValidationIssue { readonly severity: 'error' | 'warning'; readonly rule: string; + readonly field?: string; readonly message: string; } @@ -30,8 +35,6 @@ export interface FormattableStalenessResult { readonly atoms: readonly StaleAtomReport[]; } -import type { StaleSignal } from '../util/constants.js'; - export interface StaleReason { readonly signal: StaleSignal; readonly description: string; @@ -65,3 +68,29 @@ export interface DoctorCheck { readonly message: string; readonly details: readonly string[]; } + +/** New additive types for the config command */ + +export interface FormattableTrailerDefinition { + readonly description: string; + readonly multivalue: boolean; + readonly validation: 'values' | 'pattern' | 'none'; + readonly values?: Record; + readonly pattern?: string; + readonly required?: boolean; + readonly directives: readonly string[]; + readonly ui?: { + readonly kind?: TrailerUiKind; + readonly color?: TrailerUiColor; + }; +} + +export interface FormattableConfigResult { + readonly loreVersion: string; + readonly permissive: boolean; + readonly trailers: Record; + readonly filters: { + readonly showCore: boolean; + readonly showCustom: boolean; + }; +} diff --git a/src/types/query.ts b/src/types/query.ts index c2b37276..1fd664a6 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -19,6 +19,7 @@ export interface PathQueryOptions { readonly limit: number | null; readonly maxCommits: number | null; readonly since: string | null; + readonly until: string | null; } export interface SearchOptions { diff --git a/src/util/constants.ts b/src/util/constants.ts index 51a5b5c5..eec77253 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,93 +1,123 @@ -import type { TrailerKey, ArrayTrailerKey, EnumTrailerKey } from '../types/domain.js'; - -export const LORE_TRAILER_KEYS: readonly TrailerKey[] = [ - 'Lore-id', - 'Constraint', - 'Rejected', - 'Confidence', - 'Scope-risk', - 'Reversibility', - 'Directive', - 'Tested', - 'Not-tested', - 'Supersedes', - 'Depends-on', - 'Related', -]; - -export const ARRAY_TRAILER_KEYS: readonly ArrayTrailerKey[] = [ - 'Constraint', - 'Rejected', - 'Directive', - 'Tested', - 'Not-tested', - 'Supersedes', - 'Depends-on', - 'Related', -]; - -export const ENUM_TRAILER_KEYS: readonly EnumTrailerKey[] = [ - 'Confidence', - 'Scope-risk', - 'Reversibility', -]; - -export const CONFIDENCE_VALUES = ['low', 'medium', 'high'] as const; -export const SCOPE_RISK_VALUES = ['narrow', 'moderate', 'wide'] as const; -export const REVERSIBILITY_VALUES = ['clean', 'migration-needed', 'irreversible'] as const; +import type { + TrailerKey, + ArrayTrailerKey, + EnumTrailerKey, + ConfidenceLevel, + ScopeRiskLevel, + ReversibilityLevel, + StaleSignal, +} from '../types/domain.js'; +import type { LoreConfig } from '../types/config.js'; +import { + LORE_TRAILER_KEYS as CORE_LORE_TRAILER_KEYS, + ARRAY_TRAILER_KEYS as CORE_ARRAY_TRAILER_KEYS, + ENUM_TRAILER_KEYS as CORE_ENUM_TRAILER_KEYS, + REFERENCE_TRAILER_KEYS as CORE_REFERENCE_TRAILER_KEYS, + CONFIDENCE_VALUES as CORE_CONFIDENCE_VALUES, + SCOPE_RISK_VALUES as CORE_SCOPE_RISK_VALUES, + REVERSIBILITY_VALUES as CORE_REVERSIBILITY_VALUES, + LORE_ID_KEY as CORE_LORE_ID_KEY, + LORE_ID_JSON_KEY as CORE_LORE_ID_JSON_KEY, + LORE_VERSION_JSON_KEY as CORE_LORE_VERSION_JSON_KEY, + CORE_TRAILER_DEFINITIONS as MASTER_CORE_TRAILER_DEFINITIONS, + STALE_SIGNAL_METADATA as CORE_STALE_SIGNAL_METADATA, +} from './core-definitions.js'; + +/** The unique trailer key used for Lore atom identity */ +export const LORE_ID_KEY = CORE_LORE_ID_KEY; + +/** The structural JSON key for atom identity */ +export const LORE_ID_JSON_KEY = CORE_LORE_ID_JSON_KEY; + +/** The structural JSON key for protocol version */ +export const LORE_VERSION_JSON_KEY = CORE_LORE_VERSION_JSON_KEY; + +/** The master specification for core protocol trailers */ +export const CORE_TRAILER_DEFINITIONS = MASTER_CORE_TRAILER_DEFINITIONS; + +/** Central source of truth for the Lore Protocol version */ +export const LORE_PROTOCOL_VERSION = '1.0'; + +/** All standard Lore trailer keys */ +export const LORE_TRAILER_KEYS: readonly TrailerKey[] = CORE_LORE_TRAILER_KEYS as TrailerKey[]; + +/** Trailer keys that contain arrays of values */ +export const ARRAY_TRAILER_KEYS: readonly ArrayTrailerKey[] = CORE_ARRAY_TRAILER_KEYS as ArrayTrailerKey[]; + +/** Trailer keys that contain a single enum value */ +export const ENUM_TRAILER_KEYS: readonly EnumTrailerKey[] = CORE_ENUM_TRAILER_KEYS as EnumTrailerKey[]; + +/** All trailer keys that reference other atoms by Lore-id */ +export const REFERENCE_TRAILER_KEYS = CORE_REFERENCE_TRAILER_KEYS as readonly TrailerKey[]; + +/** Valid enum values for core trailers */ +export const CONFIDENCE_VALUES: readonly ConfidenceLevel[] = CORE_CONFIDENCE_VALUES as ConfidenceLevel[]; +export const SCOPE_RISK_VALUES: readonly ScopeRiskLevel[] = CORE_SCOPE_RISK_VALUES as ScopeRiskLevel[]; +export const REVERSIBILITY_VALUES: readonly ReversibilityLevel[] = CORE_REVERSIBILITY_VALUES as ReversibilityLevel[]; + +/** Pattern for validating Lore-id values (8-character hex string) */ export const LORE_ID_PATTERN = /^[0-9a-f]{8}$/; + +/** Length of a standard Lore-id */ export const LORE_ID_LENGTH = 8; -export const REFERENCE_TRAILER_KEYS = ['Supersedes', 'Depends-on', 'Related'] as const; +/** Default display limit for query results */ +export const DEFAULT_QUERY_LIMIT = 20; -export const DEFAULT_QUERY_LIMIT = 100; -export const DEFAULT_STALE_OLDER_THAN = '6m'; -export const DEFAULT_STALE_DRIFT_THRESHOLD = 20; -export const GIT_FILES_CHANGED_BATCH_SIZE = 20; +/** Default max git commits to scan when looking for Lore atoms */ +export const DEFAULT_MAX_COMMITS = 1000; -export const CONFIG_FILENAME = 'config.toml'; +/** The default configuration for the Lore protocol */ +export const DEFAULT_CONFIG: LoreConfig = { + protocol: { version: '1.0' }, + trailers: { required: [], custom: [], definitions: {}, permissive: true }, + validation: { strict: false, maxMessageLines: 50, intentMaxLength: 72 }, + stale: { olderThan: '6m', driftThreshold: 20 }, + output: { defaultFormat: 'text' }, + follow: { maxDepth: 3 }, + cli: { updateCheck: true }, +}; + +/** Filesystem paths for Lore configuration */ export const CONFIG_DIR = '.lore'; +export const CONFIG_FILENAME = 'config.toml'; -export const STALE_SIGNAL = { - AGE: 'age', - DRIFT: 'drift', - LOW_CONFIDENCE: 'low-confidence', - EXPIRED_HINT: 'expired-hint', - ORPHANED_DEP: 'orphaned-dep', +/** Prompt strings for interactive mode (Intent and Body only) */ +export const PROMPT_STRINGS = { + INTENT: 'Intent (why the change was made):', + ADD_BODY: 'Add a body? (narrative context)', + BODY_INPUT: 'Body (press Enter on empty line to finish):', } as const; -export type StaleSignal = typeof STALE_SIGNAL[keyof typeof STALE_SIGNAL]; +/** Staleness detection signals */ +export const STALE_SIGNAL = CORE_STALE_SIGNAL_METADATA; + +/** Default batch size for parallel git file-change lookups */ +export const GIT_FILES_CHANGED_BATCH_SIZE = 20; +/** Exit codes for the Lore CLI */ export const EXIT_CODE_SUCCESS = 0; export const EXIT_CODE_VALIDATION_ERROR = 1; export const EXIT_CODE_GIT_ERROR = 2; export const EXIT_CODE_NO_STAGED_CHANGES = 3; -export const PROMPT_STRINGS = { - INTENT: 'Intent (why the change was made):', - ADD_BODY: 'Add a body? (narrative context)', - BODY_INPUT: 'Body (press Enter on empty line to finish):', - ADD_CONSTRAINT: 'Add a Constraint?', - CONSTRAINT_INPUT: 'Constraint:', - ADD_REJECTED: 'Add a Rejected alternative?', - REJECTED_INPUT: 'Rejected (format: alternative | reason):', - SET_CONFIDENCE: 'Set Confidence?', - CONFIDENCE_CHOICE: 'Confidence:', - SET_SCOPE_RISK: 'Set Scope-risk?', - SCOPE_RISK_CHOICE: 'Scope-risk:', - SET_REVERSIBILITY: 'Set Reversibility?', - REVERSIBILITY_CHOICE: 'Reversibility:', - ADD_DIRECTIVE: 'Add a Directive?', - DIRECTIVE_INPUT: 'Directive:', - ADD_TESTED: 'Add a Tested entry?', - TESTED_INPUT: 'Tested:', - ADD_NOT_TESTED: 'Add a Not-tested entry?', - NOT_TESTED_INPUT: 'Not-tested:', - ADD_SUPERSEDES: 'Add a Supersedes reference?', - SUPERSEDES_INPUT: 'Supersedes (8-char hex Lore-id):', - ADD_DEPENDS_ON: 'Add a Depends-on reference?', - DEPENDS_ON_INPUT: 'Depends-on (8-char hex Lore-id):', - ADD_RELATED: 'Add a Related reference?', - RELATED_INPUT: 'Related (8-char hex Lore-id):', -} as const; +/** Standard trailer UI semantic kinds */ +export const TRAILER_UI_KINDS = [ + 'identity', + 'risk', + 'decision', + 'evidence', + 'reference', + 'custom', +] as const; + +/** Standard trailer UI colors (chalk-compatible) */ +export const TRAILER_UI_COLORS = [ + 'cyan', + 'magenta', + 'yellow', + 'green', + 'red', + 'dim', +] as const; diff --git a/src/util/core-definitions.ts b/src/util/core-definitions.ts new file mode 100644 index 00000000..3eb900d8 --- /dev/null +++ b/src/util/core-definitions.ts @@ -0,0 +1,265 @@ +import type { CustomTrailerDefinition, ValueDefinition, TrailerUiKind, TrailerUiColor } from '../types/config.js'; + +/** + * Enum levels are ordered from LEAST to MOST significant. + * This order is used by 'lore squash' to determine the "most conservative" value. + */ +const CONFIDENCE_VALUES_MAP: Record = { + low: { description: 'Hypothesis/first attempt; no verification.' }, + medium: { description: 'Locally verified or based on docs.' }, + high: { description: 'Thoroughly tested (CI/staging) or extensive domain knowledge.' } +}; + +const SCOPE_RISK_VALUES_MAP: Record = { + narrow: { description: 'Isolated to a single function/file; no external callers.' }, + moderate: { description: 'Affects a module; bounded/enumerable radius.' }, + wide: { description: 'Affects cross-cutting concerns, public APIs, or schemas.' } +}; + +const REVERSIBILITY_VALUES_MAP: Record = { + clean: { description: 'Revertible via git revert with no side effects.' }, + 'migration-needed': { description: 'Requires data migration or infra updates.' }, + irreversible: { description: 'Cannot be fully undone (e.g., data deletion).' } +}; + +export const CONFIDENCE_VALUES = Object.keys(CONFIDENCE_VALUES_MAP); +export const SCOPE_RISK_VALUES = Object.keys(SCOPE_RISK_VALUES_MAP); +export const REVERSIBILITY_VALUES = Object.keys(REVERSIBILITY_VALUES_MAP); + +/** The name of the protocol (e.g. Lore, Fred, etc.) */ +export const PROTOCOL_NAME = 'Lore'; + +/** The unique trailer key used for Lore atom identity */ +export const LORE_ID_KEY = `${PROTOCOL_NAME}-id`; + +/** The structural JSON key for atom identity */ +export const LORE_ID_JSON_KEY = LORE_ID_KEY.toLowerCase().replace(/-/g, '_'); + +/** The structural JSON key for protocol version */ +export const LORE_VERSION_JSON_KEY = `${PROTOCOL_NAME.toLowerCase()}_version`; + +/** + * Formal definitions for the Core Lore Protocol trailers. +... + * This is the CENTRAL SOURCE OF TRUTH for the Lore Protocol. + * All validation, interactive prompts, and UI rendering are driven by these definitions. + */ +export const CORE_TRAILER_DEFINITIONS: Record = { + [LORE_ID_KEY]: { + description: 'Stable atom identity.', + multivalue: false, + validation: 'pattern', + pattern: '^[0-9a-f]{8}$', + required: true, + ui: { kind: 'identity' as TrailerUiKind, color: 'dim' as TrailerUiColor }, + directives: [ + '[on:amend] Preserve the existing ID to maintain graph integrity', + '[on:commit] Handled by Lore CLI; MUST NOT manually generate IDs or use git hashes', + '[on:commit] The CLI automatically assigns a random 8-character hex string' + ] + }, + 'Constraint': { + description: 'Rules that shaped this decision and may still be active.', + multivalue: true, + validation: 'none', + ui: { kind: 'decision' as TrailerUiKind, color: 'cyan' as TrailerUiColor }, + cli: { flag: 'constraint' }, + prompt: { + confirm: 'Add a Constraint?', + input: 'Constraint:', + order: 100, + }, + directives: [ + '[on:commit] Obey all active constraints. Violations are bugs unless explicitly superseded.', + '[on:squash] Carry forward only those that apply to the final state' + ] + }, + 'Rejected': { + description: 'Alternatives evaluated and dismissed.', + multivalue: true, + validation: 'pattern', + pattern: '^.+ \\| .+$', + ui: { kind: 'decision' as TrailerUiKind, color: 'magenta' as TrailerUiColor }, + cli: { flag: 'rejected' }, + prompt: { + confirm: 'Add a Rejected alternative?', + input: 'Rejected (format: alternative | reason):', + order: 110, + }, + directives: [ + '[on:squash] Record all failed starts or intermediate pivots here' + ] + }, + 'Confidence': { + description: "Author’s assessment of the decision's correctness.", + multivalue: false, + validation: 'values', + values: CONFIDENCE_VALUES_MAP, + ui: { kind: 'risk' as TrailerUiKind, color: 'cyan' as TrailerUiColor }, + cli: { flag: 'confidence' }, + prompt: { + confirm: 'Set Confidence?', + choice: 'Confidence:', + order: 120, + }, + squash: 'rank-min', + directives: [ + '[on:squash] Take the most conservative value (e.g., low + high = low)' + ] + }, + 'Scope-risk': { + description: 'The "blast radius" of the change.', + multivalue: false, + validation: 'values', + values: SCOPE_RISK_VALUES_MAP, + ui: { kind: 'risk' as TrailerUiKind, color: 'cyan' as TrailerUiColor }, + cli: { flag: 'scope-risk' }, + prompt: { + confirm: 'Set Scope-risk?', + choice: 'Scope-risk:', + order: 130, + }, + squash: 'rank-max', + directives: [ + '[on:squash] Take the most conservative value (e.g., narrow + wide = wide)' + ] + }, + 'Reversibility': { + description: 'Ease of undoing the change.', + multivalue: false, + validation: 'values', + values: REVERSIBILITY_VALUES_MAP, + ui: { kind: 'risk' as TrailerUiKind, color: 'cyan' as TrailerUiColor }, + cli: { flag: 'reversibility' }, + prompt: { + confirm: 'Set Reversibility?', + choice: 'Reversibility:', + order: 140, + }, + squash: 'rank-max', + directives: [ + '[on:modify] If "irreversible", seek explicit approval before proceeding', + '[on:squash] Take the most conservative value (e.g., clean + irreversible = irreversible)' + ] + }, + 'Directive': { + description: 'Forward-looking instructions for future modifiers.', + multivalue: true, + validation: 'none', + ui: { kind: 'decision' as TrailerUiKind, color: 'yellow' as TrailerUiColor }, + cli: { flag: 'directive' }, + prompt: { + confirm: 'Add a Directive?', + input: 'Directive:', + order: 150, + }, + directives: [ + '[on:commit] Use [until:...] prefix for temporary rules that expire', + '[on:squash] Carry forward until fulfilled, rejected, or condition met', + '[on:stale][until:YYYY-MM-DD] Flag as stale if hint is expired' + ] + }, + 'Tested': { + description: 'What was verified and how.', + multivalue: true, + validation: 'none', + ui: { kind: 'evidence' as TrailerUiKind, color: 'green' as TrailerUiColor }, + cli: { flag: 'tested' }, + prompt: { + confirm: 'Add a Tested entry?', + input: 'Tested:', + order: 160, + }, + directives: [ + '[on:squash] Consolidate evidence into 3-4 high-signal summary statements' + ] + }, + 'Not-tested': { + description: 'What was not verified and why.', + multivalue: true, + validation: 'none', + ui: { kind: 'evidence' as TrailerUiKind, color: 'red' as TrailerUiColor }, + cli: { flag: 'not-tested' }, + prompt: { + confirm: 'Add a Not-tested entry?', + input: 'Not-tested:', + order: 170, + }, + }, + 'Supersedes': { + description: 'Lore-id of the atom this decision replaces.', + multivalue: true, + validation: 'pattern', + pattern: '^[0-9a-f]{8}$', + ui: { kind: 'reference' as TrailerUiKind, color: 'dim' as TrailerUiColor }, + cli: { flag: 'supersedes' }, + prompt: { + confirm: 'Add a Supersedes reference?', + input: 'Supersedes (8-char hex Lore-id):', + order: 180, + }, + directives: [ + '[on:commit] Only for replacements. Use "Related" or "Depends-on" for additive context.', + '[on:squash] Remove IDs that only exist in local-only (squashed) commits' + ] + }, + 'Depends-on': { + description: 'Lore-id of the atom this decision requires.', + multivalue: true, + validation: 'pattern', + pattern: '^[0-9a-f]{8}$', + ui: { kind: 'reference' as TrailerUiKind, color: 'dim' as TrailerUiColor }, + cli: { flag: 'depends-on' }, + prompt: { + confirm: 'Add a Depends-on reference?', + input: 'Depends-on (8-char hex Lore-id):', + order: 190, + }, + directives: [ + '[on:commit] Run "lore trace " to verify the target exists and is relevant' + ] + }, + 'Related': { + description: 'Lore-id of an atom with a general relationship.', + multivalue: true, + validation: 'pattern', + pattern: '^[0-9a-f]{8}$', + ui: { kind: 'reference' as TrailerUiKind, color: 'dim' as TrailerUiColor }, + cli: { flag: 'related' }, + prompt: { + confirm: 'Add a Related reference?', + input: 'Related (8-char hex Lore-id):', + order: 200, + }, + } +}; + +/** Derived list of all standard trailer keys */ +export const LORE_TRAILER_KEYS = Object.keys(CORE_TRAILER_DEFINITIONS); + +/** Derived list of trailer keys that contain arrays */ +export const ARRAY_TRAILER_KEYS = Object.entries(CORE_TRAILER_DEFINITIONS) + .filter(([_, def]) => def.multivalue) + .map(([key]) => key); + +/** Derived list of trailer keys that contain single enum values */ +export const ENUM_TRAILER_KEYS = Object.entries(CORE_TRAILER_DEFINITIONS) + .filter(([_, def]) => !def.multivalue && def.validation === 'values') + .map(([key]) => key); + +/** Derived list of trailer keys that reference other atoms */ +export const REFERENCE_TRAILER_KEYS = Object.entries(CORE_TRAILER_DEFINITIONS) + .filter(([_, def]) => def.ui?.kind === 'reference') + .map(([key]) => key); + +/** The set of signals that indicate an atom may be stale. */ +const STALE_SIGNALS_MAP = { + AGE: 'age', + DRIFT: 'drift', + LOW_CONFIDENCE: 'low-confidence', + EXPIRED_HINT: 'expired-hint', + ORPHANED_DEP: 'orphaned-dep', +} as const; + +export const STALE_SIGNALS = Object.values(STALE_SIGNALS_MAP); +export const STALE_SIGNAL_METADATA = STALE_SIGNALS_MAP; diff --git a/src/util/directive-parser.ts b/src/util/directive-parser.ts new file mode 100644 index 00000000..7c191e9a --- /dev/null +++ b/src/util/directive-parser.ts @@ -0,0 +1,109 @@ +export interface DirectiveTrigger { + readonly key: string; + readonly value: string; +} + +export interface ParsedDirective { + readonly raw: string; + readonly triggers: readonly DirectiveTrigger[]; + readonly instruction: string; +} + +/** + * Metadata derived from [until:...] and other hints in a directive. + */ +export interface DirectiveHints { + readonly until?: Date; +} + +/** + * Parses Lore directives using the [trigger:parameter] grammar. + * Example: "[on:squash][step:1] Do something" + * Results in: + * triggers: [{ key: 'on', value: 'squash' }, { key: 'step', value: '1' }] + * instruction: "Do something" + * + * SOLID: SRP -- only responsible for directive syntax parsing. + */ +export class DirectiveParser { + private static readonly TRIGGER_PATTERN = /\[([^:\]]+):([^\]]+)\]/g; + + /** + * Parse a raw directive string into structured triggers and instruction. + */ + static parse(directive: string): ParsedDirective { + const triggers: DirectiveTrigger[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + // Reset regex for each call + this.TRIGGER_PATTERN.lastIndex = 0; + + while ((match = this.TRIGGER_PATTERN.exec(directive)) !== null) { + triggers.push({ + key: match[1], + value: match[2], + }); + lastIndex = this.TRIGGER_PATTERN.lastIndex; + } + + const instruction = directive.slice(lastIndex).trim(); + + return { + raw: directive, + triggers, + instruction, + }; + } + + /** + * Helper to check if a directive matches a specific trigger key/value. + */ + static matches(parsed: ParsedDirective, key: string, value?: string): boolean { + return parsed.triggers.some( + (t) => t.key === key && (value === undefined || t.value === value), + ); + } +} + +/** + * Extract time-based and behavioral hints from a directive string. + * + * Implements the logic from the original parseUntilDate method, providing + * precise date resolution for [until:...] triggers. + */ +export function parseDirectiveHints(directive: string): DirectiveHints { + const parsed = DirectiveParser.parse(directive); + const untilTrigger = parsed.triggers.find((t) => t.key === 'until'); + + if (!untilTrigger) { + return {}; + } + + const dateStr = untilTrigger.value; + + // 1. YYYY-MM format: treat as end of that month (start of next) + const monthMatch = dateStr.match(/^(\d{4})-(\d{2})$/); + if (monthMatch) { + const year = parseInt(monthMatch[1], 10); + const month = parseInt(monthMatch[2], 10); + const d = new Date(year, month, 1); + if (!isNaN(d.getTime())) { + return { until: d }; + } + } + + // 2. YYYY-MM-DD format: treat as end of that day + const dayMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dayMatch) { + const year = parseInt(dayMatch[1], 10); + const month = parseInt(dayMatch[2], 10) - 1; + const day = parseInt(dayMatch[3], 10); + const d = new Date(year, month, day, 23, 59, 59, 999); + if (!isNaN(d.getTime())) { + return { until: d }; + } + } + + return {}; +} diff --git a/tests/unit/arch/flat-protocol-boundaries.test.ts b/tests/unit/arch/flat-protocol-boundaries.test.ts new file mode 100644 index 00000000..9a42bca5 --- /dev/null +++ b/tests/unit/arch/flat-protocol-boundaries.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Protocol } from '../../../src/services/protocol.js'; +import { TrailerParser } from '../../../src/services/trailer-parser.js'; +import { JsonFormatter } from '../../../src/formatters/json-formatter.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; +import type { FormattableQueryResult } from '../../../src/types/output.js'; + +/** + * Targeted tests for the boundaries and edge cases of the Flat & Uniform Protocol. + */ +describe('Flat Protocol Boundaries', () => { + describe('Canonical Ordering', () => { + it('should always serialize in protocol-defined order regardless of insertion order', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const parser = new TrailerParser(protocol); + + // Input in "wrong" order + const trailers = { + 'Tested': ['T1'], + 'Confidence': ['high'], + [LORE_ID_KEY]: ['id123'], + 'Constraint': ['C1'], + 'My-Custom': ['Val'] + } as any; + + const output = parser.serialize(trailers); + const lines = output.split('\n'); + + // Canonical order from core-definitions.ts: + // Lore-id (always first) -> Constraint -> Confidence -> Tested -> Custom + expect(lines[0]).toBe(`${LORE_ID_KEY}: id123`); + expect(lines[1]).toBe('Constraint: C1'); + expect(lines[2]).toBe('Confidence: high'); + expect(lines[3]).toBe('Tested: T1'); + expect(lines[4]).toBe('My-Custom: Val'); + }); + }); + + describe('JSON Normalization Matrix', () => { + it('should correctly coerce core scalars and preserve all other arrays', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const formatter = new JsonFormatter(); + const atom = { + loreId: 'id', + commitHash: 'h', + date: new Date(), + author: 'a', + intent: 'i', + body: '', + trailers: { + [LORE_ID_KEY]: ['id'], + 'Confidence': ['high'], // Scalar core + 'Constraint': ['C1', 'C2'], // Array core + 'Tested': ['T1'], // Array core (single value) + 'Custom': ['V1'], // Custom (defaults to array) + } as any, + filesChanged: [] + }; + + const data: FormattableQueryResult = { + result: { atoms: [atom], meta: { totalAtoms: 1, filteredAtoms: 1, oldest: null, newest: null }, command: 'c', target: 't', targetType: 'file' }, + supersessionMap: new Map(), + visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), + }; + + const output = JSON.parse(formatter.formatQueryResult(data)); + const trailers = output.results[0].trailers; + + expect(trailers.confidence).toBe('high'); // Coerced to scalar + expect(trailers.constraint).toEqual(['C1', 'C2']); // Remained array + expect(trailers.tested).toEqual(['T1']); // Remained array (it is an ARRAY core type) + expect(trailers.custom).toEqual(['V1']); // Remained array + }); + }); + + describe('Strict Mode Boundaries', () => { + it('should strictly prune unauthorized custom trailers in non-permissive mode', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, permissive: false, custom: ['Authorized'] } + }; + const protocol = new Protocol(config); + const parser = new TrailerParser(protocol); + + const raw = `${LORE_ID_KEY}: abc\nAuthorized: yes\nUnauthorized: no`; + const parsed = parser.parse(raw); + + expect(parsed['Authorized']).toEqual(['yes']); + expect(parsed['Unauthorized']).toBeUndefined(); + + const serialized = parser.serialize(parsed); + expect(serialized).not.toContain('Unauthorized'); + }); + }); + + describe('Key Case Resilience', () => { + it('should treat trailers as case-insensitive for core mapping', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const parser = new TrailerParser(protocol); + + // User provides lowercase 'confidence' + const raw = `${LORE_ID_KEY}: abc\nconfidence: low`; + const parsed = parser.parse(raw); + + // Should be mapped to the canonical PascalCase key + expect(parsed['Confidence']).toEqual(['low']); + + const serialized = parser.serialize(parsed); + expect(serialized).toContain('Confidence: low'); + expect(serialized).not.toContain('confidence:'); + }); + }); +}); diff --git a/tests/unit/arch/protocol-integrity.test.ts b/tests/unit/arch/protocol-integrity.test.ts new file mode 100644 index 00000000..d7afd113 --- /dev/null +++ b/tests/unit/arch/protocol-integrity.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { FlagsInputReader } from '../../../src/services/readers/flags-input-reader.js'; +import { JsonFormatter } from '../../../src/formatters/json-formatter.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; +import type { CommitCommandOptions } from '../../../src/services/commit-input-resolver.js'; +import type { FormattableQueryResult } from '../../../src/types/output.js'; + +/** + * High-level integration tests for the Lore Protocol's architectural integrity. + * Verifies that the metadata-driven design correctly flows data from the CLI + * through to the formatted output. + */ +describe('Protocol Architectural Integrity', () => { + it('should flow custom trailers from CLI flags to JSON output via metadata', async () => { + // 1. Setup metadata in config + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Ticket-ID': { + description: 'Issue tracker reference', + multivalue: true, + validation: 'pattern' as const, + pattern: '^[A-Z]+-[0-9]+$', + ui: { kind: 'reference' as any, color: 'dim' as any }, + }, + }, + }, + }; + + const protocol = new Protocol(config); + const options: CommitCommandOptions = { + intent: 'feat: add stuff', + 'ticket-id': ['PROJ-123', 'PROJ-456'], + } as any; + + const reader = new FlagsInputReader(options, protocol); + const input = await reader.read(); + + // 2. Verify Reader mapped it correctly as a top-level property + expect(input.trailers?.['Ticket-ID']).toEqual(['PROJ-123', 'PROJ-456']); + + // 3. Simulate Query Result (Core Logic) + const atom = { + loreId: 'atom-123', + commitHash: 'abc', + date: new Date(), + author: 'alice', + intent: input.intent, + body: '', + trailers: { + [LORE_ID_KEY]: ['atom-123'], + ...input.trailers, + } as any, + filesChanged: [], + }; + + const data: FormattableQueryResult = { + result: { + command: 'context', + target: 't', + targetType: 'file', + atoms: [atom], + meta: { totalAtoms: 1, filteredAtoms: 1, oldest: atom.date, newest: atom.date }, + }, + supersessionMap: new Map(), + visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), + }; + + // 4. Verify Formatter serializes it correctly + const formatter = new JsonFormatter(); + const output = JSON.parse(formatter.formatQueryResult(data)); + + // Key should be snake_cased in JSON + expect(output.results[0].trailers.ticket_id).toEqual(['PROJ-123', 'PROJ-456']); + }); + + it('should handle a hybrid flow of core and custom trailers simultaneously', async () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const options: CommitCommandOptions = { + intent: 'feat', + confidence: 'high', + trailer: ['Project-Code:LORE-001'], + }; + + const reader = new FlagsInputReader(options, protocol); + const input = await reader.read(); + + // Verify both are captured correctly at top level + expect(input.trailers?.Confidence).toEqual(['high']); + expect(input.trailers?.['Project-Code']).toEqual(['LORE-001']); + }); +}); diff --git a/tests/unit/commands/commit-amend.test.ts b/tests/unit/commands/commit-amend.test.ts index c24c8357..76f6f19e 100644 --- a/tests/unit/commands/commit-amend.test.ts +++ b/tests/unit/commands/commit-amend.test.ts @@ -6,6 +6,9 @@ import type { IGitClient } from '../../../src/interfaces/git-client.js'; import type { IOutputFormatter } from '../../../src/interfaces/output-formatter.js'; import type { CommitInputResolver } from '../../../src/services/commit-input-resolver.js'; import type { HeadLoreIdReader } from '../../../src/services/head-lore-id-reader.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; function createMockGitClient(): IGitClient { return { @@ -21,7 +24,7 @@ function createMockGitClient(): IGitClient { getHeadMessage: vi.fn().mockResolvedValue(''), countAllCommits: vi.fn().mockResolvedValue(0), listTrackedFiles: vi.fn().mockResolvedValue([]), - }; + } as any; } function createMockFormatter(): IOutputFormatter { @@ -73,6 +76,8 @@ function createDeps(overrides?: { getFormatter: () => createMockFormatter(), commitInputResolver: createMockInputResolver(), headLoreIdReader: overrides?.headLoreIdReader ?? createMockHeadLoreIdReader(), + config: DEFAULT_CONFIG, + protocol: new Protocol(DEFAULT_CONFIG), }; } @@ -93,7 +98,7 @@ describe('lore commit --amend', () => { expect(gitClient.hasStagedChanges).not.toHaveBeenCalled(); }); - it('should pass existing Lore-id to commitBuilder.build when amending', async () => { + it(`should pass existing ${LORE_ID_KEY} to commitBuilder.build when amending`, async () => { const headLoreIdReader = createMockHeadLoreIdReader('cafebabe'); const deps = createDeps({ headLoreIdReader }); @@ -188,7 +193,7 @@ describe('lore commit --amend', () => { ).rejects.toThrow('--no-edit can only be used with --amend'); }); - it('should generate new Lore-id when amending a non-Lore commit', async () => { + it(`should generate new ${LORE_ID_KEY} when amending a non-Lore commit`, async () => { const headLoreIdReader = createMockHeadLoreIdReader(null); const deps = createDeps({ headLoreIdReader }); @@ -202,7 +207,7 @@ describe('lore commit --amend', () => { ); }); - it('should not read Lore-id from HEAD for normal commits', async () => { + it(`should not read ${LORE_ID_KEY} from HEAD for normal commits`, async () => { const headLoreIdReader = createMockHeadLoreIdReader('cafebabe'); const deps = createDeps({ headLoreIdReader }); diff --git a/tests/unit/commands/commit-flags.test.ts b/tests/unit/commands/commit-flags.test.ts new file mode 100644 index 00000000..de5d0a81 --- /dev/null +++ b/tests/unit/commands/commit-flags.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerCommitCommand } from '../../../src/commands/commit.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; + +describe('lore commit (dynamic flags)', () => { + const mockDeps = { + commitBuilder: { build: vi.fn(), validate: vi.fn(() => []) }, + gitClient: { commit: vi.fn().mockResolvedValue({ hash: 'h1' }), hasStagedChanges: vi.fn().mockResolvedValue(true) }, + getFormatter: () => ({ formatSuccess: vi.fn() }), + commitInputResolver: { resolve: vi.fn().mockResolvedValue({ intent: 'i' }) }, + headLoreIdReader: { read: vi.fn() }, + config: DEFAULT_CONFIG, + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should register flags for custom trailers defined in config', async () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Department: { + description: 'Dept', + multivalue: false, + validation: 'none' as const, + cli: { flag: 'dept' } + } + } + } + }; + const protocol = new Protocol(config); + const program = new Command(); + + registerCommitCommand(program, { ...mockDeps, config, protocol }); + + const commitCmd = program.commands.find(c => c.name() === 'commit'); + expect(commitCmd).toBeDefined(); + + const deptOption = commitCmd?.options.find(o => o.long === '--dept'); + expect(deptOption).toBeDefined(); + expect(deptOption?.description).toContain('Dept'); + }); + + it('should automatically slugify custom trailer keys into flags', async () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Assisted-by': { + description: 'A', + multivalue: true, + validation: 'none' as const + } + } + } + }; + const protocol = new Protocol(config); + const program = new Command(); + + registerCommitCommand(program, { ...mockDeps, config, protocol }); + + const commitCmd = program.commands.find(c => c.name() === 'commit'); + const assistedOption = commitCmd?.options.find(o => o.long === '--assisted-by'); + expect(assistedOption).toBeDefined(); + }); +}); diff --git a/tests/unit/commands/helpers/path-query-limit.test.ts b/tests/unit/commands/helpers/path-query-limit.test.ts index db704321..606f440c 100644 --- a/tests/unit/commands/helpers/path-query-limit.test.ts +++ b/tests/unit/commands/helpers/path-query-limit.test.ts @@ -2,26 +2,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { executePathQuery } from '../../../../src/commands/helpers/path-query.js'; import type { PathQueryDeps, PathQueryCommandOptions } from '../../../../src/commands/helpers/path-query.js'; import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../../../../src/types/domain.js'; -import type { LoreConfig } from '../../../../src/types/config.js'; -import { DEFAULT_CONFIG } from '../../../../src/types/config.js'; -import { CustomTrailerCollection } from '../../../../src/types/custom-trailer-collection.js'; +import { DEFAULT_CONFIG } from '../../../../src/util/constants.js'; +import { Protocol } from '../../../../src/services/protocol.js'; +import { LORE_ID_KEY } from '../../../../src/util/constants.js'; function makeTrailers(loreId: string): LoreTrailers { return { - 'Lore-id': loreId, + [LORE_ID_KEY]: [loreId], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: [], - custom: CustomTrailerCollection.empty(), - }; + } as any; } function makeAtom(id: string, supersedes: string[] = []): LoreAtom { @@ -44,12 +43,14 @@ describe('executePathQuery — --limit as post-supersession result cap', () => { let mockResolve: ReturnType; let mockFilterActive: ReturnType; let formattedOutput: string; + let protocol: Protocol; beforeEach(() => { mockFindByTarget = vi.fn(); mockResolve = vi.fn(); mockFilterActive = vi.fn(); formattedOutput = ''; + protocol = new Protocol(DEFAULT_CONFIG); deps = { atomRepository: { @@ -81,6 +82,7 @@ describe('executePathQuery — --limit as post-supersession result cap', () => { }), }) as any, config: DEFAULT_CONFIG, + protocol, }; }); @@ -134,8 +136,9 @@ describe('executePathQuery — --limit as post-supersession result cap', () => { // Verify findByTarget received maxCommits in PathQueryOptions const queryOptions = mockFindByTarget.mock.calls[0][1]; expect(queryOptions.maxCommits).toBe(100); - // limit is in the options but should NOT affect git scan - expect(queryOptions.limit).toBe(5); + // limit is in the options but should NOT affect git scan (in repository call) + // Actually our executePathQuery passes null limit to findByTarget + expect(queryOptions.limit).toBeNull(); vi.mocked(console.log).mockRestore(); }); diff --git a/tests/unit/commands/log.test.ts b/tests/unit/commands/log.test.ts index 68068fda..3c4b1ac7 100644 --- a/tests/unit/commands/log.test.ts +++ b/tests/unit/commands/log.test.ts @@ -5,7 +5,9 @@ import type { AtomRepository } from '../../../src/services/atom-repository.js'; import type { SupersessionResolver } from '../../../src/services/supersession-resolver.js'; import type { IOutputFormatter } from '../../../src/interfaces/output-formatter.js'; import type { LoreAtom } from '../../../src/types/domain.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; /** * Regression tests for issue #22: `lore log` must accept positional path @@ -25,20 +27,19 @@ function makeAtom(overrides: Partial & { filesChanged: readonly string intent: 'fix: example', body: '', trailers: { - 'Lore-id': 'abcd1234', + [LORE_ID_KEY]: ['abcd1234'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: [], - custom: CustomTrailerCollection.empty(), - }, + } as any, ...overrides, }; } @@ -84,6 +85,8 @@ function buildHarness(atoms: LoreAtom[], filteredAtoms?: LoreAtom[]): Harness { atomRepository, supersessionResolver, getFormatter: () => formatter, + config: DEFAULT_CONFIG, + protocol: new Protocol(DEFAULT_CONFIG), }); return { program, capturedResult, findAll, findByTarget, consoleSpy }; @@ -171,7 +174,7 @@ describe('registerLogCommand (issue #22 path arguments)', () => { const atom = makeAtom({ loreId: 'mc000001', filesChanged: ['src/main.ts'] }); const h = buildHarness([], [atom]); - await h.program.parseAsync(['node', 'lore', 'log', '--max-commits', '50', 'src/main.ts']); + await h.program.parseAsync(['node', 'lore', 'log', 'src/main.ts', '--max-commits', '50']); const queryOptions = h.findByTarget.mock.calls[0][1]; expect(queryOptions.maxCommits).toBe(50); diff --git a/tests/unit/formatters/json-formatter.test.ts b/tests/unit/formatters/json-formatter.test.ts index 6498de3e..c2598d8f 100644 --- a/tests/unit/formatters/json-formatter.test.ts +++ b/tests/unit/formatters/json-formatter.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { JsonFormatter } from '../../../src/formatters/json-formatter.js'; -import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../../../src/types/domain.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY, LORE_ID_JSON_KEY, LORE_VERSION_JSON_KEY } from '../../../src/util/constants.js'; import type { FormattableQueryResult, FormattableValidationResult, @@ -8,24 +10,24 @@ import type { FormattableTraceResult, FormattableDoctorResult, } from '../../../src/types/output.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../../../src/types/domain.js'; function makeTrailers(overrides: Partial = {}): LoreTrailers { return { - 'Lore-id': overrides['Lore-id'] ?? 'a1b2c3d4', + [LORE_ID_KEY]: overrides[LORE_ID_KEY] ?? ['a1b2c3d4'], Constraint: overrides.Constraint ?? [], Rejected: overrides.Rejected ?? [], - Confidence: overrides.Confidence ?? null, - 'Scope-risk': overrides['Scope-risk'] ?? null, - Reversibility: overrides.Reversibility ?? null, + Confidence: overrides.Confidence ?? [], + 'Scope-risk': overrides['Scope-risk'] ?? [], + Reversibility: overrides.Reversibility ?? [], Directive: overrides.Directive ?? [], Tested: overrides.Tested ?? [], 'Not-tested': overrides['Not-tested'] ?? [], Supersedes: overrides.Supersedes ?? [], 'Depends-on': overrides['Depends-on'] ?? [], Related: overrides.Related ?? [], - custom: overrides.custom ?? CustomTrailerCollection.empty(), - }; + ...overrides, + } as any; } function makeAtom(overrides: Partial = {}): LoreAtom { @@ -43,9 +45,14 @@ function makeAtom(overrides: Partial = {}): LoreAtom { describe('JsonFormatter', () => { const formatter = new JsonFormatter(); + let protocol: Protocol; + + beforeEach(() => { + protocol = new Protocol(DEFAULT_CONFIG); + }); describe('formatQueryResult', () => { - it('should produce valid JSON with lore_version', () => { + it('should produce valid JSON with [LORE_VERSION_JSON_KEY]', () => { const atom = makeAtom(); const data: FormattableQueryResult = { result: { @@ -62,12 +69,13 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.command).toBe('context'); expect(parsed.target).toBe('src/auth.ts'); expect(parsed.target_type).toBe('file'); @@ -90,6 +98,7 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -97,7 +106,7 @@ describe('JsonFormatter', () => { expect(parsed.meta.total_atoms).toBe(5); expect(parsed.meta.filtered_atoms).toBe(1); - expect(parsed.results[0].lore_id).toBe('a1b2c3d4'); + expect(parsed.results[0][LORE_ID_JSON_KEY]).toBe('a1b2c3d4'); expect(parsed.results[0].commit).toBe('abc1234567890'); expect(parsed.results[0].files_changed).toEqual(['src/auth.ts']); }); @@ -119,6 +128,7 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -144,6 +154,7 @@ describe('JsonFormatter', () => { }, supersessionMap, visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -157,7 +168,7 @@ describe('JsonFormatter', () => { const atom = makeAtom({ trailers: makeTrailers({ Constraint: ['Must use OAuth2'], - Confidence: 'high', + Confidence: ['high'], Rejected: ['Session tokens'], }), }); @@ -172,6 +183,7 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: ['Constraint'], + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -193,6 +205,7 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -206,7 +219,7 @@ describe('JsonFormatter', () => { it('should convert trailer keys to snake_case', () => { const atom = makeAtom({ trailers: makeTrailers({ - 'Scope-risk': 'wide', + 'Scope-risk': ['wide'], 'Not-tested': ['edge cases'], 'Depends-on': ['aabbccdd'], }), @@ -222,6 +235,7 @@ describe('JsonFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -231,6 +245,85 @@ describe('JsonFormatter', () => { expect(parsed.results[0].trailers.not_tested).toEqual(['edge cases']); expect(parsed.results[0].trailers.depends_on).toEqual(['aabbccdd']); }); + + it('should normalize custom trailers to scalars or arrays based on metadata', () => { + const atom = makeAtom({ + trailers: { + ...makeTrailers(), + 'Assisted-by': ['Gemini'], + 'Team': ['Engineering', 'Product'], + 'Project': ['Lore'], + }, + }); + + const data: FormattableQueryResult = { + result: { + command: 'context', + target: 'all', + targetType: 'global', + atoms: [atom], + meta: { totalAtoms: 1, filteredAtoms: 1, oldest: atom.date, newest: atom.date }, + }, + supersessionMap: new Map(), + visibleTrailers: 'all', + trailerDefinitions: { + ...protocol.getFormattableDefinitions(), + 'Assisted-by': { + description: 'A', + multivalue: false, + validation: 'none', + directives: [], + }, + 'Team': { + description: 'T', + multivalue: true, + validation: 'none', + directives: [], + }, + // Project has no definition (should default to array) + }, + }; + + const output = formatter.formatQueryResult(data); + const parsed = JSON.parse(output); + const trailers = parsed.results[0].trailers; + + // Assisted-by is multivalue: false -> scalar + expect(trailers.assisted_by).toBe('Gemini'); + // Team is multivalue: true -> array + expect(trailers.team).toEqual(['Engineering', 'Product']); + // Project is undefined -> array (default) + expect(trailers.project).toEqual(['Lore']); + }); + + it('should use rebranded structural keys in JSON output when protocol name is changed', () => { + // We simulate rebranding by manually constructing the expected key using the current PROTOCOL_NAME + // Since we can't easily re-run the module with a different PROTOCOL_NAME constant in unit tests, + // we verify that the current output matches the constants. + const atom = makeAtom(); + const data: FormattableQueryResult = { + result: { + command: 'context', + target: 'all', + targetType: 'global', + atoms: [atom], + meta: { totalAtoms: 1, filteredAtoms: 1, oldest: atom.date, newest: atom.date }, + }, + supersessionMap: new Map(), + visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), + }; + + const output = formatter.formatQueryResult(data); + const parsed = JSON.parse(output); + + // Verify that structural keys match the constants defined in core-definitions.ts + expect(parsed).toHaveProperty(LORE_VERSION_JSON_KEY); + expect(parsed.results[0]).toHaveProperty(LORE_ID_JSON_KEY); + + // If we were Fred, these would be fred_version and fred_id. + // The fact that they match the constants proves the derivation is working. + }); }); describe('formatValidationResult', () => { @@ -247,11 +340,11 @@ describe('JsonFormatter', () => { const output = formatter.formatValidationResult(data); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.valid).toBe(true); expect(parsed.summary.commits_checked).toBe(2); expect(parsed.results).toHaveLength(2); - expect(parsed.results[0].lore_id).toBe('a1b2c3d4'); + expect(parsed.results[0][LORE_ID_JSON_KEY]).toBe('a1b2c3d4'); }); it('should include issues in results', () => { @@ -264,7 +357,7 @@ describe('JsonFormatter', () => { loreId: null, valid: false, issues: [ - { severity: 'error', rule: 'lore-id-present', message: 'Missing Lore-id' }, + { severity: 'error', rule: 'lore-id-present', message: `Missing ${LORE_ID_KEY}` }, ], }, ], @@ -273,7 +366,7 @@ describe('JsonFormatter', () => { const output = formatter.formatValidationResult(data); const parsed = JSON.parse(output); - expect(parsed.results[0].lore_id).toBeNull(); + expect(parsed.results[0][LORE_ID_JSON_KEY]).toBeNull(); expect(parsed.results[0].issues[0].severity).toBe('error'); expect(parsed.results[0].issues[0].rule).toBe('lore-id-present'); }); @@ -295,9 +388,9 @@ describe('JsonFormatter', () => { const output = formatter.formatStalenessResult(data); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.stale_atoms).toHaveLength(1); - expect(parsed.stale_atoms[0].lore_id).toBe('a1b2c3d4'); + expect(parsed.stale_atoms[0][LORE_ID_JSON_KEY]).toBe('a1b2c3d4'); expect(parsed.stale_atoms[0].date).toBe('2025-01-15T10:00:00.000Z'); expect(parsed.stale_atoms[0].reasons).toEqual([ { signal: 'age', description: 'Too old' }, @@ -330,11 +423,11 @@ describe('JsonFormatter', () => { const output = formatter.formatTraceResult(data); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); - expect(parsed.root.lore_id).toBe('aaaabbbb'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); + expect(parsed.root[LORE_ID_JSON_KEY]).toBe('aaaabbbb'); expect(parsed.edges).toHaveLength(2); expect(parsed.edges[0].resolved).toBe(true); - expect(parsed.edges[0].target_atom.lore_id).toBe('ccccdddd'); + expect(parsed.edges[0].target_atom[LORE_ID_JSON_KEY]).toBe('ccccdddd'); expect(parsed.edges[1].resolved).toBe(false); expect(parsed.edges[1].target_atom).toBeNull(); }); @@ -353,7 +446,7 @@ describe('JsonFormatter', () => { const output = formatter.formatDoctorResult(data); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.checks).toHaveLength(2); expect(parsed.checks[0].name).toBe('git-version'); expect(parsed.checks[0].status).toBe('ok'); @@ -364,19 +457,20 @@ describe('JsonFormatter', () => { describe('formatSuccess', () => { it('should produce valid JSON with success flag', () => { - const output = formatter.formatSuccess('Commit created', { lore_id: 'a1b2c3d4' }); + const output = formatter.formatSuccess('Commit created', { [LORE_ID_JSON_KEY]: 'a1b2c3d4' }); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.success).toBe(true); expect(parsed.message).toBe('Commit created'); - expect(parsed.lore_id).toBe('a1b2c3d4'); + expect(parsed[LORE_ID_JSON_KEY]).toBe('a1b2c3d4'); }); it('should work without extra data', () => { const output = formatter.formatSuccess('Done'); const parsed = JSON.parse(output); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.success).toBe(true); expect(parsed.message).toBe('Done'); }); @@ -390,7 +484,7 @@ describe('JsonFormatter', () => { ]); const parsed = JSON.parse(output); - expect(parsed.lore_version).toBe('1.0'); + expect(parsed[LORE_VERSION_JSON_KEY]).toBe('1.0'); expect(parsed.error).toBe(true); expect(parsed.code).toBe(1); expect(parsed.messages).toHaveLength(2); diff --git a/tests/unit/formatters/text-formatter.test.ts b/tests/unit/formatters/text-formatter.test.ts index 47cd9abc..91cc1d3f 100644 --- a/tests/unit/formatters/text-formatter.test.ts +++ b/tests/unit/formatters/text-formatter.test.ts @@ -1,5 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { TextFormatter } from '../../../src/formatters/text-formatter.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../../../src/types/domain.js'; import type { FormattableQueryResult, @@ -8,24 +11,23 @@ import type { FormattableTraceResult, FormattableDoctorResult, } from '../../../src/types/output.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; function makeTrailers(overrides: Partial = {}): LoreTrailers { return { - 'Lore-id': overrides['Lore-id'] ?? 'a1b2c3d4', + [LORE_ID_KEY]: overrides[LORE_ID_KEY] ?? ['a1b2c3d4'], Constraint: overrides.Constraint ?? [], Rejected: overrides.Rejected ?? [], - Confidence: overrides.Confidence ?? null, - 'Scope-risk': overrides['Scope-risk'] ?? null, - Reversibility: overrides.Reversibility ?? null, + Confidence: overrides.Confidence ?? [], + 'Scope-risk': overrides['Scope-risk'] ?? [], + Reversibility: overrides.Reversibility ?? [], Directive: overrides.Directive ?? [], Tested: overrides.Tested ?? [], 'Not-tested': overrides['Not-tested'] ?? [], Supersedes: overrides.Supersedes ?? [], 'Depends-on': overrides['Depends-on'] ?? [], Related: overrides.Related ?? [], - custom: overrides.custom ?? CustomTrailerCollection.empty(), - }; + ...overrides, + } as any; } function makeAtom(overrides: Partial = {}): LoreAtom { @@ -43,6 +45,11 @@ function makeAtom(overrides: Partial = {}): LoreAtom { describe('TextFormatter', () => { const formatter = new TextFormatter({ color: false }); + let protocol: Protocol; + + beforeEach(() => { + protocol = new Protocol(DEFAULT_CONFIG); + }); describe('formatQueryResult', () => { it('should show "No lore atoms found" when empty', () => { @@ -56,6 +63,7 @@ describe('TextFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -66,7 +74,7 @@ describe('TextFormatter', () => { const atom = makeAtom({ trailers: makeTrailers({ Constraint: ['Must use OAuth2'], - Confidence: 'high', + Confidence: ['high'], }), }); const data: FormattableQueryResult = { @@ -84,6 +92,7 @@ describe('TextFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -112,6 +121,7 @@ describe('TextFormatter', () => { }, supersessionMap, visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -122,7 +132,7 @@ describe('TextFormatter', () => { const atom = makeAtom({ trailers: makeTrailers({ Constraint: ['Must use OAuth2'], - Confidence: 'high', + Confidence: ['high'], Rejected: ['Session tokens'], }), }); @@ -136,6 +146,7 @@ describe('TextFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: ['Constraint'], + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -157,6 +168,7 @@ describe('TextFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -175,6 +187,7 @@ describe('TextFormatter', () => { }, supersessionMap: new Map(), visibleTrailers: 'all', + trailerDefinitions: protocol.getFormattableDefinitions(), }; const output = formatter.formatQueryResult(data); @@ -213,7 +226,7 @@ describe('TextFormatter', () => { loreId: null, valid: false, issues: [ - { severity: 'error', rule: 'lore-id-present', message: 'Lore-id trailer is missing' }, + { severity: 'error', rule: 'lore-id-present', message: `${LORE_ID_KEY} trailer is missing` }, { severity: 'warning', rule: 'intent-length', message: 'Intent too long' }, ], }, @@ -223,14 +236,14 @@ describe('TextFormatter', () => { const output = formatter.formatValidationResult(data); expect(output).toContain('\u2717'); expect(output).toContain('lore-id-present'); - expect(output).toContain('Lore-id trailer is missing'); + expect(output).toContain(`${LORE_ID_KEY} trailer is missing`); expect(output).toContain('\u26A0'); expect(output).toContain('Intent too long'); expect(output).toContain('1 errors'); expect(output).toContain('1 warnings'); }); - it('should use commit hash prefix when no lore-id', () => { + it(`should use commit hash prefix when no ${LORE_ID_KEY}`, () => { const data: FormattableValidationResult = { valid: false, summary: { errors: 1, warnings: 0, commitsChecked: 1 }, @@ -240,7 +253,7 @@ describe('TextFormatter', () => { loreId: null, valid: false, issues: [ - { severity: 'error', rule: 'lore-id-present', message: 'Missing Lore-id' }, + { severity: 'error', rule: 'lore-id-present', message: `Missing ${LORE_ID_KEY}` }, ], }, ], @@ -326,7 +339,7 @@ describe('TextFormatter', () => { checks: [ { name: 'git-version', status: 'ok', message: 'Git 2.40+ detected', details: [] }, { name: 'config', status: 'warning', message: 'No config found', details: ['Using defaults'] }, - { name: 'duplicates', status: 'error', message: '2 duplicate Lore-ids', details: ['a1b2c3d4', 'e5f6a7b8'] }, + { name: 'duplicates', status: 'error', message: `2 duplicate ${LORE_ID_KEY}s`, details: ['a1b2c3d4', 'e5f6a7b8'] }, ], summary: { errors: 1, warnings: 1, info: 0 }, }; @@ -338,7 +351,7 @@ describe('TextFormatter', () => { expect(output).toContain('No config found'); expect(output).toContain('Using defaults'); expect(output).toContain('ERROR'); - expect(output).toContain('2 duplicate Lore-ids'); + expect(output).toContain(`2 duplicate ${LORE_ID_KEY}s`); expect(output).toContain('1 errors'); expect(output).toContain('1 warnings'); }); diff --git a/tests/unit/services/atom-repository.test.ts b/tests/unit/services/atom-repository.test.ts index 45d502f7..29b1f792 100644 --- a/tests/unit/services/atom-repository.test.ts +++ b/tests/unit/services/atom-repository.test.ts @@ -3,15 +3,17 @@ import { AtomRepository } from '../../../src/services/atom-repository.js'; import type { IGitClient, RawCommit } from '../../../src/interfaces/git-client.js'; import type { PathQueryOptions } from '../../../src/types/query.js'; import type { LoreTrailers } from '../../../src/types/domain.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; /** * Minimal TrailerParser mock that satisfies the AtomRepository's usage. */ function createMockTrailerParser() { return { - containsLoreTrailers: vi.fn((text: string) => text.includes('Lore-id')), - parse: vi.fn((rawTrailers: string, _customKeys: readonly string[]): LoreTrailers => { + containsLoreTrailers: vi.fn((text: string) => text.includes(LORE_ID_KEY)), + parse: vi.fn((rawTrailers: string): LoreTrailers => { const trailers = parseTrailersFromText(rawTrailers); return trailers; }), @@ -26,18 +28,21 @@ function createMockTrailerParser() { */ function parseTrailersFromText(raw: string): LoreTrailers { const lines = raw.split('\n').filter((l) => l.trim().length > 0); - let loreId = ''; - const constraints: string[] = []; - const rejected: string[] = []; - let confidence: LoreTrailers['Confidence'] = null; - let scopeRisk: LoreTrailers['Scope-risk'] = null; - let reversibility: LoreTrailers['Reversibility'] = null; - const directives: string[] = []; - const tested: string[] = []; - const notTested: string[] = []; - const supersedes: string[] = []; - const dependsOn: string[] = []; - const related: string[] = []; + + const result: any = { + [LORE_ID_KEY]: [], + Constraint: [], + Rejected: [], + Confidence: [], + 'Scope-risk': [], + Reversibility: [], + Directive: [], + Tested: [], + 'Not-tested': [], + Supersedes: [], + 'Depends-on': [], + Related: [], + }; for (const line of lines) { const colonIdx = line.indexOf(':'); @@ -45,37 +50,14 @@ function parseTrailersFromText(raw: string): LoreTrailers { const key = line.substring(0, colonIdx).trim(); const value = line.substring(colonIdx + 1).trim(); - switch (key) { - case 'Lore-id': loreId = value; break; - case 'Constraint': constraints.push(value); break; - case 'Rejected': rejected.push(value); break; - case 'Confidence': confidence = value as LoreTrailers['Confidence']; break; - case 'Scope-risk': scopeRisk = value as LoreTrailers['Scope-risk']; break; - case 'Reversibility': reversibility = value as LoreTrailers['Reversibility']; break; - case 'Directive': directives.push(value); break; - case 'Tested': tested.push(value); break; - case 'Not-tested': notTested.push(value); break; - case 'Supersedes': supersedes.push(value); break; - case 'Depends-on': dependsOn.push(value); break; - case 'Related': related.push(value); break; + if (result[key]) { + result[key].push(value); + } else { + result[key] = [value]; } } - return { - 'Lore-id': loreId, - Constraint: constraints, - Rejected: rejected, - Confidence: confidence, - 'Scope-risk': scopeRisk, - Reversibility: reversibility, - Directive: directives, - Tested: tested, - 'Not-tested': notTested, - Supersedes: supersedes, - 'Depends-on': dependsOn, - Related: related, - custom: CustomTrailerCollection.empty(), - }; + return result; } function createMockGitClient(overrides: Partial = {}): IGitClient { @@ -90,7 +72,7 @@ function createMockGitClient(overrides: Partial = {}): IGitClient { countCommitsSince: vi.fn(async () => 0), resolveRef: vi.fn(async () => 'abc123'), ...overrides, - }; + } as any; } function makeLoreCommit(options: { @@ -110,7 +92,7 @@ function makeLoreCommit(options: { author: options.author ?? 'dev@example.com', subject: options.subject ?? 'feat(auth): add login', body: options.body ?? 'Implemented login flow.', - trailers: `Lore-id: ${loreId}\n${extras}`.trim(), + trailers: `${LORE_ID_KEY}: ${loreId}\n${extras}`.trim(), }; } @@ -127,6 +109,7 @@ function makeQueryOptions(overrides: Partial = {}): PathQueryO limit: null, maxCommits: null, since: null, + until: null, ...overrides, }; } @@ -135,11 +118,13 @@ describe('AtomRepository', () => { let gitClient: IGitClient; let trailerParser: ReturnType; let repo: AtomRepository; + let protocol: Protocol; beforeEach(() => { gitClient = createMockGitClient(); trailerParser = createMockTrailerParser(); - repo = new AtomRepository(gitClient, trailerParser as any); + protocol = new Protocol(DEFAULT_CONFIG); + repo = new AtomRepository(gitClient, trailerParser as any, protocol); }); describe('findByTarget', () => { @@ -199,6 +184,16 @@ describe('AtomRepository', () => { expect(logArgs).toContain('--since=2025-01-01'); }); + it('should pass until filter to git log args', async () => { + vi.mocked(gitClient.log).mockResolvedValue([]); + + const options = makeQueryOptions({ until: '2025-06-01' }); + await repo.findByTarget(makeGitLogArgs(), options); + + const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; + expect(logArgs).toContain('--until=2025-06-01'); + }); + it('should pass maxCommits to git log args', async () => { vi.mocked(gitClient.log).mockResolvedValue([]); @@ -247,7 +242,7 @@ describe('AtomRepository', () => { }); describe('findByLoreId', () => { - it('should find an atom by its Lore-id', async () => { + it(`should find an atom by its ${LORE_ID_KEY}`, async () => { const commit = makeLoreCommit({ loreId: 'deadbeef' }); vi.mocked(gitClient.log).mockResolvedValue([commit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue(['src/auth.ts']); @@ -258,7 +253,7 @@ describe('AtomRepository', () => { expect(result!.loreId).toBe('deadbeef'); }); - it('should return null if no atom matches the Lore-id', async () => { + it(`should return null if no atom matches the ${LORE_ID_KEY}`, async () => { const commit = makeLoreCommit({ loreId: 'a1b2c3d4' }); vi.mocked(gitClient.log).mockResolvedValue([commit]); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); @@ -268,14 +263,14 @@ describe('AtomRepository', () => { expect(result).toBeNull(); }); - it('should return null for invalid Lore-id format', async () => { + it(`should return null for invalid ${LORE_ID_KEY} format`, async () => { const result = await repo.findByLoreId('not-valid'); expect(result).toBeNull(); expect(gitClient.log).not.toHaveBeenCalled(); }); - it('should return null for empty Lore-id', async () => { + it(`should return null for empty ${LORE_ID_KEY}`, async () => { const result = await repo.findByLoreId(''); expect(result).toBeNull(); @@ -297,7 +292,7 @@ describe('AtomRepository', () => { }); it('should strip trailers from body when body is exactly the trailer block', async () => { - const trailersRaw = 'Lore-id: aaaa1111\nDirective: keep simple'; + const trailersRaw = `${LORE_ID_KEY}: aaaa1111\nDirective: keep simple`; const commit: RawCommit = { hash: 'aaa', date: '2025-01-15T10:00:00Z', @@ -405,17 +400,17 @@ describe('AtomRepository', () => { describe('resolveFollowLinks', () => { it('should resolve atoms referenced by Related trailers', async () => { - const atom1Trailers = 'Lore-id: aaaa1111\nRelated: bbbb2222'; - const atom2Trailers = 'Lore-id: bbbb2222'; - const commit1 = makeLoreCommit({ hash: 'aaa', loreId: 'aaaa1111', trailerExtras: 'Related: bbbb2222' }); const commit2 = makeLoreCommit({ hash: 'bbb', loreId: 'bbbb2222' }); - // First call for initial atoms, second call for findByLoreId - vi.mocked(gitClient.log).mockResolvedValue([commit1, commit2]); + // Search matches based on grep + vi.mocked(gitClient.log).mockImplementation(async (args) => { + if (args.some(a => a.includes('bbbb2222'))) return [commit2]; + return []; + }); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); - // Parse the initial atoms ourselves + // Create initial atoms manually with flat structure const initialAtoms = [{ loreId: 'aaaa1111', commitHash: 'aaa', @@ -424,20 +419,19 @@ describe('AtomRepository', () => { intent: 'feat(auth): add login', body: '', trailers: { - 'Lore-id': 'aaaa1111', + [LORE_ID_KEY]: ['aaaa1111'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: ['bbbb2222'], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: [], }]; @@ -458,20 +452,19 @@ describe('AtomRepository', () => { intent: 'test', body: '', trailers: { - 'Lore-id': 'aaaa1111', + [LORE_ID_KEY]: ['aaaa1111'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: ['bbbb2222'], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: [], }]; @@ -490,20 +483,19 @@ describe('AtomRepository', () => { intent: 'test', body: '', trailers: { - 'Lore-id': 'aaaa1111', + [LORE_ID_KEY]: ['aaaa1111'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: [], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: [], }]; @@ -517,7 +509,11 @@ describe('AtomRepository', () => { const commitA = makeLoreCommit({ hash: 'aaa', loreId: 'aaaa1111', trailerExtras: 'Related: bbbb2222' }); const commitB = makeLoreCommit({ hash: 'bbb', loreId: 'bbbb2222', trailerExtras: 'Related: aaaa1111' }); - vi.mocked(gitClient.log).mockResolvedValue([commitA, commitB]); + vi.mocked(gitClient.log).mockImplementation(async (args) => { + if (args.some(a => a.includes('aaaa1111'))) return [commitA]; + if (args.some(a => a.includes('bbbb2222'))) return [commitB]; + return []; + }); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); const atomA = { @@ -528,20 +524,19 @@ describe('AtomRepository', () => { intent: 'test', body: '', trailers: { - 'Lore-id': 'aaaa1111', + [LORE_ID_KEY]: ['aaaa1111'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: ['bbbb2222'], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: [], }; @@ -556,8 +551,12 @@ describe('AtomRepository', () => { const commitC = makeLoreCommit({ hash: 'ccc', loreId: 'cccc3333', trailerExtras: 'Related: dddd4444' }); const commitD = makeLoreCommit({ hash: 'ddd', loreId: 'dddd4444' }); - // findByLoreId will search all commits - vi.mocked(gitClient.log).mockResolvedValue([commitB, commitC, commitD]); + vi.mocked(gitClient.log).mockImplementation(async (args) => { + if (args.some(a => a.includes('bbbb2222'))) return [commitB]; + if (args.some(a => a.includes('cccc3333'))) return [commitC]; + if (args.some(a => a.includes('dddd4444'))) return [commitD]; + return []; + }); vi.mocked(gitClient.getFilesChanged).mockResolvedValue([]); const atomA = { @@ -568,20 +567,19 @@ describe('AtomRepository', () => { intent: 'test', body: '', trailers: { - 'Lore-id': 'aaaa1111', + [LORE_ID_KEY]: ['aaaa1111'], Constraint: [], Rejected: [], - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], Directive: [], Tested: [], 'Not-tested': [], Supersedes: [], 'Depends-on': [], Related: ['bbbb2222'], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: [], }; @@ -601,13 +599,12 @@ describe('AtomRepository', () => { }); describe('git log format', () => { - it('should pass args to git client log (format is applied by GitClient)', async () => { + it('should pass args to git client log', async () => { vi.mocked(gitClient.log).mockResolvedValue([]); await repo.findAll(); const logArgs = vi.mocked(gitClient.log).mock.calls[0][0]; - // The format string is now applied by GitClient.log(), not AtomRepository expect(Array.isArray(logArgs)).toBe(true); }); @@ -625,7 +622,7 @@ describe('AtomRepository', () => { describe('batching behavior', () => { it('should call getFilesChanged only for Lore commits, not non-Lore commits', async () => { - const loreCommit = makeLoreCommit({ loreId: 'aaaa1111' }); + const loreCommit = makeLoreCommit({ hash: 'abc12345', loreId: 'aaaa1111' }); const nonLoreCommit: RawCommit = { hash: 'non-lore', date: '2025-01-16T10:00:00Z', diff --git a/tests/unit/services/commit-builder.test.ts b/tests/unit/services/commit-builder.test.ts index 0d76f207..562132f5 100644 --- a/tests/unit/services/commit-builder.test.ts +++ b/tests/unit/services/commit-builder.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CommitBuilder } from '../../../src/services/commit-builder.js'; -import type { CommitInput } from '../../../src/services/commit-builder.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import type { CommitInput } from '../../../src/types/commit.js'; import type { LoreConfig } from '../../../src/types/config.js'; import type { LoreTrailers } from '../../../src/types/domain.js'; -import { DEFAULT_CONFIG } from '../../../src/types/config.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; // Mock TrailerParser function createMockTrailerParser() { @@ -12,18 +13,29 @@ function createMockTrailerParser() { parse: vi.fn(), serialize: vi.fn((trailers: LoreTrailers) => { const lines: string[] = []; - lines.push(`Lore-id: ${trailers['Lore-id']}`); + if (trailers[LORE_ID_KEY] && trailers[LORE_ID_KEY].length > 0) { + lines.push(`${LORE_ID_KEY}: ${trailers[LORE_ID_KEY][0]}`); + } for (const v of trailers.Constraint) lines.push(`Constraint: ${v}`); for (const v of trailers.Rejected) lines.push(`Rejected: ${v}`); - if (trailers.Confidence) lines.push(`Confidence: ${trailers.Confidence}`); - if (trailers['Scope-risk']) lines.push(`Scope-risk: ${trailers['Scope-risk']}`); - if (trailers.Reversibility) lines.push(`Reversibility: ${trailers.Reversibility}`); + if (trailers.Confidence && trailers.Confidence.length > 0) lines.push(`Confidence: ${trailers.Confidence[0]}`); + if (trailers['Scope-risk'] && trailers['Scope-risk'].length > 0) lines.push(`Scope-risk: ${trailers['Scope-risk'][0]}`); + if (trailers.Reversibility && trailers.Reversibility.length > 0) lines.push(`Reversibility: ${trailers.Reversibility[0]}`); for (const v of trailers.Directive) lines.push(`Directive: ${v}`); for (const v of trailers.Tested) lines.push(`Tested: ${v}`); for (const v of trailers['Not-tested']) lines.push(`Not-tested: ${v}`); for (const v of trailers.Supersedes) lines.push(`Supersedes: ${v}`); for (const v of trailers['Depends-on']) lines.push(`Depends-on: ${v}`); for (const v of trailers.Related) lines.push(`Related: ${v}`); + + // Custom trailers + const coreKeys = [[LORE_ID_KEY], 'Constraint', 'Rejected', 'Confidence', 'Scope-risk', 'Reversibility', 'Directive', 'Tested', 'Not-tested', 'Supersedes', 'Depends-on', 'Related']; + for (const key of Object.keys(trailers)) { + if (!coreKeys.includes(key)) { + for (const v of trailers[key]) lines.push(`${key}: ${v}`); + } + } + return lines.join('\n'); }), containsLoreTrailers: vi.fn(), @@ -43,20 +55,23 @@ describe('CommitBuilder', () => { let mockParser: ReturnType; let mockIdGen: ReturnType; let config: LoreConfig; + let protocol: Protocol; beforeEach(() => { mockParser = createMockTrailerParser(); mockIdGen = createMockIdGenerator(); config = { ...DEFAULT_CONFIG }; + protocol = new Protocol(config); builder = new CommitBuilder( mockParser as any, mockIdGen as any, config, + protocol, ); }); describe('build', () => { - it('should build a minimal commit with intent and Lore-id', () => { + it(`should build a minimal commit with intent and ${LORE_ID_KEY}`, () => { const input: CommitInput = { intent: 'feat(auth): add login flow', }; @@ -66,7 +81,7 @@ describe('CommitBuilder', () => { expect(mockIdGen.generate).toHaveBeenCalledOnce(); expect(mockParser.serialize).toHaveBeenCalledOnce(); expect(result).toContain('feat(auth): add login flow'); - expect(result).toContain('Lore-id: a1b2c3d4'); + expect(result).toContain(`${LORE_ID_KEY}: a1b2c3d4`); }); it('should include body separated by blank lines', () => { @@ -87,9 +102,9 @@ describe('CommitBuilder', () => { trailers: { Constraint: ['Must use HTTPS', 'No external deps'], Rejected: ['Polling approach'], - Confidence: 'high', - 'Scope-risk': 'narrow', - Reversibility: 'clean', + Confidence: ['high'], + 'Scope-risk': ['narrow'], + Reversibility: ['clean'], Directive: ['Review in 3 months'], Tested: ['Unit tests for auth module'], 'Not-tested': ['Edge case with expired tokens'], @@ -115,13 +130,13 @@ describe('CommitBuilder', () => { expect(result).toContain('Related: aabbccdd'); }); - it('should auto-generate Lore-id', () => { + it(`should auto-generate ${LORE_ID_KEY}`, () => { mockIdGen.generate.mockReturnValue('deadbeef'); const input: CommitInput = { intent: 'test' }; const result = builder.build(input); - expect(result).toContain('Lore-id: deadbeef'); + expect(result).toContain(`${LORE_ID_KEY}: deadbeef`); }); it('should use provided existingLoreId instead of generating one', () => { @@ -129,11 +144,11 @@ describe('CommitBuilder', () => { const result = builder.build(input, 'cafebabe'); - expect(result).toContain('Lore-id: cafebabe'); + expect(result).toContain(`${LORE_ID_KEY}: cafebabe`); expect(mockIdGen.generate).not.toHaveBeenCalled(); }); - it('should generate new Lore-id when no existingLoreId is provided', () => { + it(`should generate new ${LORE_ID_KEY} when no existingLoreId is provided`, () => { const input: CommitInput = { intent: 'new commit' }; builder.build(input); @@ -144,46 +159,45 @@ describe('CommitBuilder', () => { it('should pass correct trailers to serialize', () => { const input: CommitInput = { intent: 'test', - trailers: { Confidence: 'medium' }, + trailers: { Confidence: ['medium'] }, }; builder.build(input); const passedTrailers = mockParser.serialize.mock.calls[0][0] as LoreTrailers; - expect(passedTrailers['Lore-id']).toBe('a1b2c3d4'); - expect(passedTrailers.Confidence).toBe('medium'); + expect(passedTrailers[LORE_ID_KEY]).toEqual(['a1b2c3d4']); + expect(passedTrailers.Confidence).toEqual(['medium']); expect(passedTrailers.Constraint).toEqual([]); }); - it('should pass custom trailers through to LoreTrailers.custom map', () => { - const customMap = new Map(); - customMap.set('Assisted-by', ['Gemini:CLI']); - customMap.set('Ticket', ['PROJ-123']); + it('should pass custom trailers through to LoreTrailers as arrays', () => { const input: CommitInput = { intent: 'feat: with custom trailers', trailers: { - Confidence: 'high' as const, - custom: new CustomTrailerCollection(customMap), + Confidence: ['high'], + 'Assisted-by': ['Gemini:CLI'], + 'Ticket': ['PROJ-123'], }, }; builder.build(input); const passedTrailers = vi.mocked(mockParser.serialize).mock.calls[0][0] as LoreTrailers; - expect(passedTrailers.custom.get('Assisted-by')).toEqual(['Gemini:CLI']); - expect(passedTrailers.custom.get('Ticket')).toEqual(['PROJ-123']); + expect(passedTrailers['Assisted-by']).toEqual(['Gemini:CLI']); + expect(passedTrailers['Ticket']).toEqual(['PROJ-123']); }); - it('should produce empty custom map when no custom trailers provided', () => { + it('should produce empty object when no custom trailers provided', () => { const input: CommitInput = { intent: 'feat: no custom', - trailers: { Confidence: 'high' as const }, + trailers: { Confidence: ['high'] }, }; builder.build(input); const passedTrailers = vi.mocked(mockParser.serialize).mock.calls[0][0] as LoreTrailers; - expect(passedTrailers.custom.size).toBe(0); + // Core keys are present as empty arrays + expect(passedTrailers.Constraint).toEqual([]); }); }); @@ -192,7 +206,7 @@ describe('CommitBuilder', () => { const input: CommitInput = { intent: 'feat: valid commit message', trailers: { - Confidence: 'medium', + Confidence: ['medium'], }, }; @@ -226,7 +240,7 @@ describe('CommitBuilder', () => { it('should error on invalid Confidence enum', () => { const input: CommitInput = { intent: 'test', - trailers: { Confidence: 'super-high' as any }, + trailers: { Confidence: ['super-high'] as any }, }; const issues = builder.validate(input); @@ -240,7 +254,7 @@ describe('CommitBuilder', () => { it('should error on invalid Scope-risk enum', () => { const input: CommitInput = { intent: 'test', - trailers: { 'Scope-risk': 'huge' as any }, + trailers: { 'Scope-risk': ['huge'] as any }, }; const issues = builder.validate(input); @@ -253,7 +267,7 @@ describe('CommitBuilder', () => { it('should error on invalid Reversibility enum', () => { const input: CommitInput = { intent: 'test', - trailers: { Reversibility: 'maybe' as any }, + trailers: { Reversibility: ['maybe'] as any }, }; const issues = builder.validate(input); @@ -295,10 +309,16 @@ describe('CommitBuilder', () => { it('should check required trailers from config', () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence', 'Constraint'], custom: [] }, + trailers: { + required: ['Confidence', 'Constraint'], + custom: [], + definitions: {}, + permissive: false + }, validation: { ...DEFAULT_CONFIG.validation, strict: false }, }; - const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig); + const strictProtocol = new Protocol(strictConfig); + const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig, strictProtocol); const input: CommitInput = { intent: 'test', @@ -313,10 +333,16 @@ describe('CommitBuilder', () => { it('should error on missing required trailers in strict mode', () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence'], custom: [] }, + trailers: { + required: ['Confidence'], + custom: [], + definitions: {}, + permissive: false + }, validation: { ...DEFAULT_CONFIG.validation, strict: true }, }; - const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig); + const strictProtocol = new Protocol(strictConfig); + const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig, strictProtocol); const input: CommitInput = { intent: 'test', @@ -354,13 +380,19 @@ describe('CommitBuilder', () => { it('should pass with valid required trailer present', () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence'], custom: [] }, + trailers: { + required: ['Confidence'], + custom: [], + definitions: {}, + permissive: false + }, }; - const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig); + const strictProtocol = new Protocol(strictConfig); + const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig, strictProtocol); const input: CommitInput = { intent: 'test', - trailers: { Confidence: 'medium' }, + trailers: { Confidence: ['medium'] }, }; const issues = strictBuilder.validate(input); @@ -371,14 +403,20 @@ describe('CommitBuilder', () => { it('should report missing required custom trailer', () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { ...DEFAULT_CONFIG.trailers, required: ['Assisted-by'] }, + trailers: { + required: ['Assisted-by'], + custom: [], + definitions: {}, + permissive: false + }, validation: { ...DEFAULT_CONFIG.validation, strict: true }, }; - const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig); + const strictProtocol = new Protocol(strictConfig); + const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig, strictProtocol); const input: CommitInput = { intent: 'test', - trailers: { Confidence: 'high' as const }, + trailers: { Confidence: ['high'] }, }; const issues = strictBuilder.validate(input); @@ -391,16 +429,22 @@ describe('CommitBuilder', () => { it('should not report missing required trailer when custom trailer is present', () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { ...DEFAULT_CONFIG.trailers, required: ['Assisted-by'] }, + trailers: { + required: ['Assisted-by'], + custom: [], + definitions: {}, + permissive: false + }, validation: { ...DEFAULT_CONFIG.validation, strict: true }, }; - const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig); + const strictProtocol = new Protocol(strictConfig); + const strictBuilder = new CommitBuilder(mockParser as any, mockIdGen as any, strictConfig, strictProtocol); const input: CommitInput = { intent: 'test', trailers: { - Confidence: 'high' as const, - custom: new CustomTrailerCollection(new Map([['Assisted-by', ['Gemini:CLI']]])), + Confidence: ['high'], + 'Assisted-by': ['Gemini:CLI'], }, }; diff --git a/tests/unit/services/commit-input-resolver.test.ts b/tests/unit/services/commit-input-resolver.test.ts index 4fb03ccb..1df76d9a 100644 --- a/tests/unit/services/commit-input-resolver.test.ts +++ b/tests/unit/services/commit-input-resolver.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CommitInputResolver } from '../../../src/services/commit-input-resolver.js'; import type { IPrompt } from '../../../src/interfaces/prompt.js'; import type { CommitCommandOptions } from '../../../src/services/commit-input-resolver.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { Protocol } from '../../../src/services/protocol.js'; /** * Creates a mock IPrompt for testing. @@ -26,9 +28,11 @@ function emptyOptions(overrides: Partial = {}): CommitComm describe('CommitInputResolver', () => { let originalIsTTY: boolean | undefined; + let protocol: Protocol; beforeEach(() => { originalIsTTY = process.stdin.isTTY; + protocol = new Protocol(DEFAULT_CONFIG); }); afterEach(() => { @@ -45,7 +49,7 @@ describe('CommitInputResolver', () => { askText: vi.fn().mockResolvedValue('test intent'), askConfirm: vi.fn().mockResolvedValue(false), }); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const result = await resolver.resolve(emptyOptions({ interactive: true })); @@ -55,7 +59,7 @@ describe('CommitInputResolver', () => { it('should dispatch to file/JSON reader when --file is set', async () => { const prompt = createMockPrompt(); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const tmpPath = '/tmp/test-lore-input.json'; const { writeFileSync } = await import('node:fs'); @@ -73,7 +77,7 @@ describe('CommitInputResolver', () => { it('should dispatch to flags reader when --intent is set', async () => { const prompt = createMockPrompt(); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const result = await resolver.resolve(emptyOptions({ intent: 'from flags', @@ -81,7 +85,7 @@ describe('CommitInputResolver', () => { })); expect(result.intent).toBe('from flags'); - expect(result.trailers?.Confidence).toBe('high'); + expect(result.trailers?.Confidence).toEqual(['high']); expect(prompt.askText).not.toHaveBeenCalled(); }); @@ -96,7 +100,7 @@ describe('CommitInputResolver', () => { askText: vi.fn().mockResolvedValue('tty interactive intent'), askConfirm: vi.fn().mockResolvedValue(false), }); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const result = await resolver.resolve(emptyOptions()); @@ -112,7 +116,7 @@ describe('CommitInputResolver', () => { }); const prompt = createMockPrompt(); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); // Mock stdin to emit data then end const jsonInput = JSON.stringify({ intent: 'from stdin' }); @@ -139,7 +143,7 @@ describe('CommitInputResolver', () => { askText: vi.fn().mockResolvedValue('interactive wins'), askConfirm: vi.fn().mockResolvedValue(false), }); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const result = await resolver.resolve(emptyOptions({ interactive: true, @@ -152,7 +156,7 @@ describe('CommitInputResolver', () => { it('should prefer file over flags when both are set', async () => { const prompt = createMockPrompt(); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const tmpPath = '/tmp/test-lore-file-over-flags.json'; const { writeFileSync } = await import('node:fs'); @@ -178,7 +182,7 @@ describe('CommitInputResolver', () => { }); const prompt = createMockPrompt(); - const resolver = new CommitInputResolver(prompt); + const resolver = new CommitInputResolver(prompt, protocol); const result = await resolver.resolve(emptyOptions({ intent: 'flags win', diff --git a/tests/unit/services/config-loader.test.ts b/tests/unit/services/config-loader.test.ts index f4e4482e..635131dd 100644 --- a/tests/unit/services/config-loader.test.ts +++ b/tests/unit/services/config-loader.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ConfigLoader } from '../../../src/services/config-loader.js'; -import { DEFAULT_CONFIG } from '../../../src/types/config.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -159,6 +160,68 @@ maxMessageLines = 50 expect(config.validation.maxMessageLines).toBe(100); }); + it('should parse permissive mode and custom definitions', async () => { + const configPath = await createConfigFile(tempDir, ` +[trailers] +permissive = false +custom = ["Team"] + +[trailers.definitions.Department] +description = "The department" +multivalue = false +validation = "options" +options = ["Eng", "Prod"] +required = true + +[trailers.definitions.Tags] +description = "Topic tags" +multivalue = true +validation = "pattern" +pattern = "^#[a-z]+$" +`); + + const config = await loader.loadFromFile(configPath); + + expect(config.trailers.permissive).toBe(false); + expect(config.trailers.custom).toEqual(['Team']); + expect(config.trailers.definitions.Department).toEqual({ + description: 'The department', + multivalue: false, + validation: 'values', + values: { + Eng: { description: '' }, + Prod: { description: '' }, + }, + pattern: undefined, + required: true, + directives: undefined, + }); + expect(config.trailers.definitions.Tags).toEqual({ + description: 'Topic tags', + multivalue: true, + validation: 'pattern', + options: undefined, + pattern: '^#[a-z]+$', + required: false, + directives: undefined, + }); + }); + + it('should handle detailed option metadata', async () => { + const configPath = await createConfigFile(tempDir, ` +[trailers.definitions.Priority.options] +P0 = "Critical" +P1 = { description = "High" } +`); + + const config = await loader.loadFromFile(configPath); + + expect(config.trailers.definitions.Priority.values).toEqual({ + P0: { description: 'Critical' }, + P1: { description: 'High' }, + }); + }); + it('should handle output format validation', async () => { const configPath = await createConfigFile(tempDir, ` [output] @@ -170,6 +233,51 @@ defaultFormat = "invalid" // Invalid format should fall back to default expect(config.output.defaultFormat).toBe('text'); }); + + it('should parse UI hints for trailer definitions', async () => { + const configPath = await createConfigFile(tempDir, ` +[trailers.definitions.Department] +description = "The department" +multivalue = false +validation = "options" +options = ["Eng", "Prod"] +ui = { kind = "risk", color = "cyan" } + +[trailers.definitions.Ticket] +description = "Issue ID" +multivalue = true +validation = "pattern" +pattern = "^[A-Z]+-[0-9]+$" +ui = { kind = "reference", color = "dim" } +`); + + const config = await loader.loadFromFile(configPath); + + expect(config.trailers.definitions.Department).toEqual({ + description: 'The department', + multivalue: false, + validation: 'values', + values: { + Eng: { description: '' }, + Prod: { description: '' }, + }, + pattern: undefined, + required: false, + directives: undefined, + ui: { kind: 'risk', color: 'cyan' }, + }); + + expect(config.trailers.definitions.Ticket).toEqual({ + description: 'Issue ID', + multivalue: true, + validation: 'pattern', + options: undefined, + pattern: '^[A-Z]+-[0-9]+$', + required: false, + directives: undefined, + ui: { kind: 'reference', color: 'dim' }, + }); + }); }); describe('findConfigPath', () => { @@ -314,14 +422,14 @@ custom = ["Team", "Sprint"] const childDir = join(tempDir, 'app'); await createConfigFile(childDir, ` [trailers] -required = ["Lore-id"] +required = ["${LORE_ID_KEY}"] custom = [] `); const config = await loader.loadForPath(childDir); // Child replaces the entire trailers section, not individual fields - expect(config.trailers.required).toEqual(['Lore-id']); + expect(config.trailers.required).toEqual([LORE_ID_KEY]); expect(config.trailers.custom).toEqual([]); }); diff --git a/tests/unit/services/head-lore-id-reader.test.ts b/tests/unit/services/head-lore-id-reader.test.ts index b3b91844..92ae2bf2 100644 --- a/tests/unit/services/head-lore-id-reader.test.ts +++ b/tests/unit/services/head-lore-id-reader.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HeadLoreIdReader } from '../../../src/services/head-lore-id-reader.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; import { TrailerParser } from '../../../src/services/trailer-parser.js'; import type { IGitClient } from '../../../src/interfaces/git-client.js'; @@ -25,11 +26,11 @@ describe('HeadLoreIdReader', () => { trailerParser = new TrailerParser(); }); - it('should return Lore-id when HEAD has Lore trailers', async () => { + it(`should return ${LORE_ID_KEY} when HEAD has Lore trailers`, async () => { const message = [ 'feat: add login flow', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, 'Confidence: high', ].join('\n'); @@ -41,7 +42,7 @@ describe('HeadLoreIdReader', () => { expect(result).toBe('a1b2c3d4'); }); - it('should return null when HEAD has no trailers', async () => { + it('should return null when HEAD has no `trailers', async () => { const message = 'feat: simple commit with no trailers'; const gitClient = createMockGitClient(message); @@ -52,7 +53,7 @@ describe('HeadLoreIdReader', () => { expect(result).toBeNull(); }); - it('should return null when HEAD has trailers but no Lore-id', async () => { + it(`should return null when HEAD has trailers but no ${LORE_ID_KEY}`, async () => { const message = [ 'feat: add feature', '', @@ -76,14 +77,14 @@ describe('HeadLoreIdReader', () => { expect(result).toBeNull(); }); - it('should return Lore-id from a full commit message with body', async () => { + it(`should return ${LORE_ID_KEY} from a full commit message with body`, async () => { const message = [ 'feat: add authentication module', '', 'This implements OAuth2 flow with PKCE.', 'Includes refresh token rotation.', '', - 'Lore-id: deadbeef', + `${LORE_ID_KEY}: deadbeef`, 'Constraint: Must use HTTPS', 'Confidence: medium', 'Scope-risk: moderate', @@ -107,11 +108,11 @@ describe('HeadLoreIdReader', () => { expect(result).toBeNull(); }); - it('should return null when Lore-id is not valid hex format', async () => { + it(`should return null when ${LORE_ID_KEY} is not valid hex format`, async () => { const message = [ 'feat: broken lore-id', '', - 'Lore-id: not-valid', + `${LORE_ID_KEY}: not-valid`, 'Confidence: high', ].join('\n'); diff --git a/tests/unit/services/protocol.test.ts b/tests/unit/services/protocol.test.ts new file mode 100644 index 00000000..61d2c795 --- /dev/null +++ b/tests/unit/services/protocol.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from 'vitest'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; + +describe('Protocol Service', () => { + it('should load all core trailers by default', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const keys = protocol.getAuthorizedKeys(); + + expect(keys).toContain('Constraint'); + expect(keys).toContain('Confidence'); + expect(protocol.isCore('Constraint')).toBe(true); + }); + + it('should merge custom definitions into the protocol', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Team: { + description: 'The team responsible', + multivalue: false, + validation: 'none' as const, + }, + }, + }, + }; + const protocol = new Protocol(config); + + const def = protocol.getDefinition('Team'); + expect(def).toBeDefined(); + expect(def?.description).toBe('The team responsible'); + expect(def?.isCore).toBe(false); + expect(protocol.isCore('Team')).toBe(false); + }); + + it('should identify configured custom trailers as non-core even if they are in core-definitions', () => { + // This tests the case where a user might try to override a core definition + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Constraint: { + description: 'User override', + multivalue: true, + validation: 'none' as const, + }, + }, + }, + }; + const protocol = new Protocol(config); + + expect(protocol.isCore('Constraint')).toBe(true); + expect(protocol.getDefinition('Constraint')?.description).toBe('User override'); + }); + + it('should authorize any key in permissive mode', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, permissive: true }, + }; + const protocol = new Protocol(config); + + expect(protocol.authorize('Random-Key')).toBe('Random-Key'); + }); + + it('should not authorize unknown keys in strict mode', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + permissive: false, + definitions: {}, + custom: [] + }, + }; + const protocol = new Protocol(config); + + expect(protocol.authorize('Random-Key')).toBeNull(); + }); + + it('should sort authorized keys based on prompt order', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Urgent: { + description: 'U', + multivalue: false, + validation: 'none' as const, + prompt: { order: 105 } // Between Constraint (100) and Rejected (110) + }, + }, + }, + }; + const protocol = new Protocol(config); + const keys = protocol.getAuthorizedKeys(); + + expect(keys[0]).toBe('Constraint'); + expect(keys[1]).toBe('Urgent'); + expect(keys[2]).toBe('Rejected'); + }); + + it('should default custom trailers to the end of the sort order', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + custom: ['Adhoc'], + }, + }; + const protocol = new Protocol(config); + const keys = protocol.getAuthorizedKeys(); + + expect(keys[keys.length - 1]).toBe('Adhoc'); + }); + + describe('Case-Insensitive Normalization', () => { + it('should normalize core keys regardless of input casing', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + + expect(protocol.authorize('confidence')).toBe('Confidence'); + expect(protocol.authorize('CONFIDENCE')).toBe('Confidence'); + expect(protocol.authorize('Scope-Risk')).toBe('Scope-risk'); + }); + + it('should normalize custom definition keys', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Assisted-by': { description: 'A', multivalue: true, validation: 'none' as const } + } + } + }; + const protocol = new Protocol(config); + + expect(protocol.authorize('assisted-by')).toBe('Assisted-by'); + expect(protocol.authorize('ASSISTED-BY')).toBe('Assisted-by'); + }); + + it('should preserve original casing for ad-hoc trailers in permissive mode', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, permissive: true } + }; + const protocol = new Protocol(config); + + // If it's not a core or custom defined key, it keeps its casing + expect(protocol.authorize('My-New-Trailer')).toBe('My-New-Trailer'); + }); + + it('should prioritize core casing over ad-hoc casing', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, permissive: true } + }; + const protocol = new Protocol(config); + + expect(protocol.authorize(LORE_ID_KEY.toLowerCase())).toBe(LORE_ID_KEY); + }); + }); + + describe('Required Unification', () => { + it('should mark a trailer as required if set in definitions', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { Team: { description: 'D', multivalue: false, validation: 'none' as const, required: true } }, + } + }; + const protocol = new Protocol(config); + expect(protocol.getDefinition('Team')?.required).toBe(true); + }); + + it('should mark a trailer as required if set in trailers.required list', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + required: ['Team'], + custom: ['Team'], + } + }; + const protocol = new Protocol(config); + expect(protocol.getDefinition('Team')?.required).toBe(true); + }); + + it('should handle case-insensitive required list entries', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + required: ['team'], // Lowercase entry + custom: ['Team'], + } + }; + const protocol = new Protocol(config); + expect(protocol.getDefinition('Team')?.required).toBe(true); + }); + }); + + describe('Custom Overrides', () => { + it('should allow custom definitions to override core trailer metadata (e.g. color)', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Confidence: { + description: 'Custom confidence', + multivalue: false, + validation: 'values' as const, + ui: { color: 'magenta' as const } + } + } + } + }; + const protocol = new Protocol(config); + const def = protocol.getDefinition('Confidence'); + + expect(def?.description).toBe('Custom confidence'); + expect(def?.ui?.color).toBe('magenta'); + expect(def?.isCore).toBe(true); // Still a core key + }); + }); +}); diff --git a/tests/unit/services/readers/collectors/enum-choice-trailer-collector.test.ts b/tests/unit/services/readers/collectors/enum-choice-trailer-collector.test.ts index d44d7b31..c4d954eb 100644 --- a/tests/unit/services/readers/collectors/enum-choice-trailer-collector.test.ts +++ b/tests/unit/services/readers/collectors/enum-choice-trailer-collector.test.ts @@ -2,56 +2,54 @@ import { describe, it, expect, vi } from 'vitest'; import { EnumChoiceTrailerCollector } from '../../../../../src/services/readers/collectors/enum-choice-trailer-collector.js'; import type { IPrompt } from '../../../../../src/interfaces/prompt.js'; -function createMockPrompt(overrides: Partial = {}): IPrompt { - return { - askText: vi.fn().mockResolvedValue(''), - askMultiline: vi.fn().mockResolvedValue(''), - askChoice: vi.fn().mockResolvedValue('low'), - askConfirm: vi.fn().mockResolvedValue(false), - close: vi.fn(), - ...overrides, - }; -} - describe('EnumChoiceTrailerCollector', () => { - const config = { - key: 'Confidence' as const, - confirmMessage: 'Set Confidence?', - choiceMessage: 'Confidence:', - values: ['low', 'medium', 'high'] as const, - }; - it('should return undefined when user declines', async () => { - const prompt = createMockPrompt({ - askConfirm: vi.fn().mockResolvedValue(false), + const askConfirm = vi.fn().mockResolvedValue(false); + const prompt = { askConfirm } as any; + + const collector = new EnumChoiceTrailerCollector({ + key: 'Confidence', + confirmMessage: 'Set Confidence?', + choiceMessage: 'Confidence:', + values: ['low', 'medium', 'high'], }); - const collector = new EnumChoiceTrailerCollector(config); const result = await collector.collect(prompt); - expect(result.key).toBe('Confidence'); - expect(result.value).toBeUndefined(); + expect(result).toEqual({ key: 'Confidence', value: undefined }); + expect(askConfirm).toHaveBeenCalled(); }); it('should return chosen value when user accepts', async () => { - const prompt = createMockPrompt({ - askConfirm: vi.fn().mockResolvedValue(true), - askChoice: vi.fn().mockResolvedValue('high'), + const askConfirm = vi.fn().mockResolvedValue(true); + const askChoice = vi.fn().mockResolvedValue('medium'); + const prompt = { askConfirm, askChoice } as any; + + const collector = new EnumChoiceTrailerCollector({ + key: 'Confidence', + confirmMessage: 'Set Confidence?', + choiceMessage: 'Confidence:', + values: ['low', 'medium', 'high'], }); - const collector = new EnumChoiceTrailerCollector(config); const result = await collector.collect(prompt); - expect(result.key).toBe('Confidence'); - expect(result.value).toBe('high'); + expect(result).toEqual({ key: 'Confidence', value: 'medium' }); + expect(askChoice).toHaveBeenCalled(); }); it('should pass correct messages and values to prompt', async () => { const askConfirm = vi.fn().mockResolvedValue(true); - const askChoice = vi.fn().mockResolvedValue('medium'); - const prompt = createMockPrompt({ askConfirm, askChoice }); + const askChoice = vi.fn().mockResolvedValue('high'); + const prompt = { askConfirm, askChoice } as any; + + const collector = new EnumChoiceTrailerCollector({ + key: 'Confidence', + confirmMessage: 'Set Confidence?', + choiceMessage: 'Confidence:', + values: ['low', 'medium', 'high'], + }); - const collector = new EnumChoiceTrailerCollector(config); await collector.collect(prompt); expect(askConfirm).toHaveBeenCalledWith('Set Confidence?', false); @@ -59,35 +57,37 @@ describe('EnumChoiceTrailerCollector', () => { }); it('should not call askChoice when user declines', async () => { + const askConfirm = vi.fn().mockResolvedValue(false); const askChoice = vi.fn(); - const prompt = createMockPrompt({ - askConfirm: vi.fn().mockResolvedValue(false), - askChoice, + const prompt = { askConfirm, askChoice } as any; + + const collector = new EnumChoiceTrailerCollector({ + key: 'Confidence', + confirmMessage: 'Set Confidence?', + choiceMessage: 'Confidence:', + values: ['low', 'medium', 'high'], }); - const collector = new EnumChoiceTrailerCollector(config); await collector.collect(prompt); expect(askChoice).not.toHaveBeenCalled(); }); it('should work with Scope-risk config', async () => { - const scopeRiskConfig = { - key: 'Scope-risk' as const, + const askConfirm = vi.fn().mockResolvedValue(true); + const askChoice = vi.fn().mockResolvedValue('narrow'); + const prompt = { askConfirm, askChoice } as any; + + const collector = new EnumChoiceTrailerCollector({ + key: 'Scope-risk', confirmMessage: 'Set Scope-risk?', choiceMessage: 'Scope-risk:', - values: ['narrow', 'moderate', 'wide'] as const, - }; - - const prompt = createMockPrompt({ - askConfirm: vi.fn().mockResolvedValue(true), - askChoice: vi.fn().mockResolvedValue('wide'), + values: ['narrow', 'moderate', 'wide'], }); - const collector = new EnumChoiceTrailerCollector(scopeRiskConfig); const result = await collector.collect(prompt); - expect(result.key).toBe('Scope-risk'); - expect(result.value).toBe('wide'); + expect(result).toEqual({ key: 'Scope-risk', value: 'narrow' }); + expect(askChoice).toHaveBeenCalledWith('Scope-risk:', ['narrow', 'moderate', 'wide']); }); }); diff --git a/tests/unit/services/readers/collectors/trailer-collector-registry.test.ts b/tests/unit/services/readers/collectors/trailer-collector-registry.test.ts new file mode 100644 index 00000000..3cb05d4c --- /dev/null +++ b/tests/unit/services/readers/collectors/trailer-collector-registry.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { TrailerCollectorRegistry } from '../../../../../src/services/readers/collectors/trailer-collector-registry.js'; +import { DEFAULT_CONFIG } from '../../../../../src/util/constants.js'; +import { Protocol } from '../../../../../src/services/protocol.js'; + +describe('TrailerCollectorRegistry', () => { + it('should create default collectors for core trailers', () => { + const protocol = new Protocol(DEFAULT_CONFIG); + const registry = new TrailerCollectorRegistry(protocol); + const collectors = registry.getCollectors(); + + // Default core trailers count (11, excluding Lore-id) + expect(collectors.length).toBe(11); + expect(collectors.map(c => c.key)).toContain('Constraint'); + expect(collectors.map(c => c.key)).toContain('Confidence'); + }); + + it('should add custom collectors from definitions', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Project': { description: 'Project name', multivalue: false, validation: 'none' as const }, + 'Squad': { description: 'Squad name', multivalue: true, validation: 'none' as const } + } + } + }; + const protocol = new Protocol(config); + const registry = new TrailerCollectorRegistry(protocol); + const collectors = registry.getCollectors(); + + expect(collectors.length).toBe(13); // 11 core + 2 custom + expect(collectors.map(c => c.key)).toContain('Project'); + expect(collectors.map(c => c.key)).toContain('Squad'); + }); + + it('should handle multi-value enum collectors', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Features': { + description: 'Features', + multivalue: true, + validation: 'options' as const, + options: { f1: 'f1', f2: 'f2' } + }, + } + } + }; + const protocol = new Protocol(config); + const registry = new TrailerCollectorRegistry(protocol); + const collectors = registry.getCollectors(); + const featureCollector = collectors.find(c => c.key === 'Features'); + + expect(featureCollector).toBeDefined(); + // Multi-value enums use MultiValueTrailerCollector + expect(featureCollector?.constructor.name).toBe('MultiValueTrailerCollector'); + }); + + it('should create collectors for simple custom trailers', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + custom: ['Team'], + definitions: { + 'Project': { description: 'Project name', multivalue: false, validation: 'none' as const } + } + } + }; + const protocol = new Protocol(config); + const registry = new TrailerCollectorRegistry(protocol); + const collectors = registry.getCollectors(); + + // 11 core + 1 rich def (Project) + 1 simple list (Team) = 13 + expect(collectors.length).toBe(13); + expect(collectors.map(c => c.key)).toContain('Team'); + }); + + it('should sort collectors based on metadata order', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'First': { description: 'f', multivalue: false, validation: 'none' as const, prompt: { order: 1 } }, + 'Last': { description: 'l', multivalue: false, validation: 'none' as const, prompt: { order: 10000 } } + } + } + }; + const protocol = new Protocol(config); + const registry = new TrailerCollectorRegistry(protocol); + const collectors = registry.getCollectors(); + const keys = collectors.map(c => c.key); + + expect(keys[0]).toBe('First'); + expect(keys[keys.length - 1]).toBe('Last'); + }); +}); diff --git a/tests/unit/services/readers/flags-input-reader.test.ts b/tests/unit/services/readers/flags-input-reader.test.ts index 9eebdc43..648b833b 100644 --- a/tests/unit/services/readers/flags-input-reader.test.ts +++ b/tests/unit/services/readers/flags-input-reader.test.ts @@ -1,105 +1,199 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { FlagsInputReader } from '../../../../src/services/readers/flags-input-reader.js'; +import { DEFAULT_CONFIG } from '../../../../src/util/constants.js'; +import { Protocol } from '../../../../src/services/protocol.js'; import type { CommitCommandOptions } from '../../../../src/services/commit-input-resolver.js'; describe('FlagsInputReader', () => { + let protocol: Protocol; + + beforeEach(() => { + protocol = new Protocol(DEFAULT_CONFIG); + }); + it('should map all CLI options correctly', async () => { const options: CommitCommandOptions = { - intent: 'add feature X', - body: 'Feature body text', + intent: 'feat: add auth', + body: 'Detailed description', constraint: ['must be fast', 'no breaking changes'], rejected: ['approach A | too complex'], confidence: 'high', scopeRisk: 'wide', reversibility: 'clean', - directive: ['use new API'], - tested: ['unit tests pass'], - notTested: ['load testing'], - supersedes: ['abcd1234'], - dependsOn: ['dead0000'], - related: ['beef1234'], + directive: ['Review in 3 months'], + tested: ['Unit tests'], + notTested: ['Edge cases'], + supersedes: ['id1'], + dependsOn: ['id2'], + related: ['id3'], }; - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader(options, protocol); const result = await reader.read(); - expect(result.intent).toBe('add feature X'); - expect(result.body).toBe('Feature body text'); + expect(result.intent).toBe('feat: add auth'); + expect(result.body).toBe('Detailed description'); expect(result.trailers?.Constraint).toEqual(['must be fast', 'no breaking changes']); expect(result.trailers?.Rejected).toEqual(['approach A | too complex']); - expect(result.trailers?.Confidence).toBe('high'); - expect(result.trailers?.['Scope-risk']).toBe('wide'); - expect(result.trailers?.Reversibility).toBe('clean'); - expect(result.trailers?.Directive).toEqual(['use new API']); - expect(result.trailers?.Tested).toEqual(['unit tests pass']); - expect(result.trailers?.['Not-tested']).toEqual(['load testing']); - expect(result.trailers?.Supersedes).toEqual(['abcd1234']); - expect(result.trailers?.['Depends-on']).toEqual(['dead0000']); - expect(result.trailers?.Related).toEqual(['beef1234']); + expect(result.trailers?.Confidence).toEqual(['high']); + expect(result.trailers?.['Scope-risk']).toEqual(['wide']); + expect(result.trailers?.Reversibility).toEqual(['clean']); + expect(result.trailers?.Directive).toEqual(['Review in 3 months']); + expect(result.trailers?.Tested).toEqual(['Unit tests']); + expect(result.trailers?.['Not-tested']).toEqual(['Edge cases']); + expect(result.trailers?.Supersedes).toEqual(['id1']); + expect(result.trailers?.['Depends-on']).toEqual(['id2']); + expect(result.trailers?.Related).toEqual(['id3']); }); it('should default intent to empty string when undefined', async () => { - const options: CommitCommandOptions = {}; - - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader({}, protocol); const result = await reader.read(); - expect(result.intent).toBe(''); }); it('should leave body undefined when not provided', async () => { - const options: CommitCommandOptions = { - intent: 'test intent', - }; - - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader({ intent: 't' }, protocol); const result = await reader.read(); - expect(result.body).toBeUndefined(); }); it('should leave array trailers undefined when not provided', async () => { + const reader = new FlagsInputReader({ intent: 't' }, protocol); + const result = await reader.read(); + expect(result.trailers?.Constraint).toBeUndefined(); + }); + + it('should leave enum trailers undefined when not provided', async () => { + const reader = new FlagsInputReader({ intent: 't' }, protocol); + const result = await reader.read(); + expect(result.trailers?.Confidence).toBeUndefined(); + }); + + it('should handle only intent and one trailer', async () => { const options: CommitCommandOptions = { - intent: 'test intent', + intent: 'quick fix', + confidence: 'low', }; - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader(options, protocol); const result = await reader.read(); + expect(result.intent).toBe('quick fix'); + expect(result.trailers?.Confidence).toEqual(['low']); expect(result.trailers?.Constraint).toBeUndefined(); - expect(result.trailers?.Rejected).toBeUndefined(); - expect(result.trailers?.Directive).toBeUndefined(); - expect(result.trailers?.Tested).toBeUndefined(); - expect(result.trailers?.['Not-tested']).toBeUndefined(); - expect(result.trailers?.Supersedes).toBeUndefined(); - expect(result.trailers?.['Depends-on']).toBeUndefined(); - expect(result.trailers?.Related).toBeUndefined(); }); - it('should leave enum trailers undefined when not provided', async () => { + it('should parse custom trailers correctly', async () => { const options: CommitCommandOptions = { - intent: 'test intent', + intent: 'feat', + trailer: ['Team=Gamma', 'Ticket:123', 'Foo=Bar=Baz'], }; - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader(options, protocol); const result = await reader.read(); - expect(result.trailers?.Confidence).toBeUndefined(); - expect(result.trailers?.['Scope-risk']).toBeUndefined(); - expect(result.trailers?.Reversibility).toBeUndefined(); + expect(result.trailers?.Team).toEqual(['Gamma']); + expect(result.trailers?.Ticket).toEqual(['123']); + expect(result.trailers?.Foo).toEqual(['Bar=Baz']); }); - it('should handle only intent and one trailer', async () => { + it('should allow core trailers in the custom flag during parsing (validation caught later)', async () => { const options: CommitCommandOptions = { - intent: 'quick fix', - confidence: 'low', + intent: 'feat', + trailer: ['Confidence=high'], + }; + + const reader = new FlagsInputReader(options, protocol); + const result = await reader.read(); + expect(result.trailers?.Confidence).toEqual(['high']); + }); + + it('should map core trailers dynamically using metadata', async () => { + const options: any = { + intent: 'dynamic', + confidence: 'medium', + reversibility: 'clean', + constraint: ['c1'], }; - const reader = new FlagsInputReader(options); + const reader = new FlagsInputReader(options, protocol); const result = await reader.read(); - expect(result.intent).toBe('quick fix'); - expect(result.trailers?.Confidence).toBe('low'); - expect(result.trailers?.Constraint).toBeUndefined(); + expect(result.trailers?.Confidence).toEqual(['medium']); + expect(result.trailers?.Reversibility).toEqual(['clean']); + expect(result.trailers?.Constraint).toEqual(['c1']); + }); + + it('should map auto-generated flags for simple custom trailers', async () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, custom: ['Squad', 'Team-Name'] }, + }; + const customProtocol = new Protocol(config); + const options: any = { + intent: 't', + squad: ['Alpha'], + teamName: ['Omega'], + }; + + const reader = new FlagsInputReader(options, customProtocol); + const result = await reader.read(); + + expect(result.trailers?.Squad).toEqual(['Alpha']); + expect(result.trailers?.['Team-Name']).toEqual(['Omega']); + }); + + it('should prioritize explicit cli flags over automatic ones', async () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Department: { + description: 'dept', + multivalue: false, + validation: 'none' as const, + cli: { flag: 'dept' }, + }, + }, + }, + }; + const customProtocol = new Protocol(config); + const options: any = { + intent: 't', + dept: 'Eng', + }; + + const reader = new FlagsInputReader(options, customProtocol); + const result = await reader.read(); + + expect(result.trailers?.Department).toEqual(['Eng']); + }); + + it('should map case-insensitive flags for custom defined trailers', async () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + 'Assisted-By': { // Pascal-Kebab canonical key + description: 'A', + multivalue: true, + validation: 'none' as const, + }, + }, + }, + }; + const customProtocol = new Protocol(config); + const options: any = { + intent: 't', + assistedBy: ['Gemini'], // camelCase from kebab-case --assisted-by + }; + + const reader = new FlagsInputReader(options, customProtocol); + const result = await reader.read(); + + expect(result.trailers?.['Assisted-By']).toEqual(['Gemini']); }); }); diff --git a/tests/unit/services/readers/interactive-input-reader.test.ts b/tests/unit/services/readers/interactive-input-reader.test.ts index 64775f3a..e144417e 100644 --- a/tests/unit/services/readers/interactive-input-reader.test.ts +++ b/tests/unit/services/readers/interactive-input-reader.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { InteractiveInputReader } from '../../../../src/services/readers/interactive-input-reader.js'; import { createTrailerCollectors } from '../../../../src/services/readers/collectors/trailer-collector-registry.js'; import type { IPrompt } from '../../../../src/interfaces/prompt.js'; +import { DEFAULT_CONFIG } from '../../../../src/util/constants.js'; /** * Creates a mock IPrompt for testing. @@ -80,16 +81,16 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); const result = await reader.read(); expect(result.intent).toBe('refactor auth module'); expect(result.body).toBe('This is the body text.'); expect(result.trailers?.Constraint).toEqual(['must be fast']); expect(result.trailers?.Rejected).toEqual(['approach A | too slow']); - expect(result.trailers?.Confidence).toBe('high'); - expect(result.trailers?.['Scope-risk']).toBe('wide'); - expect(result.trailers?.Reversibility).toBe('clean'); + expect(result.trailers?.Confidence).toEqual(['high']); + expect(result.trailers?.['Scope-risk']).toEqual(['wide']); + expect(result.trailers?.Reversibility).toEqual(['clean']); expect(result.trailers?.Directive).toEqual(['use new API']); expect(result.trailers?.Tested).toEqual(['unit tests pass']); expect(result.trailers?.['Not-tested']).toEqual(['load testing']); @@ -108,22 +109,12 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); const result = await reader.read(); expect(result.intent).toBe('minimal intent'); expect(result.body).toBeUndefined(); - expect(result.trailers?.Constraint).toBeUndefined(); - expect(result.trailers?.Rejected).toBeUndefined(); - expect(result.trailers?.Confidence).toBeUndefined(); - expect(result.trailers?.['Scope-risk']).toBeUndefined(); - expect(result.trailers?.Reversibility).toBeUndefined(); - expect(result.trailers?.Directive).toBeUndefined(); - expect(result.trailers?.Tested).toBeUndefined(); - expect(result.trailers?.['Not-tested']).toBeUndefined(); - expect(result.trailers?.Supersedes).toBeUndefined(); - expect(result.trailers?.['Depends-on']).toBeUndefined(); - expect(result.trailers?.Related).toBeUndefined(); + expect(result.trailers).toEqual({}); expect(prompt.close).toHaveBeenCalled(); }); }); @@ -171,7 +162,7 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); const result = await reader.read(); expect(result.trailers?.Constraint).toEqual([ @@ -221,7 +212,7 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); const result = await reader.read(); expect(result.trailers?.Constraint).toEqual(['valid value']); @@ -235,7 +226,7 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); await expect(reader.read()).rejects.toThrow('prompt error'); expect(prompt.close).toHaveBeenCalled(); @@ -248,7 +239,7 @@ describe('InteractiveInputReader', () => { close: vi.fn(), }); - const reader = new InteractiveInputReader(prompt, createTrailerCollectors()); + const reader = new InteractiveInputReader(prompt, createTrailerCollectors(DEFAULT_CONFIG)); await expect(reader.read()).rejects.toThrow('confirm error'); expect(prompt.close).toHaveBeenCalled(); diff --git a/tests/unit/services/readers/json-input-reader.test.ts b/tests/unit/services/readers/json-input-reader.test.ts index 6d981dd6..e21a8e87 100644 --- a/tests/unit/services/readers/json-input-reader.test.ts +++ b/tests/unit/services/readers/json-input-reader.test.ts @@ -29,9 +29,9 @@ describe('JsonInputReader', () => { expect(result.body).toBe('Detailed explanation'); expect(result.trailers?.Constraint).toEqual(['must preserve backward compat']); expect(result.trailers?.Rejected).toEqual(['approach A | too complex']); - expect(result.trailers?.Confidence).toBe('medium'); - expect(result.trailers?.['Scope-risk']).toBe('narrow'); - expect(result.trailers?.Reversibility).toBe('clean'); + expect(result.trailers?.Confidence).toEqual(['medium']); + expect(result.trailers?.['Scope-risk']).toEqual(['narrow']); + expect(result.trailers?.Reversibility).toEqual(['clean']); expect(result.trailers?.Directive).toEqual(['use new API']); expect(result.trailers?.Tested).toEqual(['unit tests pass']); expect(result.trailers?.['Not-tested']).toEqual(['load testing']); @@ -69,9 +69,7 @@ describe('JsonInputReader', () => { const result = await reader.read(); expect(result.intent).toBe('with empty trailers'); - expect(result.trailers).toBeDefined(); - expect(result.trailers?.Constraint).toBeUndefined(); - expect(result.trailers?.Confidence).toBeUndefined(); + expect(result.trailers).toEqual({}); }); it('should default intent to empty string when not a string', async () => { @@ -140,7 +138,7 @@ describe('JsonInputReader', () => { }); describe('custom trailers', () => { - it('should collect unknown trailer keys as custom trailers', async () => { + it('should collect unknown trailer keys at the top level', async () => { const input = { intent: 'test', trailers: { @@ -152,9 +150,8 @@ describe('JsonInputReader', () => { const reader = new JsonInputReader(JSON.stringify(input)); const result = await reader.read(); - expect(result.trailers?.custom?.get('Assisted-by')).toEqual(['Gemini:CLI']); - expect(result.trailers?.custom?.size).toBe(1); - expect(result.trailers?.Confidence).toBe('high'); + expect(result.trailers?.['Assisted-by']).toEqual(['Gemini:CLI']); + expect(result.trailers?.Confidence).toEqual(['high']); }); it('should collect multiple custom trailers', async () => { @@ -170,25 +167,11 @@ describe('JsonInputReader', () => { const reader = new JsonInputReader(JSON.stringify(input)); const result = await reader.read(); - expect(result.trailers?.custom?.get('Assisted-by')).toEqual(['Gemini:CLI']); - expect(result.trailers?.custom?.get('Ticket')).toEqual(['PROJ-123', 'PROJ-456']); + expect(result.trailers?.['Assisted-by']).toEqual(['Gemini:CLI']); + expect(result.trailers?.Ticket).toEqual(['PROJ-123', 'PROJ-456']); expect(result.trailers?.Constraint).toEqual(['some constraint']); }); - it('should not include custom field when no unknown trailers exist', async () => { - const input = { - intent: 'test', - trailers: { - Confidence: 'high', - }, - }; - - const reader = new JsonInputReader(JSON.stringify(input)); - const result = await reader.read(); - - expect(result.trailers?.custom).toBeUndefined(); - }); - it('should skip custom trailers with non-string values', async () => { const input = { intent: 'test', @@ -201,13 +184,13 @@ describe('JsonInputReader', () => { const reader = new JsonInputReader(JSON.stringify(input)); const result = await reader.read(); - expect(result.trailers?.custom?.get('Valid-custom')).toEqual(['value']); - expect(result.trailers?.custom?.get('Invalid-custom')).toBeUndefined(); + expect(result.trailers?.['Valid-custom']).toEqual(['value']); + expect(result.trailers?.['Invalid-custom']).toBeUndefined(); }); }); describe('enum parsing', () => { - it('should return string values for enum trailers', async () => { + it('should return array values for enum trailers', async () => { const input = { intent: 'test', trailers: { @@ -220,9 +203,9 @@ describe('JsonInputReader', () => { const reader = new JsonInputReader(JSON.stringify(input)); const result = await reader.read(); - expect(result.trailers?.Confidence).toBe('high'); - expect(result.trailers?.['Scope-risk']).toBe('wide'); - expect(result.trailers?.Reversibility).toBe('irreversible'); + expect(result.trailers?.Confidence).toEqual(['high']); + expect(result.trailers?.['Scope-risk']).toEqual(['wide']); + expect(result.trailers?.Reversibility).toEqual(['irreversible']); }); it('should return undefined for non-string enum values', async () => { diff --git a/tests/unit/services/squash-merger.test.ts b/tests/unit/services/squash-merger.test.ts index 879a9f4e..e770b5ea 100644 --- a/tests/unit/services/squash-merger.test.ts +++ b/tests/unit/services/squash-merger.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SquashMerger } from '../../../src/services/squash-merger.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; import type { LoreAtom, LoreTrailers } from '../../../src/types/domain.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; function createMockIdGenerator(id = 'deadbeef') { return { @@ -11,20 +13,20 @@ function createMockIdGenerator(id = 'deadbeef') { function makeTrailers(overrides: Partial = {}): LoreTrailers { return { - 'Lore-id': overrides['Lore-id'] ?? 'a1b2c3d4', + [LORE_ID_KEY]: overrides[LORE_ID_KEY] ?? ['a1b2c3d4'], Constraint: overrides.Constraint ?? [], Rejected: overrides.Rejected ?? [], - Confidence: overrides.Confidence ?? null, - 'Scope-risk': overrides['Scope-risk'] ?? null, - Reversibility: overrides.Reversibility ?? null, + Confidence: overrides.Confidence ?? [], + 'Scope-risk': overrides['Scope-risk'] ?? [], + Reversibility: overrides.Reversibility ?? [], Directive: overrides.Directive ?? [], Tested: overrides.Tested ?? [], 'Not-tested': overrides['Not-tested'] ?? [], Supersedes: overrides.Supersedes ?? [], 'Depends-on': overrides['Depends-on'] ?? [], Related: overrides.Related ?? [], - custom: overrides.custom ?? CustomTrailerCollection.empty(), - }; + ...overrides, + } as any; } function makeAtom(overrides: Partial = {}): LoreAtom { @@ -43,22 +45,24 @@ function makeAtom(overrides: Partial = {}): LoreAtom { describe('SquashMerger', () => { let merger: SquashMerger; let mockIdGen: ReturnType; + let protocol: Protocol; beforeEach(() => { mockIdGen = createMockIdGenerator(); - merger = new SquashMerger(mockIdGen as any); + protocol = new Protocol(DEFAULT_CONFIG); + merger = new SquashMerger(mockIdGen as any, protocol); }); it('should throw for empty atoms', () => { expect(() => merger.merge([], {})).toThrow('Cannot merge zero atoms'); }); - it('should generate a new Lore-id', () => { + it(`should generate a new ${LORE_ID_KEY}`, () => { const atom = makeAtom(); const result = merger.merge([atom], {}); expect(mockIdGen.generate).toHaveBeenCalledOnce(); - expect(result).toContain('Lore-id: deadbeef'); + expect(result).toContain(`${LORE_ID_KEY}: deadbeef`); }); describe('intent merging', () => { @@ -121,7 +125,7 @@ describe('SquashMerger', () => { const a1 = makeAtom({ loreId: 'aaaa0001', trailers: makeTrailers({ - 'Lore-id': 'aaaa0001', + [LORE_ID_KEY]: ['aaaa0001'], Constraint: ['Must use HTTPS', 'No external deps'], Tested: ['Unit tests'], }), @@ -129,7 +133,7 @@ describe('SquashMerger', () => { const a2 = makeAtom({ loreId: 'aaaa0002', trailers: makeTrailers({ - 'Lore-id': 'aaaa0002', + [LORE_ID_KEY]: ['aaaa0002'], Constraint: ['Must use HTTPS', 'Max 100ms latency'], Tested: ['Integration tests'], }), @@ -140,7 +144,6 @@ describe('SquashMerger', () => { expect(result).toContain('Constraint: Must use HTTPS'); expect(result).toContain('Constraint: No external deps'); expect(result).toContain('Constraint: Max 100ms latency'); - // Ensure "Must use HTTPS" appears only once const matches = result.match(/Constraint: Must use HTTPS/g); expect(matches).toHaveLength(1); }); @@ -150,11 +153,11 @@ describe('SquashMerger', () => { it('should pick lowest confidence (most conservative)', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Confidence: 'high' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Confidence: ['high'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', Confidence: 'low' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], Confidence: ['low'] }), }); const result = merger.merge([a1, a2], {}); @@ -164,11 +167,11 @@ describe('SquashMerger', () => { it('should pick widest scope-risk', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', 'Scope-risk': 'narrow' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], 'Scope-risk': ['narrow'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', 'Scope-risk': 'wide' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], 'Scope-risk': ['wide'] }), }); const result = merger.merge([a1, a2], {}); @@ -178,11 +181,11 @@ describe('SquashMerger', () => { it('should pick least reversible', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Reversibility: 'clean' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Reversibility: ['clean'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', Reversibility: 'irreversible' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], Reversibility: ['irreversible'] }), }); const result = merger.merge([a1, a2], {}); @@ -192,35 +195,25 @@ describe('SquashMerger', () => { it('should handle null enum values gracefully', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Confidence: 'medium' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Confidence: ['medium'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', Confidence: null }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], Confidence: [] }), }); const result = merger.merge([a1, a2], {}); expect(result).toContain('Confidence: medium'); }); - it('should return null when all enum values are null', () => { - const a1 = makeAtom({ - loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Confidence: null }), - }); - - const result = merger.merge([a1], {}); - expect(result).not.toContain('Confidence:'); - }); - it('should pick medium over high for confidence', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Confidence: 'high' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Confidence: ['high'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', Confidence: 'medium' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], Confidence: ['medium'] }), }); const result = merger.merge([a1, a2], {}); @@ -230,11 +223,11 @@ describe('SquashMerger', () => { it('should pick moderate over narrow for scope-risk', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', 'Scope-risk': 'narrow' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], 'Scope-risk': ['narrow'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', 'Scope-risk': 'moderate' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], 'Scope-risk': ['moderate'] }), }); const result = merger.merge([a1, a2], {}); @@ -244,11 +237,11 @@ describe('SquashMerger', () => { it('should pick migration-needed over clean for reversibility', () => { const a1 = makeAtom({ loreId: 'aaaa0001', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Reversibility: 'clean' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Reversibility: ['clean'] }), }); const a2 = makeAtom({ loreId: 'aaaa0002', - trailers: makeTrailers({ 'Lore-id': 'aaaa0002', Reversibility: 'migration-needed' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0002'], Reversibility: ['migration-needed'] }), }); const result = merger.merge([a1, a2], {}); @@ -261,15 +254,15 @@ describe('SquashMerger', () => { const a1 = makeAtom({ loreId: 'aaaa0001', trailers: makeTrailers({ - 'Lore-id': 'aaaa0001', - Related: ['aaaa0002'], // internal reference + [LORE_ID_KEY]: ['aaaa0001'], + Related: ['aaaa0002'], }), }); const a2 = makeAtom({ loreId: 'aaaa0002', trailers: makeTrailers({ - 'Lore-id': 'aaaa0002', - 'Depends-on': ['aaaa0001'], // internal reference + [LORE_ID_KEY]: ['aaaa0002'], + 'Depends-on': ['aaaa0001'], }), }); @@ -278,41 +271,71 @@ describe('SquashMerger', () => { expect(result).not.toContain('Depends-on: aaaa0001'); }); - it('should keep external references', () => { - const a1 = makeAtom({ - loreId: 'aaaa0001', - trailers: makeTrailers({ - 'Lore-id': 'aaaa0001', - Related: ['external1'], - Supersedes: ['external2'], - }), - }); - - const result = merger.merge([a1], {}); - expect(result).toContain('Related: external1'); - expect(result).toContain('Supersedes: external2'); - }); - - it('should deduplicate external references across atoms', () => { + it('should merge and preserve custom trailers', () => { const a1 = makeAtom({ loreId: 'aaaa0001', trailers: makeTrailers({ - 'Lore-id': 'aaaa0001', - Related: ['external1'], - }), + [LORE_ID_KEY]: ['aaaa0001'], + Team: ['platform'], + } as any), }); const a2 = makeAtom({ loreId: 'aaaa0002', trailers: makeTrailers({ - 'Lore-id': 'aaaa0002', - Related: ['external1', 'external2'], - }), + [LORE_ID_KEY]: ['aaaa0002'], + Team: ['core'], + Ticket: ['PROJ-123'], + } as any), }); const result = merger.merge([a1, a2], {}); - const relatedMatches = result.match(/Related: external1/g); - expect(relatedMatches).toHaveLength(1); - expect(result).toContain('Related: external2'); + expect(result).toContain('Team: platform'); + expect(result).toContain('Team: core'); + expect(result).toContain('Ticket: PROJ-123'); + }); + + describe('squash strategies', () => { + it('should respect rank-min (Confidence: low < high)', () => { + const a1 = makeAtom({ trailers: makeTrailers({ Confidence: ['high'] }) }); + const a2 = makeAtom({ trailers: makeTrailers({ Confidence: ['low'] }) }); + const result = merger.merge([a1, a2], {}); + expect(result).toContain('Confidence: low'); + }); + + it('should respect rank-max (Scope-risk: wide > narrow)', () => { + const a1 = makeAtom({ trailers: makeTrailers({ 'Scope-risk': ['narrow'] }) }); + const a2 = makeAtom({ trailers: makeTrailers({ 'Scope-risk': ['wide'] }) }); + const result = merger.merge([a1, a2], {}); + expect(result).toContain('Scope-risk: wide'); + }); + + it('should support rank-max for custom enums', () => { + const configWithCustom = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Priority: { + description: 'Prio', + multivalue: false, + validation: 'options' as const, + options: { low: {}, high: {}, urgent: {} }, + squash: 'rank-max' as const, + } + }, + custom: [], + permissive: false, + } + }; + const customProtocol = new Protocol(configWithCustom); + const customMerger = new SquashMerger(mockIdGen as any, customProtocol); + + const a1 = makeAtom({ trailers: makeTrailers({ Priority: ['low'] } as any) }); + const a2 = makeAtom({ trailers: makeTrailers({ Priority: ['urgent'] } as any) }); + + const result = customMerger.merge([a1, a2], {}); + expect(result).toContain('Priority: urgent'); + }); }); }); @@ -321,16 +344,16 @@ describe('SquashMerger', () => { const atom = makeAtom({ intent: 'single atom intent', trailers: makeTrailers({ - 'Lore-id': 'a1b2c3d4', + [LORE_ID_KEY]: ['a1b2c3d4'], Constraint: ['Some constraint'], - Confidence: 'high', + Confidence: ['high'], }), }); const result = merger.merge([atom], {}); expect(result).toContain('single atom intent'); - expect(result).toContain('Lore-id: deadbeef'); // new generated id + expect(result).toContain(`${LORE_ID_KEY}: deadbeef`); expect(result).toContain('Constraint: Some constraint'); expect(result).toContain('Confidence: high'); }); @@ -341,7 +364,7 @@ describe('SquashMerger', () => { const atom = makeAtom({ loreId: 'aaaa0001', body: 'Atom body text', - trailers: makeTrailers({ 'Lore-id': 'aaaa0001', Confidence: 'high' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aaaa0001'], Confidence: ['high'] }), }); const result = merger.merge([atom], { intent: 'Merged intent' }); @@ -351,7 +374,7 @@ describe('SquashMerger', () => { expect(lines[1]).toBe(''); expect(lines[2]).toBe('Atom body text'); expect(lines[3]).toBe(''); - expect(lines[4]).toContain('Lore-id: deadbeef'); + expect(lines[4]).toContain(`${LORE_ID_KEY}: deadbeef`); }); }); }); diff --git a/tests/unit/services/staleness-detector.test.ts b/tests/unit/services/staleness-detector.test.ts index 1f10867a..ddb665ff 100644 --- a/tests/unit/services/staleness-detector.test.ts +++ b/tests/unit/services/staleness-detector.test.ts @@ -3,7 +3,7 @@ import { StalenessDetector } from '../../../src/services/staleness-detector.js'; import type { IGitClient } from '../../../src/interfaces/git-client.js'; import type { LoreConfig } from '../../../src/types/config.js'; import type { LoreAtom, LoreTrailers, SupersessionStatus } from '../../../src/types/domain.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; function createMockGitClient(overrides: Partial = {}): IGitClient { return { @@ -17,13 +17,13 @@ function createMockGitClient(overrides: Partial = {}): IGitClient { countCommitsSince: vi.fn(async () => 0), resolveRef: vi.fn(async () => 'abc123'), ...overrides, - }; + } as any; } function createDefaultConfig(overrides: Partial = {}): LoreConfig { return { protocol: { version: '1.0' }, - trailers: { required: [], custom: [] }, + trailers: { required: [], custom: [], definitions: {}, permissive: true }, validation: { strict: false, maxMessageLines: 50, intentMaxLength: 72 }, stale: { olderThan: '6m', @@ -32,6 +32,7 @@ function createDefaultConfig(overrides: Partial = {}): Lore }, output: { defaultFormat: 'text' }, follow: { maxDepth: 3 }, + cli: { updateCheck: true }, }; } @@ -42,7 +43,7 @@ function makeAtom(options: { author?: string; intent?: string; body?: string; - confidence?: LoreTrailers['Confidence']; + confidence?: string; directives?: string[]; dependsOn?: string[]; supersedes?: string[]; @@ -56,20 +57,19 @@ function makeAtom(options: { intent: options.intent ?? 'feat: test commit', body: options.body ?? '', trailers: { - 'Lore-id': options.loreId ?? 'a1b2c3d4', + [LORE_ID_KEY]: [options.loreId ?? 'a1b2c3d4'], Constraint: [], Rejected: [], - Confidence: options.confidence ?? null, - 'Scope-risk': null, - Reversibility: null, + Confidence: options.confidence ? [options.confidence] : [], + 'Scope-risk': [], + Reversibility: [], Directive: options.directives ?? [], Tested: [], 'Not-tested': [], Supersedes: options.supersedes ?? [], 'Depends-on': options.dependsOn ?? [], Related: [], - custom: CustomTrailerCollection.empty(), - } as LoreTrailers, + } as any, filesChanged: options.filesChanged ?? [], }; } @@ -126,7 +126,7 @@ describe('StalenessDetector', () => { const result = await detector.analyze([atom], makeSupersessionMap([])); expect(result).toHaveLength(1); - expect(result[0].reasons.some((r) => r.signal === 'age' && r.description.includes('threshold: 30d'))).toBe(true); + expect(result[0].reasons.some((r) => r.signal === 'age' && r.description.includes('30d'))).toBe(true); }); it('should handle year duration format', async () => { @@ -171,7 +171,7 @@ describe('StalenessDetector', () => { expect(result).toHaveLength(1); expect(result[0].reasons.some((r) => r.signal === 'drift')).toBe(true); - expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('25 commits'))).toBe(true); + expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('20'))).toBe(true); }); it('should not flag files under the drift threshold', async () => { @@ -200,7 +200,7 @@ describe('StalenessDetector', () => { const result = await detector.analyze([atom], makeSupersessionMap([])); expect(result).toHaveLength(1); - expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('src/db.ts'))).toBe(true); + expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('1 files'))).toBe(true); }); it('should handle errors from countCommitsSince gracefully', async () => { @@ -230,7 +230,7 @@ describe('StalenessDetector', () => { const result = await detector.analyze([atom], makeSupersessionMap([])); expect(result).toHaveLength(1); - expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('threshold: 5'))).toBe(true); + expect(result[0].reasons.some((r) => r.signal === 'drift' && r.description.includes('5'))).toBe(true); }); }); @@ -269,10 +269,10 @@ describe('StalenessDetector', () => { expect(result).toHaveLength(0); }); - it('should not flag atoms with null confidence', async () => { + it('should not flag atoms with empty confidence', async () => { const atom = makeAtom({ date: new Date(), - confidence: null, + confidence: undefined, }); const result = await detector.analyze([atom], makeSupersessionMap([])); @@ -339,6 +339,24 @@ describe('StalenessDetector', () => { expect(expiredReasons).toHaveLength(2); }); + it('should detect multiple expired hints within a single directive', async () => { + const atom = makeAtom({ + date: new Date(), + directives: [ + '[until:2024-01] Cleanup [until:2024-02] and then [until:2024-06]', + ], + }); + + const result = await detector.analyze([atom], makeSupersessionMap([])); + + expect(result).toHaveLength(1); + const expiredReasons = result[0].reasons.filter((r) => r.signal === 'expired-hint'); + expect(expiredReasons).toHaveLength(3); + expect(expiredReasons[0].description).toContain('until:2024-01'); + expect(expiredReasons[1].description).toContain('until:2024-02'); + expect(expiredReasons[2].description).toContain('until:2024-06'); + }); + it('should handle directives without [until:] hints', async () => { const atom = makeAtom({ date: new Date(), @@ -414,18 +432,13 @@ describe('StalenessDetector', () => { expect(orphanReasons).toHaveLength(2); }); - it('should skip invalid Lore-id references in depends-on', async () => { + it(`should skip invalid ${LORE_ID_KEY} references in depends-on`, async () => { const atom = makeAtom({ date: new Date(), dependsOn: ['not-valid-id'], }); - const supersessionMap = makeSupersessionMap([ - ['not-valid-id', { superseded: true, supersededBy: 'aaaa1111' }], - ]); - - const result = await detector.analyze([atom], supersessionMap); - + const result = await detector.analyze([atom], makeSupersessionMap([])); expect(result).toHaveLength(0); }); }); diff --git a/tests/unit/services/supersession-resolver.test.ts b/tests/unit/services/supersession-resolver.test.ts index a8ab3cb5..5ef9b1b5 100644 --- a/tests/unit/services/supersession-resolver.test.ts +++ b/tests/unit/services/supersession-resolver.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { SupersessionResolver } from '../../../src/services/supersession-resolver.js'; import type { LoreAtom, LoreTrailers } from '../../../src/types/domain.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; function makeAtom(options: { loreId: string; @@ -17,7 +17,7 @@ function makeAtom(options: { intent: 'test commit', body: '', trailers: { - 'Lore-id': options.loreId, + [LORE_ID_KEY]: options.loreId, Constraint: [], Rejected: [], Confidence: null, @@ -29,7 +29,6 @@ function makeAtom(options: { Supersedes: options.supersedes ?? [], 'Depends-on': options.dependsOn ?? [], Related: options.related ?? [], - custom: CustomTrailerCollection.empty(), } as LoreTrailers, filesChanged: [], }; @@ -159,7 +158,7 @@ describe('SupersessionResolver', () => { expect(result.get('dddd4444')!.superseded).toBe(true); }); - it('should skip invalid Lore-id references', () => { + it(`should skip invalid ${LORE_ID_KEY} references`, () => { const atoms = [ makeAtom({ loreId: 'aaaa1111', supersedes: ['not-valid', 'bbbb2222'] }), makeAtom({ loreId: 'bbbb2222' }), diff --git a/tests/unit/services/trailer-parser.test.ts b/tests/unit/services/trailer-parser.test.ts index 60973042..11d540e2 100644 --- a/tests/unit/services/trailer-parser.test.ts +++ b/tests/unit/services/trailer-parser.test.ts @@ -1,24 +1,33 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { TrailerParser } from '../../../src/services/trailer-parser.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; +import type { LoreTrailers } from '../../../src/types/domain.js'; describe('TrailerParser', () => { - const parser = new TrailerParser(); + let parser: TrailerParser; + let protocol: Protocol; + + beforeEach(() => { + protocol = new Protocol(DEFAULT_CONFIG); + parser = new TrailerParser(protocol); + }); describe('parse', () => { - it('should parse a Lore-id trailer', () => { - const raw = 'Lore-id: a1b2c3d4'; - const result = parser.parse(raw, []); - expect(result['Lore-id']).toBe('a1b2c3d4'); + it('should parse a ${LORE_ID_KEY} trailer', () => { + const raw = `${LORE_ID_KEY}: a1b2c3d4`; + const result = parser.parse(raw); + expect(result[LORE_ID_KEY]).toEqual(['a1b2c3d4']); }); it('should parse array trailers into arrays', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: Must use UTF-8 encoding', 'Constraint: Max 1000 records per batch', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual([ 'Must use UTF-8 encoding', 'Max 1000 records per batch', @@ -27,7 +36,7 @@ describe('TrailerParser', () => { it('should parse all array trailer types', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: constraint value', 'Rejected: rejected value', 'Directive: directive value', @@ -37,7 +46,7 @@ describe('TrailerParser', () => { 'Depends-on: 33334444', 'Related: 55556666', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual(['constraint value']); expect(result.Rejected).toEqual(['rejected value']); expect(result.Directive).toEqual(['directive value']); @@ -50,65 +59,65 @@ describe('TrailerParser', () => { it('should parse enum trailers', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Confidence: high', 'Scope-risk: narrow', 'Reversibility: clean', ].join('\n'); - const result = parser.parse(raw, []); - expect(result.Confidence).toBe('high'); - expect(result['Scope-risk']).toBe('narrow'); - expect(result.Reversibility).toBe('clean'); + const result = parser.parse(raw); + expect(result.Confidence).toEqual(['high']); + expect(result['Scope-risk']).toEqual(['narrow']); + expect(result.Reversibility).toEqual(['clean']); }); it('should accept all valid Confidence values', () => { for (const val of ['low', 'medium', 'high']) { - const raw = `Lore-id: abcd1234\nConfidence: ${val}`; - const result = parser.parse(raw, []); - expect(result.Confidence).toBe(val); + const raw = `${LORE_ID_KEY}: abcd1234\nConfidence: ${val}`; + const result = parser.parse(raw); + expect(result.Confidence).toEqual([val]); } }); it('should accept all valid Scope-risk values', () => { for (const val of ['narrow', 'moderate', 'wide']) { - const raw = `Lore-id: abcd1234\nScope-risk: ${val}`; - const result = parser.parse(raw, []); - expect(result['Scope-risk']).toBe(val); + const raw = `${LORE_ID_KEY}: abcd1234\nScope-risk: ${val}`; + const result = parser.parse(raw); + expect(result['Scope-risk']).toEqual([val]); } }); it('should accept all valid Reversibility values', () => { for (const val of ['clean', 'migration-needed', 'irreversible']) { - const raw = `Lore-id: abcd1234\nReversibility: ${val}`; - const result = parser.parse(raw, []); - expect(result.Reversibility).toBe(val); + const raw = `${LORE_ID_KEY}: abcd1234\nReversibility: ${val}`; + const result = parser.parse(raw); + expect(result.Reversibility).toEqual([val]); } }); it('should ignore invalid enum values', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Confidence: INVALID', 'Scope-risk: bogus', 'Reversibility: nope', ].join('\n'); - const result = parser.parse(raw, []); - expect(result.Confidence).toBeNull(); - expect(result['Scope-risk']).toBeNull(); - expect(result.Reversibility).toBeNull(); + const result = parser.parse(raw); + expect(result.Confidence).toEqual([]); + expect(result['Scope-risk']).toEqual([]); + expect(result.Reversibility).toEqual([]); }); - it('should return null for missing enum trailers', () => { - const raw = 'Lore-id: abcd1234'; - const result = parser.parse(raw, []); - expect(result.Confidence).toBeNull(); - expect(result['Scope-risk']).toBeNull(); - expect(result.Reversibility).toBeNull(); + it('should return empty arrays for missing enum trailers', () => { + const raw = `${LORE_ID_KEY}: abcd1234`; + const result = parser.parse(raw); + expect(result.Confidence).toEqual([]); + expect(result['Scope-risk']).toEqual([]); + expect(result.Reversibility).toEqual([]); }); it('should return empty arrays for missing array trailers', () => { - const raw = 'Lore-id: abcd1234'; - const result = parser.parse(raw, []); + const raw = `${LORE_ID_KEY}: abcd1234`; + const result = parser.parse(raw); expect(result.Constraint).toEqual([]); expect(result.Rejected).toEqual([]); expect(result.Directive).toEqual([]); @@ -121,11 +130,11 @@ describe('TrailerParser', () => { it('should handle continuation lines', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: This is a long constraint that', ' continues on the next line', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual([ 'This is a long constraint that continues on the next line', ]); @@ -133,103 +142,84 @@ describe('TrailerParser', () => { it('should handle continuation lines with tabs', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: First part', '\tsecond part', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual(['First part second part']); }); it('should handle multiple continuation lines', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: Line 1', ' Line 2', ' Line 3', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual(['Line 1 Line 2 Line 3']); }); - it('should parse custom trailers when customKeys is provided', () => { + it('should parse custom trailers in permissive mode (default)', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'My-custom: value1', 'My-custom: value2', ].join('\n'); - const result = parser.parse(raw, ['My-custom']); - expect(result.custom.get('My-custom')).toEqual(['value1', 'value2']); - }); - - it('should not parse custom trailers when they are not in customKeys', () => { - const raw = [ - 'Lore-id: abcd1234', - 'Unknown-key: value1', - ].join('\n'); - const result = parser.parse(raw, ['Other-key']); - expect(result.custom.has('Unknown-key')).toBe(false); - }); - - it('should accept any non-lore trailer when customKeys is empty', () => { - const raw = [ - 'Lore-id: abcd1234', - 'Whatever: value1', - ].join('\n'); - const result = parser.parse(raw, []); - expect(result.custom.get('Whatever')).toEqual(['value1']); + const result = parser.parse(raw); + expect(result['My-custom']).toEqual(['value1', 'value2']); }); it('should handle empty input', () => { - const result = parser.parse('', []); - expect(result['Lore-id']).toBe(''); + const result = parser.parse(''); + expect(result[LORE_ID_KEY]).toEqual([]); expect(result.Constraint).toEqual([]); - expect(result.custom.size).toBe(0); }); it('should handle whitespace-only input', () => { - const result = parser.parse(' \n \n ', []); - expect(result['Lore-id']).toBe(''); + const result = parser.parse(' \n \n '); + expect(result[LORE_ID_KEY]).toEqual([]); }); it('should trim trailer values', () => { - const raw = 'Lore-id: abcd1234 '; - const result = parser.parse(raw, []); - expect(result['Lore-id']).toBe('abcd1234'); + const raw = `${LORE_ID_KEY}: abcd1234 `; + const result = parser.parse(raw); + expect(result[LORE_ID_KEY]).toEqual(['abcd1234']); }); it('should handle unicode in trailer values', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: Must support emoji \u{1F680} and CJK \u4E16\u754C', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual(['Must support emoji \u{1F680} and CJK \u4E16\u754C']); }); it('should handle trailers with colons in the value', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'Constraint: Time format: HH:MM:SS', ].join('\n'); - const result = parser.parse(raw, []); + const result = parser.parse(raw); expect(result.Constraint).toEqual(['Time format: HH:MM:SS']); }); it('should skip blank lines between trailers', () => { const raw = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, '', 'Constraint: value', ].join('\n'); - const result = parser.parse(raw, []); - expect(result['Lore-id']).toBe('abcd1234'); + const result = parser.parse(raw); + expect(result[LORE_ID_KEY]).toEqual(['abcd1234']); expect(result.Constraint).toEqual(['value']); }); it('should handle a full realistic trailer block', () => { const raw = [ - 'Lore-id: a7f3b2c1', + `${LORE_ID_KEY}: a7f3b2c1`, 'Constraint: PostgreSQL >= 14 required for JSONB subscript syntax', 'Constraint: All timestamps must be stored as UTC', 'Rejected: MongoDB -- lacks transactional guarantees across collections', @@ -242,13 +232,13 @@ describe('TrailerParser', () => { 'Depends-on: c1d2e3f4', 'Related: d4e5f6a7', ].join('\n'); - const result = parser.parse(raw, []); - expect(result['Lore-id']).toBe('a7f3b2c1'); + const result = parser.parse(raw); + expect(result[LORE_ID_KEY]).toEqual(['a7f3b2c1']); expect(result.Constraint).toHaveLength(2); expect(result.Rejected).toHaveLength(1); - expect(result.Confidence).toBe('high'); - expect(result['Scope-risk']).toBe('moderate'); - expect(result.Reversibility).toBe('migration-needed'); + expect(result.Confidence).toEqual(['high']); + expect(result['Scope-risk']).toEqual(['moderate']); + expect(result.Reversibility).toEqual(['migration-needed']); expect(result.Directive).toHaveLength(1); expect(result.Tested).toHaveLength(1); expect(result.Supersedes).toEqual(['b3e4f5a6']); @@ -258,10 +248,10 @@ describe('TrailerParser', () => { }); describe('serialize', () => { - it('should serialize Lore-id', () => { - const trailers = makeTrailers({ 'Lore-id': 'abcd1234' }); + it('should serialize ${LORE_ID_KEY}', () => { + const trailers = makeTrailers({ [LORE_ID_KEY]: ['abcd1234'] }); const result = parser.serialize(trailers); - expect(result).toContain('Lore-id: abcd1234'); + expect(result).toContain(`${LORE_ID_KEY}: abcd1234`); }); it('should serialize array trailers', () => { @@ -275,9 +265,9 @@ describe('TrailerParser', () => { it('should serialize enum trailers', () => { const trailers = makeTrailers({ - Confidence: 'high', - 'Scope-risk': 'wide', - Reversibility: 'irreversible', + Confidence: ['high'], + 'Scope-risk': ['wide'], + Reversibility: ['irreversible'], }); const result = parser.serialize(trailers); expect(result).toContain('Confidence: high'); @@ -285,50 +275,42 @@ describe('TrailerParser', () => { expect(result).toContain('Reversibility: irreversible'); }); - it('should not serialize null enum trailers', () => { - const trailers = makeTrailers({ Confidence: null }); + it('should not serialize empty trailers', () => { + const trailers = makeTrailers({ Confidence: [] }); const result = parser.serialize(trailers); expect(result).not.toContain('Confidence:'); }); - it('should not serialize empty array trailers', () => { - const trailers = makeTrailers({}); - const result = parser.serialize(trailers); - expect(result).not.toContain('Constraint:'); - expect(result).not.toContain('Rejected:'); - }); - it('should serialize custom trailers', () => { - const customMap = new Map(); - customMap.set('Team', ['platform']); - customMap.set('Ticket', ['PROJ-123', 'PROJ-456']); - const custom = new CustomTrailerCollection(customMap); - const trailers = makeTrailers({ custom }); + const trailers = makeTrailers({ + 'Team': ['platform'], + 'Ticket': ['PROJ-123', 'PROJ-456'], + }); const result = parser.serialize(trailers); expect(result).toContain('Team: platform'); expect(result).toContain('Ticket: PROJ-123'); expect(result).toContain('Ticket: PROJ-456'); }); - it('should not serialize empty Lore-id', () => { - const trailers = makeTrailers({ 'Lore-id': '' }); + it('should not serialize empty ${LORE_ID_KEY}', () => { + const trailers = makeTrailers({ [LORE_ID_KEY]: [] }); const result = parser.serialize(trailers); - expect(result).not.toContain('Lore-id:'); + expect(result).not.toContain(`${LORE_ID_KEY}:`); }); - it('should output Lore-id first', () => { + it('should output ${LORE_ID_KEY} first', () => { const trailers = makeTrailers({ - 'Lore-id': 'abcd1234', + [LORE_ID_KEY]: ['abcd1234'], Constraint: ['a constraint'], }); const result = parser.serialize(trailers); const lines = result.split('\n'); - expect(lines[0]).toBe('Lore-id: abcd1234'); + expect(lines[0]).toBe(`${LORE_ID_KEY}: abcd1234`); }); it('should produce one line per array entry', () => { const trailers = makeTrailers({ - 'Lore-id': 'abcd1234', + [LORE_ID_KEY]: ['abcd1234'], Rejected: ['Option A', 'Option B', 'Option C'], }); const result = parser.serialize(trailers); @@ -338,35 +320,35 @@ describe('TrailerParser', () => { }); describe('parse/serialize roundtrip', () => { - it('should roundtrip a full trailer block', () => { + it('should roundtrip a full trailer block in canonical order', () => { const original = [ - 'Lore-id: a7f3b2c1', + `${LORE_ID_KEY}: a7f3b2c1`, 'Constraint: PostgreSQL >= 14 required', 'Constraint: All timestamps UTC', 'Rejected: MongoDB -- no transactions', + 'Confidence: high', + 'Scope-risk: moderate', + 'Reversibility: clean', 'Directive: Review in Q3', 'Tested: integration test suite', 'Not-tested: performance under load', 'Supersedes: b3e4f5a6', 'Depends-on: c1d2e3f4', 'Related: d4e5f6a7', - 'Confidence: high', - 'Scope-risk: moderate', - 'Reversibility: clean', ].join('\n'); - const parsed = parser.parse(original, []); + const parsed = parser.parse(original); const serialized = parser.serialize(parsed); // Re-parse the serialized output - const reparsed = parser.parse(serialized, []); + const reparsed = parser.parse(serialized); - expect(reparsed['Lore-id']).toBe(parsed['Lore-id']); + expect(reparsed[LORE_ID_KEY]).toEqual(parsed[LORE_ID_KEY]); expect(reparsed.Constraint).toEqual(parsed.Constraint); expect(reparsed.Rejected).toEqual(parsed.Rejected); - expect(reparsed.Confidence).toBe(parsed.Confidence); - expect(reparsed['Scope-risk']).toBe(parsed['Scope-risk']); - expect(reparsed.Reversibility).toBe(parsed.Reversibility); + expect(reparsed.Confidence).toEqual(parsed.Confidence); + expect(reparsed['Scope-risk']).toEqual(parsed['Scope-risk']); + expect(reparsed.Reversibility).toEqual(parsed.Reversibility); expect(reparsed.Directive).toEqual(parsed.Directive); expect(reparsed.Tested).toEqual(parsed.Tested); expect(reparsed['Not-tested']).toEqual(parsed['Not-tested']); @@ -377,21 +359,21 @@ describe('TrailerParser', () => { it('should roundtrip with custom trailers', () => { const original = [ - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, 'My-trailer: custom value', ].join('\n'); - const parsed = parser.parse(original, []); + const parsed = parser.parse(original); const serialized = parser.serialize(parsed); - const reparsed = parser.parse(serialized, []); + const reparsed = parser.parse(serialized); - expect(reparsed.custom.get('My-trailer')).toEqual(['custom value']); + expect(reparsed['My-trailer']).toEqual(['custom value']); }); }); describe('containsLoreTrailers', () => { - it('should return true when text contains Lore-id', () => { - expect(parser.containsLoreTrailers('Lore-id: abcd1234')).toBe(true); + it('should return true when text contains ${LORE_ID_KEY}', () => { + expect(parser.containsLoreTrailers(`${LORE_ID_KEY}: abcd1234`)).toBe(true); }); it('should return true when text contains a Constraint trailer', () => { @@ -410,14 +392,20 @@ describe('TrailerParser', () => { expect(parser.containsLoreTrailers('')).toBe(false); }); - it('should return false for non-Lore trailers', () => { - expect(parser.containsLoreTrailers('Signed-off-by: Someone')).toBe(false); + it('should return false for non-Lore trailers in strict mode', () => { + const strictConfig = { + ...DEFAULT_CONFIG, + trailers: { ...DEFAULT_CONFIG.trailers, permissive: false, definitions: {}, custom: [] } + }; + const strictProtocol = new Protocol(strictConfig); + const strictParser = new TrailerParser(strictProtocol); + expect(strictParser.containsLoreTrailers('Signed-off-by: Someone')).toBe(false); }); it('should return true when Lore trailers are mixed with non-Lore', () => { const text = [ 'Signed-off-by: Someone', - 'Lore-id: abcd1234', + `${LORE_ID_KEY}: abcd1234`, ].join('\n'); expect(parser.containsLoreTrailers(text)).toBe(true); }); @@ -428,7 +416,7 @@ describe('TrailerParser', () => { '', 'Added PgBouncer-based connection pooling.', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, 'Confidence: high', ].join('\n'); expect(parser.containsLoreTrailers(text)).toBe(true); @@ -442,11 +430,11 @@ describe('TrailerParser', () => { '', 'Added PgBouncer-based connection pooling.', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, 'Constraint: PostgreSQL >= 14', ].join('\n'); const result = parser.extractTrailerBlock(message); - expect(result).toBe('Lore-id: a1b2c3d4\nConstraint: PostgreSQL >= 14'); + expect(result).toBe(`${LORE_ID_KEY}: a1b2c3d4\nConstraint: PostgreSQL >= 14`); }); it('should return empty string when there are no trailers', () => { @@ -466,23 +454,22 @@ describe('TrailerParser', () => { const message = [ 'feat(db): add connection pooling', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, 'Confidence: high', ].join('\n'); const result = parser.extractTrailerBlock(message); - expect(result).toBe('Lore-id: a1b2c3d4\nConfidence: high'); + expect(result).toBe(`${LORE_ID_KEY}: a1b2c3d4\nConfidence: high`); }); it('should handle trailing whitespace', () => { const message = [ 'feat: something', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, ' ', ].join('\n'); - // After trimEnd, the trailing whitespace paragraph disappears const result = parser.extractTrailerBlock(message); - expect(result).toContain('Lore-id: a1b2c3d4'); + expect(result).toContain(`${LORE_ID_KEY}: a1b2c3d4`); }); it('should handle multiple blank line separators', () => { @@ -493,10 +480,10 @@ describe('TrailerParser', () => { 'Body text here.', '', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, ].join('\n'); const result = parser.extractTrailerBlock(message); - expect(result).toBe('Lore-id: a1b2c3d4'); + expect(result).toBe(`${LORE_ID_KEY}: a1b2c3d4`); }); it('should return empty if last paragraph is not all trailers', () => { @@ -513,12 +500,12 @@ describe('TrailerParser', () => { const message = [ 'feat: something', '', - 'Lore-id: a1b2c3d4', + `${LORE_ID_KEY}: a1b2c3d4`, 'Constraint: A long constraint', ' that continues here', ].join('\n'); const result = parser.extractTrailerBlock(message); - expect(result).toContain('Lore-id: a1b2c3d4'); + expect(result).toContain(`${LORE_ID_KEY}: a1b2c3d4`); expect(result).toContain('Constraint: A long constraint'); expect(result).toContain(' that continues here'); }); @@ -528,34 +515,20 @@ describe('TrailerParser', () => { /** * Helper to create a LoreTrailers object with defaults and overrides. */ -function makeTrailers(overrides: Partial<{ - 'Lore-id': string; - Constraint: readonly string[]; - Rejected: readonly string[]; - Confidence: 'low' | 'medium' | 'high' | null; - 'Scope-risk': 'narrow' | 'moderate' | 'wide' | null; - Reversibility: 'clean' | 'migration-needed' | 'irreversible' | null; - Directive: readonly string[]; - Tested: readonly string[]; - 'Not-tested': readonly string[]; - Supersedes: readonly string[]; - 'Depends-on': readonly string[]; - Related: readonly string[]; - custom: CustomTrailerCollection; -}>) { +function makeTrailers(overrides: Partial): LoreTrailers { return { - 'Lore-id': overrides['Lore-id'] ?? '', - Constraint: overrides.Constraint ?? [], - Rejected: overrides.Rejected ?? [], - Confidence: overrides.Confidence ?? null, - 'Scope-risk': overrides['Scope-risk'] ?? null, - Reversibility: overrides.Reversibility ?? null, - Directive: overrides.Directive ?? [], - Tested: overrides.Tested ?? [], - 'Not-tested': overrides['Not-tested'] ?? [], - Supersedes: overrides.Supersedes ?? [], - 'Depends-on': overrides['Depends-on'] ?? [], - Related: overrides.Related ?? [], - custom: overrides.custom ?? CustomTrailerCollection.empty(), - }; + [LORE_ID_KEY]: [], + Constraint: [], + Rejected: [], + Confidence: [], + 'Scope-risk': [], + Reversibility: [], + Directive: [], + Tested: [], + 'Not-tested': [], + Supersedes: [], + 'Depends-on': [], + Related: [], + ...overrides, + } as any; } diff --git a/tests/unit/services/validator.test.ts b/tests/unit/services/validator.test.ts index 62d702a4..d436ab30 100644 --- a/tests/unit/services/validator.test.ts +++ b/tests/unit/services/validator.test.ts @@ -1,28 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Validator } from '../../../src/services/validator.js'; +import { Protocol } from '../../../src/services/protocol.js'; +import { LORE_ID_KEY } from '../../../src/util/constants.js'; import type { LoreConfig } from '../../../src/types/config.js'; import type { RawCommit } from '../../../src/interfaces/git-client.js'; import type { LoreTrailers } from '../../../src/types/domain.js'; import type { AtomRepository } from '../../../src/services/atom-repository.js'; -import { DEFAULT_CONFIG } from '../../../src/types/config.js'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; function makeTrailers(overrides: Partial = {}): LoreTrailers { return { - 'Lore-id': overrides['Lore-id'] ?? 'a1b2c3d4', + [LORE_ID_KEY]: overrides[LORE_ID_KEY] ?? ['a1b2c3d4'], Constraint: overrides.Constraint ?? [], Rejected: overrides.Rejected ?? [], - Confidence: overrides.Confidence ?? null, - 'Scope-risk': overrides['Scope-risk'] ?? null, - Reversibility: overrides.Reversibility ?? null, + Confidence: overrides.Confidence ?? [], + 'Scope-risk': overrides['Scope-risk'] ?? [], + Reversibility: overrides.Reversibility ?? [], Directive: overrides.Directive ?? [], Tested: overrides.Tested ?? [], 'Not-tested': overrides['Not-tested'] ?? [], Supersedes: overrides.Supersedes ?? [], 'Depends-on': overrides['Depends-on'] ?? [], Related: overrides.Related ?? [], - custom: overrides.custom ?? CustomTrailerCollection.empty(), - }; + ...overrides, + } as any; } function createMockTrailerParser(resultOverrides: Partial = {}) { @@ -47,7 +48,7 @@ function makeCommit(overrides: Partial = {}): RawCommit { author: overrides.author ?? 'alice@example.com', subject: overrides.subject ?? 'feat(auth): add login flow', body: overrides.body ?? '', - trailers: overrides.trailers ?? 'Lore-id: a1b2c3d4', + trailers: overrides.trailers ?? `${LORE_ID_KEY}: a1b2c3d4`, }; } @@ -56,12 +57,14 @@ describe('Validator', () => { let mockParser: ReturnType; let mockAtomRepo: Partial; let config: LoreConfig; + let protocol: Protocol; beforeEach(() => { mockParser = createMockTrailerParser(); mockAtomRepo = createMockAtomRepository(); config = { ...DEFAULT_CONFIG }; - validator = new Validator(mockParser as any, mockAtomRepo as any, config); + protocol = new Protocol(config); + validator = new Validator(mockParser as any, mockAtomRepo as any, config, protocol); }); describe('basic validation', () => { @@ -100,9 +103,9 @@ describe('Validator', () => { }); }); - describe('Rule 2: Lore-id present', () => { - it('should error when Lore-id is missing', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': '' })); + describe(`Rule 2: ${LORE_ID_KEY} present`, () => { + it(`should error when ${LORE_ID_KEY} is missing`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: [] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -113,9 +116,9 @@ describe('Validator', () => { }); }); - describe('Rule 3: Lore-id format', () => { - it('should error when Lore-id is not 8-char hex', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': 'not-hex!' })); + describe(`Rule 3: ${LORE_ID_KEY} format`, () => { + it(`should error when ${LORE_ID_KEY} is not 8-char hex`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['not-hex!'] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -125,8 +128,8 @@ describe('Validator', () => { expect(formatIssue!.severity).toBe('error'); }); - it('should pass for valid 8-char hex Lore-id', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': 'abcd1234' })); + it(`should pass for valid 8-char hex ${LORE_ID_KEY}`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['abcd1234'] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -135,8 +138,8 @@ describe('Validator', () => { expect(formatIssue).toBeUndefined(); }); - it('should error for too-short Lore-id', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': 'abc123' })); + it(`should error for too-short ${LORE_ID_KEY}`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['abc123'] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -145,8 +148,8 @@ describe('Validator', () => { expect(formatIssue).toBeDefined(); }); - it('should error for uppercase hex Lore-id', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': 'ABCD1234' })); + it(`should error for uppercase hex ${LORE_ID_KEY}`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['ABCD1234'] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -156,10 +159,63 @@ describe('Validator', () => { }); }); + describe('Rule: invalid-cardinality', () => { + it('should error when a single-value core trailer has multiple values', async () => { + mockParser.parse.mockReturnValue(makeTrailers({ + 'Confidence': ['low', 'high'] + })); + + const commit = makeCommit(); + const results = await validator.validate([commit]); + + const cardinalityIssue = results[0].issues.find((i) => i.rule === 'invalid-cardinality'); + expect(cardinalityIssue).toBeDefined(); + expect(cardinalityIssue!.severity).toBe('error'); + expect(cardinalityIssue!.field).toBe('Confidence'); + }); + + it('should error when a single-value custom trailer has multiple values', async () => { + const customConfig = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Team: { description: 'T', multivalue: false, validation: 'none' as const } + } + } + }; + const customProtocol = new Protocol(customConfig); + const customValidator = new Validator(mockParser as any, mockAtomRepo as any, customConfig, customProtocol); + + mockParser.parse.mockReturnValue(makeTrailers({ + 'Team': ['Engineering', 'Product'] + } as any)); + + const commit = makeCommit(); + const results = await customValidator.validate([commit]); + + const cardinalityIssue = results[0].issues.find((i) => i.rule === 'invalid-cardinality'); + expect(cardinalityIssue).toBeDefined(); + expect(cardinalityIssue!.field).toBe('Team'); + }); + + it('should pass when an array core trailer has multiple values', async () => { + mockParser.parse.mockReturnValue(makeTrailers({ + 'Constraint': ['C1', 'C2'] + })); + + const commit = makeCommit(); + const results = await validator.validate([commit]); + + const cardinalityIssue = results[0].issues.find((i) => i.rule === 'invalid-cardinality'); + expect(cardinalityIssue).toBeUndefined(); + }); + }); + describe('Rule 4: valid enum values', () => { it('should error on invalid Confidence', async () => { mockParser.parse.mockReturnValue( - makeTrailers({ Confidence: 'super-high' as any }), + makeTrailers({ Confidence: ['super-high'] as any }), ); const commit = makeCommit(); @@ -174,7 +230,7 @@ describe('Validator', () => { it('should error on invalid Scope-risk', async () => { mockParser.parse.mockReturnValue( - makeTrailers({ 'Scope-risk': 'huge' as any }), + makeTrailers({ 'Scope-risk': ['huge'] as any }), ); const commit = makeCommit(); @@ -188,7 +244,7 @@ describe('Validator', () => { it('should error on invalid Reversibility', async () => { mockParser.parse.mockReturnValue( - makeTrailers({ Reversibility: 'maybe' as any }), + makeTrailers({ Reversibility: ['maybe'] as any }), ); const commit = makeCommit(); @@ -203,9 +259,9 @@ describe('Validator', () => { it('should accept valid enum values', async () => { mockParser.parse.mockReturnValue( makeTrailers({ - Confidence: 'medium', - 'Scope-risk': 'narrow', - Reversibility: 'clean', + Confidence: ['medium'], + 'Scope-risk': ['narrow'], + Reversibility: ['clean'], }), ); @@ -216,12 +272,12 @@ describe('Validator', () => { expect(enumIssues).toHaveLength(0); }); - it('should not error when enum trailers are null', async () => { + it('should not error when enum trailers are empty', async () => { mockParser.parse.mockReturnValue( makeTrailers({ - Confidence: null, - 'Scope-risk': null, - Reversibility: null, + Confidence: [], + 'Scope-risk': [], + Reversibility: [], }), ); @@ -256,7 +312,8 @@ describe('Validator', () => { ...DEFAULT_CONFIG, validation: { ...DEFAULT_CONFIG.validation, intentMaxLength: 50 }, }; - const customValidator = new Validator(mockParser as any, mockAtomRepo as any, customConfig); + const customProtocol = new Protocol(customConfig); + const customValidator = new Validator(mockParser as any, mockAtomRepo as any, customConfig, customProtocol); const commit = makeCommit({ subject: 'a'.repeat(51) }); const results = await customValidator.validate([commit]); @@ -271,11 +328,12 @@ describe('Validator', () => { it('should warn on missing required trailers (non-strict)', async () => { const requiredConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence', 'Constraint'], custom: [] }, + trailers: { required: ['Confidence', 'Constraint'], custom: [], definitions: {}, permissive: true }, validation: { ...DEFAULT_CONFIG.validation, strict: false }, }; - const requiredValidator = new Validator(mockParser as any, mockAtomRepo as any, requiredConfig); - mockParser.parse.mockReturnValue(makeTrailers({ Confidence: null })); + const requiredProtocol = new Protocol(requiredConfig); + const requiredValidator = new Validator(mockParser as any, mockAtomRepo as any, requiredConfig, requiredProtocol); + mockParser.parse.mockReturnValue(makeTrailers({ Confidence: [] })); const commit = makeCommit(); const results = await requiredValidator.validate([commit]); @@ -290,11 +348,12 @@ describe('Validator', () => { it('should error on missing required trailers (strict)', async () => { const strictConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence'], custom: [] }, + trailers: { required: ['Confidence'], custom: [], definitions: {}, permissive: true }, validation: { ...DEFAULT_CONFIG.validation, strict: true }, }; - const strictValidator = new Validator(mockParser as any, mockAtomRepo as any, strictConfig); - mockParser.parse.mockReturnValue(makeTrailers({ Confidence: null })); + const strictProtocol = new Protocol(strictConfig); + const strictValidator = new Validator(mockParser as any, mockAtomRepo as any, strictConfig, strictProtocol); + mockParser.parse.mockReturnValue(makeTrailers({ Confidence: [] })); const commit = makeCommit(); const results = await strictValidator.validate([commit]); @@ -305,14 +364,15 @@ describe('Validator', () => { expect(requiredIssues[0].severity).toBe('error'); }); - it('should not warn when required trailers are present', async () => { + it('should not warn when required trailers are `present', async () => { const requiredConfig: LoreConfig = { ...DEFAULT_CONFIG, - trailers: { required: ['Confidence'], custom: [] }, + trailers: { required: ['Confidence'], custom: [], definitions: {}, permissive: true }, }; - const requiredValidator = new Validator(mockParser as any, mockAtomRepo as any, requiredConfig); + const requiredProtocol = new Protocol(requiredConfig); + const requiredValidator = new Validator(mockParser as any, mockAtomRepo as any, requiredConfig, requiredProtocol); mockParser.parse.mockReturnValue( - makeTrailers({ Confidence: 'medium' }), + makeTrailers({ Confidence: ['medium'] }), ); const commit = makeCommit(); @@ -422,7 +482,7 @@ describe('Validator', () => { describe('overall validity', () => { it('should be invalid if any error exists', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': '' })); + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: [] })); const commit = makeCommit(); const results = await validator.validate([commit]); @@ -443,7 +503,7 @@ describe('Validator', () => { }); }); - describe('commit hash and lore-id reporting', () => { + describe(`commit hash and ${LORE_ID_KEY} reporting`, () => { it('should report commit hash', async () => { const commit = makeCommit({ hash: 'specific_hash_123' }); const results = await validator.validate([commit]); @@ -451,15 +511,15 @@ describe('Validator', () => { expect(results[0].commit).toBe('specific_hash_123'); }); - it('should report lore-id when present', async () => { - mockParser.parse.mockReturnValue(makeTrailers({ 'Lore-id': 'deadbeef' })); + it(`should report ${LORE_ID_KEY} when present`, async () => { + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['deadbeef'] })); const commit = makeCommit(); const results = await validator.validate([commit]); expect(results[0].loreId).toBe('deadbeef'); }); - it('should report null lore-id when parse fails', async () => { + it(`should report null ${LORE_ID_KEY} when parse fails`, async () => { mockParser.parse.mockImplementation(() => { throw new Error('Parse error'); }); @@ -498,7 +558,7 @@ describe('Validator', () => { author: 'dev@example.com', intent: 'test', body: '', - trailers: makeTrailers({ 'Lore-id': 'aabbccdd' }), + trailers: makeTrailers({ [LORE_ID_KEY]: ['aabbccdd'] }), filesChanged: [], }); mockParser.parse.mockReturnValue( @@ -516,4 +576,85 @@ describe('Validator', () => { expect(refExistsIssues).toHaveLength(0); }); }); + + describe('Rule 11: custom trailer definitions', () => { + it('should error when a trailer marked as required in definitions `is missing', async () => { + const configWithDef: LoreConfig = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Department: { description: 'dept', multivalue: false, validation: 'none', required: true }, + }, + custom: [], + permissive: false, + }, + }; + const customProtocol = new Protocol(configWithDef); + const customValidator = new Validator(mockParser as any, mockAtomRepo as any, configWithDef, customProtocol); + mockParser.parse.mockReturnValue(makeTrailers({ [LORE_ID_KEY]: ['abc'] } as any)); // Missing Department + + const commit = makeCommit(); + const results = await customValidator.validate([commit]); + + const requiredIssues = results[0].issues.filter((i) => i.rule === 'required-trailer'); + expect(requiredIssues).toHaveLength(1); + expect(requiredIssues[0].message).toContain('Department'); + }); + + it('should error on invalid enum value for custom trailer', async () => { + const configWithDef: LoreConfig = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Team: { + description: 'team', + multivalue: false, + validation: 'values', + values: { Alpha: '', Beta: '' }, + }, + }, + custom: [], + permissive: false, + }, + }; + const customProtocol = new Protocol(configWithDef); + const customValidator = new Validator(mockParser as any, mockAtomRepo as any, configWithDef, customProtocol); + + mockParser.parse.mockReturnValue(makeTrailers({ Team: ['Gamma'] } as any)); + + const commit = makeCommit(); + const results = await customValidator.validate([commit]); + + const enumIssues = results[0].issues.filter((i) => i.rule === 'invalid-enum'); + expect(enumIssues).toHaveLength(1); + expect(enumIssues[0].message).toContain('Expected one of: Alpha, Beta'); + }); + + it('should error on invalid pattern for custom trailer', async () => { + const configWithDef: LoreConfig = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + definitions: { + Ticket: { description: 'jira', multivalue: false, validation: 'pattern', pattern: '^PROJ-[0-9]+$' }, + }, + custom: [], + permissive: false, + }, + }; + const customProtocol = new Protocol(configWithDef); + const customValidator = new Validator(mockParser as any, mockAtomRepo as any, configWithDef, customProtocol); + + mockParser.parse.mockReturnValue(makeTrailers({ Ticket: ['invalid-123'] } as any)); + + const commit = makeCommit(); + const results = await customValidator.validate([commit]); + + const formatIssues = results[0].issues.filter((i) => i.rule === 'invalid-format'); + expect(formatIssues).toHaveLength(1); + expect(formatIssues[0].message).toContain('does not match pattern'); + }); + }); }); diff --git a/tests/unit/types/config.test.ts b/tests/unit/types/config.test.ts new file mode 100644 index 00000000..83bbbe7b --- /dev/null +++ b/tests/unit/types/config.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { Protocol } from '../../../src/services/protocol.js'; +import { DEFAULT_CONFIG } from '../../../src/util/constants.js'; + +describe('getEffectiveTrailerKeys (Protocol logic replacement)', () => { + it('should return empty list in permissive mode', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + permissive: true, + custom: ['Team'], + definitions: { Dept: { description: 'D', multivalue: false, validation: 'none' as const } }, + } + }; + const protocol = new Protocol(config); + // Permissive mode includes all, so we check for defined custom keys only if needed, + // but originally getEffectiveTrailerKeys returned [] for permissive. + // The Protocol service doesn't have a direct equivalent to 'empty list' because it + // authorizes everything. + const customKeys = protocol.getAuthorizedKeys().filter(k => !protocol.isCore(k)); + expect(customKeys).toContain('Team'); + expect(customKeys).toContain('Dept'); + }); + + it('should union custom array and definitions keys', () => { + const config = { + ...DEFAULT_CONFIG, + trailers: { + ...DEFAULT_CONFIG.trailers, + permissive: false, + custom: ['Team', 'Project'], + definitions: { + Dept: { description: 'D', multivalue: false, validation: 'none' as const }, + Project: { description: 'P', multivalue: false, validation: 'none' as const } // Duplicate + }, + } + }; + const protocol = new Protocol(config); + const customKeys = protocol.getAuthorizedKeys().filter(k => !protocol.isCore(k)); + + expect(customKeys).toContain('Team'); + expect(customKeys).toContain('Project'); + expect(customKeys).toContain('Dept'); + expect(customKeys.length).toBe(3); // Deduplicated by Protocol engine + }); +}); diff --git a/tests/unit/types/custom-trailer-collection.test.ts b/tests/unit/types/custom-trailer-collection.test.ts deleted file mode 100644 index 3aaaac96..00000000 --- a/tests/unit/types/custom-trailer-collection.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { CustomTrailerCollection } from '../../../src/types/custom-trailer-collection.js'; - -describe('CustomTrailerCollection', () => { - describe('constructor and iteration', () => { - it('should store entries and iterate over them', () => { - const entries = new Map([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123', 'PROJ-456']], - ]); - const collection = new CustomTrailerCollection(entries); - - const result: [string, readonly string[]][] = []; - for (const entry of collection) { - result.push(entry); - } - - expect(result).toEqual([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123', 'PROJ-456']], - ]); - }); - - it('should not be affected by mutations to the original map', () => { - const entries = new Map([ - ['Team', ['platform']], - ]); - const collection = new CustomTrailerCollection(entries); - - entries.set('Team', ['changed']); - entries.set('New', ['value']); - - expect(collection.get('Team')).toEqual(['platform']); - expect(collection.has('New')).toBe(false); - }); - }); - - describe('empty', () => { - it('should return an empty collection', () => { - const collection = CustomTrailerCollection.empty(); - - expect(collection.isEmpty).toBe(true); - expect(collection.size).toBe(0); - expect(collection.lineCount).toBe(0); - }); - - it('should return the same instance on repeated calls', () => { - const a = CustomTrailerCollection.empty(); - const b = CustomTrailerCollection.empty(); - - expect(a).toBe(b); - }); - }); - - describe('fromRaw', () => { - it('should extract unknown keys as custom trailers', () => { - const raw = { - Confidence: 'high', - 'Scope-risk': 'narrow', - 'Assisted-by': ['Gemini:CLI'], - Ticket: ['PROJ-123'], - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.get('Assisted-by')).toEqual(['Gemini:CLI']); - expect(collection.get('Ticket')).toEqual(['PROJ-123']); - expect(collection.has('Confidence')).toBe(false); - expect(collection.has('Scope-risk')).toBe(false); - }); - - it('should filter out all known Lore trailer keys', () => { - const raw = { - 'Lore-id': 'abcd1234', - Constraint: ['a constraint'], - Rejected: ['alt A'], - Confidence: 'high', - 'Scope-risk': 'narrow', - Reversibility: 'clean', - Directive: ['directive'], - Tested: ['tested'], - 'Not-tested': ['not tested'], - Supersedes: ['11223344'], - 'Depends-on': ['55667788'], - Related: ['aabbccdd'], - 'My-custom': ['custom value'], - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.size).toBe(1); - expect(collection.get('My-custom')).toEqual(['custom value']); - }); - - it('should coerce a single string to a [string] array', () => { - const raw = { - 'Assisted-by': 'Claude:CLI', - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.get('Assisted-by')).toEqual(['Claude:CLI']); - }); - - it('should skip non-string and non-array values', () => { - const raw = { - 'Valid-custom': 'value', - 'Number-custom': 42, - 'Bool-custom': true, - 'Null-custom': null, - 'Object-custom': { nested: 'value' }, - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.size).toBe(1); - expect(collection.get('Valid-custom')).toEqual(['value']); - }); - - it('should filter non-string values from arrays', () => { - const raw = { - 'Mixed-array': ['valid', 42, 'also valid', null, true], - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.get('Mixed-array')).toEqual(['valid', 'also valid']); - }); - - it('should skip empty arrays', () => { - const raw = { - 'Empty-array': [], - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection.isEmpty).toBe(true); - }); - - it('should return empty() singleton when no custom keys exist', () => { - const raw = { - Confidence: 'high', - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - - expect(collection).toBe(CustomTrailerCollection.empty()); - }); - - it('should return empty() singleton for empty input', () => { - const collection = CustomTrailerCollection.fromRaw({}); - - expect(collection).toBe(CustomTrailerCollection.empty()); - }); - }); - - describe('has', () => { - it('should return true for an existing key with values', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', ['platform']]]), - ); - - expect(collection.has('Team')).toBe(true); - }); - - it('should return false for a non-existent key', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', ['platform']]]), - ); - - expect(collection.has('Missing')).toBe(false); - }); - - it('should return false for a key with an empty array', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', []]]), - ); - - expect(collection.has('Team')).toBe(false); - }); - }); - - describe('get', () => { - it('should return the values for an existing key', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', ['platform', 'infra']]]), - ); - - expect(collection.get('Team')).toEqual(['platform', 'infra']); - }); - - it('should return undefined for a non-existent key', () => { - const collection = CustomTrailerCollection.empty(); - - expect(collection.get('Missing')).toBeUndefined(); - }); - }); - - describe('lineCount', () => { - it('should return 0 for empty collection', () => { - expect(CustomTrailerCollection.empty().lineCount).toBe(0); - }); - - it('should count total values across all keys', () => { - const collection = new CustomTrailerCollection( - new Map([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123', 'PROJ-456']], - ['Reviewer', ['alice', 'bob', 'carol']], - ]), - ); - - expect(collection.lineCount).toBe(6); - }); - - it('should count single values correctly', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', ['platform']]]), - ); - - expect(collection.lineCount).toBe(1); - }); - }); - - describe('size', () => { - it('should return 0 for empty collection', () => { - expect(CustomTrailerCollection.empty().size).toBe(0); - }); - - it('should return the number of distinct keys', () => { - const collection = new CustomTrailerCollection( - new Map([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123', 'PROJ-456']], - ]), - ); - - expect(collection.size).toBe(2); - }); - }); - - describe('isEmpty', () => { - it('should return true for empty collection', () => { - expect(CustomTrailerCollection.empty().isEmpty).toBe(true); - }); - - it('should return false for non-empty collection', () => { - const collection = new CustomTrailerCollection( - new Map([['Team', ['platform']]]), - ); - - expect(collection.isEmpty).toBe(false); - }); - }); - - describe('toRecord', () => { - it('should convert to a plain object', () => { - const collection = new CustomTrailerCollection( - new Map([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123', 'PROJ-456']], - ]), - ); - - expect(collection.toRecord()).toEqual({ - Team: ['platform'], - Ticket: ['PROJ-123', 'PROJ-456'], - }); - }); - - it('should return empty object for empty collection', () => { - expect(CustomTrailerCollection.empty().toRecord()).toEqual({}); - }); - - it('should round-trip through fromRaw and toRecord', () => { - const raw = { - 'Assisted-by': ['Claude:CLI'], - Ticket: ['PROJ-123', 'PROJ-456'], - }; - - const collection = CustomTrailerCollection.fromRaw(raw); - const record = collection.toRecord(); - - expect(record).toEqual({ - 'Assisted-by': ['Claude:CLI'], - Ticket: ['PROJ-123', 'PROJ-456'], - }); - }); - }); - - describe('Symbol.iterator', () => { - it('should support spread into array', () => { - const collection = new CustomTrailerCollection( - new Map([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123']], - ]), - ); - - const entries = [...collection]; - - expect(entries).toEqual([ - ['Team', ['platform']], - ['Ticket', ['PROJ-123']], - ]); - }); - - it('should produce no entries for empty collection', () => { - const entries = [...CustomTrailerCollection.empty()]; - - expect(entries).toEqual([]); - }); - - it('should work with for...of loops', () => { - const collection = new CustomTrailerCollection( - new Map([['Key', ['value']]]), - ); - - const keys: string[] = []; - for (const [key] of collection) { - keys.push(key); - } - - expect(keys).toEqual(['Key']); - }); - }); -}); diff --git a/tests/unit/util/core-definitions.test.ts b/tests/unit/util/core-definitions.test.ts new file mode 100644 index 00000000..7759fce1 --- /dev/null +++ b/tests/unit/util/core-definitions.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { + CORE_TRAILER_DEFINITIONS, + LORE_TRAILER_KEYS, + ARRAY_TRAILER_KEYS, + ENUM_TRAILER_KEYS, + CONFIDENCE_VALUES, + SCOPE_RISK_VALUES, + REVERSIBILITY_VALUES, + LORE_ID_KEY +} from '../../../src/util/core-definitions.js'; + +describe('CORE_TRAILER_DEFINITIONS', () => { + it(`should have CLI metadata for all standard trailers except ${LORE_ID_KEY}`, () => { + for (const [key, def] of Object.entries(CORE_TRAILER_DEFINITIONS)) { + if (key === LORE_ID_KEY) continue; + + expect(def.cli, `Trailer "${key}" is missing CLI flag metadata`).toBeDefined(); + expect(def.cli?.flag, `Trailer "${key}" is missing a flag name`).toBeDefined(); + } + }); + + it(`should have prompt metadata for all standard trailers except ${LORE_ID_KEY}`, () => { + for (const [key, def] of Object.entries(CORE_TRAILER_DEFINITIONS)) { + if (key === LORE_ID_KEY) continue; + + expect(def.prompt, `Trailer "${key}" is missing prompt metadata`).toBeDefined(); + expect(def.prompt?.confirm, `Trailer "${key}" is missing a confirm message`).toBeDefined(); + + if (def.validation === 'values') { + expect(def.prompt?.choice, `Enum trailer "${key}" is missing a choice message`).toBeDefined(); + } else { + expect(def.prompt?.input, `Input trailer "${key}" is missing an input message`).toBeDefined(); + } + } + }); + + it('should have UI kinds and colors for all standard trailers', () => { + for (const [key, def] of Object.entries(CORE_TRAILER_DEFINITIONS)) { + expect(def.ui, `Trailer "${key}" is missing UI metadata`).toBeDefined(); + expect(def.ui?.color, `Trailer "${key}" is missing a UI color`).toBeDefined(); + expect(def.ui?.kind, `Trailer "${key}" is missing a UI kind`).toBeDefined(); + } + }); + + describe('derivation logic', () => { + it('should derive LORE_TRAILER_KEYS from metadata', () => { + const keys = Object.keys(CORE_TRAILER_DEFINITIONS); + expect(LORE_TRAILER_KEYS).toEqual(keys); + }); + + it('should correctly identify ARRAY_TRAILER_KEYS', () => { + expect(ARRAY_TRAILER_KEYS).toContain('Constraint'); + expect(ARRAY_TRAILER_KEYS).toContain('Rejected'); + expect(ARRAY_TRAILER_KEYS).not.toContain('Confidence'); + }); + + it('should correctly identify ENUM_TRAILER_KEYS', () => { + expect(ENUM_TRAILER_KEYS).toContain('Confidence'); + expect(ENUM_TRAILER_KEYS).toContain('Scope-risk'); + expect(ENUM_TRAILER_KEYS).not.toContain('Constraint'); + }); + + it('should derive enum values from metadata options', () => { + expect(CONFIDENCE_VALUES).toEqual(['low', 'medium', 'high']); + expect(SCOPE_RISK_VALUES).toEqual(['narrow', 'moderate', 'wide']); + expect(REVERSIBILITY_VALUES).toEqual(['clean', 'migration-needed', 'irreversible']); + }); + }); +}); diff --git a/tests/unit/util/directive-parser.test.ts b/tests/unit/util/directive-parser.test.ts new file mode 100644 index 00000000..7b375e7c --- /dev/null +++ b/tests/unit/util/directive-parser.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { DirectiveParser, parseDirectiveHints } from '../../../src/util/directive-parser.js'; + +describe('DirectiveParser', () => { + describe('parse', () => { + it('should parse a simple instruction without triggers', () => { + const result = DirectiveParser.parse('Do something'); + expect(result.instruction).toBe('Do something'); + expect(result.triggers).toHaveLength(0); + }); + + it('should parse a single trigger', () => { + const result = DirectiveParser.parse('[on:squash] Do something'); + expect(result.instruction).toBe('Do something'); + expect(result.triggers).toEqual([{ key: 'on', value: 'squash' }]); + }); + + it('should parse multiple triggers', () => { + const result = DirectiveParser.parse('[on:squash][step:1] Do something'); + expect(result.instruction).toBe('Do something'); + expect(result.triggers).toEqual([ + { key: 'on', value: 'squash' }, + { key: 'step', value: '1' }, + ]); + }); + + it('should handle complex triggers with special characters', () => { + const result = DirectiveParser.parse('[until:2026-05-21][scope:src/api] Caution'); + expect(result.instruction).toBe('Caution'); + expect(result.triggers).toEqual([ + { key: 'until', value: '2026-05-21' }, + { key: 'scope', value: 'src/api' }, + ]); + }); + + it('should trim whitespace from instruction', () => { + const result = DirectiveParser.parse('[on:init] Clean up '); + expect(result.instruction).toBe('Clean up'); + }); + }); + + describe('matches', () => { + const parsed = DirectiveParser.parse('[on:squash][step:1] Do something'); + + it('should return true for matching key and value', () => { + expect(DirectiveParser.matches(parsed, 'on', 'squash')).toBe(true); + }); + + it('should return true for matching key only', () => { + expect(DirectiveParser.matches(parsed, 'step')).toBe(true); + }); + + it('should return false for non-matching value', () => { + expect(DirectiveParser.matches(parsed, 'on', 'commit')).toBe(false); + }); + + it('should return false for non-matching key', () => { + expect(DirectiveParser.matches(parsed, 'until')).toBe(false); + }); + }); + + describe('parseDirectiveHints', () => { + it('should resolve YYYY-MM as the start of the next month (inclusive end of month)', () => { + const hints = parseDirectiveHints('[until:2026-06] Remove this'); + expect(hints.until).toBeDefined(); + // 2026-06 -> Date(2026, 6, 1) in JS (which is July 1st) + expect(hints.until?.getFullYear()).toBe(2026); + expect(hints.until?.getMonth()).toBe(6); // July + expect(hints.until?.getDate()).toBe(1); + }); + + it('should resolve YYYY-MM-DD as the very end of that day', () => { + const hints = parseDirectiveHints('[until:2026-06-15] Remove this'); + expect(hints.until).toBeDefined(); + expect(hints.until?.getFullYear()).toBe(2026); + expect(hints.until?.getMonth()).toBe(5); // June + expect(hints.until?.getDate()).toBe(15); + expect(hints.until?.getHours()).toBe(23); + expect(hints.until?.getMinutes()).toBe(59); + expect(hints.until?.getMilliseconds()).toBe(999); + }); + + it('should return empty hints for invalid dates', () => { + const hints = parseDirectiveHints('[until:invalid-date] Remove this'); + expect(hints.until).toBeUndefined(); + }); + + it('should return empty hints when no until trigger is present', () => { + const hints = parseDirectiveHints('[on:squash] Remove this'); + expect(hints.until).toBeUndefined(); + }); + }); +});