Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,246 changes: 1,198 additions & 48 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"name":"formforge","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"next lint","test":"vitest run","test:watch":"vitest","cli":"tsx scripts/formforge-cli.ts"},"dependencies":{"@dnd-kit/core":"^6.3.1","@dnd-kit/sortable":"^10.0.0","@dnd-kit/utilities":"^3.2.2","fast-xml-parser":"^5.3.3","lucide-react":"^0.562.0","next":"^16.1.4","react":"^18","react-dom":"^18","zustand":"^5.0.10"},"devDependencies":{"@types/node":"^20","@types/react":"^18","@types/react-dom":"^18","autoprefixer":"^10.4.20","eslint":"^8","eslint-config-next":"14.2.15","postcss":"^8","tailwindcss":"^3.4.1","tsx":"^4.23.1","typescript":"^5","vitest":"^4.1.10"},"optionalDependencies":{"@rolldown/binding-darwin-arm64":"^1.2.0"}}
{"name":"formforge","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"next lint","test":"vitest run","test:watch":"vitest","cli":"tsx scripts/formforge-cli.ts"},"dependencies":{"@anthropic-ai/claude-agent-sdk":"^0.3.222","@anthropic-ai/sdk":"^0.115.0","@dnd-kit/core":"^6.3.1","@dnd-kit/sortable":"^10.0.0","@dnd-kit/utilities":"^3.2.2","fast-xml-parser":"^5.3.3","lucide-react":"^0.562.0","mammoth":"^1.12.0","next":"^16.1.4","react":"^18","react-dom":"^18","zod":"^4.4.3","zustand":"^5.0.10"},"devDependencies":{"@types/node":"^20","@types/react":"^18","@types/react-dom":"^18","autoprefixer":"^10.4.20","eslint":"^8","eslint-config-next":"14.2.15","jszip":"^3.10.1","postcss":"^8","tailwindcss":"^3.4.1","tsx":"^4.23.1","typescript":"^5","vitest":"^4.1.10"},"optionalDependencies":{"@rolldown/binding-darwin-arm64":"^1.2.0"}}
74 changes: 74 additions & 0 deletions scripts/scan-corpus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* eslint-disable no-console */
// Batch-validate every form/subform under a directory tree, in one process.
// Usage: npx tsx scripts/scan-corpus.ts <rootDir> [--warnings]
// Prints, per file that has findings, the error (and optionally warning) messages.
// Exit 0 always; this is a triage tool, not a gate.

import fs from 'fs';
import path from 'path';
import { parseAnyXML } from '../src/lib/xmlParser';
import { validateForm } from '../src/lib/validation';

const root = process.argv[2];
const showWarnings = process.argv.includes('--warnings');
if (!root) {
console.error('usage: scan-corpus <rootDir> [--warnings]');
process.exit(2);
}

const files: string[] = [];
const walk = (dir: string) => {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p);
else if (p.endsWith('.xml') && (/[/\\]xml[/\\](forms|subforms)[/\\]/.test(p))) files.push(p);
}
};
walk(root);
files.sort();

let scanned = 0;
let withErr = 0;
let withWarn = 0;
let parseFail = 0;
const errorTally: Record<string, number> = {};

for (const f of files) {
scanned++;
let form;
try {
form = parseAnyXML(fs.readFileSync(f, 'utf-8'));
} catch {
form = null;
}
if (!form) {
parseFail++;
console.log(`PARSE-FAIL ${f}`);
continue;
}
const issues = validateForm(form);
const errs = issues.filter((i) => i.type === 'error');
const warns = issues.filter((i) => i.type === 'warning');
if (errs.length) withErr++;
if (warns.length) withWarn++;
for (const e of errs) {
// tally by the leading phrase for a quick histogram of error kinds
const key = e.message.replace(/id="[^"]*"/g, 'id="…"').replace(/"[0-9]+"/g, '"…"').slice(0, 80);
errorTally[key] = (errorTally[key] || 0) + 1;
}
if (errs.length || (showWarnings && warns.length)) {
const rel = f.replace(/^.*\/states\//, 'states/');
console.log(`\n${rel} [${errs.length} errors, ${warns.length} warnings]`);
for (const e of errs) console.log(` ERROR ${e.message}`);
if (showWarnings) for (const w of warns) console.log(` warn ${w.message}`);
}
}

console.log(`\n---\nscanned ${scanned} files; ${withErr} with errors; ${withWarn} with warnings; ${parseFail} parse failures`);
if (Object.keys(errorTally).length) {
console.log('\nerror histogram:');
Object.entries(errorTally)
.sort((a, b) => b[1] - a[1])
.forEach(([k, n]) => console.log(` ${String(n).padStart(4)} ${k}`));
}
95 changes: 95 additions & 0 deletions src/app/api/ai/fix/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { runAiFix } from '@/lib/aiFixAgent';
import { detectAttachmentKind, extractDocxText, UNSUPPORTED_ATTACHMENT_MESSAGE } from '@/lib/docExtract';

export const dynamic = 'force-dynamic';

// POST /api/ai/fix
// multipart/form-data fields: xml (string, required), instruction (string,
// required unless an attachment is provided), attachment (File, optional —
// PDF or modern Word .docx), mode (string, optional — "fix" (default) or
// "generate"; same engine, see runAiFix's mode param).
//
// Runs the AI Fix agentic loop (edit -> validate -> self-correct) against the
// form XML the caller currently has open and returns a reviewable result —
// nothing is written to disk here; the browser applies the result to the
// in-memory form on Accept, same as opening a file.
//
// No ANTHROPIC_API_KEY is used or required: runAiFix authenticates via the
// machine's existing Claude subscription login (Claude Code / Claude
// Desktop), so usage draws from that subscription's included usage instead
// of metered API billing.
export async function POST(req: NextRequest) {
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ ok: false, error: 'Invalid form data body.' }, { status: 400 });
}

const xml = formData.get('xml');
if (!xml || typeof xml !== 'string') {
return NextResponse.json({ ok: false, error: 'Missing "xml" in request body.' }, { status: 400 });
}

const instructionRaw = formData.get('instruction');
const instruction = typeof instructionRaw === 'string' ? instructionRaw : '';

const modeRaw = formData.get('mode');
const mode = modeRaw === 'generate' ? 'generate' : 'fix';

const file = formData.get('attachment');
const hasAttachment = file instanceof File && file.size > 0;

if (!instruction.trim() && !hasAttachment) {
return NextResponse.json(
{ ok: false, error: 'Provide an instruction, an attachment, or both.' },
{ status: 400 }
);
}

let effectiveInstruction = instruction;
let pdfAttachment: { filename: string; data: Buffer } | undefined;

if (hasAttachment) {
const uploaded = file as File;
const kind = detectAttachmentKind(uploaded.name);
if (kind === 'unsupported') {
return NextResponse.json({ ok: false, error: UNSUPPORTED_ATTACHMENT_MESSAGE }, { status: 400 });
}
const buffer = Buffer.from(await uploaded.arrayBuffer());
if (kind === 'docx') {
let docText: string;
try {
docText = await extractDocxText(buffer);
} catch (err) {
return NextResponse.json(
{ ok: false, error: `Could not read the .docx attachment: ${err instanceof Error ? err.message : String(err)}` },
{ status: 400 }
);
}
effectiveInstruction = [instruction.trim(), `Ticket attachment (${uploaded.name}):\n${docText}`]
.filter(Boolean)
.join('\n\n');
} else {
// PDF: pass through as a file for the agent's own Read tool.
pdfAttachment = { filename: uploaded.name, data: buffer };
}
}

try {
const result = await runAiFix({
xml,
instruction: effectiveInstruction,
model: process.env.AI_FIX_MODEL,
attachment: pdfAttachment,
mode,
});
return NextResponse.json(result);
} catch (err) {
return NextResponse.json(
{ ok: false, error: err instanceof Error ? err.message : String(err) },
{ status: 200 }
);
}
}
23 changes: 23 additions & 0 deletions src/app/api/repo/changed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { repoExists } from '@/lib/repoConfig';
import { currentBranch, changedForms } from '@/lib/gitRepo';

export const dynamic = 'force-dynamic';

// GET /api/repo/changed
// Lists every changed/untracked form+subform in the E-Bar working tree, plus the
// current branch. Used by the Test Lab to offer forms without a manual upload.
export async function GET() {
if (!repoExists()) {
return NextResponse.json(
{ ok: false, error: 'E-Bar repo not found. Set EBAR_REPO_PATH to the ILG-EBAS-E-Bar checkout.' },
{ status: 200 }
);
}
try {
const [branch, files] = await Promise.all([currentBranch(), changedForms()]);
return NextResponse.json({ ok: true, branch, files });
} catch (e) {
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 200 });
}
}
23 changes: 23 additions & 0 deletions src/app/api/repo/file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { repoExists } from '@/lib/repoConfig';
import { formVersions } from '@/lib/gitRepo';

export const dynamic = 'force-dynamic';

// GET /api/repo/file?path=states/<st>/xml/forms/<f>.xml
// Returns the committed (HEAD) version, the working-tree version, and the git
// unified diff between them, so the Test Lab can show branch-vs-edited.
export async function GET(req: NextRequest) {
if (!repoExists()) {
return NextResponse.json({ ok: false, error: 'E-Bar repo not found.' }, { status: 200 });
}
const relPath = req.nextUrl.searchParams.get('path') || '';
if (!relPath) {
return NextResponse.json({ ok: false, error: 'missing path' }, { status: 400 });
}
const versions = await formVersions(relPath);
if (!versions) {
return NextResponse.json({ ok: false, error: 'illegal or non-form path' }, { status: 400 });
}
return NextResponse.json({ ok: true, ...versions });
}
5 changes: 4 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import { Toolbar } from '@/components/Toolbar';
import { WelcomeScreen } from '@/components/WelcomeScreen';
import { FormPreview } from '@/components/FormPreview';
import { SmartFormGenerator } from '@/components/SmartFormGenerator';
import { AiFixModal } from '@/components/AiFixModal';
import { ValidationStatus } from '@/components/ValidationStatus';

export default function Home() {
const { form, isPreviewing } = useFormStore();
const [showGenerator, setShowGenerator] = useState(false);
const [showAiFix, setShowAiFix] = useState(false);

return (
<div className="h-screen flex flex-col overflow-hidden">
<Toolbar onGenerateClick={() => setShowGenerator(true)} />
<Toolbar onGenerateClick={() => setShowGenerator(true)} onAiFixClick={() => setShowAiFix(true)} />
<SmartFormGenerator isOpen={showGenerator} onClose={() => setShowGenerator(false)} />
<AiFixModal isOpen={showAiFix} onClose={() => setShowAiFix(false)} />

<div className="flex-1 flex overflow-hidden">
{/* Hide sidebar during preview */}
Expand Down
Loading