mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Plan AA1.
apps/daemon/src/plugins/diff.ts ships a pure helper:
diffPlugins({ a, b }) \u2192 PluginDiffReport
{ pluginId?, entries: PluginDiffEntry[], added, removed, changed }
Each entry carries field path + kind ('added' | 'removed' |
'changed') + before / after / optional summary. Entries sort by
field path so the report is byte-deterministic across re-runs
(modulo timestamps, which the helper avoids comparing).
Compared fields:
Top-level: id, title, version, sourceKind, source, trust,
capabilitiesGranted
Manifest: title, version, description, license, tags
od.*: kind, taskKind, mode, capabilities, inputs[]
(by name), context.skills (by ref / path),
context.craft, context.assets, connectors.required
(by id), genui.surfaces (by id)
Pipeline: stages roster + per-stage atoms[] + until + repeat
Collection diffs collapse to '<n> added, <m> removed' summaries
so the CLI renders one line per field instead of dragging in a
generic deep-diff library.
CLI: `od plugin diff <id-a> <id-b> [--json]`. Renders +/-/~
glyphs per kind. printPluginHelp() updated.
Daemon tests: 1705 \u2192 1716 (+11 cases on plugins-diff:
equivalence, top-level version + trust changes, capabilitiesGranted
add / remove, id rename surfaces (no shared pluginId), taskKind
change, inputs[] roster diff, pipeline added / removed / per-
stage atoms churn, connectors.required churn, lexicographic
sort, aggregate count parity).
Co-authored-by: Tom Huang <1043269994@qq.com>
151 lines
6 KiB
TypeScript
151 lines
6 KiB
TypeScript
// Plan §3.AA1 — diffPlugins() pure helper.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
|
|
import { diffPlugins } from '../src/plugins/diff.js';
|
|
|
|
const make = (
|
|
id: string,
|
|
manifest: NonNullable<PluginManifest['od']> | undefined = undefined,
|
|
over: Partial<InstalledPluginRecord> = {},
|
|
): InstalledPluginRecord => ({
|
|
id,
|
|
title: `Title for ${id}`,
|
|
version: '0.1.0',
|
|
sourceKind: 'local',
|
|
source: '/tmp/' + id,
|
|
fsPath: '/tmp/' + id,
|
|
trust: 'trusted',
|
|
capabilitiesGranted: [],
|
|
installedAt: 1,
|
|
updatedAt: 1,
|
|
manifest: {
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: id,
|
|
version: '0.1.0',
|
|
title: `Title for ${id}`,
|
|
...(manifest ? { od: manifest } : {}),
|
|
} as PluginManifest,
|
|
...over,
|
|
});
|
|
|
|
describe('diffPlugins — equivalence', () => {
|
|
it('returns an empty entries list for byte-equal records', () => {
|
|
const a = make('p', { taskKind: 'new-generation' });
|
|
const b = make('p', { taskKind: 'new-generation' });
|
|
const r = diffPlugins({ a, b });
|
|
expect(r.entries).toEqual([]);
|
|
expect(r.added).toBe(0);
|
|
expect(r.removed).toBe(0);
|
|
expect(r.changed).toBe(0);
|
|
expect(r.pluginId).toBe('p');
|
|
});
|
|
});
|
|
|
|
describe('diffPlugins — top-level record', () => {
|
|
it('detects version + trust changes', () => {
|
|
const a = make('p');
|
|
const b = make('p', undefined, { version: '0.2.0', trust: 'restricted' });
|
|
const r = diffPlugins({ a, b });
|
|
const fields = r.entries.map((e) => e.field).sort();
|
|
expect(fields).toEqual(expect.arrayContaining(['trust', 'version']));
|
|
const versionEntry = r.entries.find((e) => e.field === 'version');
|
|
expect(versionEntry?.kind).toBe('changed');
|
|
expect(versionEntry?.before).toBe('0.1.0');
|
|
expect(versionEntry?.after).toBe('0.2.0');
|
|
});
|
|
|
|
it('detects added / removed entries on capabilitiesGranted', () => {
|
|
const a = make('p', undefined, { capabilitiesGranted: ['fs:read'] });
|
|
const b = make('p', undefined, { capabilitiesGranted: ['fs:read', 'connector:slack'] });
|
|
const r = diffPlugins({ a, b });
|
|
const cap = r.entries.find((e) => e.field === 'capabilitiesGranted');
|
|
expect(cap?.kind).toBe('changed');
|
|
expect(cap?.summary).toMatch(/1 added/);
|
|
expect(cap?.after).toContain('connector:slack');
|
|
});
|
|
|
|
it('renames surface as id changed (no shared pluginId on the report)', () => {
|
|
const r = diffPlugins({ a: make('alpha'), b: make('beta') });
|
|
expect(r.pluginId).toBeUndefined();
|
|
expect(r.entries.find((e) => e.field === 'id')?.kind).toBe('changed');
|
|
});
|
|
});
|
|
|
|
describe('diffPlugins — manifest body', () => {
|
|
it('detects od.taskKind changes', () => {
|
|
const a = make('p', { taskKind: 'new-generation' });
|
|
const b = make('p', { taskKind: 'code-migration' });
|
|
const r = diffPlugins({ a, b });
|
|
const e = r.entries.find((x) => x.field === 'od.taskKind');
|
|
expect(e?.kind).toBe('changed');
|
|
expect(e?.before).toBe('new-generation');
|
|
expect(e?.after).toBe('code-migration');
|
|
});
|
|
|
|
it('detects added / removed inputs[] by name', () => {
|
|
const a = make('p', { taskKind: 'new-generation', inputs: [{ name: 'topic', type: 'string' }] });
|
|
const b = make('p', { taskKind: 'new-generation', inputs: [{ name: 'topic', type: 'string' }, { name: 'tone', type: 'string' }] });
|
|
const r = diffPlugins({ a, b });
|
|
const e = r.entries.find((x) => x.field === 'od.inputs[]');
|
|
expect(e?.summary).toMatch(/1 added/);
|
|
expect(e?.after).toContain('tone');
|
|
});
|
|
|
|
it('detects pipeline added / removed / per-stage atoms changes', () => {
|
|
const a = make('p', {
|
|
pipeline: { stages: [
|
|
{ id: 'plan', atoms: ['todo-write'] },
|
|
{ id: 'do', atoms: ['file-write'] },
|
|
] },
|
|
});
|
|
const b = make('p', {
|
|
pipeline: { stages: [
|
|
{ id: 'plan', atoms: ['todo-write', 'direction-picker'] },
|
|
{ id: 'critique', atoms: ['critique-theater'] },
|
|
] },
|
|
});
|
|
const r = diffPlugins({ a, b });
|
|
// Stage-id roster differs.
|
|
expect(r.entries.find((e) => e.field === 'od.pipeline.stages')?.kind).toBe('changed');
|
|
// Per-stage atoms diff for the surviving stage 'plan'.
|
|
const planAtoms = r.entries.find((e) => e.field === 'od.pipeline.stages[plan].atoms');
|
|
expect(planAtoms?.summary).toMatch(/1 added/);
|
|
});
|
|
|
|
it('emits od.pipeline=added when only the rhs has a pipeline', () => {
|
|
const a = make('p', { taskKind: 'new-generation' });
|
|
const b = make('p', { taskKind: 'new-generation', pipeline: { stages: [{ id: 'x', atoms: ['todo-write'] }] } });
|
|
const r = diffPlugins({ a, b });
|
|
const e = r.entries.find((x) => x.field === 'od.pipeline');
|
|
expect(e?.kind).toBe('added');
|
|
expect(e?.after).toContain('x');
|
|
});
|
|
|
|
it('detects connectors required-list churn', () => {
|
|
const a = make('p', { taskKind: 'figma-migration', connectors: { required: [{ id: 'figma', tools: ['files.get'] }] } });
|
|
const b = make('p', { taskKind: 'figma-migration', connectors: { required: [{ id: 'figma', tools: ['files.get'] }, { id: 'slack', tools: [] }] } });
|
|
const r = diffPlugins({ a, b });
|
|
const e = r.entries.find((x) => x.field === 'od.connectors.required');
|
|
expect(e?.summary).toMatch(/1 added/);
|
|
});
|
|
});
|
|
|
|
describe('diffPlugins — output shape', () => {
|
|
it('sorts entries by field path lexicographically (deterministic output)', () => {
|
|
const a = make('p');
|
|
const b = make('p', undefined, { version: '0.2.0', trust: 'restricted', source: '/tmp/new' });
|
|
const r = diffPlugins({ a, b });
|
|
const fields = r.entries.map((e) => e.field);
|
|
expect(fields).toEqual([...fields].sort());
|
|
});
|
|
|
|
it("aggregate counts (added/removed/changed) match entries' kind tally", () => {
|
|
const a = make('p', { taskKind: 'new-generation' });
|
|
const b = make('p', { taskKind: 'code-migration', mode: 'edit' });
|
|
const r = diffPlugins({ a, b });
|
|
const counted = { added: 0, removed: 0, changed: 0 };
|
|
for (const e of r.entries) counted[e.kind]++;
|
|
expect({ added: r.added, removed: r.removed, changed: r.changed }).toEqual(counted);
|
|
});
|
|
});
|