mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
e026a54fd9
commit
729ef2ebb5
4 changed files with 349 additions and 0 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
114
apps/daemon/src/plugins/validate.ts
Normal file
114
apps/daemon/src/plugins/validate.ts
Normal 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;
|
||||
}
|
||||
136
apps/daemon/tests/plugins-validate.test.ts
Normal file
136
apps/daemon/tests/plugins-validate.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue