feat(cli): prepare and validate workspace documents offline
Reuse the runtime catalog and workspace parsers in a standalone helper paired with tht. Add document templates, safe diagnostics, local Evidence checks, native bundle builds, shared CLI fixtures and IT/EN preparation guides. Record the approved document-first specification and ticket breakdown. Refs #43.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
/** Compiled with its runtime for the host CLI: no installation, Docker or host Node required. */
|
||||
import { runWorkspaceDocuments } from "./workspaces/documents.js";
|
||||
|
||||
const result = runWorkspaceDocuments(process.argv.slice(2));
|
||||
console.log(result.output);
|
||||
process.exitCode = result.status;
|
||||
@@ -44,8 +44,8 @@ const catalogSchema = z.object({
|
||||
});
|
||||
});
|
||||
|
||||
function safeCatalogError(): Error {
|
||||
return new Error("Workspace catalog is invalid");
|
||||
function safeCatalogError(cause?: unknown): Error {
|
||||
return new Error("Workspace catalog is invalid", { cause });
|
||||
}
|
||||
|
||||
export function parseWorkspaceCatalogYaml(source: string): WorkspaceCatalog {
|
||||
@@ -57,7 +57,7 @@ export function parseWorkspaceCatalogYaml(source: string): WorkspaceCatalog {
|
||||
return catalogSchema.parse(document.toJSON()) as WorkspaceCatalog;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "Workspace catalog is invalid") throw error;
|
||||
throw safeCatalogError();
|
||||
throw safeCatalogError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parseAllDocuments, stringify } from "yaml";
|
||||
import { ZodError } from "zod";
|
||||
import { CATALOG_PATH, parseWorkspaceCatalogYaml, assertCatalogMatchesDescriptor } from "./catalog.js";
|
||||
import { parseWorkspaceYaml, type WorkspaceDescriptor } from "./schema.js";
|
||||
|
||||
interface Issue { document: string; field: string; code: string; correction: string; line?: number }
|
||||
interface Report {
|
||||
schema_version: 1;
|
||||
scope: "local-documents";
|
||||
ok: boolean;
|
||||
workspaces: { id: string; evidence: "absent" | "local-files" | "remote-deferred" }[];
|
||||
issues: Issue[];
|
||||
deferred_checks: string[];
|
||||
}
|
||||
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
||||
const usage = "tht workspace prepare --directory NEW_PATH --id ID --name NAME [--language en|it] [--json]\ntht workspace validate --directory PATH [--json]";
|
||||
|
||||
class DocumentError extends Error {
|
||||
constructor(readonly issue: Issue) { super(issue.correction); }
|
||||
}
|
||||
function fail(document: string, field: string, code: string, correction: string): never {
|
||||
throw new DocumentError({ document, field, code, correction });
|
||||
}
|
||||
|
||||
/** Never include parser messages or submitted values: YAML and Zod errors can contain secrets. */
|
||||
function decode<T>(source: string, document: string, parser: (text: string) => T): T {
|
||||
try {
|
||||
const documents = parseAllDocuments(source, { uniqueKeys: true });
|
||||
if (documents.length !== 1) fail(document, "$", "yaml_documents", "Keep exactly one YAML document in this file.");
|
||||
const problem = [...documents[0].errors, ...documents[0].warnings][0];
|
||||
if (problem) {
|
||||
throw new DocumentError({ document, field: "$", code: "yaml_syntax", line: problem.linePos?.[0].line,
|
||||
correction: "Correct YAML syntax, remove duplicate keys and unsupported tags at the indicated line." });
|
||||
}
|
||||
return parser(source);
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentError) throw error;
|
||||
const cause = error instanceof Error && error.cause instanceof ZodError ? error.cause : error;
|
||||
if (cause instanceof ZodError) {
|
||||
const issue = cause.issues[0];
|
||||
// Strict schemas produce paths containing schema-defined keys and array indices only.
|
||||
fail(document, issue.path.join(".") || "$", "schema_invalid",
|
||||
issue.code === "unrecognized_keys" ? "Remove fields not defined by the current workspace/catalog contract."
|
||||
: "Correct this field using workspace schema v4 or catalog schema v1; check type, required value, uniqueness and allowed values.");
|
||||
}
|
||||
fail(document, "$", "schema_invalid", "Use an authored workspace v4 descriptor; remove database configuration and keep it in the PostgreSQL Metadata Catalog.");
|
||||
}
|
||||
}
|
||||
|
||||
function stat(root: string, document: string, directory: boolean) {
|
||||
let info;
|
||||
try { info = lstatSync(join(root, document)); }
|
||||
catch { fail(document, "$", "missing_reference", "Create the referenced local file or directory and grant read access."); }
|
||||
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
|
||||
fail(document, "$", "unsafe_reference", "Use a regular local file or directory, without symbolic links or special files.");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
function readDocument(root: string, document: string): string {
|
||||
if (stat(root, document, false).size > MAX_DOCUMENT_BYTES) {
|
||||
fail(document, "$", "document_too_large", "Keep YAML documents below the 1 MiB local validation limit.");
|
||||
}
|
||||
try { return readFileSync(join(root, document), "utf8"); }
|
||||
catch { fail(document, "$", "unreadable", "Grant read access to this document and retry validation."); }
|
||||
}
|
||||
function directories(root: string, document: string) {
|
||||
stat(root, document, true);
|
||||
try { return readdirSync(join(root, document), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
||||
catch { fail(document, "$", "unreadable", "Grant read and traversal access to this directory and retry validation."); }
|
||||
}
|
||||
|
||||
function inspectEvidence(root: string, descriptor: WorkspaceDescriptor): "absent" | "local-files" | "remote-deferred" {
|
||||
const evidence = descriptor.evidence;
|
||||
if (!evidence) return "absent";
|
||||
if (evidence.source.type !== "filesystem") return "remote-deferred";
|
||||
const base = evidence.source.uri;
|
||||
stat(root, base, true);
|
||||
if (evidence.schema_version === 2) stat(root, `${base}/curated`, true);
|
||||
const patterns = evidence.source.patterns ?? ["**/*.md"];
|
||||
const literals = patterns.filter((pattern) => !/[?*\[]/.test(pattern));
|
||||
for (const pattern of literals) {
|
||||
const parts = pattern.split("/");
|
||||
for (let index = 1; index < parts.length; index++) stat(root, `${base}/${parts.slice(0, index).join("/")}`, true);
|
||||
stat(root, `${base}/${pattern}`, false);
|
||||
}
|
||||
// Inventory without following links; curated-unit interpretation remains a runtime check.
|
||||
const pending = [base];
|
||||
let count = 0;
|
||||
while (pending.length) {
|
||||
const current = pending.pop()!;
|
||||
for (const entry of directories(root, current)) {
|
||||
if (++count > 100_000) fail(base, "evidence.source", "inventory_limit", "Reduce the Evidence tree below 100,000 entries before local validation.");
|
||||
const path = `${current}/${entry.name}`;
|
||||
if (entry.isDirectory()) pending.push(path);
|
||||
else {
|
||||
const info = stat(root, path, false);
|
||||
const relative = path.slice(base.length + 1);
|
||||
// Only apply content checks to selections whose meaning is unambiguous locally.
|
||||
// Arbitrary globs are expanded by the canonical Python adapter after startup.
|
||||
const curated = evidence.schema_version === 2 && relative.startsWith("curated/") && relative.endsWith(".md");
|
||||
const selected = curated || literals.includes(relative) || (patterns.includes("**/*.md") && relative.endsWith(".md"));
|
||||
if (selected && info.size > evidence.source.max_bytes) fail(path, "evidence.source.max_bytes", "evidence_too_large", "Reduce the source file or increase the declared max_bytes limit deliberately.");
|
||||
try { closeSync(openSync(join(root, path), "r")); }
|
||||
catch { fail(path, "$", "unreadable", "Grant read access to this Evidence file and retry validation."); }
|
||||
if (curated) {
|
||||
const text = readDocument(root, path);
|
||||
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!frontmatter) fail(path, "$", "evidence_frontmatter", "Add a YAML frontmatter block delimited by --- to the curated Markdown unit; follow the Curated Evidence contract.");
|
||||
decode(frontmatter[1], path, (source) => parseAllDocuments(source)[0].toJSON());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "local-files";
|
||||
}
|
||||
|
||||
function validate(root: string, report: Report): void {
|
||||
directories(root, ".");
|
||||
const catalog = decode(readDocument(root, CATALOG_PATH), CATALOG_PATH, parseWorkspaceCatalogYaml);
|
||||
const ids = new Set(catalog.workspaces.map((entry) => entry.id));
|
||||
for (const entry of directories(root, ".")) {
|
||||
if (entry.name === ".git") continue;
|
||||
if (entry.isSymbolicLink()) fail(".", "$", "unsafe_reference", "Replace repository-root symbolic links with regular files or directories.");
|
||||
if (entry.isDirectory() && entry.name !== "workspace-docs" && !ids.has(entry.name)) {
|
||||
fail(".", "workspaces", "unlisted_directory", "Every root directory except workspace-docs must match a catalog workspace id; remove or register the extra directory.");
|
||||
}
|
||||
if (entry.name === "workspace-docs") {
|
||||
for (const docs of directories(root, "workspace-docs")) {
|
||||
if (!ids.has(docs.name)) fail("workspace-docs", "$", "unlisted_documentation", "Keep documentation only for workspace ids listed in the catalog.");
|
||||
for (const file of directories(root, `workspace-docs/${docs.name}`)) {
|
||||
const path = `workspace-docs/${docs.name}/${file.name}`;
|
||||
if (!["README.md", "contract.env.example"].includes(file.name)) fail(`workspace-docs/${docs.name}`, "$", "unsupported_documentation", "Keep only README.md and contract.env.example in workspace-docs/<id>. Place Evidence inside the workspace directory.");
|
||||
stat(root, path, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const entry of catalog.workspaces) {
|
||||
const document = `${entry.id}/workspace.yaml`;
|
||||
try {
|
||||
stat(root, entry.id, true);
|
||||
const descriptor = decode(readDocument(root, document), document, parseWorkspaceYaml);
|
||||
try { assertCatalogMatchesDescriptor(entry, descriptor); }
|
||||
catch { fail(document, "workspace", "catalog_mismatch", "Make id, name and description identical in the catalog, descriptor and workspace directory name."); }
|
||||
const evidence = inspectEvidence(root, descriptor);
|
||||
report.workspaces.push({ id: entry.id, evidence });
|
||||
if (evidence !== "absent") report.deferred_checks.push(`${entry.id}:evidence-source-selection`, `${entry.id}:evidence-content-provenance-and-indexing`);
|
||||
if (evidence === "remote-deferred") report.deferred_checks.push(`${entry.id}:remote-evidence-access`);
|
||||
} catch (error) {
|
||||
if (error instanceof DocumentError) report.issues.push(error.issue);
|
||||
else throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepare(root: string, options: Map<string, string>): void {
|
||||
const id = options.get("--id");
|
||||
const name = options.get("--name");
|
||||
const language = options.get("--language") ?? "en";
|
||||
const catalog = stringify({ schema_version: 1, workspaces: [{ id, name }] });
|
||||
const descriptor = stringify({ workspace: { schema_version: 4, id, name, language } });
|
||||
decode(catalog, CATALOG_PATH, parseWorkspaceCatalogYaml);
|
||||
decode(descriptor, "workspace.yaml", parseWorkspaceYaml);
|
||||
try { mkdirSync(root); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") fail(".", "--directory", "destination_exists", "Choose a new directory; preparation never overwrites an existing directory or its documents.");
|
||||
fail(".", "--directory", "destination_unavailable", "Create the parent directory and grant write access, then choose a new destination.");
|
||||
}
|
||||
try {
|
||||
mkdirSync(join(root, id!));
|
||||
mkdirSync(join(root, "workspace-docs", id!), { recursive: true });
|
||||
writeFileSync(join(root, CATALOG_PATH), "# Index of workspace identities. Keep metadata identical to each descriptor.\n" + catalog, { flag: "wx" });
|
||||
writeFileSync(join(root, id!, "workspace.yaml"), "# Authored workspace v4: optional Evidence; database binding belongs to the Metadata Catalog.\n" + descriptor, { flag: "wx" });
|
||||
writeFileSync(join(root, "workspace-docs", id!, "README.md"),
|
||||
"# Workspace documents / Documenti workspace\n\n" +
|
||||
"EN: Edit thoth-workspaces.yaml and <id>/workspace.yaml together. Evidence is optional and absent by default. Add reviewed source material under <id>/evidence only when configured. Database connections and schema belong to the installation Metadata Catalog. No example databases are downloaded.\n\n" +
|
||||
"IT: Modificare insieme thoth-workspaces.yaml e <id>/workspace.yaml. Le Evidence sono facoltative e inizialmente assenti. Inserire materiale verificato in <id>/evidence solo quando configurato. Connessioni e schema dei database appartengono al Metadata Catalog dell'installazione. Nessun database di esempio viene scaricato.\n\n" +
|
||||
"Repeat / Ripetere: `tht workspace validate --directory <repository>`. Local success does not establish runtime readiness or semantic truth / Il successo locale non certifica readiness o verità semantica.\n", { flag: "wx" });
|
||||
} catch {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
fail(".", "$", "prepare_failed", "Preparation could not write the documents; check disk space and permissions, then retry with a new directory.");
|
||||
}
|
||||
}
|
||||
|
||||
export function runWorkspaceDocuments(args: string[]): { status: number; output: string } {
|
||||
const report: Report = { schema_version: 1, scope: "local-documents", ok: false, workspaces: [], issues: [], deferred_checks: ["catalog-database-binding", "database-connectivity", "runtime-preprocessing"] };
|
||||
const json = args.includes("--json");
|
||||
let status = 1;
|
||||
try {
|
||||
const [command, ...rest] = args;
|
||||
if ((command === "prepare" || command === "validate") && rest.length === 1 && rest[0] === "--help") return { status: 0, output: usage };
|
||||
const allowed = command === "prepare" ? ["--directory", "--id", "--name", "--language"] : command === "validate" ? ["--directory"] : [];
|
||||
const options = new Map<string, string>();
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
const key = rest[index];
|
||||
if (seen.has(key)) fail("CLI", "$", "usage", usage);
|
||||
seen.add(key);
|
||||
if (key === "--json") continue;
|
||||
if (!allowed.includes(key) || !rest[index + 1] || rest[index + 1].startsWith("--")) fail("CLI", "$", "usage", usage);
|
||||
options.set(key, rest[++index]);
|
||||
}
|
||||
if (!allowed.length || !options.has("--directory") || (command === "prepare" && (!options.has("--id") || !options.has("--name")))) fail("CLI", "$", "usage", usage);
|
||||
const root = resolve(options.get("--directory")!);
|
||||
if (command === "prepare") prepare(root, options);
|
||||
validate(root, report);
|
||||
report.ok = report.issues.length === 0;
|
||||
status = report.ok ? 0 : 1;
|
||||
} catch (error) {
|
||||
report.issues.push(error instanceof DocumentError ? error.issue : { document: ".", field: "$", code: "io_error", correction: "Check local permissions and regular files, then retry; no services were started." });
|
||||
if (report.issues[0].code === "usage") status = 2;
|
||||
}
|
||||
const output = json ? JSON.stringify(report) : [
|
||||
report.ok ? "Workspace documents pass local validation." : "Workspace documents require corrections.",
|
||||
...report.issues.map((issue) => `${issue.document}${issue.line ? `:${issue.line}` : ""} [${issue.field}] ${issue.code}: ${issue.correction}`),
|
||||
...report.workspaces.map((entry) => `${entry.id}: Evidence ${entry.evidence}`),
|
||||
`Deferred until runtime: ${report.deferred_checks.join(", ")}.`,
|
||||
].join("\n");
|
||||
return { status, output };
|
||||
}
|
||||
Reference in New Issue
Block a user