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:
Codex
2026-09-28 15:35:25 +02:00
parent 67ee52624c
commit 64e6b9664a
20 changed files with 2195 additions and 4 deletions
+6
View File
@@ -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;
+3 -3
View File
@@ -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);
}
}
+222
View File
@@ -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 };
}