/** * `mandu_ate_save` — Phase A.3 spec persistence with lint-before-write. * * See `docs/ate/roadmap-v2-agent-native.md` §4.7 and the §7 extension * ("mandu_ate_save lint-before-write"). * * Semantics: * 1. Run `lintSpecContent` (from @mandujs/ate) which: * - parses with ts-morph (syntax errors block), * - walks import declarations (banned typos, unknown @mandujs/* barrels), * - detects anti-patterns (bare localhost, hand-rolled CSRF, DB mocks). * 2. If any *blocking* diagnostic fires, return { saved: false, ... } WITHOUT * writing. Otherwise write and return { saved: true, path }. * * Snake_case tool name (§11 decision #4). */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { writeFileSync, mkdirSync, existsSync, statSync } from "node:fs"; import { dirname, isAbsolute } from "node:path"; import { lintSpecContent, appendMemoryEvent, nowTimestamp, type LintDiagnostic, } from "@mandujs/ate"; // Re-export the diagnostic shape so callers can type-check against it without // pulling @mandujs/ate directly. export type { LintDiagnostic, LintSeverity } from "@mandujs/ate"; export const ateSaveToolDefinitions: Tool[] = [ { name: "mandu_ate_save", description: "Phase A.3 persist-with-lint. Writes an agent-generated test file to " + "disk, but first runs a small lint pass that blocks common LLM mistakes: " + "ts-morph syntax errors, unresolved / banned import paths, hand-rolled " + "CSRF cookies, DB mocks when createTestDb is available, and bare " + "`localhost:` URLs (prefer 127.0.0.1 per roadmap §9.2). Returns " + "{ saved: true, path, lintDiagnostics: [warnings...] } on success or " + "{ saved: false, blockingErrors: [...], lintDiagnostics: [...] } when " + "a blocker fires (in which case no file is written).", inputSchema: { type: "object", properties: { path: { type: "string", description: "Absolute path where the spec will be written. Parent directories are created if needed.", }, content: { type: "string", description: "The full TypeScript test source to write.", }, intent: { type: "string", description: "Optional short description of what the test is verifying (logged to ATE memory).", }, kind: { type: "string", description: "Optional prompt kind this spec was generated for (filling_unit, filling_integration, e2e_playwright).", }, sourcePrompt: { type: "object", description: "Optional { kind, version } back-reference to the prompt that produced this spec (used by future memory queries).", additionalProperties: true, }, allowWarnings: { type: "boolean", description: "When true, non-blocking warnings still allow the write (default true). When false, even warnings block.", }, }, required: ["path", "content"], }, }, ]; export function ateSaveTools(projectRoot: string) { return { mandu_ate_save: async (args: Record) => { const path = args.path as string | undefined; const content = args.content as string | undefined; const intent = typeof args.intent === "string" ? args.intent : undefined; const kind = typeof args.kind === "string" ? args.kind : undefined; const allowWarnings = args.allowWarnings !== false; if (!path || typeof path !== "string") { return { saved: false, error: "'path' is required" }; } if (!isAbsolute(path)) { return { saved: false, error: "'path' must be absolute — relative paths are rejected to prevent cwd drift.", }; } if (typeof content !== "string") { return { saved: false, error: "'content' is required and must be a string" }; } const diagnostics = await lintSpecContent(path, content); const blocking = diagnostics.filter((d) => d.blocking); const warnings = diagnostics.filter((d) => !d.blocking); if (blocking.length > 0 || (!allowWarnings && warnings.length > 0)) { return { saved: false, path, blockingErrors: blocking, lintDiagnostics: diagnostics, }; } const parent = dirname(path); if (!existsSync(parent)) { mkdirSync(parent, { recursive: true }); } if (!statSync(parent).isDirectory()) { return { saved: false, path, error: `Parent path is not a directory: ${parent}` }; } writeFileSync(path, content, "utf8"); // Phase B.2 — auto-record intent_history event. Non-fatal on failure. try { appendMemoryEvent(projectRoot, { kind: "intent_history", timestamp: nowTimestamp(), intent: intent ?? "(no intent supplied)", agent: typeof args.agent === "string" ? (args.agent as string) : "unknown", resulting: { saved: [path] }, ...(kind ? { routeId: kind } : {}), }); } catch { // swallow } return { saved: true, path, bytes: Buffer.byteLength(content, "utf8"), lintDiagnostics: diagnostics, }; }, }; } // Re-export for tests that need direct access (package.json test patterns // already reach here). export async function lintContent( path: string, content: string, ): Promise { return lintSpecContent(path, content); }