feat(plugins): od plugin validate <folder> author-side lint (Phase 4)

Plan W1 / spec §11.5 / §16 Phase 4.

apps/daemon/src/plugins/validate.ts ships the pre-install lint
helper:

  validatePluginFolder({ folder, registry?, connectorProbe? })
    \u2192 { ok, resolveErrors, resolveWarnings, doctor?, folder }

Reads the folder via the same resolvePluginFolder() the installer
uses (so manifest parsing is byte-equal), then runs doctorPlugin()
against the supplied registry view. Without a registry, ref-bound
checks skip cleanly (offline mode); with an empty/full registry,
unresolved skill / DS / atom refs surface as warnings.

  flattenValidationDiagnostics(result) \u2192 Diagnostic[]

Convenience flattener for renderers that don't want to special-case
resolve vs. doctor diagnostics.

apps/daemon/src/cli.ts `od plugin validate <folder>`:

  Usage: od plugin validate <folder> [--json] [--no-daemon] [--daemon-url <url>]

  Optional --no-daemon skips the registry fetch (works fully
  offline). When the daemon is reachable, the registry view is
  fetched from /api/skills + /api/design-systems + /api/atoms so
  ref checks happen against the operator's actual library.

Exit codes:
  0  doctor.ok = true
  4  doctor.ok = false (errors present)
  2  CLI usage error / folder unreadable

Help block in printPluginHelp() updated.

Daemon tests: 1666 \u2192 1674 (+8 cases on plugins-validate: clean
manifest passes, empty folder errors at resolve, malformed JSON
parse error, unknown atom id error, unparseable until expression
error, missing skill ref no-op without registry, missing skill ref
flagged with empty registry, flatten ordering).

Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
Cursor Agent 2026-05-09 16:21:57 +00:00
parent e026a54fd9
commit 729ef2ebb5
No known key found for this signature in database
4 changed files with 349 additions and 0 deletions

View file

@ -832,6 +832,7 @@ async function runPlugin(args) {
case 'snapshots': return runPluginSnapshots(rest);
case 'run': return runPluginRun(rest);
case 'scaffold': return runPluginScaffold(rest);
case 'validate': return runPluginValidate(rest);
case 'export': return runPluginExport(rest);
case 'publish': return runPluginPublish(rest);
default:
@ -901,6 +902,96 @@ Writes <out|cwd>/<id>/{SKILL.md,open-design.json,README.md}.`);
}
}
// Phase 4 / spec §11.5 / plan §3.W1 — `od plugin validate <folder>`.
//
// Pre-install lint pass against an author's working dir. Optionally
// fetches the daemon's registry view so skill / DS / atom refs in
// the manifest can be checked too; falls back to an empty registry
// when --no-daemon is set or the daemon is unreachable.
async function runPluginValidate(rest) {
const flags = parseFlags(rest, {
string: new Set(['daemon-url']),
boolean: new Set(['help', 'h', 'json', 'no-daemon']),
});
if (flags.help || flags.h || rest.length === 0 || rest[0]?.startsWith('-')) {
console.log(`Usage:
od plugin validate <folder> [--json] [--no-daemon] [--daemon-url <url>]
Runs the plugin doctor against an unfinished plugin folder before
install. Validates manifest shape, atom ids, until expressions, and
context refs against the live daemon registry (skip with --no-daemon).
Exit codes:
0 doctor.ok = true
4 doctor.ok = false (errors present)
2 CLI usage error / folder unreadable`);
process.exit(rest.length === 0 ? 2 : 0);
}
const folder = rest[0];
// Try to load the daemon's registry view; the validator works
// offline too — emits warnings instead of errors for refs we
// can't resolve.
let registry;
if (!flags['no-daemon']) {
const base = libraryDaemonUrl(flags).replace(/\/$/, '');
try {
const [skillsResp, dsResp, atomsResp] = await Promise.all([
fetch(`${base}/api/skills`).catch(() => null),
fetch(`${base}/api/design-systems`).catch(() => null),
fetch(`${base}/api/atoms`).catch(() => null),
]);
const skills = (skillsResp?.ok ? (await skillsResp.json())?.skills : []) ?? [];
const designSystems = (dsResp?.ok ? (await dsResp.json())?.designSystems : []) ?? [];
const atoms = (atomsResp?.ok ? (await atomsResp.json())?.atoms : []) ?? [];
registry = {
skills: skills.map((s) => ({ id: s.id, title: s.name ?? s.title, description: s.description })),
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
craft: [],
atoms: atoms.map((a) => ({ id: a.id, label: a.label })),
};
} catch {
registry = undefined;
}
}
let result;
try {
const { validatePluginFolder, flattenValidationDiagnostics } = await import('./plugins/validate.js');
result = await validatePluginFolder({ folder, ...(registry ? { registry } : {}) });
if (flags.json) {
const flat = flattenValidationDiagnostics(result);
process.stdout.write(JSON.stringify({
ok: result.ok,
folder: result.folder,
...(result.doctor ? { freshDigest: result.doctor.freshDigest, pluginId: result.doctor.pluginId } : {}),
diagnostics: flat,
}, null, 2) + '\n');
} else {
console.log(`[validate] folder: ${result.folder}`);
if (result.doctor) {
console.log(`[validate] pluginId: ${result.doctor.pluginId}`);
console.log(`[validate] freshDigest: ${result.doctor.freshDigest.slice(0, 12)}\u2026`);
}
const diagnostics = (await import('./plugins/validate.js')).flattenValidationDiagnostics(result);
const errors = diagnostics.filter((d) => d.severity === 'error');
const warnings = diagnostics.filter((d) => d.severity === 'warning');
const infos = diagnostics.filter((d) => d.severity === 'info');
for (const d of errors) console.error(` [error] ${d.code}: ${d.message}`);
for (const d of warnings) console.warn (` [warning] ${d.code}: ${d.message}`);
for (const d of infos) console.log (` [info] ${d.code}: ${d.message}`);
if (errors.length === 0 && warnings.length === 0 && infos.length === 0) {
console.log('[validate] no issues');
}
console.log(`[validate] ok=${result.ok}`);
}
} catch (err) {
console.error(`[validate] failed: ${err?.message ?? err}`);
process.exit(2);
}
process.exit(result.ok ? 0 : 4);
}
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
//
// Produces a publish-ready folder from the AppliedPluginSnapshot
@ -1893,6 +1984,8 @@ function printPluginHelp() {
Re-emit the immutable snapshot a run launched against.
od plugin trust <id> --capabilities a,b
Stage a capability grant (full mutation lands Phase 3).
od plugin validate <folder> [--json] Lint a plugin folder before installing
(manifest parse + atom + ref checks).
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).

View file

@ -3,6 +3,12 @@
// and accidentally bypasses the snapshot writer (spec §8.2.1).
export * from './atoms.js';
export * from './apply.js';
export {
validatePluginFolder,
flattenValidationDiagnostics,
type ValidatePluginFolderInput,
type ValidatePluginFolderResult,
} from './validate.js';
export * from './atoms/build-test.js';
export * from './atoms/code-import.js';
export * from './atoms/design-extract.js';

View file

@ -0,0 +1,114 @@
// Phase 4 / spec §11.5 / plan §3.W1 — author-side plugin validation.
//
// Pre-install lint pass: takes a path to a plugin folder on disk
// (typically the author's local working dir) and returns the same
// DoctorReport shape the post-install `od plugin doctor <id>`
// command emits.
//
// The lift from `od plugin doctor`:
// - reads the folder via the same resolvePluginFolder() the
// installer uses, so manifest parsing is byte-equal,
// - calls doctorPlugin() with the supplied registry view (which
// the CLI fetches from the daemon when reachable; falls back
// to an empty registry so the lint runs offline),
// - skips the `snapshot-stale` cross-check (no SQLite involved
// because nothing is installed yet).
//
// Rationale: spec §16 Phase 4 ships `od plugin scaffold`, `od
// plugin export`, `od plugin publish` for the author tooling slice.
// `od plugin validate` closes the loop: the author can run lint
// before pushing to a marketplace catalog, without installing into
// their own daemon (which would dirty the registry table).
import path from 'node:path';
import type { RegistryView } from '@open-design/plugin-runtime';
import { doctorPlugin, type DoctorReport, type Diagnostic } from './doctor.js';
import { resolvePluginFolder } from './registry.js';
import type { ConnectorProbe } from './connector-gate.js';
export interface ValidatePluginFolderInput {
// Path to the plugin folder. Must contain at least one of
// `open-design.json` / `SKILL.md` / `.claude-plugin/plugin.json`
// for resolvePluginFolder() to succeed.
folder: string;
// Optional pre-fetched registry. Tests pass a stub; CLI fetches
// from a reachable daemon. Empty / undefined means the validator
// skips registry-bound ref checks (skills / DS / craft refs in
// the manifest just emit warnings).
registry?: RegistryView;
// Optional connector probe. Same semantics as the post-install
// doctor.
connectorProbe?: ConnectorProbe;
}
export interface ValidatePluginFolderResult {
ok: boolean;
// Warnings/errors raised during folder resolution (manifest
// parse + adapter merge), separate from the doctorPlugin pass.
resolveErrors: string[];
resolveWarnings: string[];
// Doctor report; absent only when resolve failed.
doctor?: DoctorReport;
// Echoed for the CLI's audit / JSON output.
folder: string;
}
const EMPTY_REGISTRY: RegistryView = {
skills: [],
designSystems: [],
craft: [],
atoms: [],
};
export async function validatePluginFolder(
input: ValidatePluginFolderInput,
): Promise<ValidatePluginFolderResult> {
const folder = path.resolve(input.folder);
const folderId = path.basename(folder).toLowerCase();
// Match the installer's safe-id check so the author sees the
// same rejection they'll get at install time.
const probe = await resolvePluginFolder({
folder,
folderId,
sourceKind: 'local',
source: folder,
trust: 'restricted',
});
if (!probe.ok) {
return {
ok: false,
resolveErrors: probe.errors,
resolveWarnings: probe.warnings,
folder,
};
}
const doctor = doctorPlugin(probe.record, input.registry ?? EMPTY_REGISTRY, {
warnOnMissingRefs: !!input.registry,
...(input.connectorProbe ? { connectorProbe: input.connectorProbe } : {}),
});
return {
ok: probe.warnings.length > 0 ? doctor.ok : doctor.ok,
resolveErrors: [],
resolveWarnings: probe.warnings,
doctor,
folder,
};
}
// Helper a CLI / API renderer can use to flatten the result into a
// flat list the output formatter walks. Useful when the consumer
// doesn't want to special-case resolve vs. doctor diagnostics.
export function flattenValidationDiagnostics(result: ValidatePluginFolderResult): Diagnostic[] {
const out: Diagnostic[] = [];
for (const err of result.resolveErrors) {
out.push({ severity: 'error', code: 'manifest.resolve', message: err });
}
for (const warn of result.resolveWarnings) {
out.push({ severity: 'warning', code: 'manifest.resolve', message: warn });
}
if (result.doctor) {
for (const issue of result.doctor.issues) out.push(issue);
}
return out;
}

View file

@ -0,0 +1,136 @@
// Phase 4 / spec §11.5 / plan §3.W1 — validatePluginFolder.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
flattenValidationDiagnostics,
validatePluginFolder,
} from '../src/plugins/validate.js';
let folder: string;
beforeEach(async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-validate-'));
folder = path.join(tmp, 'my-plugin');
await mkdir(folder, { recursive: true });
});
afterEach(async () => {
await rm(path.dirname(folder), { recursive: true, force: true });
});
async function writeManifest(body: Record<string, unknown>) {
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify(body, null, 2));
}
async function writeSkill(body: string) {
await writeFile(path.join(folder, 'SKILL.md'), body);
}
describe('validatePluginFolder', () => {
it('passes a clean manifest + SKILL.md with no registry refs', async () => {
await writeManifest({
name: 'my-plugin',
version: '0.1.0',
title: 'Test plugin',
od: { taskKind: 'new-generation' },
});
await writeSkill('---\nname: my-plugin\n---\n# Test plugin\n');
const result = await validatePluginFolder({ folder });
expect(result.ok).toBe(true);
const diagnostics = flattenValidationDiagnostics(result);
expect(diagnostics.filter((d) => d.severity === 'error')).toEqual([]);
});
it('flags an empty folder with a resolve-time error', async () => {
const result = await validatePluginFolder({ folder });
expect(result.ok).toBe(false);
expect(result.resolveErrors.length).toBeGreaterThan(0);
});
it('rejects malformed open-design.json with a parse error', async () => {
await writeFile(path.join(folder, 'open-design.json'), '{ this is not json');
const result = await validatePluginFolder({ folder });
expect(result.ok).toBe(false);
expect(result.resolveErrors.some((e) => e.includes('open-design.json'))).toBe(true);
});
it('flags an unknown atom id in od.pipeline', async () => {
await writeManifest({
name: 'pipe',
version: '0.1.0',
od: {
taskKind: 'new-generation',
pipeline: { stages: [{ id: 'one', atoms: ['no-such-atom'] }] },
},
});
const result = await validatePluginFolder({ folder });
const diagnostics = flattenValidationDiagnostics(result);
expect(diagnostics.some((d) => d.severity === 'error' && d.code.includes('atom'))).toBe(true);
});
it('flags an unparseable until expression as an error', async () => {
await writeManifest({
name: 'pipe',
version: '0.1.0',
od: {
taskKind: 'new-generation',
pipeline: {
stages: [
{
id: 'critique',
atoms: ['critique-theater'],
repeat: true,
until: 'this is not a valid until expression',
},
],
},
},
});
const result = await validatePluginFolder({ folder });
const diagnostics = flattenValidationDiagnostics(result);
expect(diagnostics.some((d) => d.severity === 'error' && d.code.includes('until'))).toBe(true);
});
it('emits warnings (not errors) for unresolved skill refs when no registry is supplied', async () => {
await writeManifest({
name: 'with-skill',
version: '0.1.0',
od: {
taskKind: 'new-generation',
context: { skills: [{ ref: 'missing-skill' }] },
},
});
const result = await validatePluginFolder({ folder });
// Without a registry, ref-resolution warnings stay informational.
expect(result.ok).toBe(true);
});
it('promotes a missing skill ref to a warning when an empty registry is supplied', async () => {
await writeManifest({
name: 'with-skill',
version: '0.1.0',
od: {
taskKind: 'new-generation',
context: { skills: [{ ref: 'missing-skill' }] },
},
});
const result = await validatePluginFolder({
folder,
registry: { skills: [], designSystems: [], craft: [], atoms: [] },
});
const diagnostics = flattenValidationDiagnostics(result);
expect(diagnostics.some((d) => d.message.includes('missing-skill') || d.message.includes('skill'))).toBe(true);
});
it('flattenValidationDiagnostics merges resolve + doctor diagnostics in order', async () => {
await writeFile(path.join(folder, 'open-design.json'), '{ broken');
const result = await validatePluginFolder({ folder });
const flat = flattenValidationDiagnostics(result);
// Resolve errors come first.
expect(flat[0]?.severity).toBe('error');
expect(flat[0]?.code).toBe('manifest.resolve');
});
});