-
Notifications
You must be signed in to change notification settings - Fork 42
Unify config merging under a monoid #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cds-amal
wants to merge
6
commits into
johnlindquist:main
Choose a base branch
from
cds-io:feat/config-monad
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5ccaf11
docs: add ADR for config monoid unification
cds-amal fd9ac2e
refactor: unify config merging under concatFrontmatter monoid
cds-amal 26c8506
docs: add mutation proof showing property tests subsume case tests
cds-amal ec80906
test: remove case-by-case merge tests subsumed by property tests
cds-amal 37525f3
Update docs/ADR01.config-monad.md
cds-amal fa5e807
Update docs/ADR01.config-monad.md
cds-amal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # ADR-01: Unify config merging under a monoid | ||
|
|
||
| - **Status**: Accepted | ||
| - **Date**: 2025-12-12 | ||
| - **Scope**: `config.ts`, `command-builder.ts` | ||
|
|
||
| ## Context | ||
|
|
||
| mdflow's config precedence chain merges six layers, each overriding the last: | ||
|
|
||
| ``` | ||
| built-in ⊕ global ⊕ git-root ⊕ cwd ⊕ frontmatter ⊕ CLI flags | ||
| ``` | ||
|
Comment on lines
+11
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a language to the fenced code block (MD040). The precedence-chain fence is missing a language tag, which will keep markdownlint warning active. Suggested fix-```
+```text
built-in ⊕ global ⊕ git-root ⊕ cwd ⊕ frontmatter ⊕ CLI flags🧰 Tools🪛 markdownlint-cli2 (0.21.0)[warning] 11-11: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI Agents |
||
|
|
||
| This is structurally a monoidal fold: `{}` is the identity, right-biased shallow merge is the associative binary operation, and `reduce` is the fold. But before this change, three separate code paths expressed that same operation differently: | ||
|
|
||
| 1. `mergeConfigs()` in `config.ts`: deep-clones base, then shallow-merges override per command key | ||
| 2. `applyDefaults()` in `config.ts`: spreads defaults first, then iterates frontmatter entries with assignment | ||
| 3. Inline spreads in `command-builder.ts`: `{ ...defaults, ...frontmatter } as AgentFrontmatter` | ||
|
|
||
| The inline spreads don't deep-clone, so they can alias nested objects. `applyDefaults` uses iteration rather than spread, which behaves identically for flat objects but could diverge with getters or `toJSON`. There were ~18 tests across three files testing variations of the same merge logic, but no integration test verified the full precedence chain end-to-end. | ||
|
|
||
| ## Decision | ||
|
|
||
| Introduce `concatFrontmatter(base, override)` as the single canonical merge operation, with documented algebraic laws: | ||
|
|
||
| ```typescript | ||
| export function concatFrontmatter( | ||
| base: AgentFrontmatter, | ||
| override: AgentFrontmatter | ||
| ): AgentFrontmatter { | ||
| return { ...base, ...override }; | ||
| } | ||
| ``` | ||
|
|
||
| Laws (verified by property-based tests): | ||
| - **Identity**: `concatFrontmatter({}, x) ≡ x` and `concatFrontmatter(x, {}) ≡ x` | ||
| - **Right-bias**: for any key `k` in both `a` and `b`, `result[k] === b[k]` | ||
| - **Associativity**: `concat(a, concat(b, c)) ≡ concat(concat(a, b), c)` | ||
| - **No mutation**: neither input is modified | ||
|
|
||
| All three code paths now delegate to this function: | ||
| - `applyDefaults` becomes a thin wrapper: `concatFrontmatter(defaults, frontmatter)` | ||
| - `mergeConfigs` uses it for per-command merging | ||
| - `buildCommand` / `buildCommandBase` call it instead of inline spreads | ||
|
|
||
| ## Testing strategy | ||
|
|
||
| Property-based tests (fast-check, 200 randomized inputs per law) replace the case-by-case merge tests. A mutation proof (`doc/mutation-proof.md`) demonstrates equivalence: three deliberate bugs were introduced one at a time, and the property tests caught all three (3/3) while the case tests caught only one (1/3). The two bugs missed by case tests were phantom key injection (caught by identity law) and input mutation (caught by no-mutation law). | ||
|
cds-amal marked this conversation as resolved.
Outdated
|
||
|
|
||
| ## Consequences | ||
|
|
||
| - One function to reason about for all config merging, instead of three | ||
| - Property-based tests provide strictly stronger coverage with fewer test cases | ||
| - `applyDefaults` is retained as a backward-compatible wrapper (thin delegation, no separate logic) | ||
| - `mergeConfigs` still handles the `GlobalConfig` level (per-command dispatch), but its inner merge is now `concatFrontmatter` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # Mutation Proof: Property Tests vs Case Tests | ||
|
|
||
| Demonstrates that the property-based tests for `concatFrontmatter` catch | ||
| a strict superset of the bugs that the case-by-case tests catch. | ||
|
|
||
| ## Setup | ||
|
|
||
| - **Property tests**: 6 tests in `config-monoid.test.ts` (identity, right-bias, associativity, no-mutation, key preservation) | ||
| - **Case tests**: merge-related tests across `config.test.ts`, `context.test.ts`, `command-builder.test.ts` | ||
| - **Baseline**: 158 tests, 0 failures | ||
|
|
||
| ## Mutation 1: Left-bias (base wins instead of override) | ||
|
|
||
| ```diff | ||
| - return { ...base, ...override }; | ||
| + return { ...override, ...base }; | ||
| ``` | ||
|
|
||
| | Suite | Caught? | Failing tests | | ||
| |-------|---------|---------------| | ||
| | Case tests | Yes (5 failures) | `mergeConfigs > override takes priority`, `applyDefaults merges defaults with frontmatter (frontmatter wins)`, `loadFullConfig > project config overrides global config`, `config cascade > CWD config overrides git root config`, `buildCommand > frontmatter overrides config defaults` | | ||
| | Property tests | Yes (1 failure) | `right-bias: for any key in both a and b, result[k] === b[k]` | | ||
|
|
||
| **Both suites catch this mutation.** | ||
|
|
||
| ## Mutation 2: Break identity (inject phantom key) | ||
|
|
||
| ```diff | ||
| - return { ...base, ...override }; | ||
| + return { ...base, ...override, _phantom: true }; | ||
| ``` | ||
|
|
||
| | Suite | Caught? | Failing tests | | ||
| |-------|---------|---------------| | ||
| | Case tests | No | All pass (case tests only assert specific keys, not absence of extras) | | ||
| | Property tests | Yes (2 failures) | `right identity: concat(x, {}) ≡ x`, `left identity: concat({}, x) ≡ x` | | ||
|
|
||
| **Only property tests catch this mutation.** The case tests check for the presence | ||
| of expected keys but don't verify that no unexpected keys are added. The identity | ||
| law catches this immediately because `concat(x, {})` should equal `x` exactly, | ||
| and the phantom key breaks that equality. | ||
|
|
||
| ## Mutation 3: Mutate base input (return mutated base instead of new object) | ||
|
|
||
| ```diff | ||
| - return { ...base, ...override }; | ||
| + Object.assign(base, override); return base; | ||
| ``` | ||
|
|
||
| | Suite | Caught? | Failing tests | | ||
| |-------|---------|---------------| | ||
| | Case tests | No | All pass (case tests don't snapshot inputs before/after) | | ||
| | Property tests | Yes (1 failure) | `no mutation: neither input is modified` | | ||
|
|
||
| **Only property tests catch this mutation.** The case tests pass fresh literals | ||
| to each test, so mutating them has no observable effect within a single test. | ||
| The property test explicitly verifies that inputs are unchanged after the call. | ||
|
|
||
| ## Conclusion | ||
|
|
||
| | Mutation | Case tests | Property tests | | ||
| |----------|-----------|----------------| | ||
| | Left-bias | Caught | Caught | | ||
| | Phantom key injection | **Missed** | Caught | | ||
| | Input mutation | **Missed** | Caught | | ||
|
|
||
| The property tests catch all 3 mutations. The case tests catch only 1 of 3. | ||
| The property tests are strictly more powerful for verifying the algebraic | ||
| properties of `concatFrontmatter`, which justifies removing the case-by-case | ||
| merge tests that the property tests subsume. | ||
|
|
||
| Note: the case tests that test *other* behavior (config file loading, interactive | ||
| mode, project cascade with real filesystem) are NOT subsumed and should be retained. | ||
| Only the pure merge-logic tests (identity, precedence, key preservation) are | ||
| redundant with the property tests. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /** | ||
| * Property-based tests for concatFrontmatter monoid laws. | ||
| * | ||
| * These tests verify that concatFrontmatter satisfies: | ||
| * 1. Right identity: concatFrontmatter(x, {}) ≡ x | ||
| * 2. Left identity: concatFrontmatter({}, x) ≡ x | ||
| * 3. Right-bias: for any key in both a and b, result[k] === b[k] | ||
| * 4. Associativity: concat(a, concat(b, c)) ≡ concat(concat(a, b), c) | ||
| * 5. No mutation: neither input is modified | ||
| */ | ||
|
|
||
| import { describe, it, expect } from "bun:test"; | ||
| import fc from "fast-check"; | ||
| import { concatFrontmatter } from "./config"; | ||
| import type { AgentFrontmatter } from "./types"; | ||
|
|
||
| /** | ||
| * Arbitrary for generating realistic AgentFrontmatter objects. | ||
| * | ||
| * Covers the key spaces that matter: plain string/number/boolean flags, | ||
| * underscore-prefixed template vars, $N positional mappings, and arrays. | ||
| * We deliberately include undefined and null values since frontmatter | ||
| * parsed from YAML can contain these. | ||
| */ | ||
| const arbFrontmatter: fc.Arbitrary<AgentFrontmatter> = fc.dictionary( | ||
| // Keys: mix of plain flags, _template vars, and $N positionals | ||
| fc.oneof( | ||
| fc.stringMatching(/^[a-z][a-z0-9-]{0,10}$/), // plain flags: model, verbose, add-dir | ||
| fc.stringMatching(/^_[a-z][a-z0-9]{0,8}$/), // template vars: _name, _stdin | ||
| fc.constantFrom("$1", "$2", "$3"), // positional mappings | ||
| ), | ||
| // Values: the types that actually appear in frontmatter | ||
| fc.oneof( | ||
| fc.string(), | ||
| fc.integer(), | ||
| fc.boolean(), | ||
| fc.constant(null), | ||
| fc.constant(undefined), | ||
| fc.array(fc.string(), { maxLength: 3 }), | ||
| ), | ||
| ) as fc.Arbitrary<AgentFrontmatter>; | ||
|
|
||
| describe("concatFrontmatter monoid laws", () => { | ||
| it("right identity: concat(x, {}) ≡ x", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, (x) => { | ||
| const result = concatFrontmatter(x, {}); | ||
| expect(result).toEqual(x); | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
|
|
||
| it("left identity: concat({}, x) ≡ x", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, (x) => { | ||
| const result = concatFrontmatter({}, x); | ||
| expect(result).toEqual(x); | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
|
|
||
| it("right-bias: for any key in both a and b, result[k] === b[k]", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, arbFrontmatter, (a, b) => { | ||
| const result = concatFrontmatter(a, b); | ||
| for (const key of Object.keys(b)) { | ||
| expect(result[key]).toEqual(b[key]); | ||
| } | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
|
|
||
| it("associativity: concat(a, concat(b, c)) ≡ concat(concat(a, b), c)", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, arbFrontmatter, arbFrontmatter, (a, b, c) => { | ||
| const leftAssoc = concatFrontmatter(concatFrontmatter(a, b), c); | ||
| const rightAssoc = concatFrontmatter(a, concatFrontmatter(b, c)); | ||
| expect(leftAssoc).toEqual(rightAssoc); | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
|
|
||
| it("no mutation: neither input is modified", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, arbFrontmatter, (a, b) => { | ||
| // Deep clone inputs before the operation | ||
| const aCopy = JSON.parse(JSON.stringify(a)); | ||
| const bCopy = JSON.parse(JSON.stringify(b)); | ||
|
|
||
| concatFrontmatter(a, b); | ||
|
|
||
| // Inputs must be unchanged (compare via JSON since toEqual | ||
| // treats undefined values inconsistently across deep clone) | ||
| expect(JSON.stringify(a)).toEqual(JSON.stringify(aCopy)); | ||
| expect(JSON.stringify(b)).toEqual(JSON.stringify(bCopy)); | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
|
|
||
| it("base keys not in override are preserved", () => { | ||
| fc.assert( | ||
| fc.property(arbFrontmatter, arbFrontmatter, (a, b) => { | ||
| const result = concatFrontmatter(a, b); | ||
| for (const key of Object.keys(a)) { | ||
| if (!(key in b)) { | ||
| expect(result[key]).toEqual(a[key]); | ||
| } | ||
| } | ||
| }), | ||
| { numRuns: 200 }, | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.