Skip to content

Commit 9effabd

Browse files
committed
docs: add planning and architecture documentation
Add comprehensive planning documents and specifications: Planning documents: - diff-command-plans/: Implementation plans for diff command refactoring - Agent-based planning approach for codebase exploration, e2e testing, and documentation Architecture and specifications: - High-level design (HLD) and context documents - CLI specification and command structure - Orchestrator and agent plans (research, CLI core, governance engines) - GitHub Action agent implementation plan - E2E testing strategy - Documentation website plans (v1 and v2) - differs.json-schema package specification - Ecosystem analysis report These documents provide the foundation for the Contractual architecture and guide future development efforts.
1 parent 4963cb0 commit 9effabd

17 files changed

Lines changed: 12214 additions & 0 deletions
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
# Codebase Agent: Add `contractual diff` Command
2+
3+
## Context
4+
5+
Add a new `diff` command that shows all classified changes between schema versions. The command hierarchy becomes:
6+
7+
```
8+
diff → "Show me everything that changed, classified"
9+
breaking → "Are there breaking changes? Block if yes" (uses diff internally)
10+
changeset → "Generate a changeset from changes" (uses diff internally)
11+
```
12+
13+
`diff` is the primitive. `breaking` and `changeset` are opinions on top of it.
14+
15+
---
16+
17+
## Tasks
18+
19+
### Task 1: Extract shared diffing logic
20+
21+
**Create:** `packages/cli/src/core/diff.ts`
22+
23+
Currently, the diffing logic lives inside `breaking.command.ts`. Extract it into a shared module.
24+
25+
```typescript
26+
import type { ResolvedConfig, DiffResult } from '@contractual/types';
27+
28+
export interface DiffOptions {
29+
contracts?: string[]; // filter to specific contracts
30+
}
31+
32+
/**
33+
* Core diffing function. Runs the appropriate differ for each contract,
34+
* comparing current spec against last versioned snapshot.
35+
*
36+
* Returns classified changes for all contracts.
37+
* This is the primitive that `diff`, `breaking`, and `changeset` all use.
38+
*/
39+
export async function diffContracts(
40+
config: ResolvedConfig,
41+
options?: DiffOptions
42+
): Promise<DiffResult[]> {
43+
// 1. Filter to requested contracts (or all)
44+
// 2. For each contract:
45+
// a. Find snapshot (getSnapshotPath)
46+
// b. If no snapshot: return empty DiffResult (first version, no changes)
47+
// c. If breaking === false: skip (disabled)
48+
// d. Resolve differ (getDiffer from governance)
49+
// e. Run differ: snapshot (old) vs current spec (new)
50+
// f. Collect DiffResult with contract name
51+
// 3. Return DiffResult[]
52+
}
53+
```
54+
55+
---
56+
57+
### Task 2: Create diff command
58+
59+
**Create:** `packages/cli/src/commands/diff.command.ts`
60+
61+
```typescript
62+
import { Command } from 'commander';
63+
import chalk from 'chalk';
64+
import { loadConfig } from '../config/index.js';
65+
import { diffContracts } from '../core/diff.js';
66+
import { formatDiffText, formatDiffJson } from '../formatters/diff.js';
67+
68+
export function diffCommand(): Command {
69+
return new Command('diff')
70+
.description('Show all changes between current specs and last versioned snapshots')
71+
.option('--contract <name>', 'Diff only one contract')
72+
.option('--format <format>', 'Output format: text, json', 'text')
73+
.option('--severity <level>', 'Filter: all, breaking, non-breaking, patch', 'all')
74+
.option('--verbose', 'Show JSON Pointer paths for each change')
75+
.action(async (options) => {
76+
try {
77+
const config = loadConfig();
78+
const results = await diffContracts(config, {
79+
contracts: options.contract ? [options.contract] : undefined,
80+
});
81+
82+
// Apply severity filter
83+
const filtered = filterBySeverity(results, options.severity);
84+
85+
if (options.format === 'json') {
86+
console.log(formatDiffJson(filtered));
87+
} else {
88+
formatDiffText(filtered, { verbose: options.verbose });
89+
}
90+
91+
// diff always exits 0 on success
92+
process.exitCode = 0;
93+
} catch (error) {
94+
const message = error instanceof Error ? error.message : 'Unknown error';
95+
console.error(chalk.red('Error:'), message);
96+
process.exitCode = 2; // config error
97+
}
98+
});
99+
}
100+
101+
function filterBySeverity(results: DiffResult[], severity: string): DiffResult[] {
102+
if (severity === 'all') return results;
103+
104+
return results.map(r => {
105+
const filteredChanges = r.changes.filter(c => c.severity === severity);
106+
return {
107+
...r,
108+
changes: filteredChanges,
109+
summary: recalculateSummary(filteredChanges),
110+
};
111+
});
112+
}
113+
114+
function recalculateSummary(changes: Change[]): DiffSummary {
115+
return {
116+
breaking: changes.filter(c => c.severity === 'breaking').length,
117+
nonBreaking: changes.filter(c => c.severity === 'non-breaking').length,
118+
patch: changes.filter(c => c.severity === 'patch').length,
119+
unknown: changes.filter(c => c.severity === 'unknown').length,
120+
};
121+
}
122+
```
123+
124+
**Exit codes:**
125+
- 0: Success (always, regardless of changes found)
126+
- 2: Configuration error
127+
- 3: Tool execution error
128+
129+
---
130+
131+
### Task 3: Create diff formatter
132+
133+
**Create:** `packages/cli/src/formatters/diff.ts`
134+
135+
```typescript
136+
import chalk from 'chalk';
137+
import type { DiffResult, Change, ChangeSeverity } from '@contractual/types';
138+
139+
export interface FormatOptions {
140+
verbose?: boolean;
141+
}
142+
143+
export function formatDiffText(results: DiffResult[], options: FormatOptions = {}): void {
144+
for (const result of results) {
145+
if (result.changes.length === 0) {
146+
console.log(`${chalk.cyan(result.contract)}: ${chalk.dim('no changes')}`);
147+
console.log();
148+
continue;
149+
}
150+
151+
// Header: contract name + summary
152+
const parts: string[] = [];
153+
if (result.summary.breaking > 0) parts.push(`${result.summary.breaking} breaking`);
154+
if (result.summary.nonBreaking > 0) parts.push(`${result.summary.nonBreaking} non-breaking`);
155+
if (result.summary.patch > 0) parts.push(`${result.summary.patch} patch`);
156+
if (result.summary.unknown > 0) parts.push(`${result.summary.unknown} unknown`);
157+
158+
const bumpColor = result.suggestedBump === 'major' ? chalk.red
159+
: result.suggestedBump === 'minor' ? chalk.yellow
160+
: chalk.green;
161+
162+
console.log(
163+
`${chalk.cyan(result.contract)}: ${result.changes.length} changes (${parts.join(', ')}) — suggested bump: ${bumpColor(result.suggestedBump)}`
164+
);
165+
console.log();
166+
167+
// Each change
168+
for (const change of result.changes) {
169+
const label = formatSeverityLabel(change.severity);
170+
console.log(` ${label} ${change.message}`);
171+
if (options.verbose && change.path) {
172+
console.log(`${' '.repeat(14)}path: ${chalk.dim(change.path)}`);
173+
}
174+
}
175+
console.log();
176+
}
177+
}
178+
179+
function formatSeverityLabel(severity: ChangeSeverity): string {
180+
switch (severity) {
181+
case 'breaking':
182+
return chalk.red.bold('BREAKING'.padEnd(12));
183+
case 'non-breaking':
184+
return chalk.yellow('non-breaking');
185+
case 'patch':
186+
return chalk.green('patch'.padEnd(12));
187+
case 'unknown':
188+
return chalk.gray('unknown'.padEnd(12));
189+
}
190+
}
191+
192+
export function formatDiffJson(results: DiffResult[]): string {
193+
return JSON.stringify({ results }, null, 2);
194+
}
195+
```
196+
197+
**Expected output:**
198+
```
199+
orders-api: 3 changes (2 breaking, 1 non-breaking) — suggested bump: major
200+
201+
BREAKING Removed endpoint GET /orders/{id}/details
202+
path: /paths/~1orders~1{id}~1details/get
203+
BREAKING Changed type of field 'amount': string → number
204+
path: /components/schemas/Order/properties/amount/type
205+
non-breaking Added optional field 'tracking_url'
206+
path: /components/schemas/Order/properties/tracking_url
207+
208+
order-schema: no changes
209+
```
210+
211+
---
212+
213+
### Task 4: Refactor breaking command
214+
215+
**Modify:** `packages/cli/src/commands/breaking.command.ts`
216+
217+
Remove the inline diffing logic. Replace with:
218+
219+
```typescript
220+
import { diffContracts } from '../core/diff.js';
221+
222+
// In the action handler:
223+
const results = await diffContracts(config, {
224+
contracts: options.contract ? [options.contract] : undefined,
225+
});
226+
227+
const hasBreaking = results.some(r => r.summary.breaking > 0);
228+
229+
// ... existing output formatting ...
230+
231+
// Exit code logic stays here:
232+
if (failOn === 'breaking' && hasBreaking) {
233+
process.exitCode = 1;
234+
} else if (failOn === 'non-breaking' && results.some(r => r.summary.nonBreaking > 0)) {
235+
process.exitCode = 1;
236+
} else if (failOn === 'any' && results.some(r => r.changes.length > 0)) {
237+
process.exitCode = 1;
238+
}
239+
```
240+
241+
The output format of `breaking` stays the same. The only change is internal — it calls `diffContracts()` instead of having its own diffing code.
242+
243+
---
244+
245+
### Task 5: Refactor changeset command
246+
247+
**Modify:** `packages/cli/src/commands/changeset.command.ts`
248+
249+
Replace the inline diff logic with:
250+
251+
```typescript
252+
import { diffContracts } from '../core/diff.js';
253+
254+
// In the action handler, replace the diff loop with:
255+
const diffResults = await diffContracts(config);
256+
257+
// Filter to only results with changes
258+
const resultsWithChanges = diffResults.filter(r => r.changes.length > 0);
259+
260+
if (resultsWithChanges.length === 0) {
261+
diffSpinner.succeed('No changes detected');
262+
console.log(chalk.gray('No changeset created.'));
263+
process.exit(0);
264+
}
265+
266+
// ... rest of changeset generation uses resultsWithChanges ...
267+
```
268+
269+
---
270+
271+
### Task 6: Register diff command
272+
273+
**Modify:** `packages/cli/src/commands.ts`
274+
275+
Add `diff` between `lint` and `breaking`:
276+
277+
```typescript
278+
import { diffCommand } from './commands/diff.command.js';
279+
280+
// In program setup, after lint, before breaking:
281+
program.addCommand(diffCommand());
282+
```
283+
284+
Command order should be:
285+
1. init
286+
2. lint
287+
3. diff (NEW)
288+
4. breaking
289+
5. changeset
290+
6. version
291+
7. status
292+
293+
---
294+
295+
## File Structure After Implementation
296+
297+
```
298+
packages/cli/src/
299+
├── core/
300+
│ └── diff.ts # NEW - shared diffing logic
301+
├── commands/
302+
│ ├── init.command.ts
303+
│ ├── lint.command.ts
304+
│ ├── diff.command.ts # NEW
305+
│ ├── breaking.command.ts # MODIFIED
306+
│ ├── changeset.command.ts # MODIFIED
307+
│ ├── version.command.ts
308+
│ └── status.command.ts
309+
├── formatters/
310+
│ └── diff.ts # NEW
311+
└── commands.ts # MODIFIED
312+
```
313+
314+
---
315+
316+
## Verification
317+
318+
1. `pnpm build` - All packages build
319+
2. `node packages/cli/dist/commands.js diff --help` - Shows options
320+
3. `node packages/cli/dist/commands.js diff` - Shows changes or "no changes"
321+
4. `node packages/cli/dist/commands.js diff --format json` - Valid JSON
322+
5. `node packages/cli/dist/commands.js breaking` - Still works as before
323+
6. `node packages/cli/dist/commands.js changeset` - Still works as before

0 commit comments

Comments
 (0)