Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@toolctrl/plugins",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "cd ../.. && vitest run packages/plugins/src packages/plugins/test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("@toolctrl/plugins package entrypoint", () => {
|
||||
it("exports the manifest schema API", async () => {
|
||||
const module = await import("../src/index.ts");
|
||||
|
||||
expect(module.validateFeatureManifestShape).toEqual(expect.any(Function));
|
||||
expect(module.loadFeatureManifests).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type {
|
||||
FeatureManifest,
|
||||
FeatureManifestClassification,
|
||||
FeatureManifestComponent,
|
||||
FeatureManifestDiagnostic,
|
||||
FeatureManifestDiagnosticCode,
|
||||
FeatureManifestStatus,
|
||||
FeatureManifestValidationContext,
|
||||
FeatureManifestValidationResult,
|
||||
} from "./manifest-schema";
|
||||
export { validateFeatureManifestShape } from "./manifest-schema";
|
||||
export type {
|
||||
FeatureManifestFile,
|
||||
FeatureManifestLoaderDiagnostic,
|
||||
FeatureManifestLoaderDiagnosticCode,
|
||||
FeatureManifestLoaderOptions,
|
||||
FeatureManifestLoaderResult,
|
||||
} from "./manifest-loader";
|
||||
export { loadFeatureManifests } from "./manifest-loader";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { FeatureManifest, FeatureManifestDiagnostic } from "./manifest-schema";
|
||||
import { validateFeatureManifestShape } from "./manifest-schema";
|
||||
|
||||
export type FeatureManifestLoaderDiagnosticCode = FeatureManifestDiagnostic["code"] | "missing_directory" | "invalid_json" | "missing_dependency";
|
||||
|
||||
export interface FeatureManifestLoaderDiagnostic {
|
||||
code: FeatureManifestLoaderDiagnosticCode;
|
||||
manifestId: string | null;
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FeatureManifestLoaderOptions {
|
||||
rootDir?: string;
|
||||
manifestDir?: string;
|
||||
schemaFileName?: string;
|
||||
includeSchema?: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureManifestFile {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
manifestId: string | null;
|
||||
manifest: FeatureManifest | null;
|
||||
diagnostics: FeatureManifestLoaderDiagnostic[];
|
||||
}
|
||||
|
||||
export interface FeatureManifestLoaderResult {
|
||||
manifests: FeatureManifest[];
|
||||
manifestFiles: FeatureManifestFile[];
|
||||
diagnostics: FeatureManifestLoaderDiagnostic[];
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MANIFEST_DIR = path.join("docs", "feature-manifests");
|
||||
const DEFAULT_SCHEMA_FILE_NAME = "schema.json";
|
||||
|
||||
function resolveManifestDirectory(options: FeatureManifestLoaderOptions) {
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const manifestDir = options.manifestDir ?? DEFAULT_MANIFEST_DIR;
|
||||
return path.isAbsolute(manifestDir) ? manifestDir : path.join(rootDir, manifestDir);
|
||||
}
|
||||
|
||||
function toLoaderDiagnostic(diagnostic: FeatureManifestDiagnostic, filePath: string): FeatureManifestLoaderDiagnostic {
|
||||
return {
|
||||
...diagnostic,
|
||||
path: `${filePath}#${diagnostic.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
function manifestIdFromUnknownJson(input: unknown): string | null {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
|
||||
const id = (input as { id?: unknown }).id;
|
||||
return typeof id === "string" && id.length > 0 ? id : null;
|
||||
}
|
||||
|
||||
async function readManifestFile(fileName: string, filePath: string): Promise<FeatureManifestFile> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(filePath, "utf8"));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown JSON parse failure.";
|
||||
const diagnostic: FeatureManifestLoaderDiagnostic = {
|
||||
code: "invalid_json",
|
||||
manifestId: null,
|
||||
path: filePath,
|
||||
message: `Failed to parse ${fileName}: ${message}`,
|
||||
};
|
||||
return { fileName, filePath, manifestId: null, manifest: null, diagnostics: [diagnostic] };
|
||||
}
|
||||
|
||||
const validation = validateFeatureManifestShape(parsed, { manifestId: manifestIdFromUnknownJson(parsed) ?? undefined, path: filePath });
|
||||
const diagnostics = validation.diagnostics.map((diagnostic) => toLoaderDiagnostic(diagnostic, filePath));
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath,
|
||||
manifestId: validation.manifest?.id ?? manifestIdFromUnknownJson(parsed),
|
||||
manifest: validation.manifest,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function validateDependencies(manifestFiles: FeatureManifestFile[]): FeatureManifestLoaderDiagnostic[] {
|
||||
const manifestIds = new Set(manifestFiles.flatMap((file) => (file.manifest ? [file.manifest.id] : [])));
|
||||
return manifestFiles.flatMap((file) => {
|
||||
if (!file.manifest) return [];
|
||||
return file.manifest.dependencies
|
||||
.filter((dependencyId) => !manifestIds.has(dependencyId))
|
||||
.map((dependencyId) => ({
|
||||
code: "missing_dependency" as const,
|
||||
manifestId: file.manifest!.id,
|
||||
path: `${file.filePath}#dependencies`,
|
||||
message: `${file.manifest!.id} depends on missing feature manifest ${dependencyId}.`,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadFeatureManifests(options: FeatureManifestLoaderOptions = {}): Promise<FeatureManifestLoaderResult> {
|
||||
const manifestDirectory = resolveManifestDirectory(options);
|
||||
const schemaFileName = options.schemaFileName ?? DEFAULT_SCHEMA_FILE_NAME;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(manifestDirectory);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown directory read failure.";
|
||||
const diagnostic: FeatureManifestLoaderDiagnostic = {
|
||||
code: "missing_directory",
|
||||
manifestId: null,
|
||||
path: manifestDirectory,
|
||||
message: `Failed to read feature manifest directory: ${message}`,
|
||||
};
|
||||
return { manifests: [], manifestFiles: [], diagnostics: [diagnostic], ok: false };
|
||||
}
|
||||
|
||||
const fileNames = entries.filter((entry) => entry.endsWith(".json") && (options.includeSchema === true || entry !== schemaFileName)).sort((a, b) => a.localeCompare(b));
|
||||
const manifestFiles = await Promise.all(fileNames.map((fileName) => readManifestFile(fileName, path.join(manifestDirectory, fileName))));
|
||||
const dependencyDiagnostics = validateDependencies(manifestFiles);
|
||||
const diagnostics = [...manifestFiles.flatMap((file) => file.diagnostics), ...dependencyDiagnostics];
|
||||
|
||||
return {
|
||||
manifests: manifestFiles.flatMap((file) => (file.manifest ? [file.manifest] : [])),
|
||||
manifestFiles,
|
||||
diagnostics,
|
||||
ok: diagnostics.length === 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
export type FeatureManifestClassification = "core" | "plugin-candidate" | "remove-candidate" | "mixed";
|
||||
export type FeatureManifestStatus = "active" | "proposed" | "review-needed";
|
||||
export type FeatureManifestDiagnosticCode = "invalid_type" | "missing_required" | "invalid_enum" | "invalid_format" | "invalid_item";
|
||||
|
||||
export interface FeatureManifestDiagnostic {
|
||||
code: FeatureManifestDiagnosticCode;
|
||||
manifestId: string | null;
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FeatureManifestValidationContext {
|
||||
manifestId?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface FeatureManifestValidationResult {
|
||||
ok: boolean;
|
||||
manifest: FeatureManifest | null;
|
||||
diagnostics: FeatureManifestDiagnostic[];
|
||||
}
|
||||
|
||||
export interface FeatureManifestComponent {
|
||||
id: string;
|
||||
title: string;
|
||||
rationale: string;
|
||||
codeAreas: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface FeatureManifest {
|
||||
schemaVersion: "1.0";
|
||||
id: string;
|
||||
title: string;
|
||||
classification: FeatureManifestClassification;
|
||||
status: FeatureManifestStatus;
|
||||
owner: string;
|
||||
description: string;
|
||||
rationale: string[];
|
||||
codeAreas: string[];
|
||||
dependencies: string[];
|
||||
consumers: string[];
|
||||
components?: FeatureManifestComponent[];
|
||||
integration: {
|
||||
ownership: "tenant-owned" | "admin-managed" | "hybrid" | "system-only";
|
||||
surfaces: { adminRoutes: string[]; tenantRoutes: string[]; publicRoutes: string[]; apiRoutes: string[] };
|
||||
configuration: { admin: string[]; tenant: string[]; environment: string[]; secrets: string[] };
|
||||
workers: { required: boolean; queues: string[]; jobs: string[]; notes: string[] };
|
||||
externalProviders: string[];
|
||||
permissions: string[];
|
||||
auditEvents: string[];
|
||||
billingImpacts: string[];
|
||||
uat: string[];
|
||||
};
|
||||
boundaries: { mustKeep: string[]; mayMoveToPlugin: string[]; mustNotTouch: string[] };
|
||||
pruning: { phase: string; action: "keep" | "extract-plugin" | "remove" | "review"; notes: string[] };
|
||||
validation: { source: string[]; lastReviewed: string };
|
||||
}
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const PERMISSION_TOKEN_PATTERN = /^[a-z][A-Za-z0-9]*(?:\.(?:[A-Za-z][A-Za-z0-9]*|\*))+$/;
|
||||
const AUDIT_EVENT_PATTERN = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
||||
const CLASSIFICATIONS = ["core", "plugin-candidate", "remove-candidate", "mixed"] as const;
|
||||
const STATUSES = ["active", "proposed", "review-needed"] as const;
|
||||
const OWNERSHIPS = ["tenant-owned", "admin-managed", "hybrid", "system-only"] as const;
|
||||
const PRUNING_ACTIONS = ["keep", "extract-plugin", "remove", "review"] as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function add(diagnostics: FeatureManifestDiagnostic[], code: FeatureManifestDiagnosticCode, manifestId: string | null, path: string, message: string) {
|
||||
diagnostics.push({ code, manifestId, path, message });
|
||||
}
|
||||
|
||||
function manifestIdFor(input: Record<string, unknown>, context: FeatureManifestValidationContext): string | null {
|
||||
return context.manifestId ?? (typeof input.id === "string" && input.id.length > 0 ? input.id : null);
|
||||
}
|
||||
|
||||
function requireString(source: Record<string, unknown>, key: string, path: string, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null, options: { nonEmpty?: boolean; pattern?: RegExp; constValue?: string } = {}) {
|
||||
const value = source[key];
|
||||
if (value === undefined) return add(diagnostics, "missing_required", manifestId, path, `${path} is required.`);
|
||||
if (typeof value !== "string") return add(diagnostics, "invalid_type", manifestId, path, `${path} must be a string.`);
|
||||
if (options.nonEmpty && value.length === 0) add(diagnostics, "invalid_format", manifestId, path, `${path} must not be empty.`);
|
||||
if (options.constValue !== undefined && value !== options.constValue) add(diagnostics, "invalid_enum", manifestId, path, `${path} must be ${options.constValue}.`);
|
||||
if (options.pattern && !options.pattern.test(value)) add(diagnostics, "invalid_format", manifestId, path, `${path} has an invalid format.`);
|
||||
}
|
||||
|
||||
function requireEnum<T extends readonly string[]>(source: Record<string, unknown>, key: string, values: T, path: string, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null) {
|
||||
const value = source[key];
|
||||
if (value === undefined) return add(diagnostics, "missing_required", manifestId, path, `${path} is required.`);
|
||||
if (typeof value !== "string") return add(diagnostics, "invalid_type", manifestId, path, `${path} must be a string.`);
|
||||
if (!values.includes(value)) add(diagnostics, "invalid_enum", manifestId, path, `${path} must be one of: ${values.join(", ")}.`);
|
||||
}
|
||||
|
||||
function requireStringArray(source: Record<string, unknown>, key: string, path: string, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null, options: { itemPattern?: RegExp } = {}) {
|
||||
const value = source[key];
|
||||
if (value === undefined) return add(diagnostics, "missing_required", manifestId, path, `${path} is required.`);
|
||||
if (!Array.isArray(value)) return add(diagnostics, "invalid_type", manifestId, path, `${path} must be an array.`);
|
||||
value.forEach((item, index) => {
|
||||
const itemPath = `${path}.${index}`;
|
||||
if (typeof item !== "string") return add(diagnostics, "invalid_item", manifestId, itemPath, `${itemPath} must be a string.`);
|
||||
if (options.itemPattern && !options.itemPattern.test(item)) add(diagnostics, "invalid_format", manifestId, itemPath, `${itemPath} has an invalid format.`);
|
||||
});
|
||||
}
|
||||
|
||||
function requireObject(source: Record<string, unknown>, key: string, path: string, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null): Record<string, unknown> | null {
|
||||
const value = source[key];
|
||||
if (value === undefined) {
|
||||
add(diagnostics, "missing_required", manifestId, path, `${path} is required.`);
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
add(diagnostics, "invalid_type", manifestId, path, `${path} must be an object.`);
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireBoolean(source: Record<string, unknown>, key: string, path: string, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null) {
|
||||
const value = source[key];
|
||||
if (value === undefined) return add(diagnostics, "missing_required", manifestId, path, `${path} is required.`);
|
||||
if (typeof value !== "boolean") add(diagnostics, "invalid_type", manifestId, path, `${path} must be a boolean.`);
|
||||
}
|
||||
|
||||
function validateComponents(input: Record<string, unknown>, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null) {
|
||||
if (input.components === undefined) return;
|
||||
if (!Array.isArray(input.components)) return add(diagnostics, "invalid_type", manifestId, "components", "components must be an array.");
|
||||
input.components.forEach((component, index) => {
|
||||
const path = `components.${index}`;
|
||||
if (!isRecord(component)) return add(diagnostics, "invalid_item", manifestId, path, `${path} must be an object.`);
|
||||
requireString(component, "id", `${path}.id`, diagnostics, manifestId, { pattern: ID_PATTERN });
|
||||
requireString(component, "title", `${path}.title`, diagnostics, manifestId);
|
||||
requireString(component, "rationale", `${path}.rationale`, diagnostics, manifestId);
|
||||
requireStringArray(component, "codeAreas", `${path}.codeAreas`, diagnostics, manifestId);
|
||||
if (component.notes !== undefined && typeof component.notes !== "string") add(diagnostics, "invalid_type", manifestId, `${path}.notes`, `${path}.notes must be a string.`);
|
||||
});
|
||||
}
|
||||
|
||||
function validateIntegration(input: Record<string, unknown>, diagnostics: FeatureManifestDiagnostic[], manifestId: string | null) {
|
||||
const integration = requireObject(input, "integration", "integration", diagnostics, manifestId);
|
||||
if (!integration) return;
|
||||
requireEnum(integration, "ownership", OWNERSHIPS, "integration.ownership", diagnostics, manifestId);
|
||||
const surfaces = requireObject(integration, "surfaces", "integration.surfaces", diagnostics, manifestId);
|
||||
if (surfaces) {
|
||||
requireStringArray(surfaces, "adminRoutes", "integration.surfaces.adminRoutes", diagnostics, manifestId);
|
||||
requireStringArray(surfaces, "tenantRoutes", "integration.surfaces.tenantRoutes", diagnostics, manifestId);
|
||||
requireStringArray(surfaces, "publicRoutes", "integration.surfaces.publicRoutes", diagnostics, manifestId);
|
||||
requireStringArray(surfaces, "apiRoutes", "integration.surfaces.apiRoutes", diagnostics, manifestId);
|
||||
}
|
||||
const configuration = requireObject(integration, "configuration", "integration.configuration", diagnostics, manifestId);
|
||||
if (configuration) {
|
||||
requireStringArray(configuration, "admin", "integration.configuration.admin", diagnostics, manifestId);
|
||||
requireStringArray(configuration, "tenant", "integration.configuration.tenant", diagnostics, manifestId);
|
||||
requireStringArray(configuration, "environment", "integration.configuration.environment", diagnostics, manifestId);
|
||||
requireStringArray(configuration, "secrets", "integration.configuration.secrets", diagnostics, manifestId);
|
||||
}
|
||||
const workers = requireObject(integration, "workers", "integration.workers", diagnostics, manifestId);
|
||||
if (workers) {
|
||||
requireBoolean(workers, "required", "integration.workers.required", diagnostics, manifestId);
|
||||
requireStringArray(workers, "queues", "integration.workers.queues", diagnostics, manifestId);
|
||||
requireStringArray(workers, "jobs", "integration.workers.jobs", diagnostics, manifestId);
|
||||
requireStringArray(workers, "notes", "integration.workers.notes", diagnostics, manifestId);
|
||||
}
|
||||
requireStringArray(integration, "externalProviders", "integration.externalProviders", diagnostics, manifestId);
|
||||
requireStringArray(integration, "permissions", "integration.permissions", diagnostics, manifestId, { itemPattern: PERMISSION_TOKEN_PATTERN });
|
||||
requireStringArray(integration, "auditEvents", "integration.auditEvents", diagnostics, manifestId, { itemPattern: AUDIT_EVENT_PATTERN });
|
||||
requireStringArray(integration, "billingImpacts", "integration.billingImpacts", diagnostics, manifestId);
|
||||
requireStringArray(integration, "uat", "integration.uat", diagnostics, manifestId);
|
||||
}
|
||||
|
||||
function isValidDate(value: string) {
|
||||
if (!DATE_PATTERN.test(value)) return false;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
export function validateFeatureManifestShape(input: unknown, context: FeatureManifestValidationContext = {}): FeatureManifestValidationResult {
|
||||
if (!isRecord(input)) {
|
||||
const path = context.path ?? "$";
|
||||
return { ok: false, manifest: null, diagnostics: [{ code: "invalid_type", manifestId: context.manifestId ?? null, path, message: `${path} must be an object.` }] };
|
||||
}
|
||||
const diagnostics: FeatureManifestDiagnostic[] = [];
|
||||
const manifestId = manifestIdFor(input, context);
|
||||
requireString(input, "schemaVersion", "schemaVersion", diagnostics, manifestId, { constValue: "1.0" });
|
||||
requireString(input, "id", "id", diagnostics, manifestId, { pattern: ID_PATTERN });
|
||||
requireString(input, "title", "title", diagnostics, manifestId, { nonEmpty: true });
|
||||
requireEnum(input, "classification", CLASSIFICATIONS, "classification", diagnostics, manifestId);
|
||||
requireEnum(input, "status", STATUSES, "status", diagnostics, manifestId);
|
||||
requireString(input, "owner", "owner", diagnostics, manifestId);
|
||||
requireString(input, "description", "description", diagnostics, manifestId);
|
||||
requireStringArray(input, "rationale", "rationale", diagnostics, manifestId);
|
||||
requireStringArray(input, "codeAreas", "codeAreas", diagnostics, manifestId);
|
||||
requireStringArray(input, "dependencies", "dependencies", diagnostics, manifestId, { itemPattern: ID_PATTERN });
|
||||
requireStringArray(input, "consumers", "consumers", diagnostics, manifestId);
|
||||
validateComponents(input, diagnostics, manifestId);
|
||||
validateIntegration(input, diagnostics, manifestId);
|
||||
const boundaries = requireObject(input, "boundaries", "boundaries", diagnostics, manifestId);
|
||||
if (boundaries) {
|
||||
requireStringArray(boundaries, "mustKeep", "boundaries.mustKeep", diagnostics, manifestId);
|
||||
requireStringArray(boundaries, "mayMoveToPlugin", "boundaries.mayMoveToPlugin", diagnostics, manifestId);
|
||||
requireStringArray(boundaries, "mustNotTouch", "boundaries.mustNotTouch", diagnostics, manifestId);
|
||||
}
|
||||
const pruning = requireObject(input, "pruning", "pruning", diagnostics, manifestId);
|
||||
if (pruning) {
|
||||
requireString(pruning, "phase", "pruning.phase", diagnostics, manifestId);
|
||||
requireEnum(pruning, "action", PRUNING_ACTIONS, "pruning.action", diagnostics, manifestId);
|
||||
requireStringArray(pruning, "notes", "pruning.notes", diagnostics, manifestId);
|
||||
}
|
||||
const validation = requireObject(input, "validation", "validation", diagnostics, manifestId);
|
||||
if (validation) {
|
||||
requireStringArray(validation, "source", "validation.source", diagnostics, manifestId);
|
||||
requireString(validation, "lastReviewed", "validation.lastReviewed", diagnostics, manifestId);
|
||||
if (typeof validation.lastReviewed === "string" && !isValidDate(validation.lastReviewed)) add(diagnostics, "invalid_format", manifestId, "validation.lastReviewed", "validation.lastReviewed must be an ISO date in YYYY-MM-DD format.");
|
||||
}
|
||||
return { ok: diagnostics.length === 0, manifest: diagnostics.length === 0 ? (input as unknown as FeatureManifest) : null, diagnostics };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import pluginCandidates from "../../../docs/feature-manifests/plugin-candidates.json";
|
||||
import { loadFeatureManifests } from "../src/manifest-loader";
|
||||
|
||||
function manifestWith(overrides: Record<string, unknown>) {
|
||||
return structuredClone({ ...pluginCandidates, ...overrides });
|
||||
}
|
||||
|
||||
async function makeFixtureDirectory() {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "feature-manifests-"));
|
||||
const manifestDir = path.join(rootDir, "docs", "feature-manifests");
|
||||
await mkdir(manifestDir, { recursive: true });
|
||||
return { rootDir, manifestDir };
|
||||
}
|
||||
|
||||
async function writeManifest(manifestDir: string, fileName: string, overrides: Record<string, unknown> = {}) {
|
||||
const manifest = manifestWith(overrides);
|
||||
await writeFile(path.join(manifestDir, fileName), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
return manifest;
|
||||
}
|
||||
|
||||
describe("loadFeatureManifests", () => {
|
||||
it("loads valid docs manifests in deterministic order and skips the schema by default", async () => {
|
||||
const result = await loadFeatureManifests();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toContain("plugin-candidates");
|
||||
expect(result.manifestFiles.map((file) => file.fileName)).toEqual([...result.manifestFiles.map((file) => file.fileName)].sort((a, b) => a.localeCompare(b)));
|
||||
expect(result.manifestFiles.some((file) => file.fileName === "schema.json")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a missing manifest directory", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "feature-manifests-missing-"));
|
||||
const result = await loadFeatureManifests({ rootDir });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, manifests: [], manifestFiles: [] });
|
||||
expect(result.diagnostics[0]).toMatchObject({ code: "missing_directory", manifestId: null, path: path.join(rootDir, "docs", "feature-manifests") });
|
||||
});
|
||||
|
||||
it("preserves sibling valid manifests when one file contains malformed JSON", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "plugin-candidates.json", { id: "plugin-candidates", dependencies: [] });
|
||||
await writeFile(path.join(manifestDir, "broken.json"), "{ not json", "utf8");
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toEqual(["plugin-candidates"]);
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "invalid_json", manifestId: null, path: path.join(manifestDir, "broken.json") }));
|
||||
});
|
||||
|
||||
it("loads from an absolute manifest directory", async () => {
|
||||
const { manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "plugin-candidates.json", { id: "plugin-candidates", dependencies: [] });
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir: "/ignored/root", manifestDir });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toEqual(["plugin-candidates"]);
|
||||
expect(result.manifestFiles[0]).toMatchObject({
|
||||
fileName: "plugin-candidates.json",
|
||||
filePath: path.join(manifestDir, "plugin-candidates.json"),
|
||||
manifestId: "plugin-candidates",
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("can include schema-like json files when requested", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "schema.json", { id: "schema-file", dependencies: [] });
|
||||
await writeManifest(manifestDir, "plugin-candidates.json", { id: "plugin-candidates", dependencies: [] });
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir, includeSchema: true });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.manifestFiles.map((file) => file.fileName)).toEqual(["plugin-candidates.json", "schema.json"]);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toEqual(["plugin-candidates", "schema-file"]);
|
||||
});
|
||||
|
||||
it("uses custom schema file names for the default skip rule", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "manifest.schema.json", { id: "custom-schema", dependencies: [] });
|
||||
await writeManifest(manifestDir, "schema.json", { id: "ordinary-schema-name", dependencies: [] });
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir, schemaFileName: "manifest.schema.json" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.manifestFiles.map((file) => file.fileName)).toEqual(["schema.json"]);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toEqual(["ordinary-schema-name"]);
|
||||
});
|
||||
|
||||
it("keeps manifest ids null for non-object json payloads", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
const filePath = path.join(manifestDir, "array.json");
|
||||
await writeFile(filePath, "[]\n", "utf8");
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.manifests).toEqual([]);
|
||||
expect(result.manifestFiles).toEqual([
|
||||
{
|
||||
fileName: "array.json",
|
||||
filePath,
|
||||
manifestId: null,
|
||||
manifest: null,
|
||||
diagnostics: [{ code: "invalid_type", manifestId: null, path: `${filePath}#${filePath}`, message: `${filePath} must be an object.` }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports invalid manifest shape diagnostics with source file metadata", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "invalid-shape.json", { id: "invalid-shape", classification: "optional", dependencies: [] });
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.manifests).toEqual([]);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_enum",
|
||||
manifestId: "invalid-shape",
|
||||
path: `${path.join(manifestDir, "invalid-shape.json")}#classification`,
|
||||
message: "classification must be one of: core, plugin-candidate, remove-candidate, mixed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing dependency references after loading valid manifests", async () => {
|
||||
const { rootDir, manifestDir } = await makeFixtureDirectory();
|
||||
await writeManifest(manifestDir, "plugin-candidates.json", { id: "plugin-candidates", dependencies: ["missing-feature"] });
|
||||
|
||||
const result = await loadFeatureManifests({ rootDir });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.manifests.map((manifest) => manifest.id)).toEqual(["plugin-candidates"]);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "missing_dependency",
|
||||
manifestId: "plugin-candidates",
|
||||
path: `${path.join(manifestDir, "plugin-candidates.json")}#dependencies`,
|
||||
message: "plugin-candidates depends on missing feature manifest missing-feature.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import pluginCandidates from "../../../docs/feature-manifests/plugin-candidates.json";
|
||||
import { validateFeatureManifestShape } from "../src/index";
|
||||
|
||||
function manifestWith(overrides: Record<string, unknown>) {
|
||||
return structuredClone({ ...pluginCandidates, ...overrides });
|
||||
}
|
||||
|
||||
describe("validateFeatureManifestShape", () => {
|
||||
it("accepts the plugin candidates fixture", () => {
|
||||
const result = validateFeatureManifestShape(pluginCandidates, { path: "docs/feature-manifests/plugin-candidates.json" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.manifest?.id).toBe("plugin-candidates");
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports invalid classification enum values with manifest context", () => {
|
||||
const result = validateFeatureManifestShape(manifestWith({ classification: "optional" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_enum",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "classification",
|
||||
message: "classification must be one of: core, plugin-candidate, remove-candidate, mixed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing integration worker sections", () => {
|
||||
const integration = structuredClone(pluginCandidates.integration) as Record<string, unknown>;
|
||||
delete integration.workers;
|
||||
const result = validateFeatureManifestShape(manifestWith({ integration }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "missing_required",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.workers",
|
||||
message: "integration.workers is required.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports non-array dependencies", () => {
|
||||
const result = validateFeatureManifestShape(manifestWith({ dependencies: "core" }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_type",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "dependencies",
|
||||
message: "dependencies must be an array.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid component ids", () => {
|
||||
const components = structuredClone(pluginCandidates.components);
|
||||
components[0] = { ...components[0], id: "CRM" };
|
||||
const result = validateFeatureManifestShape(manifestWith({ components }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "components.0.id",
|
||||
message: "components.0.id has an invalid format.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid optional component collections and notes", () => {
|
||||
const components = [false, { ...pluginCandidates.components[0], notes: ["not prose"] }];
|
||||
const result = validateFeatureManifestShape(manifestWith({ components }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_item",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "components.0",
|
||||
message: "components.0 must be an object.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_type",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "components.1.notes",
|
||||
message: "components.1.notes must be a string.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid nested integration section types", () => {
|
||||
const integration = {
|
||||
...pluginCandidates.integration,
|
||||
surfaces: [],
|
||||
configuration: "env",
|
||||
workers: { ...pluginCandidates.integration.workers, required: "yes", queues: ["mail", 7] },
|
||||
};
|
||||
const result = validateFeatureManifestShape(manifestWith({ integration }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_type",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.surfaces",
|
||||
message: "integration.surfaces must be an object.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_type",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.configuration",
|
||||
message: "integration.configuration must be an object.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_type",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.workers.required",
|
||||
message: "integration.workers.required must be a boolean.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_item",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.workers.queues.1",
|
||||
message: "integration.workers.queues.1 must be a string.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid top-level constants, empty strings, and dependency tokens", () => {
|
||||
const result = validateFeatureManifestShape(manifestWith({ schemaVersion: "2.0", title: "", dependencies: ["CRM"] }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_enum",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "schemaVersion",
|
||||
message: "schemaVersion must be 1.0.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "title",
|
||||
message: "title must not be empty.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "dependencies.0",
|
||||
message: "dependencies.0 has an invalid format.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses context manifest ids when the input id is empty", () => {
|
||||
const result = validateFeatureManifestShape(manifestWith({ id: "" }), { manifestId: "fixture-id" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "fixture-id",
|
||||
path: "id",
|
||||
message: "id has an invalid format.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports invalid lastReviewed dates", () => {
|
||||
const validation = { ...pluginCandidates.validation, lastReviewed: "2026-99-05" };
|
||||
const result = validateFeatureManifestShape(manifestWith({ validation }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "validation.lastReviewed",
|
||||
message: "validation.lastReviewed must be an ISO date in YYYY-MM-DD format.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects prose in permission and audit-event token arrays", () => {
|
||||
const integration = {
|
||||
...pluginCandidates.integration,
|
||||
permissions: ["admin.email.* when settings are exposed"],
|
||||
auditEvents: ["Provider key updated"],
|
||||
};
|
||||
const result = validateFeatureManifestShape(manifestWith({ integration }));
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.permissions.0",
|
||||
message: "integration.permissions.0 has an invalid format.",
|
||||
});
|
||||
expect(result.diagnostics).toContainEqual({
|
||||
code: "invalid_format",
|
||||
manifestId: "plugin-candidates",
|
||||
path: "integration.auditEvents.0",
|
||||
message: "integration.auditEvents.0 has an invalid format.",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts wildcard and camel-case permission tokens plus dot-separated audit events", () => {
|
||||
const integration = {
|
||||
...pluginCandidates.integration,
|
||||
permissions: ["admin.emails.*", "app.settings.apiKeys.view"],
|
||||
auditEvents: ["mail.provider.updated", "admin.ai.provider_key.updated"],
|
||||
};
|
||||
const result = validateFeatureManifestShape(manifestWith({ integration }));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns diagnostics instead of throwing for non-object input", () => {
|
||||
const result = validateFeatureManifestShape(null, { manifestId: "bad", path: "fixture" });
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
manifest: null,
|
||||
diagnostics: [{ code: "invalid_type", manifestId: "bad", path: "fixture", message: "fixture must be an object." }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user