diff --git a/README.md b/README.md index 4f4de5e..b2ba31e 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ Design principles: vault into checkouts and worktrees. - `wspace sync` — alias for `wspace init`. - `wspace validate` — validate the manifest without touching any repository. +- `wspace factory bootstrap []` — discover a Git repository and + install deterministic, repository-local `.wazoo/` metadata. Use `--dry-run` to + inspect changes or `--force` to overwrite conflicting managed files. +- `wspace factory smoke [] [--json]` — validate and report the + factory metadata without running scripts or modifying the repository. ## Install diff --git a/src/cli.ts b/src/cli.ts index 0f7ed0d..c21dd71 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,7 @@ import type { ManifestPaths } from "./manifest.ts"; import { collectStatus, hasErrors } from "./status.ts"; import type { WorkspaceManifest } from "./types.ts"; import { runUpdate } from "./update.ts"; +import { bootstrapFactory, smokeFactory } from "./factory.ts"; import { addWorktree, branchExists, @@ -32,6 +33,7 @@ const COMMANDS = [ "worktree", "env", "validate", + "factory", ]; class CliHelp extends Error {} @@ -42,6 +44,8 @@ interface CliOptions { manifestPath: string; json: boolean; stale: boolean; + dryRun: boolean; + force: boolean; positional: string[]; } @@ -58,6 +62,8 @@ Usage: wspace worktree remove wspace env sync wspace validate + wspace factory bootstrap [] [--dry-run] [--force] + wspace factory smoke [] [--json] Options: --manifest Manifest path (default: repos.json) @@ -66,7 +72,7 @@ Options: function parseCliArgs(args: string[]): CliOptions { const parsed = parseArgs(args, { - boolean: ["help", "json", "stale"], + boolean: ["help", "json", "stale", "dry-run", "force"], string: ["manifest"], alias: { h: "help" }, }); @@ -87,6 +93,8 @@ function parseCliArgs(args: string[]): CliOptions { manifestPath: parsed.manifest ?? "repos.json", json: parsed.json ?? false, stale: parsed.stale ?? false, + dryRun: parsed["dry-run"] ?? false, + force: parsed.force ?? false, positional: positional.slice(2), }; } @@ -322,6 +330,71 @@ Required setup steps may include: export async function run(args: string[]): Promise { const opts = parseCliArgs(args); + if (opts.command === "factory") { + if (!opts.subcommand || !["bootstrap", "smoke"].includes(opts.subcommand)) { + console.error( + "Usage: wspace factory bootstrap|smoke []", + ); + return 2; + } + if (opts.positional.length > 1) { + console.error("Factory commands accept at most one repository path"); + return 2; + } + const target = opts.positional[0] ?? Deno.cwd(); + if (opts.subcommand === "bootstrap") { + const result = await bootstrapFactory(new SystemGit(), target, { + dryRun: opts.dryRun, + force: opts.force, + }); + if (opts.json) console.log(JSON.stringify(result, null, 2)); + else { + console.log(`Git root: ${result.root}`); + console.log(`Repository: ${result.discovery.identity}`); + console.log(`Dirty: ${result.discovery.dirty ? "yes" : "no"}`); + console.log( + `AGENTS.md: ${ + result.discovery.inspections.agents ? "present" : "missing" + }`, + ); + console.log( + `Workflows: ${ + result.discovery.inspections.workflows.join(", ") || "none" + }`, + ); + console.log( + `Metadata: ${ + result.discovery.inspections.metadata.join(", ") || "none" + }`, + ); + console.log( + `Agent config: ${ + result.discovery.inspections.agentConfig.join(", ") || "none" + }`, + ); + console.table(result.actions); + } + return result.actions.some((action) => action.action === "conflict") + ? 1 + : 0; + } + if (opts.subcommand === "smoke") { + const result = await smokeFactory(new SystemGit(), target); + if (opts.json) console.log(JSON.stringify(result, null, 2)); + else { + console.log( + `Factory manifest: ${ + result.valid ? "VALID" : "INVALID" + }\nGit root: ${result.root}\nDirty: ${result.dirty ? "yes" : "no"}${ + result.error ? `\nError: ${result.error}` : "" + }\nCommands: ${ + result.commands.join(", ") || "none" + }\nProtected paths: ${result.protectedPaths.join(", ") || "none"}`, + ); + } + return result.valid ? 0 : 1; + } + } const manifestPath = resolve(Deno.cwd(), opts.manifestPath); const manifest = await loadManifest(manifestPath); const paths = manifestPaths(manifest, manifestPath); diff --git a/src/factory.ts b/src/factory.ts new file mode 100644 index 0000000..4b049f3 --- /dev/null +++ b/src/factory.ts @@ -0,0 +1,489 @@ +import { basename, join, resolve } from "@std/path"; +import type { GitRunner } from "./git.ts"; +import { currentBranch, defaultBranch, isDirty } from "./git.ts"; + +export const FACTORY_VERSION = "1.0.0"; +const MANIFEST_VERSION = 1; +const MANAGED_FILES = [".wazoo/factory.json", ".wazoo/README.md"]; +const METADATA_FILES = [ + "package.json", + "deno.json", + "pyproject.toml", + "Cargo.toml", +]; +const CONFIG_FILES = ["opencode.json", ".codex/config.toml", ".claude"]; +const PREFERRED_COMMANDS = [ + "format:check", + "typecheck", + "test", + "build", + "health", + "test:e2e", + "smoke", +]; + +export interface FactoryManifest { + factoryVersion: string; + manifestVersion: number; + repository: { + identity: string; + root: string; + defaultBranch: string; + }; + workflow: { + mode: "light"; + smoke: "read-only"; + }; + commands: string[]; + checks: { + health: string[]; + smoke: string[]; + }; + protectedPaths: string[]; + definitionOfDone: string[]; +} + +export interface FactoryDiscovery { + root: string; + identity: string; + defaultBranch: string; + dirty: boolean; + inspections: { + agents: boolean; + workflows: string[]; + metadata: string[]; + agentConfig: string[]; + }; + commands: string[]; +} + +export interface BootstrapResult { + root: string; + discovery: FactoryDiscovery; + manifest: FactoryManifest; + actions: { + path: string; + action: "create" | "change" | "unchanged" | "conflict"; + }[]; + dryRun: boolean; +} + +export function validateFactoryManifest( + value: unknown, +): asserts value is FactoryManifest { + if (!value || typeof value !== "object") { + throw new Error("Factory manifest must be an object"); + } + const manifest = value as Partial; + if ( + typeof manifest.factoryVersion !== "string" || + manifest.factoryVersion !== FACTORY_VERSION + ) { + throw new Error("Unsupported or missing factoryVersion"); + } + if ( + typeof manifest.manifestVersion !== "number" || + manifest.manifestVersion !== MANIFEST_VERSION + ) { + throw new Error("Unsupported or missing manifestVersion"); + } + if ( + !manifest.repository || typeof manifest.repository !== "object" || + Array.isArray(manifest.repository) + ) { + throw new Error("Factory manifest requires repository"); + } + if ( + typeof manifest.repository.identity !== "string" || + !manifest.repository.identity || + typeof manifest.repository.root !== "string" || !manifest.repository.root || + typeof manifest.repository.defaultBranch !== "string" || + !manifest.repository.defaultBranch + ) { + throw new Error( + "Factory repository requires identity, root, and defaultBranch", + ); + } + if ( + !manifest.workflow || typeof manifest.workflow !== "object" || + Array.isArray(manifest.workflow) || + manifest.workflow.mode !== "light" || + manifest.workflow.smoke !== "read-only" + ) throw new Error("Factory workflow must be light and read-only"); + if ( + !stringArray(manifest.commands) || + !manifest.checks || typeof manifest.checks !== "object" || + Array.isArray(manifest.checks) || + !stringArray(manifest.checks.health) || + !stringArray(manifest.checks.smoke) || + !stringArray(manifest.protectedPaths) || + !stringArray(manifest.definitionOfDone) + ) { + throw new Error( + "Factory manifest requires command, checks, protectedPaths, and definitionOfDone arrays", + ); + } +} + +function stringArray(value: unknown): value is string[] { + return Array.isArray(value) && + value.every((item) => typeof item === "string" && item.length > 0); +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +async function fileExists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} + +async function directoryExists(path: string): Promise { + try { + return (await Deno.stat(path)).isDirectory; + } catch (error) { + if (error instanceof Deno.errors.NotFound) return false; + throw error; + } +} + +async function rejectSymlink(path: string, label: string): Promise { + try { + const info = await Deno.lstat(path); + if (info.isSymlink) throw new Error(`Refusing symlinked ${label}: ${path}`); + if (label === ".wazoo directory" && !info.isDirectory) { + throw new Error(`Expected .wazoo to be a directory: ${path}`); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) return; + throw error; + } +} + +async function checkWazooSafety(root: string): Promise { + await rejectSymlink(join(root, ".wazoo"), ".wazoo directory"); + for (const file of MANAGED_FILES) { + await rejectSymlink(join(root, file), `managed file ${file}`); + } +} + +async function repositoryRoot(g: GitRunner, target: string): Promise { + const result = await g.run(["rev-parse", "--show-toplevel"], target); + if (result.code !== 0 || !result.stdout) { + throw new Error( + `Not a Git repository: ${target}. Run this command inside a Git checkout or pass its path.`, + ); + } + return resolve(result.stdout); +} + +async function repositoryIdentity(g: GitRunner, root: string): Promise { + const result = await g.run(["config", "--get", "remote.origin.url"], root); + if (result.code === 0 && result.stdout) { + const value = result.stdout.replace(/[\\/]$/, "").split(/[\\/]/).pop() ?? + ""; + const identity = value.replace(/\.git$/, ""); + if (identity) return identity; + } + return basename(root); +} + +async function statusPaths(g: GitRunner, root: string): Promise { + const result = await g.run(["status", "--porcelain"], root); + if (result.code !== 0) { + throw new Error( + `Unable to inspect Git status for ${root}: ${result.stderr}`, + ); + } + return result.stdout.split("\n").filter(Boolean).flatMap((line) => { + const value = line.slice(3); + return value.includes(" -> ") ? value.split(" -> ") : [value]; + }); +} + +function isWazooPath(path: string): boolean { + return path === ".wazoo" || path.startsWith(".wazoo/") || + path.startsWith(".wazoo\\"); +} + +function checkLists(commands: string[]): FactoryManifest["checks"] { + return { + health: commands.filter((command) => command.startsWith("health:")), + smoke: commands.filter((command) => command === "smoke"), + }; +} + +async function discoverCommands( + root: string, + files: string[], +): Promise { + const commands = new Set(); + for (const file of files) { + if (!file.endsWith(".json")) continue; + let data: Record; + try { + const parsed: unknown = JSON.parse( + await Deno.readTextFile(join(root, file)), + ); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("metadata must be a JSON object"); + } + data = parsed as Record; + } catch (error) { + throw new Error( + `Invalid ${file} metadata: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + const scripts = data.scripts ?? data.tasks; + if ( + scripts !== undefined && + (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) + ) { + throw new Error( + `Invalid ${file} metadata: scripts/tasks must be an object`, + ); + } + if (scripts && typeof scripts === "object") { + for (const name of Object.keys(scripts)) { + if (PREFERRED_COMMANDS.includes(name) || name.startsWith("health:")) { + commands.add(name); + } + } + } + } + if (files.includes("pyproject.toml")) commands.add("test"); + if (files.includes("Cargo.toml")) commands.add("test"); + const rank = (name: string) => { + const exact = PREFERRED_COMMANDS.indexOf(name); + return exact >= 0 ? exact : name.startsWith("health:") ? 4 : 99; + }; + return [...commands].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); +} + +async function listWorkflows(root: string): Promise { + const directory = join(root, ".github", "workflows"); + try { + return (await Array.fromAsync(Deno.readDir(directory))).map((entry) => + entry.name + ).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")).sort(); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return []; + throw error; + } +} + +export async function discoverFactory( + g: GitRunner, + target: string, +): Promise { + if (!(await directoryExists(target))) { + throw new Error( + `Not a Git repository: ${target}. Run this command inside a Git checkout or pass its path.`, + ); + } + const root = await repositoryRoot(g, target); + await checkWazooSafety(root); + const metadata = [] as string[]; + for (const file of METADATA_FILES) { + if (await fileExists(join(root, file))) metadata.push(file); + } + const agentConfig = [] as string[]; + for (const file of CONFIG_FILES) { + if (await fileExists(join(root, file))) agentConfig.push(file); + } + return { + root, + identity: await repositoryIdentity(g, root), + defaultBranch: await defaultBranch(g, root) ?? + ((await g.run(["config", "--get", "remote.origin.url"], root)).stdout + ? (() => { + throw new Error( + "Cannot determine default branch: origin/HEAD is unavailable; configure origin/HEAD or remove the remote for a local fixture.", + ); + })() + : await currentBranch(g, root) ?? "unknown"), + dirty: await isDirty(g, root), + inspections: { + agents: await fileExists(join(root, "AGENTS.md")), + workflows: await listWorkflows(root), + metadata, + agentConfig, + }, + commands: await discoverCommands(root, metadata), + }; +} + +function makeManifest(discovery: FactoryDiscovery): FactoryManifest { + const manifest: FactoryManifest = { + factoryVersion: FACTORY_VERSION, + manifestVersion: MANIFEST_VERSION, + repository: { + identity: discovery.identity, + root: ".", + defaultBranch: discovery.defaultBranch, + }, + workflow: { mode: "light", smoke: "read-only" }, + commands: discovery.commands, + checks: checkLists(discovery.commands), + protectedPaths: [ + ".github/workflows", + "migrations", + "schema", + "auth", + "deploy", + ".env", + ".env.*", + ".dev.vars", + ".dev.vars.*", + ], + definitionOfDone: [ + "format:check passes", + "typecheck passes", + "tests pass", + "QA precedes explicit production approval", + ], + }; + return manifest; +} + +function factoryReadme(manifest: FactoryManifest): string { + return `# Wazoo factory metadata\n\nThis directory is factory-managed. The repository owns its code; this CLI records only safe, light workflow metadata.\n\n- Bootstrap: \`wspace factory bootstrap .\`\n- Read-only smoke: \`wspace factory smoke .\`\n- Smoke never runs scripts, writes files, commits, pushes, deploys, or merges.\n- \`checks.health\` records repository-local health checks; \`checks.smoke\` records discovered smoke commands. Workspace-level policy decides whether a discovered \`smoke\` command is a QA gate.\n- Secrets belong in the workspace secrets vault and env sync.\n- Worktree and unrelated-dirty changes are never overwritten.\n- Workspace-level policy owns platform architecture and repository coordination.\n\nFactory version: \`${manifest.factoryVersion}\`\n`; +} + +export async function bootstrapFactory( + g: GitRunner, + target: string, + options: { dryRun?: boolean; force?: boolean } = {}, +): Promise { + const discovery = await discoverFactory(g, target); + const manifest = makeManifest(discovery); + validateFactoryManifest(manifest); + const files = new Map([[ + MANAGED_FILES[0], + stableJson(manifest), + ], [MANAGED_FILES[1], factoryReadme(manifest)]]); + const actions: BootstrapResult["actions"] = []; + for (const [relative, content] of files) { + const path = join(discovery.root, relative); + if (!(await fileExists(path))) { + actions.push({ path: relative, action: "create" }); + } else if (await Deno.readTextFile(path) === content) { + actions.push({ path: relative, action: "unchanged" }); + } else if (options.force) { + actions.push({ path: relative, action: "change" }); + } else actions.push({ path: relative, action: "conflict" }); + } + if (!options.dryRun) { + const unrelated = (await statusPaths(g, discovery.root)).filter((path) => + !isWazooPath(path) + ); + if (unrelated.length > 0) { + throw new Error( + `Refusing bootstrap: unrelated Git changes are present (${ + unrelated.join(", ") + }). Commit or stash them, then retry; --force does not bypass this check.`, + ); + } + if (actions.some((action) => action.action === "conflict")) { + throw new Error( + "Factory-managed files conflict; re-run with --force to overwrite only .wazoo-managed files.", + ); + } + await checkWazooSafety(discovery.root); + await Deno.mkdir(join(discovery.root, ".wazoo"), { recursive: true }); + for (const [relative, content] of files) { + if (actions.find((a) => a.path === relative)?.action !== "unchanged") { + await checkWazooSafety(discovery.root); + await rejectSymlink( + join(discovery.root, relative), + `managed file ${relative}`, + ); + await Deno.writeTextFile(join(discovery.root, relative), content); + } + } + } + return { + root: discovery.root, + discovery, + manifest, + actions, + dryRun: options.dryRun ?? false, + }; +} + +export async function smokeFactory( + g: GitRunner, + target: string, +): Promise< + { + valid: boolean; + root: string; + identity: string; + defaultBranch: string; + dirty: boolean; + commands: string[]; + protectedPaths: string[]; + manifest?: FactoryManifest; + error?: string; + } +> { + let discovery: FactoryDiscovery; + try { + discovery = await discoverFactory(g, target); + } catch (error) { + const root = resolve(target); + return { + valid: false, + root, + identity: basename(root), + defaultBranch: "unknown", + dirty: false, + commands: [], + protectedPaths: [], + error: error instanceof Error ? error.message : String(error), + }; + } + const path = join(discovery.root, ".wazoo", "factory.json"); + try { + const manifest = JSON.parse(await Deno.readTextFile(path)); + validateFactoryManifest(manifest); + if (resolve(discovery.root, manifest.repository.root) !== discovery.root) { + throw new Error("manifest repository root does not match Git root"); + } + if (manifest.repository.identity !== discovery.identity) { + throw new Error("manifest repository identity does not match origin"); + } + if (manifest.repository.defaultBranch !== discovery.defaultBranch) { + throw new Error("manifest default branch does not match Git"); + } + return { + valid: true, + root: discovery.root, + identity: discovery.identity, + defaultBranch: discovery.defaultBranch, + dirty: discovery.dirty, + commands: manifest.commands, + protectedPaths: manifest.protectedPaths, + manifest, + }; + } catch (error) { + return { + valid: false, + root: discovery.root, + identity: discovery.identity, + defaultBranch: discovery.defaultBranch, + dirty: discovery.dirty, + commands: discovery.commands, + protectedPaths: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/tests/factory_test.ts b/tests/factory_test.ts new file mode 100644 index 0000000..191395f --- /dev/null +++ b/tests/factory_test.ts @@ -0,0 +1,266 @@ +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { join, normalize } from "@std/path"; +import { + bootstrapFactory, + smokeFactory, + validateFactoryManifest, +} from "../src/factory.ts"; +import { SystemGit } from "../src/git.ts"; +import { run } from "../src/cli.ts"; + +const git = new SystemGit(); + +async function repo(): Promise { + const root = await Deno.makeTempDir(); + assertEquals((await git.run(["init", "-b", "main"], root)).code, 0); + await git.run(["config", "user.email", "test@example.com"], root); + await git.run(["config", "user.name", "Factory Test"], root); + await Deno.writeTextFile( + join(root, "package.json"), + '{"scripts":{"format:check":"echo no","health:api":"echo no","smoke":"echo no"}}\n', + ); + assertEquals((await git.run(["add", "package.json"], root)).code, 0); + assertEquals((await git.run(["commit", "-m", "fixture"], root)).code, 0); + return root; +} + +Deno.test("factory manifest validation is separate and strict", () => { + assertThrows(() => validateFactoryManifest({}), Error, "factoryVersion"); + assertThrows( + () => + validateFactoryManifest({ + factoryVersion: "1.0.0", + manifestVersion: 1, + repository: { identity: "a", root: "x", defaultBranch: "main" }, + workflow: { mode: "light", smoke: "read-only" }, + commands: [], + protectedPaths: [], + definitionOfDone: [], + }), + Error, + "checks", + ); + assertThrows( + () => + validateFactoryManifest({ + factoryVersion: "1.0.0", + manifestVersion: 1, + repository: { identity: "a", root: "x", defaultBranch: "main" }, + workflow: { mode: "light", smoke: "read-only" }, + commands: [], + checks: { health: [], smoke: [] }, + protectedPaths: [], + }), + Error, + "definitionOfDone", + ); +}); + +Deno.test("factory bootstrap is deterministic, idempotent, and handles Windows paths", async () => { + const root = await repo(); + try { + const first = await bootstrapFactory(git, root); + assertEquals(first.actions.map((a) => a.action), ["create", "create"]); + const content = await Deno.readTextFile( + join(root, ".wazoo", "factory.json"), + ); + const second = await bootstrapFactory(git, normalize(root)); + assertEquals(second.actions.map((a) => a.action), [ + "unchanged", + "unchanged", + ]); + assertEquals( + await Deno.readTextFile(join(root, ".wazoo", "factory.json")), + content, + ); + assertEquals(second.manifest.commands, [ + "format:check", + "health:api", + "smoke", + ]); + assertEquals(second.manifest.checks, { + health: ["health:api"], + smoke: ["smoke"], + }); + assertEquals(second.manifest.protectedPaths, [ + ".github/workflows", + "migrations", + "schema", + "auth", + "deploy", + ".env", + ".env.*", + ".dev.vars", + ".dev.vars.*", + ]); + assertEquals(second.manifest.repository.root, "."); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory dry-run has no side effects and conflicts require force", async () => { + const root = await repo(); + try { + const dry = await bootstrapFactory(git, root, { dryRun: true }); + assertEquals(dry.actions[0].action, "create"); + assertEquals( + await Deno.stat(join(root, ".wazoo")).catch(() => undefined), + undefined, + ); + await bootstrapFactory(git, root); + await Deno.writeTextFile( + join(root, ".wazoo", "README.md"), + "developer file\n", + ); + const conflict = await bootstrapFactory(git, root, { dryRun: true }); + assertEquals(conflict.actions[1].action, "conflict"); + await bootstrapFactory(git, root, { force: true }); + assert( + (await Deno.readTextFile(join(root, ".wazoo", "README.md"))).includes( + "factory-managed", + ), + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory smoke reports validity and dirty state without writing", async () => { + const root = await repo(); + try { + await bootstrapFactory(git, root); + await Deno.writeTextFile(join(root, "local.txt"), "dirty\n"); + const result = await smokeFactory(git, root); + assertEquals(result.valid, true); + assertEquals(result.dirty, true); + assertEquals(result.manifest?.workflow.smoke, "read-only"); + assertEquals( + await Deno.stat(join(root, "local.txt")).then(() => true), + true, + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory refuses unrelated dirty changes but permits managed reruns", async () => { + const root = await repo(); + try { + await bootstrapFactory(git, root); + await Deno.writeTextFile(join(root, "unrelated.txt"), "local\n"); + await assertRejects( + () => bootstrapFactory(git, root, { force: true }), + Error, + "unrelated Git changes", + ); + const dry = await bootstrapFactory(git, root, { + dryRun: true, + force: true, + }); + assertEquals(dry.dryRun, true); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory rejects a rename from an unrelated path into .wazoo", async () => { + const root = await repo(); + try { + await bootstrapFactory(git, root); + await Deno.writeTextFile(join(root, "unrelated.txt"), "local\n"); + assertEquals((await git.run(["add", "unrelated.txt"], root)).code, 0); + assertEquals( + (await git.run(["commit", "-m", "unrelated"], root)).code, + 0, + ); + assertEquals( + (await git.run(["mv", "unrelated.txt", ".wazoo/renamed.txt"], root)) + .code, + 0, + ); + await assertRejects( + () => bootstrapFactory(git, root, { force: true }), + Error, + "unrelated Git changes", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory smoke reports malformed package metadata", async () => { + const root = await repo(); + try { + await bootstrapFactory(git, root); + await Deno.writeTextFile(join(root, "package.json"), "{\n"); + const result = await smokeFactory(git, root); + assertEquals(result.valid, false); + assert(result.error?.includes("Invalid package.json metadata")); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory rejects symlinked managed files", async () => { + const root = await repo(); + const outside = await Deno.makeTempDir(); + try { + await Deno.mkdir(join(root, ".wazoo")); + await Deno.writeTextFile(join(outside, "factory.json"), "outside\n"); + try { + await Deno.symlink( + join(outside, "factory.json"), + join(root, ".wazoo", "factory.json"), + ); + } catch (error) { + if (String(error).includes("privilege")) return; + throw error; + } + await assertRejects( + () => bootstrapFactory(git, root), + Error, + "Refusing symlinked managed file", + ); + } finally { + await Deno.remove(root, { recursive: true }); + await Deno.remove(outside, { recursive: true }); + } +}); + +Deno.test("factory CLI rejects extra repository arguments", async () => { + assertEquals(await run(["factory", "smoke", "one", "two"]), 2); +}); + +Deno.test("factory rejects an invalid path", async () => { + const root = await Deno.makeTempDir(); + try { + await assertRejects( + () => bootstrapFactory(git, join(root, "missing")), + Error, + "Not a Git repository", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("factory smoke returns structured invalid reports without Git probing", async () => { + const root = await Deno.makeTempDir(); + const missing = join(root, "missing"); + const file = join(root, "file.txt"); + await Deno.writeTextFile(file, "not a repository\n"); + try { + for (const target of [missing, file, root]) { + const result = await smokeFactory(git, target); + assertEquals(result.valid, false); + assertEquals(result.root, target); + assertEquals(result.defaultBranch, "unknown"); + assertEquals(result.dirty, false); + assertEquals(result.commands, []); + assert(result.error?.includes("Not a Git repository")); + } + } finally { + await Deno.remove(root, { recursive: true }); + } +});