diff --git a/README.md b/README.md index e914558..1ee4070 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Both `opengap` and `gitagent` refer to the same binary — use whichever you pre |---------|-------------| | `opengap init [--template]` | Scaffold new agent (`minimal`, `standard`, `full`, `llm-wiki`) | | `opengap validate [--compliance]` | Validate against spec and regulatory requirements | +| `opengap diff [from] [to]` | Semantic diff between two agent versions (git refs or directories) | | `opengap info` | Display agent summary | | `opengap export --format ` | Export to other formats (see adapters below) | | `opengap import --from ` | Import (`claude`, `cursor`, `crewai`, `opencode`) | diff --git a/docs.md b/docs.md index e07246b..51d33c2 100644 --- a/docs.md +++ b/docs.md @@ -555,6 +555,52 @@ opengap audit -d ./examples/full --- +### diff + +Show a semantic diff between two agent versions — what changed in `agent.yaml`, `SOUL.md`, `RULES.md`, `DUTIES.md`, skills, tools, workflows, compliance, hooks, and memory, instead of a raw text diff. + +```bash +opengap diff [from] [to] [options] +``` + +| Argument/Option | Default | Description | +|------------------|---------|-------------| +| `from` | `HEAD` | Git ref or directory to compare from | +| `to` | working directory | Git ref or directory to compare to | +| `-d, --dir ` | `.` | Repository/agent directory used to resolve git refs | +| `--json` | `false` | Output the structured diff as JSON instead of formatted text | + +Each side of the comparison can be a git ref (commit, branch, or tag — resolved against `--dir` via `git archive`) or a path to a standalone agent directory. Passing a single `from..to` argument is also accepted as shorthand for two separate refs. + +**What gets reported:** + +- **Identity (SOUL.md)** — presence + added/removed line counts (no NLP, just a size signal) +- **Manifest (agent.yaml)** — every changed field (name, version, model, runtime, dependencies, mcp_servers, tags, metadata, ...), reported as `field.path: old → new` +- **Rules / Duties (RULES.md / DUTIES.md)** — added/removed line counts, plus segregation-of-duties conflict pairs added or removed +- **Skills / Tools / Workflows** — added, removed, and content-modified entries +- **Compliance** — `risk_tier` changes (flagged with `⚠ tier escalation` when the tier increases), framework additions/removals, and every other changed compliance field +- **Hooks** — added/removed hook entries, flagged with `⚠ enforcement added` when a new hook has `fail_open: false` +- **Memory** — which files under `memory/` changed, flagged `⚠ review needed` + +```bash +# What changed since the last commit +opengap diff + +# Compare two tagged releases +opengap diff v1.0.0 v1.1.0 + +# Compare two branches +opengap diff main..feature/new-skill + +# Compare two standalone agent directories +opengap diff ./agent-v1 ./agent-v2 + +# Machine-readable output for CI/PR bots +opengap diff v1.0.0 v1.1.0 --json +``` + +--- + ### skills Manage agent skills — search registries, install, list, and inspect. diff --git a/src/commands/diff.ts b/src/commands/diff.ts new file mode 100644 index 0000000..40c168b --- /dev/null +++ b/src/commands/diff.ts @@ -0,0 +1,201 @@ +import { Command } from 'commander'; +import { existsSync, lstatSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { computeAgentDiff, type AgentDiffResult, type TextDiff, type ListDiff } from '../utils/agent-diff.js'; +import { materializeGitRef } from '../utils/git-ref.js'; +import { success, error, warn, label, heading, divider } from '../utils/format.js'; + +interface DiffOptions { + dir: string; + json: boolean; +} + +interface DiffSource { + dir: string; + label: string; + cleanup?: () => void; +} + +function isExistingDirectory(path: string): boolean { + return existsSync(path) && lstatSync(path).isDirectory(); +} + +function resolveDiffSource(spec: string | undefined, repoDir: string): DiffSource { + if (spec === undefined) { + return { dir: repoDir, label: 'working directory' }; + } + const asPath = resolve(spec); + if (isExistingDirectory(asPath)) { + return { dir: asPath, label: spec }; + } + const { dir, cleanup } = materializeGitRef(repoDir, spec); + return { dir, label: spec, cleanup }; +} + +export const diffCommand = new Command('diff') + .description('Show a semantic diff between two agent versions (git refs or directories)') + .argument('[from]', 'Git ref or directory to compare from (default: HEAD)') + .argument('[to]', 'Git ref or directory to compare to (default: working directory)') + .option('-d, --dir ', 'Repository/agent directory used to resolve git refs', '.') + .option('--json', 'Output as JSON', false) + .action((fromArg: string | undefined, toArg: string | undefined, options: DiffOptions) => { + const repoDir = resolve(options.dir); + + let from = fromArg; + let to = toArg; + if (!to && from?.includes('..')) { + const idx = from.indexOf('..'); + const a = from.slice(0, idx); + const b = from.slice(idx + 2); + if (a && b) { + from = a; + to = b; + } + } + from = from ?? 'HEAD'; + + let fromSource: DiffSource; + let toSource: DiffSource; + try { + fromSource = resolveDiffSource(from, repoDir); + toSource = resolveDiffSource(to, repoDir); + } catch (e) { + error((e as Error).message); + process.exit(1); + } + + let result: AgentDiffResult; + try { + result = computeAgentDiff(fromSource.dir, toSource.dir, fromSource.label, toSource.label); + } finally { + fromSource.cleanup?.(); + toSource.cleanup?.(); + } + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + renderDiff(result); + } + }); + +function textSummary(t: TextDiff): string { + if (!t.fromPresent && !t.toPresent) return 'not present'; + if (!t.fromPresent) return 'added'; + if (!t.toPresent) return 'removed'; + if (!t.changed) return 'unchanged'; + return `+${t.added} added, -${t.removed} removed`; +} + +function listSummary(l: ListDiff): string { + if (l.added.length === 0 && l.removed.length === 0 && l.modified.length === 0) return 'unchanged'; + const parts: string[] = []; + parts.push(l.added.length ? `+${l.added.length} added (${l.added.join(', ')})` : '0 added'); + parts.push(l.removed.length ? `-${l.removed.length} removed (${l.removed.join(', ')})` : '0 removed'); + if (l.modified.length) parts.push(`${l.modified.length} modified (${l.modified.join(', ')})`); + return parts.join(', '); +} + +function fmt(v: unknown): string { + if (v === undefined) return 'unset'; + if (typeof v === 'string') return v; + return JSON.stringify(v); +} + +function isEmpty(r: AgentDiffResult): boolean { + return ( + !r.identity.changed && + r.manifest.length === 0 && + !r.rules.changed && + !r.duties.changed && + r.duties.conflictsAdded.length === 0 && + r.duties.conflictsRemoved.length === 0 && + r.skills.added.length === 0 && + r.skills.removed.length === 0 && + r.skills.modified.length === 0 && + r.tools.added.length === 0 && + r.tools.removed.length === 0 && + r.tools.modified.length === 0 && + r.workflows.added.length === 0 && + r.workflows.removed.length === 0 && + r.workflows.modified.length === 0 && + r.compliance.riskTier.from === r.compliance.riskTier.to && + r.compliance.frameworksAdded.length === 0 && + r.compliance.frameworksRemoved.length === 0 && + r.compliance.changed.length === 0 && + r.hooks.added.length === 0 && + r.hooks.removed.length === 0 && + !r.memory.changed + ); +} + +function renderDiff(r: AgentDiffResult): void { + heading(`gitagent diff: ${r.from} → ${r.to}`); + divider(); + + if (!r.manifestPresent.from || !r.manifestPresent.to) { + warn(`agent.yaml missing on one side (from: ${r.manifestPresent.from}, to: ${r.manifestPresent.to}) — comparison may be incomplete`); + } + + label('Identity (SOUL.md)', textSummary(r.identity)); + + if (r.manifest.length === 0) { + label('Manifest (agent.yaml)', 'unchanged'); + } else { + for (const f of r.manifest) { + label(`Manifest.${f.path}`, `${fmt(f.from)} → ${fmt(f.to)}`); + } + } + + label('Rules (RULES.md)', textSummary(r.rules)); + + const dutiesParts = [textSummary(r.duties)]; + for (const pair of r.duties.conflictsAdded) dutiesParts.push(`conflict added: [${pair.join(', ')}]`); + for (const pair of r.duties.conflictsRemoved) dutiesParts.push(`conflict removed: [${pair.join(', ')}]`); + label('Duties (DUTIES.md)', dutiesParts.join('; ')); + + label('Skills', listSummary(r.skills)); + label('Tools', listSummary(r.tools)); + if (r.workflows.added.length || r.workflows.removed.length || r.workflows.modified.length) { + label('Workflows', listSummary(r.workflows)); + } + + if (r.compliance.riskTier.from !== r.compliance.riskTier.to) { + const line = `${r.compliance.riskTier.from ?? 'unset'} → ${r.compliance.riskTier.to ?? 'unset'}`; + if (r.compliance.riskEscalated) { + warn(`Compliance.risk_tier: ${line} ⚠ tier escalation`); + } else { + label('Compliance.risk_tier', line); + } + } + if (r.compliance.frameworksAdded.length || r.compliance.frameworksRemoved.length) { + const parts: string[] = []; + if (r.compliance.frameworksAdded.length) parts.push(`+${r.compliance.frameworksAdded.join(', ')}`); + if (r.compliance.frameworksRemoved.length) parts.push(`-${r.compliance.frameworksRemoved.join(', ')}`); + label('Compliance.frameworks', parts.join(', ')); + } + for (const f of r.compliance.changed) { + label(`Compliance.${f.path}`, `${fmt(f.from)} → ${fmt(f.to)}`); + } + + if (r.hooks.added.length || r.hooks.removed.length) { + const parts = [ + ...r.hooks.added.map(h => `+${h.event}:${h.script}${h.failOpen === false ? ' (fail_open=false)' : ''}`), + ...r.hooks.removed.map(h => `-${h.event}:${h.script}`), + ]; + if (r.hooks.enforcementAdded) { + warn(`Hooks: ${parts.join(', ')} ⚠ enforcement added`); + } else { + label('Hooks', parts.join(', ')); + } + } + + if (r.memory.changed) { + warn(`Memory: ${r.memory.files.join(', ')} changed ⚠ review needed`); + } + + divider(); + if (isEmpty(r)) { + success('No semantic changes detected'); + } +} diff --git a/src/index.ts b/src/index.ts index bf25d0d..9eba8ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { exportCommand } from './commands/export.js'; import { importCommand } from './commands/import.js'; import { installCommand } from './commands/install.js'; import { auditCommand } from './commands/audit.js'; +import { diffCommand } from './commands/diff.js'; import { skillsCommand } from './commands/skills.js'; import { runCommand } from './commands/run.js'; import { lyzrCommand } from './commands/lyzr.js'; @@ -27,6 +28,7 @@ program.addCommand(exportCommand); program.addCommand(importCommand); program.addCommand(installCommand); program.addCommand(auditCommand); +program.addCommand(diffCommand); program.addCommand(skillsCommand); program.addCommand(runCommand); program.addCommand(lyzrCommand); diff --git a/src/utils/agent-diff.test.ts b/src/utils/agent-diff.test.ts new file mode 100644 index 0000000..6de07f4 --- /dev/null +++ b/src/utils/agent-diff.test.ts @@ -0,0 +1,266 @@ +/** + * Tests for the agent semantic diff engine. + * + * Uses Node.js built-in test runner (node --test). + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { computeAgentDiff, diffLines, diffStringList } from './agent-diff.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAgentDir(): string { + return mkdtempSync(join(tmpdir(), 'gitagent-diff-test-')); +} + +function writeAgentYaml(dir: string, content: string): void { + writeFileSync(join(dir, 'agent.yaml'), content, 'utf-8'); +} + +const BASE_MANIFEST = `spec_version: "0.1.0"\nname: test-agent\nversion: 0.1.0\ndescription: A test agent\n`; + +// --------------------------------------------------------------------------- +// diffLines +// --------------------------------------------------------------------------- + +describe('diffLines', () => { + test('reports no changes for identical text', () => { + const result = diffLines('a\nb\nc', 'a\nb\nc'); + assert.deepEqual(result, { added: 0, removed: 0 }); + }); + + test('counts pure additions', () => { + const result = diffLines('a\nb', 'a\nb\nc\nd'); + assert.deepEqual(result, { added: 2, removed: 0 }); + }); + + test('counts pure removals', () => { + const result = diffLines('a\nb\nc', 'a'); + assert.deepEqual(result, { added: 0, removed: 2 }); + }); + + test('handles null (file not present) on either side', () => { + assert.deepEqual(diffLines(null, 'a\nb'), { added: 2, removed: 0 }); + assert.deepEqual(diffLines('a\nb', null), { added: 0, removed: 2 }); + assert.deepEqual(diffLines(null, null), { added: 0, removed: 0 }); + }); +}); + +// --------------------------------------------------------------------------- +// diffStringList +// --------------------------------------------------------------------------- + +describe('diffStringList', () => { + test('separates added, removed, and common entries', () => { + const result = diffStringList(['a', 'b', 'c'], ['b', 'c', 'd']); + assert.deepEqual(result.added, ['d']); + assert.deepEqual(result.removed, ['a']); + assert.deepEqual(result.common, ['b', 'c']); + }); +}); + +// --------------------------------------------------------------------------- +// computeAgentDiff +// --------------------------------------------------------------------------- + +describe('computeAgentDiff — manifest fields', () => { + test('reports no manifest changes for identical agents', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, BASE_MANIFEST); + writeAgentYaml(to, BASE_MANIFEST); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.deepEqual(result.manifest, []); + }); + + test('detects a version bump', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, BASE_MANIFEST); + writeAgentYaml(to, BASE_MANIFEST.replace('version: 0.1.0', 'version: 0.2.0')); + + const result = computeAgentDiff(from, to, 'from', 'to'); + const versionChange = result.manifest.find(c => c.path === 'version'); + assert.ok(versionChange, 'expected a version field change'); + assert.equal(versionChange!.from, '0.1.0'); + assert.equal(versionChange!.to, '0.2.0'); + }); + + test('handles a missing agent.yaml on one side without throwing', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(to, BASE_MANIFEST); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.manifestPresent.from, false); + assert.equal(result.manifestPresent.to, true); + }); +}); + +describe('computeAgentDiff — identity and rules', () => { + test('flags SOUL.md as unchanged when identical', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeFileSync(join(from, 'SOUL.md'), '# Soul\nI am helpful.\n'); + writeFileSync(join(to, 'SOUL.md'), '# Soul\nI am helpful.\n'); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.identity.changed, false); + }); + + test('counts added/removed lines in RULES.md', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeFileSync(join(from, 'RULES.md'), 'Rule 1\nRule 2\n'); + writeFileSync(join(to, 'RULES.md'), 'Rule 1\nRule 2\nRule 3\nRule 4\n'); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.rules.added, 2); + assert.equal(result.rules.removed, 0); + assert.equal(result.rules.changed, true); + }); +}); + +describe('computeAgentDiff — skills, tools, workflows', () => { + function addSkill(dir: string, name: string, description: string): void { + const skillDir = join(dir, 'skills', name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\nDo the thing.\n`); + } + + test('detects added, removed, and modified skills', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + addSkill(from, 'code-review', 'Reviews code'); + addSkill(from, 'old-skill', 'Will be removed'); + addSkill(to, 'code-review', 'Reviews code thoroughly'); // modified description + addSkill(to, 'new-skill', 'Brand new'); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.deepEqual(result.skills.added, ['new-skill']); + assert.deepEqual(result.skills.removed, ['old-skill']); + assert.deepEqual(result.skills.modified, ['code-review']); + }); + + test('detects added tool YAML files', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + mkdirSync(join(to, 'tools'), { recursive: true }); + writeFileSync(join(to, 'tools', 'lint-check.yaml'), 'name: lint-check\n'); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.deepEqual(result.tools.added, ['lint-check']); + assert.deepEqual(result.tools.removed, []); + }); +}); + +describe('computeAgentDiff — compliance', () => { + function withCompliance(riskTier: string, frameworks: string[]): string { + return `${BASE_MANIFEST}compliance:\n risk_tier: ${riskTier}\n frameworks:\n${frameworks.map(f => ` - ${f}`).join('\n')}\n`; + } + + test('flags risk tier escalation', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, withCompliance('low', [])); + writeAgentYaml(to, withCompliance('high', [])); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.compliance.riskTier.from, 'low'); + assert.equal(result.compliance.riskTier.to, 'high'); + assert.equal(result.compliance.riskEscalated, true); + }); + + test('does not flag a risk tier downgrade as escalation', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, withCompliance('critical', [])); + writeAgentYaml(to, withCompliance('medium', [])); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.compliance.riskEscalated, false); + }); + + test('reports framework additions and removals', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, withCompliance('medium', ['finra'])); + writeAgentYaml(to, withCompliance('medium', ['finra', 'sec'])); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.deepEqual(result.compliance.frameworksAdded, ['sec']); + assert.deepEqual(result.compliance.frameworksRemoved, []); + }); + + test('detects segregation-of-duties conflict pairs added, order-insensitively', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeAgentYaml(from, BASE_MANIFEST); + writeAgentYaml( + to, + `${BASE_MANIFEST}compliance:\n segregation_of_duties:\n conflicts:\n - [maker, checker]\n`, + ); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.deepEqual(result.duties.conflictsAdded, [['maker', 'checker']]); + }); +}); + +describe('computeAgentDiff — hooks', () => { + function writeHooks(dir: string, failOpen: boolean): void { + mkdirSync(join(dir, 'hooks'), { recursive: true }); + writeFileSync( + join(dir, 'hooks', 'hooks.yaml'), + `hooks:\n pre_tool_use:\n - script: scripts/spending-cap.sh\n fail_open: ${failOpen}\n`, + ); + } + + test('flags enforcement added when a new hook has fail_open: false', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeHooks(to, false); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.hooks.added.length, 1); + assert.equal(result.hooks.enforcementAdded, true); + }); + + test('does not flag enforcement when the new hook has fail_open: true', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + writeHooks(to, true); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.hooks.enforcementAdded, false); + }); +}); + +describe('computeAgentDiff — memory', () => { + test('detects changed files under memory/', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + mkdirSync(join(from, 'memory'), { recursive: true }); + mkdirSync(join(to, 'memory'), { recursive: true }); + writeFileSync(join(from, 'memory', 'MEMORY.md'), 'v1'); + writeFileSync(join(to, 'memory', 'MEMORY.md'), 'v2'); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.memory.changed, true); + assert.deepEqual(result.memory.files, ['MEMORY.md']); + }); + + test('reports unchanged when memory/ is absent on both sides', () => { + const from = makeAgentDir(); + const to = makeAgentDir(); + + const result = computeAgentDiff(from, to, 'from', 'to'); + assert.equal(result.memory.changed, false); + }); +}); diff --git a/src/utils/agent-diff.ts b/src/utils/agent-diff.ts new file mode 100644 index 0000000..fe8cd0f --- /dev/null +++ b/src/utils/agent-diff.ts @@ -0,0 +1,323 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import yaml from 'js-yaml'; +import { loadAgentManifest, loadFileIfExists, type AgentManifest } from './loader.js'; + +export interface FieldDiff { + path: string; + from: unknown; + to: unknown; +} + +export interface TextDiff { + fromPresent: boolean; + toPresent: boolean; + changed: boolean; + added: number; + removed: number; +} + +export interface DutiesDiff extends TextDiff { + conflictsAdded: string[][]; + conflictsRemoved: string[][]; +} + +export interface ListDiff { + added: string[]; + removed: string[]; + modified: string[]; +} + +export interface HookEntry { + event: string; + script: string; + failOpen?: boolean; +} + +export interface AgentDiffResult { + from: string; + to: string; + manifestPresent: { from: boolean; to: boolean }; + identity: TextDiff; + manifest: FieldDiff[]; + rules: TextDiff; + duties: DutiesDiff; + skills: ListDiff; + tools: ListDiff; + workflows: ListDiff; + compliance: { + riskTier: { from?: string; to?: string }; + riskEscalated: boolean; + frameworksAdded: string[]; + frameworksRemoved: string[]; + changed: FieldDiff[]; + }; + hooks: { added: HookEntry[]; removed: HookEntry[]; enforcementAdded: boolean }; + memory: { changed: boolean; files: string[] }; +} + +const RISK_RANK: Record = { low: 0, medium: 1, high: 2, critical: 3 }; + +function riskRank(tier: string | undefined): number { + if (!tier) return -1; + return RISK_RANK[tier] ?? -1; +} + +function tryLoadManifest(dir: string): AgentManifest | null { + try { + return loadAgentManifest(dir); + } catch { + return null; + } +} + +/** LCS-based line diff. Returns how many lines were added/removed going from oldText to newText. */ +export function diffLines(oldText: string | null, newText: string | null): { added: number; removed: number } { + const a = oldText ? oldText.split('\n') : []; + const b = newText ? newText.split('\n') : []; + const n = a.length; + const m = b.length; + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const common = lcs[0][0]; + return { added: m - common, removed: n - common }; +} + +function diffTextSection(oldText: string | null, newText: string | null): TextDiff { + const { added, removed } = diffLines(oldText, newText); + return { + fromPresent: oldText !== null, + toPresent: newText !== null, + changed: added > 0 || removed > 0, + added, + removed, + }; +} + +export function diffStringList( + oldList: string[], + newList: string[], +): { added: string[]; removed: string[]; common: string[] } { + const oldSet = new Set(oldList); + const newSet = new Set(newList); + return { + added: newList.filter(x => !oldSet.has(x)), + removed: oldList.filter(x => !newSet.has(x)), + common: oldList.filter(x => newSet.has(x)), + }; +} + +function hashFile(path: string): string | null { + if (!existsSync(path)) return null; + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** Flatten a nested object into dot-path -> leaf value pairs, for generic schema diffing. Arrays are kept whole. */ +function flatten(obj: unknown, prefix = '', out: Record = {}): Record { + if (obj === null || obj === undefined) return out; + if (Array.isArray(obj)) { + out[prefix || '(root)'] = obj; + return out; + } + if (typeof obj === 'object') { + const entries = Object.entries(obj as Record); + if (entries.length === 0) { + out[prefix || '(root)'] = obj; + return out; + } + for (const [k, v] of entries) { + flatten(v, prefix ? `${prefix}.${k}` : k, out); + } + return out; + } + out[prefix || '(root)'] = obj; + return out; +} + +function diffObjects(oldObj: unknown, newObj: unknown): FieldDiff[] { + const a = flatten(oldObj); + const b = flatten(newObj); + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + const changes: FieldDiff[] = []; + for (const key of Array.from(keys).sort()) { + if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) { + changes.push({ path: key, from: a[key], to: b[key] }); + } + } + return changes; +} + +function omit(obj: object | null | undefined, keys: string[]): Record { + if (!obj) return {}; + const copy: Record = { ...(obj as Record) }; + for (const k of keys) delete copy[k]; + return copy; +} + +function listDirNames(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort(); +} + +function listYamlFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter(f => f.endsWith('.yaml') || f.endsWith('.yml')) + .filter(f => statSync(join(dir, f)).isFile()) + .sort(); +} + +function diffSkills(fromDir: string, toDir: string): ListDiff { + const { added, removed, common } = diffStringList(listDirNames(join(fromDir, 'skills')), listDirNames(join(toDir, 'skills'))); + const modified = common.filter(name => { + const a = hashFile(join(fromDir, 'skills', name, 'SKILL.md')); + const b = hashFile(join(toDir, 'skills', name, 'SKILL.md')); + return a !== b; + }); + return { added, removed, modified }; +} + +function diffYamlDir(fromDir: string, toDir: string, sub: string): ListDiff { + const { added, removed, common } = diffStringList(listYamlFiles(join(fromDir, sub)), listYamlFiles(join(toDir, sub))); + const modified = common.filter(name => hashFile(join(fromDir, sub, name)) !== hashFile(join(toDir, sub, name))); + const strip = (f: string) => f.replace(/\.ya?ml$/, ''); + return { added: added.map(strip), removed: removed.map(strip), modified: modified.map(strip) }; +} + +function conflictKey(pair: string[]): string { + return [...pair].sort().join('<->'); +} + +function diffConflicts( + fromConflicts: Array<[string, string]>, + toConflicts: Array<[string, string]>, +): { added: string[][]; removed: string[][] } { + const fromMap = new Map(fromConflicts.map(p => [conflictKey(p), p])); + const toMap = new Map(toConflicts.map(p => [conflictKey(p), p])); + const added = [...toMap.entries()].filter(([k]) => !fromMap.has(k)).map(([, p]) => p); + const removed = [...fromMap.entries()].filter(([k]) => !toMap.has(k)).map(([, p]) => p); + return { added, removed }; +} + +interface RawHooksFile { + hooks?: Record>; +} + +function loadHooks(dir: string): HookEntry[] { + const content = loadFileIfExists(join(dir, 'hooks', 'hooks.yaml')); + if (!content) return []; + + let parsed: RawHooksFile; + try { + parsed = yaml.load(content) as RawHooksFile; + } catch { + return []; + } + + const out: HookEntry[] = []; + for (const [event, entries] of Object.entries(parsed.hooks ?? {})) { + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (entry?.script) out.push({ event, script: entry.script, failOpen: entry.fail_open }); + } + } + return out; +} + +function hookKey(h: HookEntry): string { + return `${h.event}:${h.script}`; +} + +function diffHooks(fromDir: string, toDir: string): { added: HookEntry[]; removed: HookEntry[]; enforcementAdded: boolean } { + const fromHooks = loadHooks(fromDir); + const toHooks = loadHooks(toDir); + const fromKeys = new Set(fromHooks.map(hookKey)); + const toKeys = new Set(toHooks.map(hookKey)); + const added = toHooks.filter(h => !fromKeys.has(hookKey(h))); + const removed = fromHooks.filter(h => !toKeys.has(hookKey(h))); + return { added, removed, enforcementAdded: added.some(h => h.failOpen === false) }; +} + +function listFilesRecursive(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + const walk = (d: string, rel: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + const relPath = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(full, relPath); + else out.push(relPath); + } + }; + walk(dir, ''); + return out.sort(); +} + +function diffMemory(fromDir: string, toDir: string): { changed: boolean; files: string[] } { + const allFiles = new Set([...listFilesRecursive(join(fromDir, 'memory')), ...listFilesRecursive(join(toDir, 'memory'))]); + const files: string[] = []; + for (const rel of allFiles) { + if (hashFile(join(fromDir, 'memory', rel)) !== hashFile(join(toDir, 'memory', rel))) { + files.push(rel); + } + } + files.sort(); + return { changed: files.length > 0, files }; +} + +/** + * Compute a semantic diff between two agent directories (already-materialized + * on disk — the caller resolves git refs / clones before calling this). + */ +export function computeAgentDiff(fromDir: string, toDir: string, fromLabel: string, toLabel: string): AgentDiffResult { + const fromManifest = tryLoadManifest(fromDir); + const toManifest = tryLoadManifest(toDir); + + const fromConflicts = fromManifest?.compliance?.segregation_of_duties?.conflicts ?? []; + const toConflicts = toManifest?.compliance?.segregation_of_duties?.conflicts ?? []; + const conflictDiff = diffConflicts(fromConflicts, toConflicts); + + const fromTier = fromManifest?.compliance?.risk_tier; + const toTier = toManifest?.compliance?.risk_tier; + const frameworksDiff = diffStringList(fromManifest?.compliance?.frameworks ?? [], toManifest?.compliance?.frameworks ?? []); + const complianceChanged = diffObjects(fromManifest?.compliance ?? null, toManifest?.compliance ?? null).filter( + c => c.path !== 'risk_tier' && c.path !== 'frameworks', + ); + + return { + from: fromLabel, + to: toLabel, + manifestPresent: { from: fromManifest !== null, to: toManifest !== null }, + identity: diffTextSection(loadFileIfExists(join(fromDir, 'SOUL.md')), loadFileIfExists(join(toDir, 'SOUL.md'))), + manifest: diffObjects( + omit(fromManifest, ['skills', 'tools', 'compliance']), + omit(toManifest, ['skills', 'tools', 'compliance']), + ), + rules: diffTextSection(loadFileIfExists(join(fromDir, 'RULES.md')), loadFileIfExists(join(toDir, 'RULES.md'))), + duties: { + ...diffTextSection(loadFileIfExists(join(fromDir, 'DUTIES.md')), loadFileIfExists(join(toDir, 'DUTIES.md'))), + conflictsAdded: conflictDiff.added, + conflictsRemoved: conflictDiff.removed, + }, + skills: diffSkills(fromDir, toDir), + tools: diffYamlDir(fromDir, toDir, 'tools'), + workflows: diffYamlDir(fromDir, toDir, 'workflows'), + compliance: { + riskTier: { from: fromTier, to: toTier }, + riskEscalated: riskRank(toTier) > riskRank(fromTier), + frameworksAdded: frameworksDiff.added, + frameworksRemoved: frameworksDiff.removed, + changed: complianceChanged, + }, + hooks: diffHooks(fromDir, toDir), + memory: diffMemory(fromDir, toDir), + }; +} diff --git a/src/utils/git-ref.test.ts b/src/utils/git-ref.test.ts new file mode 100644 index 0000000..dbd9c37 --- /dev/null +++ b/src/utils/git-ref.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for git ref materialization (used by `opengap diff` to compare + * an agent directory against a past commit, branch, or tag). + * + * Uses Node.js built-in test runner (node --test). + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +import { materializeGitRef } from './git-ref.js'; + +function makeRepoWithTwoCommits(): string { + const dir = mkdtempSync(join(tmpdir(), 'gitagent-ref-test-')); + const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + + git('init', '-q'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'Test'); + + writeFileSync(join(dir, 'agent.yaml'), 'name: test-agent\nversion: 0.1.0\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'initial'); + + writeFileSync(join(dir, 'agent.yaml'), 'name: test-agent\nversion: 0.2.0\n'); + git('add', '-A'); + git('commit', '-q', '-m', 'bump version'); + + return dir; +} + +describe('materializeGitRef', () => { + test('extracts the file tree at the given ref into a temp directory', () => { + const repo = makeRepoWithTwoCommits(); + const { dir, cleanup } = materializeGitRef(repo, 'HEAD~1'); + try { + const content = readFileSync(join(dir, 'agent.yaml'), 'utf-8'); + assert.match(content, /version: 0\.1\.0/); + } finally { + cleanup(); + } + }); + + test('HEAD resolves to the latest commit content', () => { + const repo = makeRepoWithTwoCommits(); + const { dir, cleanup } = materializeGitRef(repo, 'HEAD'); + try { + const content = readFileSync(join(dir, 'agent.yaml'), 'utf-8'); + assert.match(content, /version: 0\.2\.0/); + } finally { + cleanup(); + } + }); + + test('cleanup removes the temp directory', () => { + const repo = makeRepoWithTwoCommits(); + const { dir, cleanup } = materializeGitRef(repo, 'HEAD'); + assert.ok(existsSync(dir)); + cleanup(); + assert.equal(existsSync(dir), false); + }); + + test('throws a helpful error for a nonexistent ref', () => { + const repo = makeRepoWithTwoCommits(); + assert.throws(() => materializeGitRef(repo, 'not-a-real-ref'), /not a valid git ref/); + }); +}); diff --git a/src/utils/git-ref.ts b/src/utils/git-ref.ts new file mode 100644 index 0000000..a9bda65 --- /dev/null +++ b/src/utils/git-ref.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; + +export interface MaterializedRef { + dir: string; + cleanup: () => void; +} + +/** + * Materialize a git ref (commit, branch, tag) from repoDir into a standalone + * temp directory via `git archive`, so it can be loaded like any other agent + * directory. Caller must invoke the returned cleanup() when done. + */ +export function materializeGitRef(repoDir: string, ref: string): MaterializedRef { + try { + execFileSync('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { + stdio: 'pipe', + }); + } catch { + throw new Error(`"${ref}" is not a valid git ref in ${repoDir} (and no directory with that name exists either)`); + } + + const dir = mkdtempSync(join(tmpdir(), 'gitagent-diff-')); + const tarPath = join(dir, '.snapshot.tar'); + + try { + execFileSync('git', ['-C', repoDir, 'archive', '--format=tar', '-o', tarPath, ref], { stdio: 'pipe' }); + execFileSync('tar', ['-xf', tarPath, '-C', dir], { stdio: 'pipe' }); + } catch (e) { + rmSync(dir, { recursive: true, force: true }); + throw new Error(`Failed to materialize git ref "${ref}" from ${repoDir}: ${(e as Error).message}`); + } finally { + if (existsSync(tarPath)) rmSync(tarPath); + } + + return { + dir, + cleanup: () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }, + }; +}