From 1315c61bfeb3828773396dbf6d22355c4780ca7a Mon Sep 17 00:00:00 2001 From: Saeed Al Mansouri Date: Tue, 24 Mar 2026 23:59:52 +0400 Subject: [PATCH 1/2] feat(contract): add API schema drift detection (closes #50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add contract testing module that captures JSON response schemas, compares them against saved baselines, and reports structural drift (fields added, removed, or type-changed). New CLI subcommands: - `opencli contract snapshot ` — save baseline schema - `opencli contract check ` — diff against baseline - `opencli contract list` — list saved contracts Schema storage at ~/.opencli/contracts//.json. Includes 25 unit tests covering schema capture, diffing, formatting, and end-to-end drift detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli.ts | 118 +++++++++++++++ src/contract.test.ts | 320 +++++++++++++++++++++++++++++++++++++++++ src/contract.ts | 333 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 771 insertions(+) create mode 100644 src/contract.test.ts create mode 100644 src/contract.ts diff --git a/src/cli.ts b/src/cli.ts index 71f409f5..73d514d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -246,6 +246,124 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void { printCompletionScript(shell); }); + // ── Built-in: contract (API schema drift detection) ────────────────────── + + const contractCmd = program.command('contract').description('API schema drift detection'); + + contractCmd + .command('snapshot') + .description('Run a command and save its response schema as baseline') + .argument('', 'Site name (e.g. hackernews)') + .argument('', 'Command name (e.g. top)') + .argument('[args...]', 'Extra arguments forwarded to the command') + .action(async (site: string, command: string, extraArgs: string[]) => { + const { captureSchema, saveContract, formatSchemaTree } = await import('./contract.js'); + const { getRegistry } = await import('./registry.js'); + const { executeCommand } = await import('./execution.js'); + + const key = `${site}/${command}`; + const cmd = getRegistry().get(key); + if (!cmd) { + console.error(chalk.red(`Command not found: ${key}`)); + process.exitCode = 1; + return; + } + + // Parse extra args as --key value pairs + const kwargs: Record = {}; + for (let i = 0; i < extraArgs.length; i++) { + const arg = extraArgs[i]; + if (arg.startsWith('--') && i + 1 < extraArgs.length) { + kwargs[arg.slice(2)] = extraArgs[++i]; + } + } + + try { + const result = await executeCommand(cmd, kwargs); + const schema = captureSchema(result); + const filePath = saveContract(site, command, schema); + console.log(chalk.green(`Schema snapshot saved: ${filePath}`)); + console.log(formatSchemaTree(schema)); + } catch (err: any) { + console.error(chalk.red(`Error executing ${key}: ${err.message}`)); + process.exitCode = 1; + } + }); + + contractCmd + .command('check') + .description('Run a command and diff its response schema against the saved baseline') + .argument('', 'Site name') + .argument('', 'Command name') + .argument('[args...]', 'Extra arguments forwarded to the command') + .action(async (site: string, command: string, extraArgs: string[]) => { + const { captureSchema, loadContract, diffSchema, formatDiff } = await import('./contract.js'); + const { getRegistry } = await import('./registry.js'); + const { executeCommand } = await import('./execution.js'); + + const key = `${site}/${command}`; + const cmd = getRegistry().get(key); + if (!cmd) { + console.error(chalk.red(`Command not found: ${key}`)); + process.exitCode = 1; + return; + } + + const baseline = loadContract(site, command); + if (!baseline) { + console.error(chalk.red(`No baseline found for ${key}. Run 'opencli contract snapshot ${site} ${command}' first.`)); + process.exitCode = 1; + return; + } + + const kwargs: Record = {}; + for (let i = 0; i < extraArgs.length; i++) { + const arg = extraArgs[i]; + if (arg.startsWith('--') && i + 1 < extraArgs.length) { + kwargs[arg.slice(2)] = extraArgs[++i]; + } + } + + try { + const result = await executeCommand(cmd, kwargs); + const currentSchema = captureSchema(result); + const diffs = diffSchema(baseline.schema, currentSchema); + + if (diffs.length === 0) { + console.log(chalk.green(`No schema drift detected for ${key} (baseline from ${baseline.capturedAt})`)); + } else { + console.log(chalk.yellow(formatDiff(diffs))); + console.log(); + console.log(chalk.dim(`Baseline captured: ${baseline.capturedAt}`)); + process.exitCode = 1; + } + } catch (err: any) { + console.error(chalk.red(`Error executing ${key}: ${err.message}`)); + process.exitCode = 1; + } + }); + + contractCmd + .command('list') + .description('List saved contract baselines') + .action(async () => { + const { listContracts } = await import('./contract.js'); + const contracts = listContracts(); + if (contracts.length === 0) { + console.log(chalk.dim(' No saved contracts. Use "opencli contract snapshot " to create one.')); + return; + } + console.log(); + console.log(chalk.bold(' Saved contract baselines')); + console.log(); + for (const c of contracts) { + console.log(` ${chalk.cyan(`${c.site}/${c.command}`)} ${chalk.dim(`captured ${c.capturedAt}`)}`); + } + console.log(); + console.log(chalk.dim(` ${contracts.length} contract(s)`)); + console.log(); + }); + // ── Plugin management ────────────────────────────────────────────────────── const pluginCmd = program.command('plugin').description('Manage opencli plugins'); diff --git a/src/contract.test.ts b/src/contract.test.ts new file mode 100644 index 00000000..cd8e8f07 --- /dev/null +++ b/src/contract.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for API schema drift detection (contract.ts). + */ + +import { describe, it, expect } from 'vitest'; +import { captureSchema, diffSchema, formatDiff, type Schema, type SchemaDiff } from './contract.js'; + +// ── captureSchema ──────────────────────────────────────────────────────────── + +describe('captureSchema', () => { + it('captures primitives', () => { + expect(captureSchema('hello')).toEqual({ type: 'string' }); + expect(captureSchema(42)).toEqual({ type: 'number' }); + expect(captureSchema(true)).toEqual({ type: 'boolean' }); + expect(captureSchema(null)).toEqual({ type: 'null' }); + expect(captureSchema(undefined)).toEqual({ type: 'null' }); + }); + + it('captures flat object', () => { + const schema = captureSchema({ id: 1, name: 'test', active: true }); + expect(schema.type).toBe('object'); + expect(schema.properties).toEqual({ + id: { type: 'number' }, + name: { type: 'string' }, + active: { type: 'boolean' }, + }); + }); + + it('captures nested object', () => { + const schema = captureSchema({ user: { name: 'alice', age: 30 } }); + expect(schema.type).toBe('object'); + expect(schema.properties!.user).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }); + }); + + it('captures empty array', () => { + const schema = captureSchema([]); + expect(schema).toEqual({ type: 'array', items: { type: 'unknown' } }); + }); + + it('captures array of primitives', () => { + const schema = captureSchema([1, 2, 3]); + expect(schema).toEqual({ type: 'array', items: { type: 'number' } }); + }); + + it('captures array of objects and merges keys', () => { + const data = [ + { id: 1, title: 'first' }, + { id: 2, title: 'second', extra: true }, + ]; + const schema = captureSchema(data); + expect(schema.type).toBe('array'); + expect(schema.items!.type).toBe('object'); + // Merged schema includes keys from all elements + expect(Object.keys(schema.items!.properties!).sort()).toEqual(['extra', 'id', 'title']); + }); + + it('captures nested arrays', () => { + const schema = captureSchema({ tags: ['a', 'b'] }); + expect(schema.type).toBe('object'); + expect(schema.properties!.tags).toEqual({ + type: 'array', + items: { type: 'string' }, + }); + }); + + it('captures object with null field', () => { + const schema = captureSchema({ id: 1, avatar: null }); + expect(schema.properties!.avatar).toEqual({ type: 'null' }); + }); +}); + +// ── diffSchema ─────────────────────────────────────────────────────────────── + +describe('diffSchema', () => { + it('returns empty array for identical schemas', () => { + const schema: Schema = { + type: 'object', + properties: { + id: { type: 'number' }, + name: { type: 'string' }, + }, + }; + expect(diffSchema(schema, schema)).toEqual([]); + }); + + it('detects top-level type change', () => { + const baseline: Schema = { type: 'object', properties: {} }; + const current: Schema = { type: 'array', items: { type: 'string' } }; + const diffs = diffSchema(baseline, current); + expect(diffs).toEqual([ + { path: '(root)', kind: 'type-changed', baseline: 'object', current: 'array' }, + ]); + }); + + it('detects added field', () => { + const baseline: Schema = { + type: 'object', + properties: { id: { type: 'number' } }, + }; + const current: Schema = { + type: 'object', + properties: { id: { type: 'number' }, newField: { type: 'string' } }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toHaveLength(1); + expect(diffs[0]).toEqual({ path: 'newField', kind: 'added', current: 'string' }); + }); + + it('detects removed field', () => { + const baseline: Schema = { + type: 'object', + properties: { id: { type: 'number' }, removed: { type: 'boolean' } }, + }; + const current: Schema = { + type: 'object', + properties: { id: { type: 'number' } }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toHaveLength(1); + expect(diffs[0]).toEqual({ path: 'removed', kind: 'removed', baseline: 'boolean' }); + }); + + it('detects type change on a field', () => { + const baseline: Schema = { + type: 'object', + properties: { count: { type: 'number' } }, + }; + const current: Schema = { + type: 'object', + properties: { count: { type: 'string' } }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toEqual([ + { path: 'count', kind: 'type-changed', baseline: 'number', current: 'string' }, + ]); + }); + + it('detects nested field changes', () => { + const baseline: Schema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + }; + const current: Schema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + // email removed, phone added + phone: { type: 'string' }, + }, + }, + }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toHaveLength(2); + const paths = diffs.map(d => d.path); + expect(paths).toContain('user.email'); + expect(paths).toContain('user.phone'); + expect(diffs.find(d => d.path === 'user.email')!.kind).toBe('removed'); + expect(diffs.find(d => d.path === 'user.phone')!.kind).toBe('added'); + }); + + it('detects array item schema changes', () => { + const baseline: Schema = { + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'number' }, title: { type: 'string' } }, + }, + }; + const current: Schema = { + type: 'array', + items: { + type: 'object', + properties: { id: { type: 'string' }, title: { type: 'string' } }, + }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toEqual([ + { path: '[].id', kind: 'type-changed', baseline: 'number', current: 'string' }, + ]); + }); + + it('handles nested array inside object', () => { + const baseline: Schema = { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' } }, + }, + }, + }, + }; + const current: Schema = { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { name: { type: 'string' }, score: { type: 'number' } }, + }, + }, + }, + }; + const diffs = diffSchema(baseline, current); + expect(diffs).toHaveLength(1); + expect(diffs[0]).toEqual({ path: 'data[].score', kind: 'added', current: 'number' }); + }); +}); + +// ── formatDiff ─────────────────────────────────────────────────────────────── + +describe('formatDiff', () => { + it('returns no-change message for empty diffs', () => { + expect(formatDiff([])).toBe('No schema changes detected.'); + }); + + it('formats added field', () => { + const diffs: SchemaDiff[] = [{ path: 'newField', kind: 'added', current: 'string' }]; + const output = formatDiff(diffs); + expect(output).toContain('1 schema change(s)'); + expect(output).toContain('+ newField'); + expect(output).toContain('field added'); + }); + + it('formats removed field', () => { + const diffs: SchemaDiff[] = [{ path: 'oldField', kind: 'removed', baseline: 'number' }]; + const output = formatDiff(diffs); + expect(output).toContain('- oldField'); + expect(output).toContain('was number'); + expect(output).toContain('field removed'); + }); + + it('formats type change', () => { + const diffs: SchemaDiff[] = [ + { path: 'count', kind: 'type-changed', baseline: 'number', current: 'string' }, + ]; + const output = formatDiff(diffs); + expect(output).toContain('~ count'); + expect(output).toContain('number -> string'); + expect(output).toContain('type changed'); + }); + + it('formats multiple diffs', () => { + const diffs: SchemaDiff[] = [ + { path: 'a', kind: 'added', current: 'string' }, + { path: 'b', kind: 'removed', baseline: 'number' }, + { path: 'c', kind: 'type-changed', baseline: 'boolean', current: 'string' }, + ]; + const output = formatDiff(diffs); + expect(output).toContain('3 schema change(s)'); + }); +}); + +// ── End-to-end: captureSchema → diffSchema ────────────────────────────────── + +describe('end-to-end drift detection', () => { + it('detects when an API adds a field', () => { + const v1 = { data: [{ id: 1, title: 'hello' }] }; + const v2 = { data: [{ id: 1, title: 'hello', slug: 'hello-1' }] }; + + const baselineSchema = captureSchema(v1); + const currentSchema = captureSchema(v2); + const diffs = diffSchema(baselineSchema, currentSchema); + + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe('data[].slug'); + expect(diffs[0].kind).toBe('added'); + }); + + it('detects when an API changes a field type', () => { + const v1 = { count: 42, items: [{ id: 1 }] }; + const v2 = { count: '42', items: [{ id: 1 }] }; + + const diffs = diffSchema(captureSchema(v1), captureSchema(v2)); + + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe('count'); + expect(diffs[0].kind).toBe('type-changed'); + expect(diffs[0].baseline).toBe('number'); + expect(diffs[0].current).toBe('string'); + }); + + it('detects when an API removes a field', () => { + const v1 = { meta: { page: 1, total: 100 } }; + const v2 = { meta: { page: 1 } }; + + const diffs = diffSchema(captureSchema(v1), captureSchema(v2)); + + expect(diffs).toHaveLength(1); + expect(diffs[0].path).toBe('meta.total'); + expect(diffs[0].kind).toBe('removed'); + }); + + it('reports no drift for identical responses', () => { + const response = { status: 'ok', data: [{ id: 1, name: 'a' }] }; + const diffs = diffSchema(captureSchema(response), captureSchema(response)); + expect(diffs).toHaveLength(0); + }); +}); diff --git a/src/contract.ts b/src/contract.ts new file mode 100644 index 00000000..016e3ae9 --- /dev/null +++ b/src/contract.ts @@ -0,0 +1,333 @@ +/** + * API schema drift detection. + * + * Captures the structural schema of a JSON response, compares it against + * a saved baseline, and reports differences (fields added, removed, or + * type-changed). Used by `opencli contract` to detect when upstream APIs + * silently change their response shape. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// ── Schema types ───────────────────────────────────────────────────────────── + +export type SchemaType = + | 'string' + | 'number' + | 'boolean' + | 'null' + | 'object' + | 'array' + | 'unknown'; + +export interface Schema { + type: SchemaType; + /** For objects: keys → child schemas */ + properties?: Record; + /** For arrays: schema of the representative element (union of all elements) */ + items?: Schema; +} + +export interface SchemaDiff { + path: string; + kind: 'added' | 'removed' | 'type-changed' | 'items-changed'; + baseline?: string; + current?: string; +} + +// ── Schema capture ─────────────────────────────────────────────────────────── + +/** + * Recursively traverse a JSON value and extract its structural schema. + * Arrays are represented by merging all element schemas into one. + */ +export function captureSchema(data: unknown): Schema { + if (data === null || data === undefined) { + return { type: 'null' }; + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return { type: 'array', items: { type: 'unknown' } }; + } + // Merge schemas of all elements to capture the "union" element type. + let merged = captureSchema(data[0]); + for (let i = 1; i < data.length; i++) { + merged = mergeSchemas(merged, captureSchema(data[i])); + } + return { type: 'array', items: merged }; + } + + if (typeof data === 'object') { + const properties: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + properties[key] = captureSchema(value); + } + return { type: 'object', properties }; + } + + if (typeof data === 'string') return { type: 'string' }; + if (typeof data === 'number') return { type: 'number' }; + if (typeof data === 'boolean') return { type: 'boolean' }; + + return { type: 'unknown' }; +} + +/** + * Merge two schemas: if both are objects merge their properties; + * if types differ, prefer the first but keep the broader shape. + */ +function mergeSchemas(a: Schema, b: Schema): Schema { + // Same primitive type — nothing to merge + if (a.type === b.type && a.type !== 'object' && a.type !== 'array') { + return a; + } + + // Both objects — merge property sets + if (a.type === 'object' && b.type === 'object') { + const merged: Record = { ...a.properties }; + for (const [key, schema] of Object.entries(b.properties ?? {})) { + if (merged[key]) { + merged[key] = mergeSchemas(merged[key], schema); + } else { + merged[key] = schema; + } + } + return { type: 'object', properties: merged }; + } + + // Both arrays — merge their item schemas + if (a.type === 'array' && b.type === 'array') { + const aItems = a.items ?? { type: 'unknown' as SchemaType }; + const bItems = b.items ?? { type: 'unknown' as SchemaType }; + return { type: 'array', items: mergeSchemas(aItems, bItems) }; + } + + // Type mismatch — return first (baseline wins) + return a; +} + +// ── Schema diff ────────────────────────────────────────────────────────────── + +/** + * Compare a baseline schema against a current schema. + * Returns a list of differences. + */ +export function diffSchema(baseline: Schema, current: Schema, prefix = ''): SchemaDiff[] { + const diffs: SchemaDiff[] = []; + const currentPath = prefix || '(root)'; + + // Top-level type change + if (baseline.type !== current.type) { + diffs.push({ + path: currentPath, + kind: 'type-changed', + baseline: baseline.type, + current: current.type, + }); + return diffs; + } + + // Object comparison: check for added/removed/changed properties + if (baseline.type === 'object' && current.type === 'object') { + const baseKeys = new Set(Object.keys(baseline.properties ?? {})); + const curKeys = new Set(Object.keys(current.properties ?? {})); + + // Removed fields + for (const key of baseKeys) { + if (!curKeys.has(key)) { + diffs.push({ + path: prefix ? `${prefix}.${key}` : key, + kind: 'removed', + baseline: (baseline.properties![key]).type, + }); + } + } + + // Added fields + for (const key of curKeys) { + if (!baseKeys.has(key)) { + diffs.push({ + path: prefix ? `${prefix}.${key}` : key, + kind: 'added', + current: (current.properties![key]).type, + }); + } + } + + // Recurse into shared fields + for (const key of baseKeys) { + if (curKeys.has(key)) { + const childPath = prefix ? `${prefix}.${key}` : key; + diffs.push( + ...diffSchema( + baseline.properties![key], + current.properties![key], + childPath, + ), + ); + } + } + } + + // Array comparison: diff item schemas + if (baseline.type === 'array' && current.type === 'array') { + const baseItems = baseline.items ?? { type: 'unknown' as SchemaType }; + const curItems = current.items ?? { type: 'unknown' as SchemaType }; + const itemPath = prefix ? `${prefix}[]` : '[]'; + diffs.push(...diffSchema(baseItems, curItems, itemPath)); + } + + return diffs; +} + +// ── Human-readable diff formatting ────────────────────────────────────────── + +/** + * Format a list of diffs into a human-readable string. + */ +export function formatDiff(diffs: SchemaDiff[]): string { + if (diffs.length === 0) { + return 'No schema changes detected.'; + } + + const lines: string[] = [`${diffs.length} schema change(s) detected:`, '']; + + for (const d of diffs) { + switch (d.kind) { + case 'added': + lines.push(` + ${d.path} (${d.current}) — field added`); + break; + case 'removed': + lines.push(` - ${d.path} (was ${d.baseline}) — field removed`); + break; + case 'type-changed': + lines.push(` ~ ${d.path} ${d.baseline} -> ${d.current} — type changed`); + break; + case 'items-changed': + lines.push(` ~ ${d.path} items changed`); + break; + } + } + + return lines.join('\n'); +} + +// ── Schema tree display ───────────────────────────────────────────────────── + +/** + * Format a schema as a readable tree for terminal output. + */ +export function formatSchemaTree(schema: Schema, indent = ''): string { + const lines: string[] = []; + + if (schema.type === 'object' && schema.properties) { + if (indent === '') lines.push(`${indent}(object)`); + const keys = Object.keys(schema.properties); + for (const key of keys) { + const child = schema.properties[key]; + if (child.type === 'object' && child.properties) { + lines.push(`${indent} ${key}: (object)`); + lines.push(formatSchemaTree(child, indent + ' ')); + } else if (child.type === 'array') { + const itemType = child.items?.type ?? 'unknown'; + if (child.items?.type === 'object' && child.items.properties) { + lines.push(`${indent} ${key}: (array of object)`); + lines.push(formatSchemaTree(child.items, indent + ' ')); + } else { + lines.push(`${indent} ${key}: (array of ${itemType})`); + } + } else { + lines.push(`${indent} ${key}: ${child.type}`); + } + } + } else if (schema.type === 'array' && schema.items) { + lines.push(`${indent}(array of ${schema.items.type})`); + if (schema.items.type === 'object') { + lines.push(formatSchemaTree(schema.items, indent)); + } + } else { + lines.push(`${indent}(${schema.type})`); + } + + return lines.join('\n'); +} + +// ── Contract storage ──────────────────────────────────────────────────────── + +function contractDir(site: string): string { + return path.join(os.homedir(), '.opencli', 'contracts', site); +} + +function contractPath(site: string, command: string): string { + return path.join(contractDir(site), `${command}.json`); +} + +export interface ContractFile { + site: string; + command: string; + schema: Schema; + capturedAt: string; +} + +/** + * Save a schema snapshot to disk. + */ +export function saveContract(site: string, command: string, schema: Schema): string { + const dir = contractDir(site); + fs.mkdirSync(dir, { recursive: true }); + const filePath = contractPath(site, command); + const data: ContractFile = { + site, + command, + schema, + capturedAt: new Date().toISOString(), + }; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n'); + return filePath; +} + +/** + * Load a saved contract, or null if none exists. + */ +export function loadContract(site: string, command: string): ContractFile | null { + const filePath = contractPath(site, command); + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as ContractFile; +} + +/** + * List all saved contracts. Returns an array of { site, command, capturedAt }. + */ +export function listContracts(): Array<{ site: string; command: string; capturedAt: string }> { + const baseDir = path.join(os.homedir(), '.opencli', 'contracts'); + if (!fs.existsSync(baseDir)) return []; + + const results: Array<{ site: string; command: string; capturedAt: string }> = []; + const sites = fs.readdirSync(baseDir, { withFileTypes: true }); + + for (const entry of sites) { + if (!entry.isDirectory()) continue; + const site = entry.name; + const siteDir = path.join(baseDir, site); + const files = fs.readdirSync(siteDir).filter(f => f.endsWith('.json')); + for (const file of files) { + try { + const raw = fs.readFileSync(path.join(siteDir, file), 'utf-8'); + const contract = JSON.parse(raw) as ContractFile; + results.push({ + site: contract.site, + command: contract.command, + capturedAt: contract.capturedAt, + }); + } catch { + // Skip malformed files + } + } + } + + return results; +} From c0fe73bdd4a1db6a4009cbc848346fbc9eac7332 Mon Sep 17 00:00:00 2001 From: Saeed Al Mansouri Date: Fri, 27 Mar 2026 06:21:33 +0000 Subject: [PATCH 2/2] fix(contract): add explicit string type annotation in readdirSync filter https://claude.ai/code/session_01HzXGpCjETH2cS779dXW7U8 --- src/contract.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/contract.ts b/src/contract.ts index 016e3ae9..010783fb 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -313,7 +313,7 @@ export function listContracts(): Array<{ site: string; command: string; captured if (!entry.isDirectory()) continue; const site = entry.name; const siteDir = path.join(baseDir, site); - const files = fs.readdirSync(siteDir).filter(f => f.endsWith('.json')); + const files = fs.readdirSync(siteDir).filter((f: string) => f.endsWith('.json')); for (const file of files) { try { const raw = fs.readFileSync(path.join(siteDir, file), 'utf-8');