Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/create-feature-manifest.mjs
T

139 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifestRoot = path.join(repoRoot, "docs", "feature-manifests");
const classifications = new Set(["core", "plugin-candidate", "remove-candidate", "mixed"]);
const ownerships = new Set(["tenant-owned", "admin-managed", "hybrid", "system-only"]);
export function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument: ${token}`);
}
const key = token.slice(2);
if (key === "force") {
args.force = true;
continue;
}
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for --${key}`);
}
args[key] = value;
index += 1;
}
return args;
}
export function createFeatureManifest({
id,
title,
classification = "plugin-candidate",
ownership = "hybrid",
owner = "boilerplate",
description = "Describe the feature contract before implementation.",
lastReviewed = process.env.TOOLCTRL_MANIFEST_DATE ?? new Date().toISOString().slice(0, 10),
}) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(id ?? "")) {
throw new Error("--id must match ^[a-z0-9][a-z0-9-]*$");
}
if (!title?.trim()) {
throw new Error("--title is required");
}
if (!classifications.has(classification)) {
throw new Error(`--classification must be one of ${Array.from(classifications).join(", ")}`);
}
if (!ownerships.has(ownership)) {
throw new Error(`--ownership must be one of ${Array.from(ownerships).join(", ")}`);
}
const pruningAction = classification === "core" ? "keep" : classification === "remove-candidate" ? "remove" : classification === "plugin-candidate" ? "extract-plugin" : "review";
return {
schemaVersion: "1.0",
id,
title,
classification,
status: "proposed",
owner,
description,
rationale: ["Document why this belongs in the boilerplate, a plugin, or the removal backlog before code changes land."],
codeAreas: ["Add concrete source paths before implementation, for example apps/web/app/routes/... or packages/..."],
dependencies: [],
consumers: [],
integration: {
ownership,
surfaces: {
adminRoutes: [],
tenantRoutes: [],
publicRoutes: [],
apiRoutes: [],
},
configuration: {
admin: [],
tenant: [],
environment: [],
secrets: [],
},
workers: {
required: false,
queues: [],
jobs: [],
notes: [],
},
externalProviders: [],
permissions: ["app.feature.view"],
auditEvents: ["feature.viewed"],
billingImpacts: ["None declared yet; confirm plan gates, metering, and provider costs before implementation."],
uat: ["Verify positive access, denied access, tenant isolation, i18n output, and host-aware routing for this feature."],
},
boundaries: {
mustKeep: [],
mayMoveToPlugin: [],
mustNotTouch: [],
},
pruning: {
phase: "planning",
action: pruningAction,
notes: ["Generated scaffold. Replace placeholders before treating this manifest as implementation-ready."],
},
validation: {
source: [`docs/feature-manifests/${id}.json`],
lastReviewed,
},
};
}
export function writeFeatureManifest(args, root = repoRoot) {
const manifest = createFeatureManifest(args);
const target = path.join(root, "docs", "feature-manifests", `${manifest.id}.json`);
if (fs.existsSync(target) && !args.force) {
throw new Error(`${path.relative(root, target)} already exists. Pass --force to overwrite.`);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`);
return { manifest, target };
}
function printUsage() {
console.error("Usage: node scripts/create-feature-manifest.mjs --id my-feature --title \"My Feature\" [--classification plugin-candidate] [--ownership hybrid] [--owner boilerplate] [--description text] [--force]");
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
try {
const result = writeFeatureManifest(parseArgs(process.argv.slice(2)));
console.log(`Created ${path.relative(repoRoot, result.target)}`);
} catch (error) {
printUsage();
console.error(`FAIL ${error.message}`);
process.exit(1);
}
}