import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, expect, test } from "vitest"; import { parseWorkspaceCatalogYaml } from "../src/workspaces/catalog.js"; import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; const roots: string[] = []; function directory() { const root = mkdtempSync(join(tmpdir(), "tht-documents-")); roots.push(root); return join(root, "workspaces"); } afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); function cli(...args: string[]) { const packaged = process.env.THT_WORKSPACE_TEST_CLI; const result = spawnSync(packaged ?? process.execPath, [...(packaged ? ["workspace"] : ["--import", "tsx", resolve("src/workspace-documents-cli.ts")]), ...args, "--json"], { encoding: "utf8", env: { ...process.env, PATH: "" }, }); return { ...result, report: result.stdout.trim() ? JSON.parse(result.stdout) : null }; } const descriptor = "workspace:\n schema_version: 4\n id: practice\n name: Practice\n language: en\n"; const catalog = "schema_version: 1\nworkspaces:\n - id: practice\n name: Practice\n"; const corpus = [ { name: "minimal", descriptor, catalog, valid: true }, { name: "optional Evidence", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://example.com/manual.md]\n", catalog, valid: true }, { name: "duplicate YAML key", descriptor: descriptor + " id: practice\n", catalog, valid: false }, { name: "ambiguous document", descriptor: descriptor + "---\n" + descriptor, catalog, valid: false }, { name: "unknown workspace field", descriptor: descriptor + " secret: VERY_SECRET_VALUE\n", catalog, valid: false }, { name: "database binding in authored descriptor", descriptor: descriptor + "dwh: {password: VERY_SECRET_VALUE}\n", catalog, valid: false }, { name: "duplicate catalog id", descriptor, catalog: catalog + " - id: practice\n name: Practice\n", valid: false }, { name: "unknown catalog field", descriptor, catalog: catalog + "secret: VERY_SECRET_VALUE\n", valid: false }, { name: "invalid catalog YAML", descriptor, catalog: catalog + "schema_version: 1\n", valid: false }, { name: "unsafe Evidence URI", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://user:VERY_SECRET_VALUE@example.com/file]\n", catalog, valid: false }, ]; test.each(corpus)("CLI and runtime agree: $name", (fixture) => { const root = directory(); mkdirSync(join(root, "practice"), { recursive: true }); writeFileSync(join(root, "thoth-workspaces.yaml"), fixture.catalog); writeFileSync(join(root, "practice/workspace.yaml"), fixture.descriptor); let accepted = true; try { parseWorkspaceCatalogYaml(fixture.catalog); parseWorkspaceYaml(fixture.descriptor); } catch { accepted = false; } expect(accepted).toBe(fixture.valid); const checked = cli("validate", "--directory", root); expect(checked.status).toBe(fixture.valid ? 0 : 1); expect(checked.report.ok).toBe(accepted); expect(checked.stdout + checked.stderr).not.toContain("VERY_SECRET_VALUE"); if (!fixture.valid) { expect(checked.report.issues[0].document).not.toBe("."); expect(checked.report.issues[0].correction.length).toBeGreaterThan(10); } }); test("prepare refuses existing directories and invalid options without changing documents", () => { const root = directory(); expect(cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice").status).toBe(0); const before = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8"); expect(cli("prepare", "--directory", root, "--id", "other", "--name", "Other").report.issues[0].code).toBe("destination_exists"); expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(before); expect(cli("validate", "--directory", root, "--typo", "VERY_SECRET_VALUE").status).toBe(2); }); test("validation rejects directory mismatches, missing references and symlinks without following them", () => { const root = directory(); cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice"); mkdirSync(join(root, "unlisted")); expect(cli("validate", "--directory", root).report.issues.some((i: {code: string}) => i.code === "unlisted_directory")).toBe(true); rmSync(join(root, "unlisted"), { recursive: true }); writeFileSync(join(root, "practice/workspace.yaml"), descriptor.replace("name: Practice", "name: Different")); expect(cli("validate", "--directory", root).report.issues[0].code).toBe("catalog_mismatch"); writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n"); expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference"); symlinkSync(roots[roots.length - 1], join(root, "practice/evidence"), "dir"); expect(cli("validate", "--directory", root).report.issues[0].code).toBe("unsafe_reference"); }); test("local Evidence checks references and YAML frontmatter, and explicitly defers canonical content validation", () => { const root = directory(); cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice"); writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n schema_version: 2\n source:\n type: filesystem\n uri: practice/evidence\n patterns: ['curated/**/*.md']\n"); mkdirSync(join(root, "practice/evidence/curated/domain"), { recursive: true }); const evidence = join(root, "practice/evidence/curated/domain/rule.md"); writeFileSync(evidence, "---\nschema_version: 4\nid: evidence:rule\nkind: domain\nlanguage: en\npurposes: [sql_generation]\n---\n# Rule\n\n## Rule\nUse the order number.\n"); const checked = cli("validate", "--directory", root); expect(checked.status).toBe(0); expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "local-files" }]); expect(checked.report.deferred_checks).toContain("practice:evidence-content-provenance-and-indexing"); writeFileSync(evidence, "---\nid: first\nid: VERY_SECRET_VALUE\n---\n# Rule\n"); const invalid = cli("validate", "--directory", root); expect(invalid.status).toBe(1); expect(invalid.report.issues[0].document).toBe("practice/evidence/curated/domain/rule.md"); expect(invalid.stdout).not.toContain("VERY_SECRET_VALUE"); writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n patterns: [missing.md]\n"); expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference"); }); test("prepare creates documents accepted by the runtime and validate is repeatable without installation or services", () => { const root = directory(); const prepared = cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice"); expect(prepared.stderr).toBe(""); expect(prepared.status).toBe(0); expect(prepared.report.ok).toBe(true); const catalog = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8"); const descriptor = readFileSync(join(root, "practice/workspace.yaml"), "utf8"); expect(parseWorkspaceCatalogYaml(catalog).workspaces[0].id).toBe("practice"); expect(parseWorkspaceYaml(descriptor).evidence).toBeUndefined(); for (let index = 0; index < 2; index++) { const checked = cli("validate", "--directory", root); expect(checked.status).toBe(0); expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "absent" }]); } expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(catalog); expect(readFileSync(join(root, "practice/workspace.yaml"), "utf8")).toBe(descriptor); });