Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added bun.lockb
Binary file not shown.
56 changes: 56 additions & 0 deletions docs/ADR01.config-monad.md
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
Comment thread
cds-amal marked this conversation as resolved.
Outdated
- **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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 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
Verify each finding against the current code and only fix it if needed.

In `@docs/ADR01.config-monad.md` around lines 11 - 13, The fenced code block
containing "built-in ⊕ global ⊕ git-root ⊕ cwd ⊕ frontmatter ⊕ CLI flags" is
missing a language tag; update the opening triple-backtick fence to include a
language (e.g., use ```text) so the block is marked as text and the MD040
markdownlint warning is resolved.


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).
Comment thread
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`
75 changes: 75 additions & 0 deletions docs/mutation-proof.md
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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@types/bun": "latest",
"@types/js-yaml": "^4.0.9",
"@types/mdast": "^4.0.4",
"fast-check": "^4.4.0",
"fast-check": "^4.6.0",
"semantic-release": "^25.0.2"
},
"peerDependencies": {
Expand Down
9 changes: 5 additions & 4 deletions src/command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type { AgentFrontmatter } from "./types";
import type { GlobalConfig } from "./config";
import { concatFrontmatter } from "./config";

/**
* Specification for a command to be executed
Expand Down Expand Up @@ -279,9 +280,9 @@ export function buildCommand(
config: GlobalConfig,
cwd: string = process.cwd()
): CommandSpec {
// Apply command defaults from config
// Apply command defaults from config (defaults ⊕ frontmatter; frontmatter wins)
const defaults = getCommandDefaultsFromConfig(command, config);
const mergedFrontmatter = { ...defaults, ...frontmatter } as AgentFrontmatter;
const mergedFrontmatter = concatFrontmatter(defaults as AgentFrontmatter, frontmatter);

// Build base args from frontmatter
const baseArgs = buildArgsFromFrontmatter(mergedFrontmatter, templateVars);
Expand Down Expand Up @@ -329,9 +330,9 @@ export function buildCommandBase(
config: GlobalConfig,
cwd: string = process.cwd()
): CommandSpec {
// Apply command defaults from config
// Apply command defaults from config (defaults ⊕ frontmatter; frontmatter wins)
const defaults = getCommandDefaultsFromConfig(command, config);
const mergedFrontmatter = { ...defaults, ...frontmatter } as AgentFrontmatter;
const mergedFrontmatter = concatFrontmatter(defaults as AgentFrontmatter, frontmatter);

// Build base args from frontmatter
const args = buildArgsFromFrontmatter(mergedFrontmatter, templateVars);
Expand Down
118 changes: 118 additions & 0 deletions src/config-monoid.test.ts
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 },
);
});
});
19 changes: 3 additions & 16 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import {
loadGlobalConfig,
getCommandDefaults,
applyDefaults,
applyInteractiveMode,
clearConfigCache,
findGitRoot,
Expand Down Expand Up @@ -40,21 +39,9 @@ describe("config", () => {
expect(defaults).toBeUndefined();
});

test("applyDefaults merges defaults with frontmatter (frontmatter wins)", () => {
const frontmatter = { model: "opus", $1: "custom" };
const defaults = { $1: "prompt", verbose: true };
const result = applyDefaults(frontmatter, defaults);

expect(result.model).toBe("opus");
expect(result.$1).toBe("custom"); // frontmatter wins
expect(result.verbose).toBe(true); // default applied
});

test("applyDefaults returns frontmatter unchanged when no defaults", () => {
const frontmatter = { model: "opus" };
const result = applyDefaults(frontmatter, undefined);
expect(result).toEqual(frontmatter);
});
// NOTE: Pure merge-logic tests (identity, right-bias, associativity) have been
// replaced by property-based tests in config-monoid.test.ts. See docs/mutation-proof.md
// for the equivalence proof showing property tests catch a strict superset of bugs.
});

describe("findGitRoot", () => {
Expand Down
Loading