diff --git a/LICENSE b/LICENSE index 14fac91..3e20a68 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 632d04f..d653ab2 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,110 @@ -# pxml Compiler - -`pxml` is a structured XML DSL and compiler for AI-driven code generation. Instead of writing free-form prompts, you specify your web application architecture in XML, manage modifications using a Manifest, and allow the AI to perform local self-healing repairs at minimal cost. - -## Installation - -Install the compiler globally via npm: -```bash -npm install -g @two-tech-dev/pxml -``` - -## End-to-End Getting Started Guide - -### 1. Initialize Sample Project -```bash -pxml init -``` -This command initializes the folder structure: -- `project.xml`: main config file that imports defined flows and local packages. -- `flows/blog.xml`: defines individual nodes (e.g. `api.posts.create`) containing paths, constraints, and test scenarios. -- `packages/`: directory to save local packages (initialized with a sample plugin `init-nextjs-project`). - -### 2. Compile Specification (Compile) -Ensure you set the environment variable `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` if using OpenAI): -```bash -export ANTHROPIC_API_KEY="your-api-key" -pxml compile - -# Or using OpenAI provider -export OPENAI_API_KEY="your-api-key" -pxml compile --provider openai --model gpt-4o - -# Or using Ollama provider locally -pxml compile --provider ollama --model llama3 --baseUrl http://localhost:11434 - -# Disable automatic AI test generation to save tokens -pxml compile --no-autogen-tests -``` -Or check the compile execution plan with `--dry-run`: -```bash -pxml compile --dry-run -``` - -### 3. Run Self-Generated Tests (Test) -```bash -pxml test -``` -This compiles the `` tags into Vitest files and runs them, saving the outcome to `.pxml/manifest.json`. - -### 4. Self-Healing Bug Repairs (Fix) -If any test fails, instead of regenerating the entire codebase, you can execute a target self-healing fix: -```bash -pxml fix --flow=blog.write - -# Or using OpenAI provider -pxml fix --flow=blog.write --provider openai --model gpt-4o - -# Or using Ollama provider locally -pxml fix --flow=blog.write --provider ollama --model llama3 --baseUrl http://localhost:11434 -``` -This formulates a minimal context patch prompt and retries local SEARCH/REPLACE edits up to 3 times. - -#### Regression Prevention via `bugs_history.xml` -You can document persistent bugs in `bugs_history.xml`. When you run `pxml fix`, the compiler automatically aggregates these descriptions and feeds them to the AI to prevent code regressions. - -To enable editor validation and autocomplete, link your `bugs_history.xml` file to the provided `bugs.xsd` schema: -```xml - - - Cart badge count displays 0 despite items being in the shopping cart database. Always fetch active cart status from the backend database route instead of localStorage. - - -``` - -### 5. Validate Specifications (Validate) -```bash -pxml validate -``` -This validates your XML configuration files. It checks: -- Standard XML schema compliance. -- Nodes defining `` fields must contain at least one `` case (prevents deployment failures due to missing tests; ignores `db-model` and `setup-command` nodes). -- All required inputs declared in `` must be supplied in test `` parameters (either at root, in `body`, `query`, or `headers`). -- Test `` parameters cannot contain extra fields not declared in `` to prevent spec inconsistencies. - -### 6. Validate Environment (Doctor) -```bash -pxml doctor -``` -Checks tool environment settings and required environment keys. - -### 7. Token Usage & Cost Statistics -After executing `pxml compile` or `pxml fix`, the CLI outputs a comprehensive token usage summary (Input, Output, and Cached tokens) along with an estimated dollar cost based on the active LLM provider rates. - -### 8. Multi-Stack Support -`pxml` supports non-JS/TS stacks (e.g. `python`, `rust`, `go`) by dynamically adjusting the code generator's prompt guidelines and style directives to match the `` `stack` attribute. - -### 9. XML Schema Autocomplete & Validation -To get XML autocomplete, inline documentation, and real-time syntax checking in editors like VS Code, associate your `.xml` files with the provided `pxml.xsd` schema: - -### Controlling AI Test Generation -You can control per-project or per-node whether the AI should automatically generate test files. Set `autogen-tests="false"` on the `` or `` element to skip AI test generation (saves token costs). Equivalent CLI flag: `--no-autogen-tests`. -```xml - - ... - -``` -*(Requires VS Code XML extension by Red Hat or equivalent)* +# pxml Compiler + +`pxml` is a structured XML DSL and compiler for AI-driven code generation. Instead of writing free-form prompts, you specify your web application architecture in XML, manage modifications using a Manifest, and allow the AI to perform local self-healing repairs at minimal cost. + +## Installation + +Install the compiler globally via npm: +```bash +npm install -g @two-tech-dev/pxml +``` + +## End-to-End Getting Started Guide + +### 1. Initialize Sample Project +```bash +pxml init +``` +This command initializes the folder structure: +- `project.xml`: main config file that imports defined flows and local packages. +- `flows/blog.xml`: defines individual nodes (e.g. `api.posts.create`) containing paths, constraints, and test scenarios. +- `packages/`: directory to save local packages (initialized with a sample plugin `init-nextjs-project`). + +### 2. Compile Specification (Compile) +Ensure you set the environment variable `ANTHROPIC_API_KEY` (or `OPENAI_API_KEY` if using OpenAI): +```bash +export ANTHROPIC_API_KEY="your-api-key" +pxml compile + +# Or using OpenAI provider +export OPENAI_API_KEY="your-api-key" +pxml compile --provider openai --model gpt-4o + +# Or using Ollama provider locally +pxml compile --provider ollama --model llama3 --baseUrl http://localhost:11434 + +# Disable automatic AI test generation to save tokens +pxml compile --no-autogen-tests +``` +Or check the compile execution plan with `--dry-run`: +```bash +pxml compile --dry-run +``` + +### 3. Run Self-Generated Tests (Test) +```bash +pxml test +``` +This compiles the `` tags into Vitest files and runs them, saving the outcome to `.pxml/manifest.json`. + +### 4. Self-Healing Bug Repairs (Fix) +If any test fails, instead of regenerating the entire codebase, you can execute a target self-healing fix: +```bash +pxml fix --flow=blog.write + +# Or using OpenAI provider +pxml fix --flow=blog.write --provider openai --model gpt-4o + +# Or using Ollama provider locally +pxml fix --flow=blog.write --provider ollama --model llama3 --baseUrl http://localhost:11434 +``` +This formulates a minimal context patch prompt and retries local SEARCH/REPLACE edits up to 3 times. + +#### Regression Prevention via `bugs_history.xml` +You can document persistent bugs in `bugs_history.xml`. When you run `pxml fix`, the compiler automatically aggregates these descriptions and feeds them to the AI to prevent code regressions. + +To enable editor validation and autocomplete, link your `bugs_history.xml` file to the provided `bugs.xsd` schema: +```xml + + + Cart badge count displays 0 despite items being in the shopping cart database. Always fetch active cart status from the backend database route instead of localStorage. + + +``` + +### 5. Validate Specifications (Validate) +```bash +pxml validate +``` +This validates your XML configuration files. It checks: +- Standard XML schema compliance. +- Nodes defining `` fields must contain at least one `` case (prevents deployment failures due to missing tests; ignores `db-model` and `setup-command` nodes). +- All required inputs declared in `` must be supplied in test `` parameters (either at root, in `body`, `query`, or `headers`). +- Test `` parameters cannot contain extra fields not declared in `` to prevent spec inconsistencies. + +### 6. Validate Environment (Doctor) +```bash +pxml doctor +``` +Checks tool environment settings and required environment keys. + +### 7. Token Usage & Cost Statistics +After executing `pxml compile` or `pxml fix`, the CLI outputs a comprehensive token usage summary (Input, Output, and Cached tokens) along with an estimated dollar cost based on the active LLM provider rates. + +### 8. Multi-Stack Support +`pxml` supports non-JS/TS stacks (e.g. `python`, `rust`, `go`) by dynamically adjusting the code generator's prompt guidelines and style directives to match the `` `stack` attribute. + +### 9. XML Schema Autocomplete & Validation +To get XML autocomplete, inline documentation, and real-time syntax checking in editors like VS Code, associate your `.xml` files with the provided `pxml.xsd` schema: + +### Controlling AI Test Generation +You can control per-project or per-node whether the AI should automatically generate test files. Set `autogen-tests="false"` on the `` or `` element to skip AI test generation (saves token costs). Equivalent CLI flag: `--no-autogen-tests`. +```xml + + ... + +``` +*(Requires VS Code XML extension by Red Hat or equivalent)* diff --git a/bugs.xsd b/bugs.xsd index 27a9892..b3a06a4 100644 --- a/bugs.xsd +++ b/bugs.xsd @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/dist/cache/index.js b/dist/cache/index.js new file mode 100644 index 0000000..a1a1d7b --- /dev/null +++ b/dist/cache/index.js @@ -0,0 +1,29 @@ +import * as crypto from 'crypto'; +export class PxmlCache { + /** + * Generates a stable hash for a node's configuration to verify if it has changed. + */ + static hashNode(node) { + const serialized = JSON.stringify({ + id: node.id, + type: node.type, + flow: node.flow, + extends: node.extends, + meta: node.meta, + input: node.input, + output: node.output, + constraints: node.constraints, + tests: node.tests + }); + return crypto.createHash('sha256').update(serialized).digest('hex'); + } + static hashNodeTests(node) { + const serialized = JSON.stringify({ + input: node.input, + output: node.output, + constraints: node.constraints, + tests: node.tests + }); + return crypto.createHash('sha256').update(serialized).digest('hex'); + } +} diff --git a/dist/cli/fix.js b/dist/cli/fix.js new file mode 100644 index 0000000..20e9421 --- /dev/null +++ b/dist/cli/fix.js @@ -0,0 +1,171 @@ +import { getTestFilePath } from '../runner/index.js'; +import { PxmlPatcher } from '../patcher/index.js'; +import * as fs from 'fs'; +import * as path from 'path'; +const colors = { + red: (text) => `\x1b[31m${text}\x1b[0m`, + green: (text) => `\x1b[32m${text}\x1b[0m`, + yellow: (text) => `\x1b[33m${text}\x1b[0m`, + blue: (text) => `\x1b[34m${text}\x1b[0m`, + cyan: (text) => `\x1b[36m${text}\x1b[0m`, + bold: (text) => `\x1b[1m${text}\x1b[0m` +}; +export async function runFixLoop(node, projectDir, manifest, codegen, runner, writer, mockFixResponse, bugContext, forceFirstRun, stack = 'nextjs') { + const maxRetries = 3; + let attempt = 0; + console.log(`${colors.yellow(colors.bold('[FIX]'))} Starting self-healing loop for node: ${node.id} (Max ${maxRetries} attempts)`); + while (attempt < maxRetries) { + attempt++; + console.log(`${colors.yellow('[FIX]')} Attempt ${attempt}/${maxRetries}...`); + // 1. Gather failure context + const currentCode = fs.existsSync(node.meta.path) ? fs.readFileSync(node.meta.path, 'utf-8') : ''; + const testResult = runner.runNodeTests(node, stack); + // Bypass check only on attempt 1 if forceFirstRun is requested + const bypassCheck = forceFirstRun && attempt === 1; + if (testResult.passed && !bypassCheck) { + console.log(`${colors.green(colors.bold('[FIX]'))} Success! Node ${node.id} tests passed on attempt ${attempt}.`); + // Update manifest + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: testResult.results + }); + manifest.save(); + } + return true; + } + // Identify failed cases + const failedCases = Object.entries(testResult.results) + .filter(([_, status]) => status === 'fail') + .map(([name]) => name); + console.log(`${colors.red(colors.bold('[FIX]'))} Failed test cases: ${failedCases.join(', ')}`); + const testFilePath = getTestFilePath(node.meta.path, stack); + const absTestFilePath = path.resolve(projectDir, testFilePath); + const currentTestCode = fs.existsSync(absTestFilePath) ? fs.readFileSync(absTestFilePath, 'utf-8') : ''; + // 2. Formulate minimal fix-prompt + const patchPrompt = `You are a software repair AI. The following code or tests for node '${node.id}' have issues. +${testResult.output ? `Test Execution Failure Errors:\n${testResult.output}\n` : ''} +${bugContext ? `Raw Bug Context / Error Logs:\n${bugContext}\n` : ''} +Path: ${node.meta.path} +Test Path: ${testFilePath} +Failed Cases: ${failedCases.join(', ')} +Node XML spec: +- Input: ${JSON.stringify(node.input)} +- Output: ${JSON.stringify(node.output)} +- Constraints: ${JSON.stringify(node.constraints)} + +Current Code: +\`\`\`typescript +${currentCode} +\`\`\` + +Current Test Code: +\`\`\`typescript +${currentTestCode} +\`\`\` + +Analyze if the issue is in the implementation code or the test code (or both). +CRITICAL: Do not break the code's core business logic or violate the XML constraints. If the test fails due to incorrect test assertions, mock setups, or environment mismatches, patch the test file (${testFilePath}) instead of the implementation file (${node.meta.path}). +Generate SEARCH/REPLACE blocks to patch the files. You MUST prefix each file's search/replace blocks with the header "FILE: [file_path]" where [file_path] is the relative path (either ${node.meta.path} or ${testFilePath}). + +Format: +FILE: ${node.meta.path} +<<<<<<< SEARCH +[code to replace] +======= +[replacement code] +>>>>>>> REPLACE + +FILE: ${testFilePath} +<<<<<<< SEARCH +[code to replace] +======= +[replacement code] +>>>>>>> REPLACE`; + // 3. Request diff/patch from AI (or use mock if provided) + let patch = ''; + if (mockFixResponse) { + patch = mockFixResponse; + } + else { + try { + patch = await codegen.generateDirect(patchPrompt, "Generate only SEARCH/REPLACE patch block."); + } + catch (err) { + console.error(`[FIX] AI call failed: ${err.message}. Escalating to user.`); + return false; + } + } + // 4. Apply patch + try { + const filePatches = patch.split(/FILE:\s+/); + if (filePatches.length > 1) { + for (const fp of filePatches) { + if (!fp.trim()) + continue; + const firstLineBreak = fp.indexOf('\n'); + if (firstLineBreak === -1) + continue; + const relativePath = fp.slice(0, firstLineBreak).trim(); + const filePatchContent = fp.slice(firstLineBreak + 1); + let targetFilePath = path.resolve(projectDir, relativePath); + if (!fs.existsSync(targetFilePath) && relativePath.startsWith('tests/')) { + const strippedPath = relativePath.replace(/^tests\//, ''); + const fallbackPath = path.resolve(projectDir, strippedPath); + if (fs.existsSync(fallbackPath)) { + targetFilePath = fallbackPath; + } + } + if (fs.existsSync(targetFilePath)) { + const fileContent = fs.readFileSync(targetFilePath, 'utf-8'); + const patched = PxmlPatcher.applyPatch(fileContent, filePatchContent); + writer.write(targetFilePath, patched); + console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${path.relative(projectDir, targetFilePath)}.`); + } + } + } + else { + const patchedCode = PxmlPatcher.applyPatch(currentCode, patch); + writer.write(node.meta.path, patchedCode); + console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${node.meta.path}.`); + } + console.log(`${colors.cyan(colors.bold('[FIX]'))} Patch details:\n${summarizePatch(patch)}\n`); + } + catch (err) { + console.warn(`${colors.red(colors.bold('[FIX]'))} Failed to apply patch: ${err.message}`); + // If patch application failed, we retry or escalate + continue; + } + } + console.error(`${colors.red(colors.bold('[FIX]'))} Failed to self-heal node ${node.id} after ${maxRetries} attempts. Escalating to user.`); + return false; +} +function summarizePatch(patch) { + const filePatches = patch.split(/FILE:\s+/); + if (filePatches.length <= 1) { + const blocks = PxmlPatcher.parsePatch(patch); + if (blocks.length > 0) { + return ` → Modified file: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).`; + } + return patch.trim(); + } + let summary = ''; + for (const fp of filePatches) { + if (!fp.trim()) + continue; + const firstLineBreak = fp.indexOf('\n'); + if (firstLineBreak === -1) + continue; + const relativePath = fp.slice(0, firstLineBreak).trim(); + const filePatchContent = fp.slice(firstLineBreak + 1); + const blocks = PxmlPatcher.parsePatch(filePatchContent); + if (blocks.length > 0) { + summary += ` → ${relativePath}: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).\n`; + } + else { + summary += ` → ${relativePath}: replaced content entirely.\n`; + } + } + return summary.trim(); +} diff --git a/dist/cli/index.js b/dist/cli/index.js new file mode 100644 index 0000000..de30a71 --- /dev/null +++ b/dist/cli/index.js @@ -0,0 +1,619 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { PxmlParser, validateProject } from '../parser/index.js'; +import { DependencyGraph } from '../graph/index.js'; +import { PxmlManifest } from '../manifest/index.js'; +import { PxmlCache } from '../cache/index.js'; +import { PxmlCodegen } from '../codegen/index.js'; +import { PxmlRunner, getTestFilePath } from '../runner/index.js'; +import { FileWriter } from '../writer/index.js'; +import { runFixLoop } from './fix.js'; +import { XMLParser } from 'fast-xml-parser'; +import * as fs from 'fs'; +import * as path from 'path'; +const colors = { + red: (text) => `\x1b[31m${text}\x1b[0m`, + green: (text) => `\x1b[32m${text}\x1b[0m`, + yellow: (text) => `\x1b[33m${text}\x1b[0m`, + blue: (text) => `\x1b[34m${text}\x1b[0m`, + magenta: (text) => `\x1b[35m${text}\x1b[0m`, + cyan: (text) => `\x1b[36m${text}\x1b[0m`, + bold: (text) => `\x1b[1m${text}\x1b[0m` +}; +const program = new Command(); +program + .name('pxml') + .description('pxml compiler and build tool') + .version('0.1.0'); +program + .command('init') + .description('Initialize sample pxml project structure') + .action(() => { + const cwd = process.cwd(); + const configPath = path.join(cwd, 'project.xml'); + if (fs.existsSync(configPath)) { + console.log('Project already initialized.'); + return; + } + // Create example directory structure for flows and packages + fs.mkdirSync(path.join(cwd, 'flows'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'shared'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'packages', 'init-nextjs-project'), { recursive: true }); + const mainXml = ` + + + + + + + + app/page.tsx + setup.nextjs + + File exports default React component + Page contains a link with href="/posts" + Replace the entire homepage with a beautifully styled landing page (clean dark theme, tailwind classes). Do not call any dashboard APIs like /api/network or /api/ram. Show a hero section, and a prominent link/button pointing to the Posts page at '/posts'. + +`; + const initNextjsProjectXml = ` + + + package.json + + Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest + +`; + const blogXml = ` + + + app/api/posts/route.ts + + + + + + + + + Initialize a better-sqlite3 database named 'blog.db'. Create a 'posts' table with columns id (INTEGER PRIMARY KEY), title (TEXT), and content (TEXT) if it does not exist. Implement both POST (to insert a post) and GET (to select all posts) handlers in this route file. Ensure the route is dynamic and not cached by exporting: export const dynamic = 'force-dynamic'; + + Create post successful + + + Hello World + My first post + + + + 200 + success + + + + + + + app/posts/page.tsx + api.posts.create + + + + + Create a beautifully designed blog posts manager page at app/posts/page.tsx (clean dark layout, tailwind cards, inputs, and buttons). It must fetch posts from '/api/posts' on render/mount, display them, and show a form to submit new posts via a POST request to '/api/posts'. Refresh the posts list automatically on successful submission. + +`; + const fileUrl = new URL(import.meta.url); + const sourceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../pxml.xsd'); + if (fs.existsSync(sourceXsd)) { + fs.copyFileSync(sourceXsd, path.join(cwd, 'pxml.xsd')); + } + else { + const fallbackXsd = path.resolve(cwd, 'pxml.xsd'); + if (!fs.existsSync(fallbackXsd)) { + const workspaceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../pxml.xsd'); + if (fs.existsSync(workspaceXsd)) { + fs.copyFileSync(workspaceXsd, path.join(cwd, 'pxml.xsd')); + } + } + } + const sourceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../bugs.xsd'); + if (fs.existsSync(sourceBugsXsd)) { + fs.copyFileSync(sourceBugsXsd, path.join(cwd, 'bugs.xsd')); + } + else { + const fallbackBugsXsd = path.resolve(cwd, 'bugs.xsd'); + if (!fs.existsSync(fallbackBugsXsd)) { + const workspaceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../bugs.xsd'); + if (fs.existsSync(workspaceBugsXsd)) { + fs.copyFileSync(workspaceBugsXsd, path.join(cwd, 'bugs.xsd')); + } + } + } + const bugsHistoryXml = ` + + SQLite database file locks when executing parallel write operations. Ensure connections are closed properly or run db queries sequentially. + +`; + fs.writeFileSync(configPath, mainXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'flows', 'blog.xml'), blogXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'packages', 'init-nextjs-project', 'project.xml'), initNextjsProjectXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'bugs_history.xml'), bugsHistoryXml, 'utf-8'); + console.log('Successfully initialized Next.js project with pxml templates.'); +}); +program + .command('compile') + .description('Compile XML nodes to implementation code') + .option('--dry-run', 'Show execution plan without writing changes') + .option('--no-autogen-tests', 'Disable automatic test case generation') + .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') + .option('--apiKey ', 'API key') + .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') + .option('--model ', 'LLM model name', 'claude-3-5-sonnet') + .action(async (options) => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + try { + validateProject(project); + } + catch (err) { + console.error(err.message); + process.exit(1); + } + injectHistoricalBugs(project.nodes, cwd); + const graph = new DependencyGraph(project.nodes); + const order = graph.getSortOrder(); + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(!!options.dryRun); + const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); + const codegen = new PxmlCodegen({ + provider: options.provider, + apiKey: apiKey, + baseUrl: options.baseUrl, + model: options.model + }); + console.log(`Compiling project ${project.name} (stack: ${project.stack})...`); + const extendedNodeIds = new Set(); + for (const node of project.nodes) { + if (node.extends) { + extendedNodeIds.add(node.extends); + } + } + const compiledNodeIds = []; + for (const nodeId of order) { + if (extendedNodeIds.has(nodeId)) { + continue; + } + const node = project.nodes.find(n => n.id === nodeId); + const xmlHash = PxmlCache.hashNode(node); + const cached = manifest.getNode(nodeId); + if (cached && cached.xml_hash === xmlHash) { + console.log(`${colors.yellow('[SKIP]')} Node ${nodeId} has not changed.`); + continue; + } + if (cached && cached.locked) { + console.log(`${colors.red(colors.bold('[LOCKED]'))} Node ${nodeId} is locked. Skipping codegen.`); + continue; + } + // Build rich project context by loading contents of already generated files + let projectContext = project.nodes.map(n => `Node: ${n.id}, Path: ${n.meta.path}`).join('\n'); + projectContext += '\n\nAlready generated files contents:\n'; + const manifestData = manifest.get(); + for (const [mNodeId, mNode] of Object.entries(manifestData.nodes)) { + for (const filePath of mNode.output_files) { + const absPath = path.resolve(cwd, filePath); + if (fs.existsSync(absPath)) { + const content = fs.readFileSync(absPath, 'utf-8'); + projectContext += `\n--- File: ${filePath} (Node: ${mNodeId}) ---\n${content}\n`; + } + } + } + console.log(`${colors.cyan(colors.bold('[CODEGEN]'))} Generating code for node: ${nodeId}`); + try { + const code = await codegen.generateNodeCode(node, projectContext, writer, project.stack); + const testFilePath = getTestFilePath(node.meta.path, project.stack); + const testXmlHash = PxmlCache.hashNodeTests(node); + const cachedTestHash = cached?.test_xml_hash; + const absTestFilePath = path.resolve(cwd, testFilePath); + const shouldAutogen = options.autogenTests && node.autogenTests; + if (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') { + if (!cached || cached.xml_hash !== xmlHash || !fs.existsSync(absTestFilePath) || cachedTestHash !== testXmlHash) { + console.log(`${colors.magenta(colors.bold('[TESTGEN]'))} Generating/Updating test file at: ${testFilePath}`); + await codegen.generateNodeTest(node, absTestFilePath, code, project.stack, writer); + } + } + manifest.setNode(nodeId, { + node_id: nodeId, + source_file: 'project.xml', + xml_hash: xmlHash, + test_xml_hash: testXmlHash, + output_files: (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') ? [node.meta.path, testFilePath] : [node.meta.path], + depends_on: node.meta.depends_on, + flow: node.flow, + generated_at: new Date().toISOString() + }); + manifest.save(); + compiledNodeIds.push(nodeId); + } + catch (err) { + console.error(`[ERROR] Failed to compile node ${nodeId}: ${err.message}`); + writer.rollback(); + process.exit(1); + } + } + if (compiledNodeIds.length > 0) { + console.log(colors.cyan(colors.bold('\nRunning tests for compiled nodes...'))); + const runner = new PxmlRunner(cwd, writer); + let allPassed = true; + for (const nodeId of compiledNodeIds) { + const node = project.nodes.find(n => n.id === nodeId); + if (node.type === 'setup-command' || node.type === 'config-file') { + continue; + } + console.log(`${colors.blue(colors.bold('[TEST]'))} Running tests for node: ${node.id}`); + const res = runner.runNodeTests(node, project.stack); + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: res.results + }); + manifest.save(); + } + if (!res.passed) { + console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed tests. Triggering self-healing...`); + let bugContext = ''; + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (fs.existsSync(bugsHistoryPath)) { + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (parsed.bugs && parsed.bugs.bug) { + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + let historyText = '\n--- Historical Bug Prevention Checklist ---\n'; + for (const bug of rawBugs) { + const flowAttr = bug['@_flow'] || 'general'; + const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); + historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; + } + bugContext = historyText; + } + } + catch (err) { } + } + const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, false, project.stack); + if (!success) { + allPassed = false; + console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed to self-heal.`); + } + else { + console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} healed successfully.`); + } + } + else { + console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} tests passed.`); + } + } + if (!allPassed) { + console.error(colors.red(colors.bold('\n[ERROR] Some compiled nodes failed tests and could not self-heal.'))); + process.exit(1); + } + } + const stats = codegen.getStats(); + if (stats.inputTokens > 0 || stats.outputTokens > 0) { + const cost = calculateEstimatedCost(options.model, stats); + console.log(`\nToken Usage & Cost Summary:`); + console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); + console.log(`- Output Tokens: ${stats.outputTokens}`); + console.log(`- Estimated Cost: $${cost.toFixed(4)}`); + } + console.log('Compilation finished successfully.'); +}); +program + .command('validate') + .description('Validate XML files against schema and rules') + .action(() => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + try { + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + validateProject(project); + console.log('Project validation successful.'); + } + catch (err) { + console.error(err.message); + process.exit(1); + } +}); +program + .command('test') + .description('Run Vitest test suite on all compiled nodes') + .action(() => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(); + const runner = new PxmlRunner(cwd, writer); + let allPassed = true; + for (const node of project.nodes) { + console.log(`[TEST] Running tests for node: ${node.id}`); + const res = runner.runNodeTests(node, project.stack); + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: res.results + }); + manifest.save(); + } + if (!res.passed) { + allPassed = false; + console.log(`[FAIL] Node ${node.id} failed tests.`); + } + else { + console.log(`[PASS] Node ${node.id} tests passed.`); + } + } + if (allPassed) { + console.log('All tests passed successfully.'); + } + else { + process.exit(1); + } +}); +program + .command('diagnose') + .description('Diagnose logs and locate nodes with issues') + .option('--log ', 'Path to log file to parse') + .action((options) => { + const cwd = process.cwd(); + const logFilePath = options.log ? path.resolve(options.log) : null; + if (!logFilePath || !fs.existsSync(logFilePath)) { + console.error('Log file not found or not specified. Usage: pxml diagnose --log '); + process.exit(1); + } + const logContent = fs.readFileSync(logFilePath, 'utf-8'); + const logs = logContent.split('\n').map(line => { + try { + return JSON.parse(line); + } + catch (err) { + return { message: line }; + } + }); + const PxmlDiagnostics = require('../diagnostics/index.js').PxmlDiagnostics; + console.log('Running diagnostics on logs...'); + for (const log of logs) { + const diagnosis = PxmlDiagnostics.diagnoseHeuristic(log); + if (diagnosis) { + console.log(`[DIAGNOSIS] Suspected issue in flow: "${diagnosis.flow}" (Node type: "${diagnosis.suspectedType}")`); + console.log(` Reason: "${log.message}"`); + } + } +}); +program + .command('fix') + .description('Invoke self-healing loop for failed nodes') + .option('--flow ', 'Fix specific flow') + .option('--node ', 'Fix specific node') + .option('--bug ', 'Path to raw error log or custom description text to aid the fix loop') + .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') + .option('--apiKey ', 'API key') + .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') + .option('--model ', 'LLM model name', 'claude-3-5-sonnet') + .action(async (options) => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(); + const runner = new PxmlRunner(cwd, writer); + const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); + const codegen = new PxmlCodegen({ + provider: options.provider, + apiKey: apiKey, + baseUrl: options.baseUrl, + model: options.model + }); + let targetNodes = project.nodes; + if (options.node) { + targetNodes = targetNodes.filter(n => n.id === options.node); + } + else if (options.flow) { + targetNodes = targetNodes.filter(n => n.flow === options.flow); + } + let bugContext = ''; + if (options.bug) { + if (fs.existsSync(options.bug)) { + bugContext = fs.readFileSync(options.bug, 'utf-8'); + } + else { + bugContext = options.bug; + } + } + // Load bugs_history.xml if it exists to add regression prevention checklist + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (fs.existsSync(bugsHistoryPath)) { + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (parsed.bugs && parsed.bugs.bug) { + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + let historyText = '\n--- Historical Bug Prevention Checklist (Ensure these bugs do not exist in the code) ---\n'; + for (const bug of rawBugs) { + const flowAttr = bug['@_flow'] || 'general'; + const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); + historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; + } + bugContext = bugContext ? `${bugContext}\n${historyText}` : historyText; + } + } + catch (err) { + console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); + } + } + for (const node of targetNodes) { + // Check if node is failing tests, or force fix if a custom bug context is provided + console.log(`[FIX] Verifying node: ${node.id}`); + const testRes = runner.runNodeTests(node, project.stack); + if (!testRes.passed || bugContext) { + // If bugContext is provided, force run at least once by passing a flag or bypassing check + const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, !!bugContext, project.stack); + if (success) { + console.log(`[FIX] Node ${node.id} healed successfully.`); + } + else { + console.error(`[FIX] Could not self-heal node ${node.id}.`); + } + } + else { + console.log(`[FIX] Node ${node.id} is healthy.`); + } + } + const stats = codegen.getStats(); + if (stats.inputTokens > 0 || stats.outputTokens > 0) { + const cost = calculateEstimatedCost(options.model, stats); + console.log(`\nToken Usage & Cost Summary (Fix Loop):`); + console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); + console.log(`- Output Tokens: ${stats.outputTokens}`); + console.log(`- Estimated Cost: $${cost.toFixed(4)}`); + } +}); +program + .command('doctor') + .description('Diagnostics checklist (configurations, env keys, databases)') + .action(() => { + console.log('--- Doctor Check ---'); + console.log(`[x] Node version: ${process.version}`); + const projectXml = path.join(process.cwd(), 'project.xml'); + if (fs.existsSync(projectXml)) { + console.log('[x] project.xml exists'); + } + else { + console.log('[ ] project.xml is missing'); + } + if (process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY) { + console.log('[x] AI API Key (ANTHROPIC_API_KEY or OPENAI_API_KEY) is configured'); + } + else { + console.log('[ ] AI API Key is missing (needed for pxml compile/fix)'); + } +}); +function injectHistoricalBugs(nodes, cwd) { + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (!fs.existsSync(bugsHistoryPath)) + return; + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (!parsed.bugs || !parsed.bugs.bug) + return; + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + for (const node of nodes) { + for (const bug of rawBugs) { + const bugId = bug['@_id']; + const bugFlow = bug['@_flow'] || 'general'; + const bugDesc = (typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug)).trim(); + const matchesFlow = bugFlow !== 'general' && (node.flow === bugFlow || node.id.includes(bugFlow)); + const hasExplicitLearned = node.constraints.some((c) => c.learnedFrom === bugId); + if (matchesFlow || hasExplicitLearned) { + const alreadyExists = node.constraints.some((c) => c.learnedFrom === bugId || c.description.includes(bugDesc)); + if (!alreadyExists) { + node.constraints.push({ + verify: 'static', + description: `Prevent regression of bug ${bugId}: ${bugDesc}`, + learnedFrom: bugId + }); + } + } + } + } + } + catch (err) { + console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); + } +} +function calculateEstimatedCost(model, stats) { + const modelLower = model.toLowerCase(); + let inputRate = 0.000003; // Default input rate per token ($3/M) + let outputRate = 0.000015; // Default output rate per token ($15/M) + let cacheReadRate = 0.0000003; // Default cache read rate ($0.30/M) + if (modelLower.includes('gpt-4o-mini')) { + inputRate = 0.00000015; // $0.15/M + outputRate = 0.0000006; // $0.60/M + cacheReadRate = 0.000000075; + } + else if (modelLower.includes('gpt-4o')) { + inputRate = 0.000005; // $5/M + outputRate = 0.000015; // $15/M + cacheReadRate = 0.0000025; + } + else if (modelLower.includes('haiku')) { + inputRate = 0.00000025; // $0.25/M + outputRate = 0.00000125; // $1.25/M + cacheReadRate = 0.00000003; + } + else if (modelLower.includes('sonnet')) { + inputRate = 0.000003; // $3/M + outputRate = 0.000015; // $15/M + cacheReadRate = 0.0000003; // $0.30/M + } + else if (modelLower.includes('opus')) { + inputRate = 0.000015; // $15/M + outputRate = 0.000075; // $75/M + cacheReadRate = 0.0000015; + } + const normalInput = Math.max(0, stats.inputTokens - stats.cachedTokens); + const cost = (normalInput * inputRate) + (stats.cachedTokens * cacheReadRate) + (stats.outputTokens * outputRate); + return cost; +} +program.parse(process.argv); diff --git a/dist/codegen/index.js b/dist/codegen/index.js new file mode 100644 index 0000000..a646071 --- /dev/null +++ b/dist/codegen/index.js @@ -0,0 +1,454 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +const colors = { + red: (text) => `\x1b[31m${text}\x1b[0m`, + green: (text) => `\x1b[32m${text}\x1b[0m`, + yellow: (text) => `\x1b[33m${text}\x1b[0m`, + blue: (text) => `\x1b[34m${text}\x1b[0m`, + magenta: (text) => `\x1b[35m${text}\x1b[0m`, + cyan: (text) => `\x1b[36m${text}\x1b[0m`, + bold: (text) => `\x1b[1m${text}\x1b[0m` +}; +function getStackInstructions(stack) { + const stackLower = stack.toLowerCase(); + if (stackLower.includes('python')) { + return { + systemPrompt: `You are an expert Python developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`python) or explanations. Only output code. +CRITICAL: Use idiomatic Python code, follow PEP 8 styling, and make sure dependencies are imported correctly.`, + promptNote: `Stack: Python. Use standard Python practices, requirements, and imports.` + }; + } + else if (stackLower.includes('rust')) { + return { + systemPrompt: `You are an expert Rust developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`rust) or explanations. Only output code. +CRITICAL: Write clean Rust code, manage lifetimes and ownership correctly, and follow Rust idioms.`, + promptNote: `Stack: Rust. Use standard Rust syntax and crate references.` + }; + } + else if (stackLower.includes('go') || stackLower === 'golang') { + return { + systemPrompt: `You are an expert Go developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`go) or explanations. Only output code. +CRITICAL: Write idiomatic Go code, ensure correct package declaration, and format using gofmt standards.`, + promptNote: `Stack: Go. Use standard Go packaging and syntax.` + }; + } + else if (stackLower.includes('c#') || stackLower === 'csharp' || stackLower.includes('dotnet')) { + return { + systemPrompt: `You are an expert C# developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`csharp) or explanations. Only output code. +CRITICAL: Use modern C# syntax, follow standard .NET conventions, and declare namespace/imports correctly.`, + promptNote: `Stack: C# / .NET. Use standard .NET namespace and architecture.` + }; + } + else if (stackLower.includes('c++') || stackLower === 'cpp') { + return { + systemPrompt: `You are an expert C++ developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`cpp) or explanations. Only output code. +CRITICAL: Use modern C++ standards (C++17/20), handle memory management correctly, and ensure clean header and source file separation.`, + promptNote: `Stack: C++. Use standard C++ library and syntax.` + }; + } + else { + return { + systemPrompt: `You are an expert software engineer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output code. +CRITICAL: The codebase uses ES Modules (ESM). You must STRICTLY use 'import ... from ...' syntax. NEVER generate CommonJS 'require(...)' calls.`, + promptNote: `Stack: JS/TS (${stack}). Ensure ES Module format.` + }; + } +} +export class AnthropicProvider { + client; + stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + constructor(apiKey) { + this.client = new Anthropic({ apiKey }); + } + async generate(prompt, systemPrompt, model) { + const response = await this.client.messages.create({ + model, + max_tokens: 4000, + system: systemPrompt, + messages: [{ role: 'user', content: prompt }] + }); + if (response.usage) { + this.stats.inputTokens += response.usage.input_tokens || 0; + this.stats.outputTokens += response.usage.output_tokens || 0; + const cacheRead = response.usage.cache_read_input_tokens || 0; + this.stats.cachedTokens += cacheRead; + } + return response.content[0].type === 'text' ? response.content[0].text : ''; + } +} +export class OpenAICompatibleProvider { + apiKey; + baseUrl; + stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + constructor(apiKey, baseUrl = 'https://api.openai.com/v1') { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + async generate(prompt, systemPrompt, model) { + const maxRetries = 3; + let attempt = 0; + while (attempt < maxRetries) { + attempt++; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ], + temperature: 0.2, + stream: false + }), + signal: controller.signal + }); + clearTimeout(timeoutId); + if (!response.ok) { + const errText = await response.text(); + throw new Error(`OpenAI Provider HTTP error! status: ${response.status}, details: ${errText}`); + } + const data = await response.json(); + if (data.usage) { + this.stats.inputTokens += data.usage.prompt_tokens || 0; + this.stats.outputTokens += data.usage.completion_tokens || 0; + const cached = data.usage.prompt_tokens_details?.cached_tokens || 0; + this.stats.cachedTokens += cached; + } + return data.choices?.[0]?.message?.content || ''; + } + catch (err) { + clearTimeout(timeoutId); + if (attempt >= maxRetries) { + throw new Error(`OpenAI API request failed after ${maxRetries} attempts: ${err.message}`); + } + console.warn(`[API WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); + // Backoff delay + await new Promise(res => setTimeout(res, 2000 * attempt)); + } + } + throw new Error('OpenAI API request failed.'); + } +} +export class OllamaProvider { + baseUrl; + stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + constructor(baseUrl = 'http://localhost:11434') { + this.baseUrl = baseUrl; + } + async generate(prompt, systemPrompt, model) { + const maxRetries = 3; + let attempt = 0; + while (attempt < maxRetries) { + attempt++; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout + try { + const response = await fetch(`${this.baseUrl}/api/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model, + prompt: `${systemPrompt}\n\nUser specifications:\n${prompt}`, + stream: false, + options: { + temperature: 0.2 + } + }), + signal: controller.signal + }); + clearTimeout(timeoutId); + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Ollama Provider HTTP error! status: ${response.status}, details: ${errText}`); + } + const data = await response.json(); + if (data) { + this.stats.inputTokens += data.prompt_eval_count || 0; + this.stats.outputTokens += data.eval_count || 0; + } + return data.response || ''; + } + catch (err) { + clearTimeout(timeoutId); + if (attempt >= maxRetries) { + throw new Error(`Ollama API request failed after ${maxRetries} attempts: ${err.message}`); + } + console.warn(`[OLLAMA WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); + await new Promise(res => setTimeout(res, 2000 * attempt)); + } + } + throw new Error('Ollama API request failed.'); + } +} +export class PxmlCodegen { + config; + provider; + constructor(config) { + this.config = config; + if (config.mockResponse) { + return; + } + if (config.customProvider) { + this.provider = config.customProvider; + } + else if (config.provider === 'openai') { + if (!config.apiKey) + throw new Error('API Key required for OpenAI provider'); + this.provider = new OpenAICompatibleProvider(config.apiKey, config.baseUrl); + } + else if (config.provider === 'ollama') { + this.provider = new OllamaProvider(config.baseUrl); + } + else { + // Default to anthropic + if (!config.apiKey) + throw new Error('API Key required for Anthropic provider'); + this.provider = new AnthropicProvider(config.apiKey); + } + } + getStats() { + if (this.provider && 'stats' in this.provider) { + return this.provider.stats; + } + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + } + async generateDirect(prompt, systemPrompt) { + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + return this.provider.generate(prompt, systemPrompt, this.config.model); + } + async generateNodeCode(node, projectContext, writer, stack = 'nextjs') { + const stackInfo = getStackInstructions(stack); + if (node.type === 'setup-command') { + if (this.config.mockResponse) { + const mockCmd = this.config.mockResponse(node); + console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Would execute: ${mockCmd}`); + return mockCmd; + } + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + const prompt = `Project Context: +${projectContext} + +Generate the exact terminal shell command to initialize/configure this project: +- ID: ${node.id} +- Type: ${node.type} +- Flow: ${node.flow} +- Stack: ${stack} +- Target: Run setup tasks (e.g. create project or install packages) +- Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} + +Generate ONLY the single-line shell command. Do not include explanation, comment, or markdown block wrapping.`; + const systemPrompt = `You are a DevOps engineer generating setup shell commands. Generate ONLY the executable terminal command text. Do not wrap in markdown or backticks.`; + const commandText = (await this.provider.generate(prompt, systemPrompt, this.config.model)).trim(); + console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Executing command: "${commandText}"`); + // Conflict avoidance workaround for npx create-next-app . + const isCreateNextApp = commandText.includes('create-next-app'); + const tempDir = path.join(process.cwd(), '../.pxml-temp-init'); + const conflictItems = ['project.xml', 'pxml.xsd', 'flows', 'shared', 'packages', '.pxml', 'README.md', 'LICENSE', '.gitignore', 'bugs_history.xml', 'bugs.xsd', 'AGENTS.md', 'CLAUDE.md']; + const movedItems = []; + if (isCreateNextApp) { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + for (const item of conflictItems) { + const itemPath = path.join(process.cwd(), item); + if (fs.existsSync(itemPath)) { + const destPath = path.join(tempDir, item); + if (fs.existsSync(destPath)) { + fs.rmSync(destPath, { recursive: true, force: true }); + } + fs.renameSync(itemPath, destPath); + movedItems.push({ src: destPath, dest: itemPath }); + } + } + } + try { + execSync(commandText, { stdio: 'inherit', cwd: process.cwd() }); + } + finally { + // Restore moved files + if (isCreateNextApp) { + for (const item of movedItems) { + if (fs.existsSync(item.src)) { + fs.renameSync(item.src, item.dest); + } + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + return commandText; + } + if (this.config.mockResponse) { + const mockCode = this.config.mockResponse(node); + writer.write(node.meta.path, mockCode); + this.logAIResponse(node.id, "MOCK PROMPT", mockCode); + return mockCode; + } + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + const prompt = this.buildPrompt(node, projectContext, stackInfo.promptNote); + const systemPrompt = stackInfo.systemPrompt; + const code = await this.provider.generate(prompt, systemPrompt, this.config.model); + let cleanedCode = this.cleanMarkdown(code); + // AI Code Verification & Self-Correction step + try { + const verificationPrompt = `Verify the correctness and deployment stability of the following generated code for node '${node.id}'. +Destination Path: ${node.meta.path} +Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} + +Generated Code: +${cleanedCode} + +Analyze the code. Are there any bugs, schema inconsistencies, or missing imports/exports? +If there are issues, output the corrected code. If the code is fully stable, output the word "STABLE".`; + const verificationResponse = await this.provider.generate(verificationPrompt, "You are a senior code reviewer. Return ONLY the corrected code or the exact word 'STABLE'. Do not include markdown code blocks or explanations.", this.config.model); + const cleanedVerification = this.cleanMarkdown(verificationResponse); + if (cleanedVerification.toUpperCase() !== 'STABLE' && cleanedVerification.length > 20) { + console.log(`${colors.green(colors.bold('[VERIFY]'))} AI self-corrected generated code for node: ${node.id}`); + cleanedCode = cleanedVerification; + } + } + catch (err) { + console.warn(`[VERIFY WARNING] Self-verification step skipped: ${err.message}`); + } + writer.write(node.meta.path, cleanedCode); + this.logAIResponse(node.id, prompt, cleanedCode); + return cleanedCode; + } + async generateNodeTest(node, testPath, implementationCode, stack = 'nextjs', writer) { + if (this.config.mockResponse) { + const mockTest = `import { describe, it, expect } from 'vitest';\n// Mock test for ${node.id}\n`; + writer.write(testPath, mockTest); + return mockTest; + } + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + const testFileExists = fs.existsSync(testPath); + const currentTestCode = testFileExists ? fs.readFileSync(testPath, 'utf-8') : ''; + const systemPrompt = `You are an expert QA and software testing engineer. +Generate ONLY the complete test file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output test code. +CRITICAL: The test framework matches the stack. For JS/TS, use Vitest. For Python, use pytest. For Go, use testing. For C#, use xUnit or NUnit. +CRITICAL: Never attempt to bind/start a live HTTP server or make real external network calls. Always mock inputs, mock requests, mock responses, and use virtual mock routing/internal test request objects (e.g., mock 'Request' in Next.js, 'httptest' in Go, 'responses' or mock frameworks in Python/C#). +CRITICAL: For Next.js page components where 'searchParams' is a Promise (Next.js 15/React 19), always wrap the rendered component in '' inside the test to prevent suspension boundary errors. +CRITICAL: In JS/TS component tests, always add '// @vitest-environment jsdom' at the very top of the test file. Tests are co-located in the same folder as code, so always use local relative paths (e.g. './page' or './route') for importing the implementation code. Never use path aliases (like '@/...'). +CRITICAL: When mocking constructors or classes (such as 'better-sqlite3' Database), always mock them using a standard JavaScript class (e.g., 'default: class { ... }') instead of an arrow function (e.g., 'default: () => ...') to prevent 'is not a constructor' TypeErrors. +CRITICAL: To ensure the DOM is cleared between tests when Vitest globals are disabled, always import 'cleanup' and call 'afterEach(cleanup)' explicitly in the test file (e.g. 'import { cleanup } from "@testing-library/react"; afterEach(cleanup);').`; + const implExt = path.extname(node.meta.path); + const implBase = path.basename(node.meta.path, implExt); + let importStatement = ''; + const stackLower = stack.toLowerCase(); + if (stackLower.includes('python')) { + importStatement = `from .${implBase} import ...`; + } + else if (stackLower.includes('go') || stackLower === 'golang') { + importStatement = `// package matches other files in same directory`; + } + else { + importStatement = `import ${node.type === 'api-route' ? '* as handlerModule' : 'Component'} from './${implBase}';`; + } + let prompt = ''; + if (testFileExists && currentTestCode) { + prompt = `Improve and update the existing test file for this node to match the updated implementation and specifications. +Implementation File Path: ${node.meta.path} +Implementation Code: +\`\`\` +${implementationCode} +\`\`\` + +Test File Path: ${testPath} +Existing Test Code: +\`\`\` +${currentTestCode} +\`\`\` + +XML Specifications: +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) +- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} + +Generate the updated complete test code. Do not include markdown wrapping or explanation.`; + } + else { + prompt = `Generate a comprehensive test file for the following implementation node based on its specification and code. +Implementation File Path: ${node.meta.path} +Implementation Code: +\`\`\` +${implementationCode} +\`\`\` + +Target Test File Path: ${testPath} +XML Specifications: +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) +- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} +- Defined Test Scenarios: ${JSON.stringify(node.tests)} + +Generate the complete test code. Do not include markdown wrapping or explanation.`; + } + const testCode = await this.provider.generate(prompt, systemPrompt, this.config.model); + const cleaned = this.cleanMarkdown(testCode); + writer.write(testPath, cleaned); + this.logAIResponse(node.id + "_test", prompt, cleaned); + return cleaned; + } + buildPrompt(node, projectContext, promptNote) { + return `Project Context: +${projectContext} + +Generate implementation file for this node: +- ID: ${node.id} +- Type: ${node.type} +- Flow: ${node.flow} +- Destination Path: ${node.meta.path} +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- ${promptNote} +- Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}${c.learnedFrom ? ` (Learned from bug: ${c.learnedFrom})` : ''}`).join('\n')} + +Generate the cleanest code matching this specification. Do not include markdown wrapping or explanation.`; + } + cleanMarkdown(code) { + let cleaned = code.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '').trim(); + // Remove any trailing AI skipped pattern comment or annotation + cleaned = cleaned.replace(/\s*→\s*skipped:.*$/gm, ''); + cleaned = cleaned.replace(/\s*\/\/\s*skipped:.*$/gm, ''); + return cleaned.trim(); + } + logAIResponse(nodeId, prompt, response) { + const logsDir = path.resolve('.pxml', 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + const safeNodeId = nodeId.replace(/:/g, '_'); + const logPath = path.join(logsDir, `${safeNodeId}.log`); + const logContent = `--- PROMPT ---\n${prompt}\n\n--- RESPONSE ---\n${response}\n`; + fs.writeFileSync(logPath, logContent, 'utf-8'); + } +} diff --git a/dist/diagnostics/index.js b/dist/diagnostics/index.js new file mode 100644 index 0000000..15916a8 --- /dev/null +++ b/dist/diagnostics/index.js @@ -0,0 +1,22 @@ +export class PxmlDiagnostics { + /** + * Evaluates runtime error logs/diagnostics and maps them heuristically to a node flow + */ + static diagnoseHeuristic(log) { + const msg = log.message.toLowerCase() + ' ' + (log.stack || '').toLowerCase(); + // Heuristic mappings + if (log.statusCode === 401 || log.statusCode === 403 || msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('jwt') || msg.includes('token')) { + return { flow: 'auth', suspectedType: 'middleware' }; + } + if (msg.includes('cookie') || msg.includes('session')) { + return { flow: 'session', suspectedType: 'middleware' }; + } + if (msg.includes('cors') || msg.includes('origin') || log.statusCode === 405) { + return { flow: 'api', suspectedType: 'api-route' }; + } + if (msg.includes('prisma') || msg.includes('database') || msg.includes('db ') || msg.includes('query') || msg.includes('unique constraint')) { + return { flow: 'db', suspectedType: 'db-model' }; + } + return null; + } +} diff --git a/dist/graph/index.js b/dist/graph/index.js new file mode 100644 index 0000000..2b66111 --- /dev/null +++ b/dist/graph/index.js @@ -0,0 +1,46 @@ +export class DependencyGraph { + adjacencyList = new Map(); + nodes = new Map(); + constructor(nodes) { + for (const node of nodes) { + this.nodes.set(node.id, node); + this.adjacencyList.set(node.id, []); + } + for (const node of nodes) { + for (const dep of node.meta.depends_on) { + if (!this.nodes.has(dep)) { + throw new Error(`Node ${node.id} depends on missing node: ${dep}`); + } + // dependency edge: dep -> node.id (dep must be built before node.id) + this.adjacencyList.get(dep).push(node.id); + } + } + } + getSortOrder() { + const visited = new Map(); + const order = []; + const visit = (nodeId) => { + const state = visited.get(nodeId); + if (state === 'VISITING') { + throw new Error(`Circular dependency detected involving node: ${nodeId}`); + } + if (state === 'VISITED') { + return; + } + visited.set(nodeId, 'VISITING'); + const neighbors = this.adjacencyList.get(nodeId) || []; + for (const neighbor of neighbors) { + visit(neighbor); + } + visited.set(nodeId, 'VISITED'); + order.push(nodeId); + }; + for (const nodeId of this.nodes.keys()) { + if (!visited.has(nodeId)) { + visit(nodeId); + } + } + // Since we put dependencies first, we reverse the topological sort order + return order.reverse(); + } +} diff --git a/dist/manifest/index.js b/dist/manifest/index.js new file mode 100644 index 0000000..995a48c --- /dev/null +++ b/dist/manifest/index.js @@ -0,0 +1,57 @@ +import * as fs from 'fs'; +import * as path from 'path'; +export class PxmlManifest { + manifestPath; + currentManifest; + constructor(projectDir, projectName, version) { + this.manifestPath = path.join(projectDir, '.pxml', 'manifest.json'); + this.currentManifest = this.loadOrCreate(projectName, version); + } + loadOrCreate(projectName, version) { + const dir = path.dirname(this.manifestPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (fs.existsSync(this.manifestPath)) { + try { + const content = fs.readFileSync(this.manifestPath, 'utf-8'); + return JSON.parse(content); + } + catch (err) { + // Fallback to fresh if corrupted + } + } + return { + project_name: projectName, + version: version, + nodes: {} + }; + } + get() { + return this.currentManifest; + } + getNode(nodeId) { + return this.currentManifest.nodes[nodeId]; + } + setNode(nodeId, nodeData) { + const existing = this.currentManifest.nodes[nodeId]; + this.currentManifest.nodes[nodeId] = { + ...nodeData, + locked: nodeData.locked ?? existing?.locked ?? false + }; + } + save() { + fs.writeFileSync(this.manifestPath, JSON.stringify(this.currentManifest, null, 2), 'utf-8'); + } + lockNode(nodeId, locked) { + const node = this.currentManifest.nodes[nodeId]; + if (node) { + node.locked = locked; + this.save(); + } + } + clear() { + this.currentManifest.nodes = {}; + this.save(); + } +} diff --git a/dist/parser/index.js b/dist/parser/index.js new file mode 100644 index 0000000..40fa63a --- /dev/null +++ b/dist/parser/index.js @@ -0,0 +1,310 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { XMLParser } from 'fast-xml-parser'; +import { ProjectSchema, NodeSchema } from './schema.js'; +export class PxmlParser { + visitedFiles = new Set(); + loadedProjects = new Map(); + parse(filePath) { + const absolutePath = path.resolve(filePath); + if (this.visitedFiles.has(absolutePath)) { + throw new Error(`Circular import detected: ${Array.from(this.visitedFiles).join(' -> ')} -> ${absolutePath}`); + } + this.visitedFiles.add(absolutePath); + if (!fs.existsSync(absolutePath)) { + throw new Error(`File not found: ${absolutePath}`); + } + const xmlContent = fs.readFileSync(absolutePath, 'utf-8'); + const options = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const parser = new XMLParser(options); + const parsedObj = parser.parse(xmlContent); + if (!parsedObj.project) { + throw new Error(`Invalid pxml file: root element must be in ${absolutePath}`); + } + const rawProj = parsedObj.project; + const name = String(rawProj['@_name'] || ''); + const stack = String(rawProj['@_stack'] || ''); + const version = String(rawProj['@_version'] || ''); + const autogenTestsProj = rawProj['@_autogen-tests'] !== undefined ? String(rawProj['@_autogen-tests']) === 'true' : true; + const rawImports = rawProj.import + ? (Array.isArray(rawProj.import) ? rawProj.import : [rawProj.import]) + : []; + const parsedImports = rawImports.map(imp => ({ + src: imp['@_src'], + package: imp['@_package'], + from: imp['@_from'], + as: imp['@_as'] + })); + const rawNodes = rawProj.node + ? (Array.isArray(rawProj.node) ? rawProj.node : [rawProj.node]) + : []; + const nodes = rawNodes.map(rn => { + const dependsRaw = rn.meta?.depends_on; + const dependsOn = []; + if (typeof dependsRaw === 'string') { + dependsOn.push(dependsRaw); + } + else if (Array.isArray(dependsRaw)) { + dependsOn.push(...dependsRaw); + } + const inputRaw = rn.input?.field; + const input = inputRaw + ? (Array.isArray(inputRaw) ? inputRaw : [inputRaw]).map(f => ({ + name: f['@_name'], + type: f['@_type'], + required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, + format: f['@_format'] + })) + : []; + const outputRaw = rn.output?.field; + const output = outputRaw + ? (Array.isArray(outputRaw) ? outputRaw : [outputRaw]).map(f => ({ + name: f['@_name'], + type: f['@_type'], + required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, + format: f['@_format'] + })) + : []; + const constraintRaw = rn.constraint; + const constraints = constraintRaw + ? (Array.isArray(constraintRaw) ? constraintRaw : [constraintRaw]).map(c => { + const verify = c['@_verify'] || 'static'; + const description = typeof c === 'object' ? c['#text'] || '' : String(c); + const learnedFrom = typeof c === 'object' ? c['@_learned-from'] : undefined; + return { verify, description, learnedFrom }; + }) + : []; + const testRaw = rn.test; + const tests = testRaw + ? (Array.isArray(testRaw) ? testRaw : [testRaw]).map(t => { + const nameVal = t.name || ''; + const given = t.given || {}; + const expectRaw = t.expect || {}; + const expect = { + field: expectRaw.field, + status: expectRaw.status !== undefined ? Number(expectRaw.status) : undefined, + body: expectRaw.body, + contains: expectRaw.contains, + match: expectRaw.match + }; + return { name: nameVal, given, expect }; + }) + : []; + const autogenTestsNode = rn['@_autogen-tests'] !== undefined ? String(rn['@_autogen-tests']) === 'true' : undefined; + const autogenTests = autogenTestsNode ?? autogenTestsProj; + return NodeSchema.parse({ + id: rn['@_id'], + type: rn['@_type'], + flow: rn['@_flow'], + extends: rn['@_extends'], + autogenTests, + meta: { + path: rn.meta?.path || '', + depends_on: dependsOn + }, + input, + output, + constraints, + tests + }); + }); + const currentProject = ProjectSchema.parse({ + name, + stack, + version, + autogenTests: autogenTestsProj, + imports: parsedImports, + nodes + }); + this.loadedProjects.set(absolutePath, currentProject); + // Resolve imports recursively and build final flattened project AST + const baseDir = path.dirname(absolutePath); + const resolvedNodes = []; + // Track imported files to avoid duplicate parsing/nodes if imported multiple times + const importedPaths = new Set(); + const prefixNode = (node, namespace) => { + const prefixId = (id) => { + if (id.includes(':')) { + // If it already has namespace, prepend new namespace + return `${namespace}:${id}`; + } + return `${namespace}:${id}`; + }; + return { + ...node, + id: prefixId(node.id), + extends: node.extends ? prefixId(node.extends) : undefined, + meta: { + ...node.meta, + depends_on: node.meta.depends_on.map(prefixId) + } + }; + }; + for (const imp of currentProject.imports) { + let importedPath = ''; + if (imp.src) { + importedPath = path.resolve(baseDir, imp.src); + } + else if (imp.package && imp.from) { + if (imp.from.startsWith('github:')) { + const parts = imp.from.replace(/^github:/, '').split('/'); + const owner = parts[0]; + const repo = parts[1]; + const cacheDir = path.join(process.cwd(), '.pxml', 'packages', 'github', owner, repo); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + const gitUrl = `https://github.com/${owner}/${repo}.git`; + console.log(`[PACKAGE] Cloning package ${imp.package} from ${gitUrl}...`); + const { execSync } = require('child_process'); + execSync(`git clone --depth 1 ${gitUrl} ${cacheDir}`, { stdio: 'ignore' }); + } + importedPath = path.join(cacheDir, 'project.xml'); + } + else { + importedPath = path.resolve(process.cwd(), imp.from, 'project.xml'); + } + } + if (!importedPath || !fs.existsSync(importedPath)) { + throw new Error(`Import failed: package/src not found at ${importedPath || imp.src || imp.from}`); + } + if (importedPaths.has(importedPath)) + continue; + importedPaths.add(importedPath); + const importedProj = this.parse(importedPath); + // We only prefix nodes that were defined in the imported project, + // which includes nodes it has imported. Let's make sure we prefix all of them. + const prefixedNodes = importedProj.nodes.map(node => prefixNode(node, imp.as)); + resolvedNodes.push(...prefixedNodes); + } + // Only include currentProject nodes if this is NOT an imported project, + // or include them and let the caller manage prefixing. + // Actually, to make a unified flat AST, the entry project XML (e.g. project.xml) has nodes of its own, + // and also brings in imported nodes. + // If we are parsing a nested import, we return its nodes, which get prefixed by the parent parser. + // So the parser call should just return the currentProject.nodes. + // But wait! If currentProject has nodes and imports, does currentProject.nodes already get returned? Yes. + // But does currentProject.nodes contain the resolved import nodes? No, they are only in resolvedNodes. + // So we should return resolvedNodes (which includes currentProject.nodes plus the prefixed imported nodes). + resolvedNodes.push(...currentProject.nodes); + this.visitedFiles.delete(absolutePath); + // Print resolved node IDs for debugging if needed + // console.log(resolvedNodes.map(n => n.id)); + // Dedup nodes here to avoid extending issues or duplicate resolve calls + const resolvedNodesMap = new Map(); + for (const node of resolvedNodes) { + resolvedNodesMap.set(node.id, node); + } + const uniqueResolvedNodes = Array.from(resolvedNodesMap.values()); + const finalNodes = this.resolveExtends(uniqueResolvedNodes); + // Deduplicate nodes by id, taking the last defined (allows overrides) + const dedupedMap = new Map(); + for (const node of finalNodes) { + dedupedMap.set(node.id, node); + } + return { + ...currentProject, + imports: [], // Empty imports as they are now flattened + nodes: Array.from(dedupedMap.values()) + }; + } + resolveExtends(nodes) { + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const resolvedMap = new Map(); + const resolveNode = (id, depth = 0) => { + if (depth > 2) { + throw new Error(`Inheritance depth limit exceeded (max 2 levels) for node: ${id}`); + } + if (resolvedMap.has(id)) { + return resolvedMap.get(id); + } + const node = nodeMap.get(id); + if (!node) { + throw new Error(`Node not found to extend: ${id}`); + } + if (!node.extends) { + resolvedMap.set(id, node); + return node; + } + const parentNode = resolveNode(node.extends, depth + 1); + // Merge constraints and tests + const mergedConstraints = [...parentNode.constraints]; + for (const childC of node.constraints) { + // Prevent duplicate constraints if merged already + if (!mergedConstraints.some(c => c.description === childC.description)) { + mergedConstraints.push(childC); + } + } + const mergedTests = [...parentNode.tests]; + for (const childT of node.tests) { + if (!mergedTests.some(t => t.name === childT.name)) { + mergedTests.push(childT); + } + } + const mergedNode = { + ...node, + // If meta.path is not specified, inherit from parent + meta: { + path: node.meta.path || parentNode.meta.path, + depends_on: Array.from(new Set([...node.meta.depends_on, ...parentNode.meta.depends_on])) + }, + input: [...parentNode.input, ...node.input], + output: [...parentNode.output, ...node.output], + constraints: mergedConstraints, + tests: mergedTests + }; + resolvedMap.set(id, mergedNode); + return mergedNode; + }; + return nodes.map(node => resolveNode(node.id)); + } +} +export function validateProject(project) { + for (const node of project.nodes) { + if (node.output.length > 0 && node.type !== 'db-model' && node.type !== 'setup-command' && node.tests.length === 0) { + throw new Error(`Validation Error: Node '${node.id}' has output fields defined, but is missing test cases.`); + } + if (node.input.length > 0) { + for (const test of node.tests) { + const given = test.given || {}; + for (const field of node.input) { + if (field.required) { + const inRoot = given[field.name] !== undefined; + const inBody = given.body && typeof given.body === 'object' && given.body[field.name] !== undefined; + const inQuery = given.query && typeof given.query === 'object' && given.query[field.name] !== undefined; + const inHeaders = given.headers && typeof given.headers === 'object' && given.headers[field.name] !== undefined; + if (!inRoot && !inBody && !inQuery && !inHeaders) { + throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' is missing required input field '${field.name}' in 'given'.`); + } + } + } + const allowedRootKeys = new Set(['method', 'headers', 'query', 'body']); + const inputFieldNames = new Set(node.input.map(f => f.name)); + const checkKeys = (obj, locationName) => { + if (!obj || typeof obj !== 'object') + return; + for (const key of Object.keys(obj)) { + if (key.startsWith('@_')) + continue; + if (locationName === 'root' && allowedRootKeys.has(key)) + continue; + if (!inputFieldNames.has(key)) { + throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' specifies field '${key}' in given ${locationName} which is not declared in node inputs.`); + } + } + }; + checkKeys(given, 'root'); + if (given.body && typeof given.body === 'object') { + checkKeys(given.body, 'body'); + } + if (given.query && typeof given.query === 'object') { + checkKeys(given.query, 'query'); + } + } + } + } +} diff --git a/dist/parser/schema.js b/dist/parser/schema.js new file mode 100644 index 0000000..df72aa9 --- /dev/null +++ b/dist/parser/schema.js @@ -0,0 +1,54 @@ +import { z } from 'zod'; +export const ImportSchema = z.object({ + src: z.string().optional(), + package: z.string().optional(), + from: z.string().optional(), + as: z.string() +}); +export const MetaSchema = z.object({ + path: z.string(), + depends_on: z.array(z.string()).default([]) +}); +export const FieldSchema = z.object({ + name: z.string(), + type: z.string(), + required: z.boolean().default(true), + format: z.string().optional() +}); +export const ConstraintSchema = z.object({ + verify: z.enum(['static', 'llm-judge']).default('static'), + description: z.string(), + learnedFrom: z.string().optional() +}); +export const TestExpectSchema = z.object({ + field: z.string().optional(), + status: z.number().optional(), + body: z.any().optional(), + contains: z.string().optional(), + match: z.string().optional() +}); +export const TestCaseSchema = z.object({ + name: z.string(), + given: z.any(), + expect: TestExpectSchema +}); +export const NodeSchema = z.object({ + id: z.string(), + type: z.string(), // EXTENSION POINT: Expand backend/frontend stack types or generic custom node types + flow: z.string(), + extends: z.string().optional(), + autogenTests: z.boolean().default(true), + meta: MetaSchema, + input: z.array(FieldSchema).default([]), + output: z.array(FieldSchema).default([]), + constraints: z.array(ConstraintSchema).default([]), + tests: z.array(TestCaseSchema).default([]) +}); +export const ProjectSchema = z.object({ + name: z.string(), + stack: z.string(), // EXTENSION POINT: Expand backend/frontend stack types + version: z.string(), + autogenTests: z.boolean().default(true), + imports: z.array(ImportSchema).default([]), + nodes: z.array(NodeSchema).default([]) +}); diff --git a/dist/patcher/index.js b/dist/patcher/index.js new file mode 100644 index 0000000..4c7258a --- /dev/null +++ b/dist/patcher/index.js @@ -0,0 +1,60 @@ +export class PxmlPatcher { + /** + * Applies a diff/patch securely to file contents. + * If it cannot apply cleanly, it throws an error. + * Expects patch format: + * <<<<<<< SEARCH + * original code + * ======= + * new code + * >>>>>>> REPLACE + */ + static applyPatch(originalContent, patch) { + const searchBlocks = this.parsePatch(patch); + if (searchBlocks.length === 0) { + // If it is not in search/replace format, check if it's just raw code and replace entirely + if (patch.trim().length > 0 && !patch.includes('<<<<<<<')) { + return patch; + } + throw new Error('Invalid patch format: no SEARCH/REPLACE blocks found.'); + } + let result = originalContent; + for (const block of searchBlocks) { + if (!result.includes(block.search)) { + throw new Error(`Failed to apply patch: search block not found in original file.\nSearch block:\n${block.search}`); + } + result = result.replace(block.search, block.replace); + } + return result; + } + static parsePatch(patch) { + const blocks = []; + const lines = patch.split('\n'); + let i = 0; + while (i < lines.length) { + if (lines[i].startsWith('<<<<<<< SEARCH')) { + const searchLines = []; + const replaceLines = []; + i++; + while (i < lines.length && !lines[i].startsWith('=======')) { + searchLines.push(lines[i]); + i++; + } + i++; // skip ======= + while (i < lines.length && !lines[i].startsWith('>>>>>>> REPLACE')) { + replaceLines.push(lines[i]); + i++; + } + i++; // skip >>>>>>> REPLACE + blocks.push({ + search: searchLines.join('\n'), + replace: replaceLines.join('\n') + }); + } + else { + i++; + } + } + return blocks; + } +} diff --git a/dist/runner/index.js b/dist/runner/index.js new file mode 100644 index 0000000..7870789 --- /dev/null +++ b/dist/runner/index.js @@ -0,0 +1,94 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PxmlTestgen } from '../testgen/index.js'; +export function getTestFilePath(srcPath, stack) { + const ext = path.extname(srcPath); + const base = srcPath.slice(0, -ext.length); + const stackLower = stack.toLowerCase(); + if (stackLower.includes('python')) { + const dir = path.dirname(srcPath); + const filename = path.basename(srcPath); + return path.join(dir, `test_${filename}`); + } + else if (stackLower.includes('go') || stackLower === 'golang') { + return `${base}_test${ext}`; + } + else if (stackLower.includes('c#') || stackLower === 'csharp') { + return `${base}.Tests${ext}`; + } + else { + const isTsx = ext === '.tsx' || ext === '.jsx'; + const testExt = isTsx ? `.test${ext}` : `.test${ext}`; + return `${base}${testExt}`; + } +} +export class PxmlRunner { + projectDir; + writer; + constructor(projectDir, writer) { + this.projectDir = path.resolve(projectDir); + this.writer = writer; + } + runNodeTests(node, stack = 'nextjs') { + let testFilePath = path.resolve(this.projectDir, getTestFilePath(node.meta.path, stack)); + let testFileExisted = fs.existsSync(testFilePath); + if (!testFileExisted) { + const testDir = path.join(this.projectDir, '.pxml', 'tests'); + const safeNodeId = node.id.replace(/:/g, '_'); + testFilePath = path.join(testDir, `${safeNodeId}.test.ts`); + const testFileContent = PxmlTestgen.generateTestFileContent(node, testFilePath); + this.writer.write(testFilePath, testFileContent); + if (this.writer.getHistory().some(h => h.filePath === testFilePath) && fs.existsSync(testFilePath) === false) { + const mockResults = {}; + for (const t of node.tests) { + mockResults[t.name] = 'pass'; + } + return { passed: true, results: mockResults }; + } + } + const stackLower = stack.toLowerCase(); + let testCmd = `npx vitest run ${testFilePath}`; + if (stackLower.includes('python')) { + testCmd = `pytest ${testFilePath}`; + } + else if (stackLower.includes('go') || stackLower === 'golang') { + testCmd = `go test ${testFilePath}`; + } + else if (stackLower.includes('c#') || stackLower === 'csharp') { + testCmd = `dotnet test --filter FullyQualifiedName~${node.id}`; + } + let passed = false; + const results = {}; + let output = ''; + try { + const stdout = execSync(testCmd, { stdio: 'pipe', cwd: this.projectDir }); + passed = true; + output = stdout.toString(); + for (const t of node.tests) { + results[t.name] = 'pass'; + } + if (node.tests.length === 0) { + results['AI-Generated General Verification'] = 'pass'; + } + } + catch (error) { + passed = false; + const stdout = error.stdout?.toString() || ''; + const stderr = error.stderr?.toString() || ''; + output = `${stdout}\n${stderr}`; + for (const t of node.tests) { + if (stdout.includes(`× ${t.name}`) || stderr.includes(`× ${t.name}`) || stdout.includes(`fail`) || error.message.includes('fail')) { + results[t.name] = 'fail'; + } + else { + results[t.name] = 'fail'; + } + } + if (node.tests.length === 0) { + results['AI-Generated General Verification'] = 'fail'; + } + } + return { passed, results, output }; + } +} diff --git a/dist/testgen/index.js b/dist/testgen/index.js new file mode 100644 index 0000000..606efca --- /dev/null +++ b/dist/testgen/index.js @@ -0,0 +1,106 @@ +import * as path from 'path'; +export class PxmlTestgen { + static generateTestFileContent(node, testFileAbsPath) { + const relativeImplPath = path.relative(path.dirname(testFileAbsPath), node.meta.path); + let importPath = relativeImplPath.replace(/\.(ts|tsx|js|jsx)$/, ''); + if (!importPath.startsWith('.') && !importPath.startsWith('/')) { + importPath = './' + importPath; + } + let testCasesCode = ''; + const tests = node.tests.length > 0 ? node.tests : [this.createFallbackTestCase(node)]; + for (const test of tests) { + const stringifiedGiven = JSON.stringify(test.given, null, 2); + // Build assertions dynamically based on expected fields + let assertions = ''; + if (test.expect.status !== undefined) { + assertions += `expect(res.status).toBe(${test.expect.status});\n`; + } + if (test.expect.contains) { + assertions += `expect(JSON.stringify(body)).toContain(${JSON.stringify(test.expect.contains)});\n`; + } + if (test.expect.match) { + assertions += `expect(JSON.stringify(body)).toMatch(${test.expect.match});\n`; + } + if (node.type === 'setup-command') { + assertions += `expect(true).toBe(true);\n`; // Simple execution check + } + testCasesCode += ` + it(${JSON.stringify(test.name)}, async () => { + const req = ${stringifiedGiven}; + let res = { status: 200, json: async () => ({}) }; + let body: any = {}; + + try { + if (nodeType === 'setup-command') { + // Just verify file module can be parsed or execution completed + body = { status: 'executed' }; + } else if (typeof handler === 'function') { + const response = await handler(req); + if (response && typeof response.json === 'function') { + res = response; + body = await response.json(); + } else { + body = response; + } + } else if (handler && typeof handler.GET === 'function' && req.method === 'GET') { + const response = await handler.GET(req); + res = response; + body = await response.json(); + } else if (handler && typeof handler.POST === 'function' && req.method === 'POST') { + const response = await handler.POST(req); + res = response; + body = await response.json(); + } else { + // Generic export verification fallback + body = handler; + } + } catch (err: any) { + res = { status: err.status || 500, json: async () => ({ error: err.message }) }; + body = { error: err.message }; + } + + ${assertions} + }); +`; + } + return `import { describe, it, expect } from 'vitest'; +// @ts-ignore +import * as handlerModule from '${importPath}'; + +const handler = handlerModule.default || handlerModule; +const nodeType = ${JSON.stringify(node.type)}; + +describe(${JSON.stringify(node.id)}, () => { +${testCasesCode} +}); +`; + } + static createFallbackTestCase(node) { + // Generate generic checks depending on node type + if (node.type === 'setup-command') { + return { + name: 'Verify setup execution finishes successfully', + given: {}, + expect: { + field: undefined, + status: undefined, + body: undefined, + contains: undefined, + match: undefined + } + }; + } + // Default validation: loading modules shouldn't throw errors + return { + name: 'Verify module loads and exports valid elements', + given: { method: 'GET', query: {}, headers: {} }, + expect: { + field: undefined, + status: undefined, + body: undefined, + contains: undefined, + match: undefined + } + }; + } +} diff --git a/dist/writer/index.js b/dist/writer/index.js new file mode 100644 index 0000000..8386daa --- /dev/null +++ b/dist/writer/index.js @@ -0,0 +1,47 @@ +import * as fs from 'fs'; +import * as path from 'path'; +export class FileWriter { + dryRun; + history = []; + constructor(dryRun = false) { + this.dryRun = dryRun; + } + write(filePath, content) { + const absolutePath = path.resolve(filePath); + const originalContent = fs.existsSync(absolutePath) ? fs.readFileSync(absolutePath, 'utf-8') : null; + this.history.push({ + filePath: absolutePath, + originalContent, + newContent: content + }); + if (this.dryRun) { + console.log(`[DRY RUN] Would write to: ${absolutePath}`); + return; + } + const dir = path.dirname(absolutePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(absolutePath, content, 'utf-8'); + } + rollback() { + if (this.dryRun) { + this.history = []; + return; + } + for (const op of [...this.history].reverse()) { + if (op.originalContent === null) { + if (fs.existsSync(op.filePath)) { + fs.unlinkSync(op.filePath); + } + } + else { + fs.writeFileSync(op.filePath, op.originalContent, 'utf-8'); + } + } + this.history = []; + } + getHistory() { + return this.history; + } +} diff --git a/docs/README.md b/docs/README.md index 639ca05..d777caf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,11 @@ -# pxml Documentation Hub - -Welcome to the `pxml` documentation. Select a section below to get started: - -## 🚀 Guides -- [Getting Started Guide](./guides/getting-started.md) — Install the CLI, initialize your first project, and compile your first routes. -- [Architecture & Core Concepts](./guides/architecture.md) — Learn about flat AST resolution, topological sort execution, and local AI self-healing. - -## 🛠 Reference -- [CLI Commands Reference](./reference/cli.md) — Complete parameter reference for `pxml compile`, `pxml test`, `pxml fix`, and other commands. -- [XML Schema Definition Reference](./reference/schema.md) — Structure, element definitions, attributes, and XSD autocomplete settings. +# pxml Documentation Hub + +Welcome to the `pxml` documentation. Select a section below to get started: + +## 🚀 Guides +- [Getting Started Guide](./guides/getting-started.md) — Install the CLI, initialize your first project, and compile your first routes. +- [Architecture & Core Concepts](./guides/architecture.md) — Learn about flat AST resolution, topological sort execution, and local AI self-healing. + +## 🛠 Reference +- [CLI Commands Reference](./reference/cli.md) — Complete parameter reference for `pxml compile`, `pxml test`, `pxml fix`, and other commands. +- [XML Schema Definition Reference](./reference/schema.md) — Structure, element definitions, attributes, and XSD autocomplete settings. diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index d75079c..b58eea4 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -1,24 +1,24 @@ -# Architecture Overview - -`pxml` is a compilation and orchestration framework that compiles structured project specifications (XML DSL) into runnable codebases using Large Language Models (LLMs). - -## Core Concepts - -### 1. Unified Flat Abstract Syntax Tree (AST) -Rather than compiling isolated files, the compiler reads a main entry file (e.g. `project.xml`), recursively resolves all external `` nodes, namespaces references, flat-maps inherited templates (`extends`), and validates the merged tree using a strict Zod schema. - -``` -project.xml (imports blog.xml) - └── blog.xml (imports types.xml) - └── types.xml (base schema templates) -``` - -The resulting parsed structure is a single, flat array of nodes in memory containing fully-qualified identifiers (e.g. `blog:types:base.api-route`). - -### 2. Dependency Graph & Topological Execution -To ensure code is generated in the correct order, the compiler builds a dependency graph mapping node dependencies (`depends_on`). -- Node configurations/APIs are compiled *before* the components or pages that call them. -- Circular dependencies are identified at build-time using a DFS search order traversal and immediately halt execution. - -### 3. Local Self-Healing Loop -When tests fail, instead of regenerating the entire codebase from scratch, the CLI uses the compiled manifest and execution logs to trace failures back to specific nodes. It then prompts the AI to generate a precise git-like SEARCH/REPLACE patch block (using `<<<<<<< SEARCH / ======= / >>>>>>> REPLACE`), runs tests again to confirm the fix, and allows up to 3 automatic repair iterations. +# Architecture Overview + +`pxml` is a compilation and orchestration framework that compiles structured project specifications (XML DSL) into runnable codebases using Large Language Models (LLMs). + +## Core Concepts + +### 1. Unified Flat Abstract Syntax Tree (AST) +Rather than compiling isolated files, the compiler reads a main entry file (e.g. `project.xml`), recursively resolves all external `` nodes, namespaces references, flat-maps inherited templates (`extends`), and validates the merged tree using a strict Zod schema. + +``` +project.xml (imports blog.xml) + └── blog.xml (imports types.xml) + └── types.xml (base schema templates) +``` + +The resulting parsed structure is a single, flat array of nodes in memory containing fully-qualified identifiers (e.g. `blog:types:base.api-route`). + +### 2. Dependency Graph & Topological Execution +To ensure code is generated in the correct order, the compiler builds a dependency graph mapping node dependencies (`depends_on`). +- Node configurations/APIs are compiled *before* the components or pages that call them. +- Circular dependencies are identified at build-time using a DFS search order traversal and immediately halt execution. + +### 3. Local Self-Healing Loop +When tests fail, instead of regenerating the entire codebase from scratch, the CLI uses the compiled manifest and execution logs to trace failures back to specific nodes. It then prompts the AI to generate a precise git-like SEARCH/REPLACE patch block (using `<<<<<<< SEARCH / ======= / >>>>>>> REPLACE`), runs tests again to confirm the fix, and allows up to 3 automatic repair iterations. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 185410f..3951481 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -1,66 +1,66 @@ -# Getting Started Guide - -This guide walks you through setting up a project using `pxml` and compiling it using an AI provider. - -## Prerequisites -- Node.js (v18 or higher) -- npm - -## 1. Installation -Install the compiler globally via npm: -```bash -npm install -g @two-tech-dev/pxml -``` - -Alternatively, clone the repository and build from source: -```bash -git clone https://github.com/two-tech-dev/pxml.git -cd pxml -npm install -npm run build -sudo npm link -``` - -## 2. Initialize a Project -Create a new directory for your app and run the initializer: -```bash -mkdir my-new-app && cd my-new-app -pxml init -``` -This will initialize: -- `project.xml`: Main compiler configuration file. -- `flows/blog.xml`: Sample blog flow node layout configuration. -- `bugs_history.xml`: A historical record of resolved bugs to prevent code regressions during healing. -- `pxml.xsd`: Local XML schema to enable auto-complete, validation, and documentation hints inside code editors. - -## 3. Compile the Code -Select your preferred AI model provider and run the compiler: - -### Using OpenAI (or compatible gateways like 9Router): -```bash -export OPENAI_API_KEY="your-api-key" -pxml compile --provider openai --model gpt-4o --baseUrl https://api.openai.com/v1 -``` - -### Using Anthropic: -```bash -export ANTHROPIC_API_KEY="your-api-key" -pxml compile --provider anthropic --model claude-3-5-sonnet -``` - -### Using Local Ollama: -```bash -pxml compile --provider ollama --model llama3 --baseUrl http://localhost:11434 -``` - -## 4. Run Tests & Self-Heal -To run compiled Vitest suites: -```bash -pxml test -``` - -If a test case fails, call the local AI repair helper loop to patch the implementation: -```bash -pxml fix --flow=blog.write -``` -The self-healing workflow will generate a minimal context code fix, patch the target file, and verify it automatically. +# Getting Started Guide + +This guide walks you through setting up a project using `pxml` and compiling it using an AI provider. + +## Prerequisites +- Node.js (v18 or higher) +- npm + +## 1. Installation +Install the compiler globally via npm: +```bash +npm install -g @two-tech-dev/pxml +``` + +Alternatively, clone the repository and build from source: +```bash +git clone https://github.com/two-tech-dev/pxml.git +cd pxml +npm install +npm run build +sudo npm link +``` + +## 2. Initialize a Project +Create a new directory for your app and run the initializer: +```bash +mkdir my-new-app && cd my-new-app +pxml init +``` +This will initialize: +- `project.xml`: Main compiler configuration file. +- `flows/blog.xml`: Sample blog flow node layout configuration. +- `bugs_history.xml`: A historical record of resolved bugs to prevent code regressions during healing. +- `pxml.xsd`: Local XML schema to enable auto-complete, validation, and documentation hints inside code editors. + +## 3. Compile the Code +Select your preferred AI model provider and run the compiler: + +### Using OpenAI (or compatible gateways like 9Router): +```bash +export OPENAI_API_KEY="your-api-key" +pxml compile --provider openai --model gpt-4o --baseUrl https://api.openai.com/v1 +``` + +### Using Anthropic: +```bash +export ANTHROPIC_API_KEY="your-api-key" +pxml compile --provider anthropic --model claude-3-5-sonnet +``` + +### Using Local Ollama: +```bash +pxml compile --provider ollama --model llama3 --baseUrl http://localhost:11434 +``` + +## 4. Run Tests & Self-Heal +To run compiled Vitest suites: +```bash +pxml test +``` + +If a test case fails, call the local AI repair helper loop to patch the implementation: +```bash +pxml fix --flow=blog.write +``` +The self-healing workflow will generate a minimal context code fix, patch the target file, and verify it automatically. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 16f4954..0cfca2e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,37 +1,37 @@ -# CLI Commands Reference - -`pxml` provides a command-line interface to manage code generation, execution, testing, and healing workflows. - -## Commands - -### `pxml init` -Initializes a standard Next.js directory layout, copies the local `pxml.xsd` validation schema, and generates a sample `project.xml` configuration. - -### `pxml compile` -Compiles all modified nodes defined in XML to source code files. -- `--dry-run`: Performs syntax check, imports flat-mapping, and topological sorting without writing code files to disk. -- `--no-autogen-tests`: Disables automatic AI generation of test files (saves token usage). -- `--provider `: AI provider to use (`anthropic`, `openai`, `ollama`, or `custom`). Defaults to `anthropic`. -- `--apiKey `: Custom API key override. -- `--baseUrl `: Override the default API endpoint URL (essential for custom gateways or local Ollama endpoints). -- `--model `: Custom LLM model name (e.g. `gpt-4o`, `claude-3-5-sonnet`, `llama3`). - -### `pxml test` -Compiles all node `` specs into real `.test.ts` Vitest test files and executes them. Test outcomes (`pass` or `fail`) are saved in `.pxml/manifest.json`. - -### `pxml fix` -Invokes the self-healing AI loop to repair failing tests automatically. -- `--flow `: Restricts repairs to a specific flow (e.g. `blog.write`). -- `--node `: Restricts repair loop to a single target node. -- `--provider `: AI provider configuration for generation. -- `--model `: AI model configuration for generation. - -### `pxml validate` -Validates XML files against the schema and validation rules. Prevents compiling if nodes with outputs are missing tests, or if test given inputs do not match node inputs. - -### `pxml diagnose` -Parses server/execution JSON log files and attempts to isolate runtime failures to specific nodes using heuristic algorithms. -- `--log `: Path to the log file to analyze. - -### `pxml doctor` -Performs validation checks on project configs, env keys, databases, and general environment options. +# CLI Commands Reference + +`pxml` provides a command-line interface to manage code generation, execution, testing, and healing workflows. + +## Commands + +### `pxml init` +Initializes a standard Next.js directory layout, copies the local `pxml.xsd` validation schema, and generates a sample `project.xml` configuration. + +### `pxml compile` +Compiles all modified nodes defined in XML to source code files. +- `--dry-run`: Performs syntax check, imports flat-mapping, and topological sorting without writing code files to disk. +- `--no-autogen-tests`: Disables automatic AI generation of test files (saves token usage). +- `--provider `: AI provider to use (`anthropic`, `openai`, `ollama`, or `custom`). Defaults to `anthropic`. +- `--apiKey `: Custom API key override. +- `--baseUrl `: Override the default API endpoint URL (essential for custom gateways or local Ollama endpoints). +- `--model `: Custom LLM model name (e.g. `gpt-4o`, `claude-3-5-sonnet`, `llama3`). + +### `pxml test` +Compiles all node `` specs into real `.test.ts` Vitest test files and executes them. Test outcomes (`pass` or `fail`) are saved in `.pxml/manifest.json`. + +### `pxml fix` +Invokes the self-healing AI loop to repair failing tests automatically. +- `--flow `: Restricts repairs to a specific flow (e.g. `blog.write`). +- `--node `: Restricts repair loop to a single target node. +- `--provider `: AI provider configuration for generation. +- `--model `: AI model configuration for generation. + +### `pxml validate` +Validates XML files against the schema and validation rules. Prevents compiling if nodes with outputs are missing tests, or if test given inputs do not match node inputs. + +### `pxml diagnose` +Parses server/execution JSON log files and attempts to isolate runtime failures to specific nodes using heuristic algorithms. +- `--log `: Path to the log file to analyze. + +### `pxml doctor` +Performs validation checks on project configs, env keys, databases, and general environment options. diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 3cffac7..9d0167b 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -1,75 +1,75 @@ -# XML Schema Reference - -`pxml` configuration is defined using a structured XML document validated against `pxml.xsd`. - -## Element Definitions - -### `` -The root element of a project config. -#### Attributes: -- `name` (required): Name of the project. -- `stack` (required): Framework stack being used (e.g. `nextjs`). -- `version` (required): Spec version. -- `autogen-tests` (optional, default `true`): Whether to have the AI automatically generate test files for nodes during compilation. - ---- - -### `` -Imports nodes from external files or remote/local packages. -#### Attributes: -- `src` (optional): Relative file path to the imported XML file. -- `package` (optional): Name of the package to import. -- `from` (optional): Location of the package. Supports local folders (e.g. `packages/my-pack`) or GitHub repositories (e.g. `github:owner/repo`). -- `as` (required): Namespace alias to prefix imported nodes. - ---- - -### `` -Defines a compilation node (code unit). -#### Attributes: -- `id` (required): Unique ID for the node. -- `type` (required): Type of node (`api-route`, `ui-component`, `db-model`, `middleware`, `config-file`, `setup-command`). -- `flow` (required): Business logic flow grouping (e.g. `blog.write`). -- `extends` (optional): ID of a base node to inherit metadata, constraints, and tests from. -- `autogen-tests` (optional, inherits project default): Override per-node whether to have the AI automatically generate test files for this node. - ---- - -### `` -Defines target paths and dependencies. -#### Children: -- `` (required): File path where generated code will be written. -- `` (optional, multiple): Node ID this node depends on. - ---- - -### `` / `` -Defines schemas for fields. -#### Children: -- `` (multiple): - - `name` (required): Field name. - - `type` (required): Data type. - - `required` (optional): `true` or `false`. - - `format` (optional): Extra format constraints (e.g. `uuid`). - ---- - -### `` -Specifies coding rules for the AI. -#### Attributes: -- `verify` (default: `static`): - - `static`: Can be tested programmatically. - - `llm-judge`: Intention checks requiring LLM judgment (e.g. "Do not leak secret keys in responses"). -- `learned-from` (optional): References a bug ID from `bugs_history.xml`. Associates the constraint with a historical bug to prevent code regressions during generation and fixing. - ---- - -### `` -Generates test scenarios in Vitest. -#### Children: -- `` (required): Test case label. -- `` (required): Input mocks. -- `` (required): Expected assertions. - - `` (optional): Expected HTTP status code. - - `` (optional): Expected response content text. - - `` (optional): Regular expression match pattern. +# XML Schema Reference + +`pxml` configuration is defined using a structured XML document validated against `pxml.xsd`. + +## Element Definitions + +### `` +The root element of a project config. +#### Attributes: +- `name` (required): Name of the project. +- `stack` (required): Framework stack being used (e.g. `nextjs`). +- `version` (required): Spec version. +- `autogen-tests` (optional, default `true`): Whether to have the AI automatically generate test files for nodes during compilation. + +--- + +### `` +Imports nodes from external files or remote/local packages. +#### Attributes: +- `src` (optional): Relative file path to the imported XML file. +- `package` (optional): Name of the package to import. +- `from` (optional): Location of the package. Supports local folders (e.g. `packages/my-pack`) or GitHub repositories (e.g. `github:owner/repo`). +- `as` (required): Namespace alias to prefix imported nodes. + +--- + +### `` +Defines a compilation node (code unit). +#### Attributes: +- `id` (required): Unique ID for the node. +- `type` (required): Type of node (`api-route`, `ui-component`, `db-model`, `middleware`, `config-file`, `setup-command`). +- `flow` (required): Business logic flow grouping (e.g. `blog.write`). +- `extends` (optional): ID of a base node to inherit metadata, constraints, and tests from. +- `autogen-tests` (optional, inherits project default): Override per-node whether to have the AI automatically generate test files for this node. + +--- + +### `` +Defines target paths and dependencies. +#### Children: +- `` (required): File path where generated code will be written. +- `` (optional, multiple): Node ID this node depends on. + +--- + +### `` / `` +Defines schemas for fields. +#### Children: +- `` (multiple): + - `name` (required): Field name. + - `type` (required): Data type. + - `required` (optional): `true` or `false`. + - `format` (optional): Extra format constraints (e.g. `uuid`). + +--- + +### `` +Specifies coding rules for the AI. +#### Attributes: +- `verify` (default: `static`): + - `static`: Can be tested programmatically. + - `llm-judge`: Intention checks requiring LLM judgment (e.g. "Do not leak secret keys in responses"). +- `learned-from` (optional): References a bug ID from `bugs_history.xml`. Associates the constraint with a historical bug to prevent code regressions during generation and fixing. + +--- + +### `` +Generates test scenarios in Vitest. +#### Children: +- `` (required): Test case label. +- `` (required): Input mocks. +- `` (required): Expected assertions. + - `` (optional): Expected HTTP status code. + - `` (optional): Expected response content text. + - `` (optional): Regular expression match pattern. diff --git a/examples/bugs.xsd b/examples/bugs.xsd index 27a9892..b3a06a4 100644 --- a/examples/bugs.xsd +++ b/examples/bugs.xsd @@ -1,21 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/bugs_history.xml b/examples/bugs_history.xml index fa73377..c751c34 100644 --- a/examples/bugs_history.xml +++ b/examples/bugs_history.xml @@ -1,6 +1,6 @@ - - - SQLite database file locks when executing parallel write operations. Ensure connections are closed properly or run db queries sequentially. - - + + + SQLite database file locks when executing parallel write operations. Ensure connections are closed properly or run db queries sequentially. + + diff --git a/examples/flows/blog.xml b/examples/flows/blog.xml index 3015c9d..73fa543 100644 --- a/examples/flows/blog.xml +++ b/examples/flows/blog.xml @@ -1,41 +1,41 @@ - - - - app/api/posts/route.ts - - - - - - - - - Initialize a better-sqlite3 database named 'blog.db'. Create a 'posts' table with columns id (INTEGER PRIMARY KEY), title (TEXT), and content (TEXT) if it does not exist. Implement both POST (to insert a post) and GET (to select all posts) handlers in this route file. Ensure the route is dynamic and not cached by exporting: export const dynamic = 'force-dynamic'; - - Create post successful - - - Hello World - My first post - - - - 200 - success - - - - - - - app/posts/page.tsx - api.posts.create - - - - - Create a beautifully designed blog posts manager page at app/posts/page.tsx (clean dark layout, tailwind cards, inputs, and buttons). It must fetch posts from '/api/posts' on render/mount, display them, and show a form to submit new posts via a POST request to '/api/posts'. Refresh the posts list automatically on successful submission. - - + + + + app/api/posts/route.ts + + + + + + + + + Initialize a better-sqlite3 database named 'blog.db'. Create a 'posts' table with columns id (INTEGER PRIMARY KEY), title (TEXT), and content (TEXT) if it does not exist. Implement both POST (to insert a post) and GET (to select all posts) handlers in this route file. Ensure the route is dynamic and not cached by exporting: export const dynamic = 'force-dynamic'; + + Create post successful + + + Hello World + My first post + + + + 200 + success + + + + + + + app/posts/page.tsx + api.posts.create + + + + + Create a beautifully designed blog posts manager page at app/posts/page.tsx (clean dark layout, tailwind cards, inputs, and buttons). It must fetch posts from '/api/posts' on render/mount, display them, and show a form to submit new posts via a POST request to '/api/posts'. Refresh the posts list automatically on successful submission. + + diff --git a/examples/flows/cart.xml b/examples/flows/cart.xml index 55cf8ef..2c1c734 100644 --- a/examples/flows/cart.xml +++ b/examples/flows/cart.xml @@ -1,32 +1,32 @@ - - - - - app/api/cart/route.ts - - Initialize the better-sqlite3 database 'shop.db'. Create a 'cart' table with columns id (INTEGER PRIMARY KEY), productId (INTEGER), quantity (INTEGER). If it is a POST request, insert/increment item quantity in the cart. If it is a GET request, select and return all items in the cart joined with product details from the 'products' table. If it is a DELETE request, clear/empty the cart table. - - Add item to cart successful - - - 1 - 1 - - - - 200 - - - - - - - app/cart/page.tsx - api.cart - - Create a shopping cart summary page at app/cart/page.tsx (modern Tailwind layout, dark/card dashboard design). Fetch cart items from '/api/cart' on mount, compute total cart price, display items in list, and show a 'Clear Cart' button calling DELETE to '/api/cart' and a mock 'Proceed to Checkout' button showing a success message. Link back to '/products'. - - - + + + + + app/api/cart/route.ts + + Initialize the better-sqlite3 database 'shop.db'. Create a 'cart' table with columns id (INTEGER PRIMARY KEY), productId (INTEGER), quantity (INTEGER). If it is a POST request, insert/increment item quantity in the cart. If it is a GET request, select and return all items in the cart joined with product details from the 'products' table. If it is a DELETE request, clear/empty the cart table. + + Add item to cart successful + + + 1 + 1 + + + + 200 + + + + + + + app/cart/page.tsx + api.cart + + Create a shopping cart summary page at app/cart/page.tsx (modern Tailwind layout, dark/card dashboard design). Fetch cart items from '/api/cart' on mount, compute total cart price, display items in list, and show a 'Clear Cart' button calling DELETE to '/api/cart' and a mock 'Proceed to Checkout' button showing a success message. Link back to '/products'. + + + diff --git a/examples/packages/init-nextjs-project/project.xml b/examples/packages/init-nextjs-project/project.xml index 2d5df04..883ff66 100644 --- a/examples/packages/init-nextjs-project/project.xml +++ b/examples/packages/init-nextjs-project/project.xml @@ -1,10 +1,10 @@ - - - - package.json - - Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest - + + + + package.json + + Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest + \ No newline at end of file diff --git a/examples/project.xml b/examples/project.xml index a9b83ed..faac0b5 100644 --- a/examples/project.xml +++ b/examples/project.xml @@ -1,18 +1,18 @@ - - - - - - - - - app/page.tsx - setup.nextjs - - File exports default React component - Page contains a link with href="/posts" - Replace the entire homepage with a beautifully styled landing page (clean dark theme, tailwind classes). Do not call any dashboard APIs like /api/network or /api/ram. Show a hero section, and a prominent link/button pointing to the Posts page at '/posts'. - - + + + + + + + + + app/page.tsx + setup.nextjs + + File exports default React component + Page contains a link with href="/posts" + Replace the entire homepage with a beautifully styled landing page (clean dark theme, tailwind classes). Do not call any dashboard APIs like /api/network or /api/ram. Show a hero section, and a prominent link/button pointing to the Posts page at '/posts'. + + diff --git a/examples/pxml.xsd b/examples/pxml.xsd index b1d0bf1..c2ad5f6 100644 --- a/examples/pxml.xsd +++ b/examples/pxml.xsd @@ -1,102 +1,102 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/reusable-commerce-frame/flows/account.xml b/examples/reusable-commerce-frame/flows/account.xml new file mode 100644 index 0000000..0f6b846 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/account.xml @@ -0,0 +1,51 @@ + + + + app/login/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.button + design:design.input + + Create a polished login page at /login with LayoutShell. Include email input, password input, remember-me checkbox, forgot password link, primary Sign in button, secondary Create account link, and short trust note about secure checkout. + Form validation must handle empty email, invalid email format, empty password, and disabled/loading button state. Submission can be mocked: show success message and link to /account after a brief loading state. + Visual layout: centered auth card max-w-md, white bg, border slate-200, shadow-sm, rounded-2xl, py-12 page padding. Avoid fullscreen dark hero or decorative AI blobs. + Accessibility: labels tied to inputs, error messages role alert, checkbox has label, forgot password link is keyboard reachable, and submit button clearly indicates loading. + Login must look trustworthy and understated. Copy should be concise, commerce-oriented, and not generic lorem ipsum. + + Login page exposes email and password fields + + GET + + + Sign in + + + + + + + app/account/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.badge + design:design.button + design:design.empty-state + api:api.account.route + + Create an account dashboard page /account that uses mock account data from /api/account or lib/mock-data. Show profile overview, order status cards, loyalty points, saved items count, delivery alerts, and recent orders list. + Page structure: breadcrumb Account, headline Welcome back, card grid for stats, a recent orders table/list, saved addresses panel, and quick actions panel linking to /catalog, /cart, and Support. + Recent orders row must include order number, date, status badge, item count, total, and View details link. Mobile view must stack row details into cards instead of overflowing table. + States: show empty state if orders array is empty, loading skeleton block if client fetching is used, and error fallback with retry button if fetch fails. + Account page must feel useful to a returning customer, not just a profile card. Use real ecommerce copy: tracking, invoices, returns window, saved payment hints. + + Account dashboard renders orders and loyalty data + + GET + + + Recent Orders + + + + diff --git a/examples/reusable-commerce-frame/flows/admin.xml b/examples/reusable-commerce-frame/flows/admin.xml new file mode 100644 index 0000000..c0769b4 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/admin.xml @@ -0,0 +1,48 @@ + + + + app/admin/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.badge + design:design.button + api:api.admin.route + + Create the admin dashboard route app/admin/page.tsx using the LayoutShell. This view must fetch real overview metrics and logs from /api/admin and display conversion metrics, top items, and system status indicators. + The top grid must include at least 4 metric tiles: Total Sales (with green positive change indicator), Orders Count, Conversion Rate (e.g. 2.4%), and Low Stock Alerts count. Metrics tiles must use card styling with muted labels. + Render two content sections below the metrics grid: 1. Recent Orders Table showing ID, Customer Email, Total Price, Date, and Status (using badge colors like paid, shipped, pending), 2. Low Stock Products list showing item name, stock count, and Restock CTA button. + Sidebar/Navigation: provide a secondary vertical navigation panel listing Overview, Products, Orders, Customers, Settings. Mobile view collapses this panel into a responsive navigation drawer or top select menu. + Accessibility: data tables must include captions and semantic table headers, stat cards must use valid headings, quick action buttons need clear hover highlights, and low stock items require assistive tags. + The dashboard must look clean, sober, and business-focused. Colors must avoid flashy gradients or glow shadows. White backgrounds with grey panels are preferred. + + Admin dashboard renders overview metrics and tables + + GET + + + Conversion Rate + + + + + + + app/admin/products/page.tsx + ui.admin.dashboard + + Create the admin products table page at app/admin/products/page.tsx using the LayoutShell and secondary navigation layout. This page must display a list of all products, offering filters for Status (active, draft, out_of_stock), Category, and a search input. + The table must display columns: Product, Category, Price, Stock level, Status, and Actions (Edit button). Out of stock items must display a badge in rose-600 with bold text, while drafts display neutral gray. + Add a primary CTA button Create Product at the top right. Clicking the button can trigger an inline slide-over drawer or redirect to a mock product creation route /admin/products/new. + Accessibility: search inputs require labels, table headers must match column data, badges require descriptive tags, and row actions must be screen-reader friendly. + Admin tables must maintain exact spacing: small line heights, consistent table borders, no horizontal text wrapping for status badges, and comfortable paginated steps. + + Admin products page renders products table and filters + + GET + + + Create Product + + + + diff --git a/examples/reusable-commerce-frame/flows/api.xml b/examples/reusable-commerce-frame/flows/api.xml new file mode 100644 index 0000000..5c157a3 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/api.xml @@ -0,0 +1,200 @@ + + + + lib/api/contracts.ts + + Export lightweight TypeScript helpers and response contracts for the reusable commerce frame: CatalogResponse, ProductDetailResponse, CartResponse, AccountResponse, AdminResponse, and an error envelope with error plus status. Keep the file framework-agnostic and free of React imports. + Contract names and fields must mirror the mock routes in this flow so teams can replace in-memory data with a real backend later without rewriting page component props. + This file should feel like practical scaffolding for a reusable frontend shell, not enterprise ceremony. + + + + + lib/mock-data.ts + api.contracts + + Export realistic mock arrays and helpers for the reusable commerce frame. Include at least 8 products across categories Desk Setup, Audio, Travel, and Storage. Each product must have id, slug, title, subtitle, 2 to 3 sentence description, price, compareAtPrice, rating, reviewCount, stock, status, gallery, tags, featured flag, and specs pairs. + Include mock cart items, user profile, orders, dashboard metrics, and admin table rows. Data must feel commercially plausible: specific titles like Walnut Laptop Stand, Carry Sleeve 14, Studio Wireless Headphones, Modular Cable Organizer, not generic Item One. Prices should look realistic and varied, for example 29, 79, 129, 249. + Export helper functions getFeaturedProducts, getProductBySlug, getRelatedProducts, searchProducts, getCartSummary, getAccountSummary, and getAdminMetrics. Keep all helpers synchronous and pure so frontend pages and API routes can reuse them without database or fetch calls. + Mock data should make generated screens look like a finished product demo rather than a code scaffold. Titles, categories, specs, and statuses should support believable cards, filters, tables, and detail pages. + + + + + app/api/products/route.ts + api.mock-data + + + + + + + + + + + + Implement a Next.js route handler exporting GET and export const dynamic = 'force-dynamic'. Read query params category, q, sort, and featured from the request URL. Return JSON with items and total using the mock product helpers. + Sorting rules must support featured, newest, price-asc, price-desc, and rating. If category is missing, return all active products. If q exists, filter title, subtitle, tags, and category case-insensitively. + Response must never include draft products in storefront mode. Include status 200 for normal responses and a valid JSON shape even when zero items match. + The route should feel like a clean mock backend contract a frontend team could later replace with a real service without changing page structure. + + Catalog route returns product list + + GET + + featured + + + + 200 + items + + + + Catalog route supports category filter + + GET + + Audio + + + + 200 + Audio + + + + + + + app/api/products/[slug]/route.ts + api.mock-data + + + + + + + + + Implement a route handler with GET accepting slug from route params or a request helper shape commonly used in tests. Return status 200 with item and related arrays when found. Return status 404 with an error field when slug is unknown. + Use getProductBySlug and getRelatedProducts from mock data. Related list must exclude the current product and contain up to 4 items from the same category or featured products. + The response should provide enough structured data for a polished product detail page with gallery, specs, pricing, trust badges, and recommendation rail. + + Product detail returns matching item + + GET + walnut-laptop-stand + + + 200 + related + + + + Product detail handles unknown slug + + GET + missing-product + + + 404 + error + + + + + + + app/api/cart/route.ts + api.mock-data + + + + + + + + + + + + Implement GET, POST, and DELETE handlers using in-memory mock data only. GET returns current cart summary. POST accepts productId and quantity and returns updated summary. DELETE empties the mock cart response and returns an empty-state payload. Do not use localStorage in the route. + Always return JSON containing items, subtotal, and count. For invalid productId or quantity less than 1, return status 400 with an error message. + This route should behave like a realistic placeholder backend contract for add-to-cart demos, simple enough to swap later for a real order service. + + Add to cart returns updated summary + + POST + + studio-wireless-headphones + 1 + + + + 200 + subtotal + + + + Delete cart clears items + + DELETE + + + 200 + items + + + + + + + app/api/account/route.ts + api.mock-data + + + + + + Implement GET returning a mock logged-in customer profile, recent orders, saved stats, and dashboard shortcuts. No authentication dependency required; this is a demo contract only. + Response JSON must contain user and orders keys and one additional summary object with loyaltyPoints, savedItemsCount, and pendingDeliveries. + The contract should be useful for rendering a realistic account dashboard, not merely a name/email echo. + + Account route exposes user dashboard data + + GET + + + 200 + orders + + + + + + + app/api/admin/route.ts + api.mock-data + + + + + + + Implement GET returning admin overview metrics, product rows, and order rows based on mock data. Include conversionRate, averageOrderValue, lowStockCount, and pendingOrders in metrics. + This is a mock admin route. Do not perform auth, database, or mutation logic. Keep it read-only and deterministic. + The payload should feel dashboard-ready, with enough variety to render stat cards, tables, and alerts convincingly. + + Admin route exposes metrics and tables + + GET + + + 200 + metrics + + + + diff --git a/examples/reusable-commerce-frame/flows/cart-or-action.xml b/examples/reusable-commerce-frame/flows/cart-or-action.xml new file mode 100644 index 0000000..aed23b7 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/cart-or-action.xml @@ -0,0 +1,51 @@ + + + + app/cart/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.card + design:design.button + design:design.input + design:design.empty-state + api:api.mock-data + api:api.cart.route + + Create the cart management page /cart using LayoutShell. It must fetch active cart items from /api/cart, display list rows, calculate subtotal, shipping, tax, total, and offer actions to update quantities, remove items, clear cart, and proceed to checkout. + Cart row component: product thumbnail image, name, variant, price, quantity stepper inputs, item total, and a clear trash-bin remove button. Stepper should restrict input to positive integers, updating backend values automatically or upon commit. + Right/Below summary panel: subtotal, shipping rate (or dynamic text Free Shipping above threshold), estimated tax, and order total. Add promo code accordion with input field and validation button. CTA checkout points to /checkout or renders checkout inline. + Empty state: if cart is empty, render the EmptyState component with a friendly shopping bag icon and CTA pointing back to /catalog. + Accessibility: table/list views must be labeled, quantity inputs must use label or aria-label per row, cart total must have semantic heading, checkout CTA button needs clear focus highlight. + The layout must be responsive, clean, and highly intuitive. Cart items list should align details nicely with prices and steppers. The total summary should remain visible without awkward scroll overlaps. + + Cart page displays item list and total summary + + GET + + + Order Summary + + + + + + + app/checkout/page.tsx + ui.cart.page + + Create the checkout page /checkout using LayoutShell. The page must display a progressive 3-step checkout form: 1. Shipping Address, 2. Payment Details, 3. Order Review, along with a sticky Order Summary showing product names, prices, quantities, and order total. + Form requirements: Shipping fields (fullName, address, city, country, zip), Payment fields (cardName, cardNumber mock, expiry, cvc), and Billing match switch. Field focus states and standard validation errors must match the design system atoms. Use role alert for field validations. + Review section: summarize user shipping choices, payment mask, and cart item details. CTA Place Order must show a success dialog or redirect to a confirmation screen /checkout/success. + Accessibility: fieldsets must use legend tags, checkout steps must have clear step indicator status, payment inputs must restrict non-numeric chars and mask sensitive data where possible, inputs require matching labels. + Checkout must represent a secure, standard digital storefront. The step navigation must clearly mark complete, active, and pending stages. Spacing should prioritize form inputs readability, without forcing layout wrapping on average laptop screens. + + Checkout renders shipping form and review summary + + GET + + + Shipping Address + + + + diff --git a/examples/reusable-commerce-frame/flows/catalog.xml b/examples/reusable-commerce-frame/flows/catalog.xml new file mode 100644 index 0000000..d2df664 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/catalog.xml @@ -0,0 +1,40 @@ + + + + app/catalog/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.card + design:design.input + design:design.empty-state + api:api.mock-data + api:api.catalog.route + + + + + + + + + Create /catalog page with LayoutShell and a structured product listing experience. Use server component rendering if possible. Support reading searchParams q, category, sort, and page from Next.js page props without crashing when missing or Promise-like. + Top area must include breadcrumb Home / Catalog, page title All Products, short category description, and results count. Use max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10. + Filter sidebar desktop: left column width around 280px with Categories, Price Range, Availability, Rating, and Sort controls. Mobile: filters collapse into a full-width details/summary accordion above grid. No offscreen drawer dependency required. + Product grid: right column uses responsive grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6 and ProductCard links to /catalog/[slug]. Cards must have consistent image heights and no layout shift. + States: loading skeleton definitions for 6 cards if client fetch is used; empty state when no products match filters; accessible pagination nav with Previous/Next buttons. If rendering directly from mock data, still include reusable empty-state and skeleton component definitions for future API wiring. + Accessibility: filter controls must have labels, sort select must have aria-label, product grid must use list semantics, pagination must have aria-current for active page. + Catalog should feel like a serious storefront: dense enough for shopping, not cluttered. Filter sidebar separators and labels should be precise; CTA labels specific; no placeholder lorem ipsum. + + Catalog renders filters and product grid + + GET + + Audio + + + + All Products + + + + diff --git a/examples/reusable-commerce-frame/flows/design-system.xml b/examples/reusable-commerce-frame/flows/design-system.xml new file mode 100644 index 0000000..4a524ec --- /dev/null +++ b/examples/reusable-commerce-frame/flows/design-system.xml @@ -0,0 +1,218 @@ + + + Base design-system prompt shared by every generated design-system file. Treat this flow as a reusable frontend frame: clean layout, predictable props, no visual gimmicks, and components that other screens can compose safely. + + + + + tailwind.config.ts + design.base + + Keep Tailwind configuration minimal and compatible with a standard Next.js App Router project. Content scanning must include app, components, and lib folders for ts, tsx, mdx, js, jsx files. + Define a reusable commercial color language through Tailwind theme extension only when needed: primary zinc-950, primaryForeground white, surface white, surfaceMuted slate-50, border slate-200, text slate-950, textMuted slate-500, success emerald-600, warning amber-600, danger rose-600. Do not add gradients, glow utilities, neon palettes, or animation plugins. + The config should feel like reusable frontend infrastructure, not a branded art experiment. Future teams must be able to restyle it by changing a few colors. + + + + + app/globals.css + design.base + design.tailwind-config + + Implement Tailwind base, components, utilities imports and standard global CSS variables for background, foreground, muted, border, ring, primary, and danger. Support light mode first, plus a controlled dark mode class with slate/zinc surfaces. + Body must use a clean sans-serif stack, bg-white text-slate-950, antialiased rendering, and no global background images. Selection color should be subtle zinc background with white foreground. + Add focus-visible baseline style only for keyboard navigation. Do not remove outlines globally. Links and buttons must remain accessible with visible focus rings. + Global styling should be barely noticeable and professional. It should never overpower page components. + + + + + lib/types.ts + design.base + design.globals-css + + Export TypeScript types Product, ProductVariant, CartItem, Order, UserProfile, NavItem, AdminMetric, and ApiEnvelope. Product must include id, slug, title, subtitle, description, price, compareAtPrice, category, rating, reviewCount, stock, status, image, gallery, tags, specs, and featured boolean. + Use string literal unions for status fields: product status values active, draft, archived, out_of_stock; order status values pending, paid, packed, shipped, completed, cancelled; user role values guest, customer, admin. + Types must not import React. Keep this file pure and usable from server routes, client components, tests, and mock data. + + + + + lib/cn.ts + design.base + design.types + + Create a tiny className merge helper named cn using only clsx if installed, otherwise a local function that filters falsy values and joins strings. Do not add tailwind-merge dependency. + + + + + components/ui/Button.tsx + design.base + design.cn-util + + Export a Button component with variants primary, secondary, outline, ghost, danger, and link; sizes sm, md, lg, icon. Support disabled, loading, type, aria-label, className, and children props. Use forwardRef only if useful for native button semantics. + Primary style must be bg-zinc-950 text-white hover:bg-zinc-800. Secondary must be bg-slate-100 text-slate-900 hover:bg-slate-200. Outline must be border border-slate-200 bg-white hover:bg-slate-50. Ghost must be transparent hover:bg-slate-100. Danger must be bg-rose-600 text-white hover:bg-rose-700. + Focus state must include focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2. Disabled state must reduce opacity and block pointer events. + Button shape and spacing must look like a real production design system: rounded-xl, consistent 40px medium height, no bounce effects, no glow, no gradient. + + Button exposes accessible CTA text + + GET + + + primary + + + + + + + components/ui/Badge.tsx + design.base + design.cn-util + + Export Badge component with neutral, success, warning, danger, info, and outline variants. Use muted backgrounds: slate-50, emerald-50, amber-50, rose-50, sky-50 with readable text and subtle border. + Badge must render as inline-flex, rounded-full, px-2.5, py-0.5, text-xs, font-medium, border. Support className and children. + Badges must communicate state without looking noisy. They should be readable in tables, cards, and mobile detail pages. + + Badge supports status copy + + GET + + + success + + + + + + + components/ui/Input.tsx + design.base + design.cn-util + + Export Input and Textarea components with label, helperText, error, id, name, required, disabled, placeholder, value, onChange, type, and className props. Associate label with control by htmlFor/id. + Input base style must be h-11 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-950 placeholder:text-slate-400. Error state must use border-rose-300 and aria-invalid true. + Helper text must be text-slate-500. Error text must be text-rose-600 and announced with role alert if error exists. Never hide validation messages with only color. + Form fields should be compact but touch-friendly. Mobile layout must keep labels readable and not crowd controls. + + Input exposes label helper and error API + + GET + + + helperText + + + + + + + components/ui/Card.tsx + design.base + design.cn-util + design.badge + + Export Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, and ProductCard components. ProductCard must accept a Product-like object and render image, category, title, subtitle, rating, review count, price, compare price, status badge, and CTA link/button. + Card container must use bg-white border border-slate-200 rounded-2xl shadow-sm. Hover state may use hover:shadow-md and hover:-translate-y-px with transition, but no dramatic animation. + Product image area must use aspect-square or aspect-[4/3], overflow-hidden, bg-slate-50, object-cover. Include alt text from product title. Use Next Image if available, otherwise standard img with width and height attributes. + ProductCard should look like a reusable e-commerce card from a real storefront. Information hierarchy: image first, badge/category second, title/subtitle third, rating/price last, CTA visible but not loud. + + Card includes product hierarchy + + GET + + + ProductCard + + + + + + + components/ui/EmptyState.tsx + design.base + design.button + + Export EmptyState component with title, description, actionLabel, actionHref, secondaryLabel, secondaryHref, and iconName props. Render a centered panel with a simple inline SVG placeholder, not an icon dependency. + EmptyState must be usable for empty cart, no search results, empty admin table, and unauthenticated account pages. Include default copy that is specific enough: Nothing here yet, Try adjusting filters, Continue browsing. + The empty state must feel intentionally designed: enough whitespace, a calm icon, clear next action, no sad robot illustration or decorative nonsense. + + Empty state shows actionable copy + + GET + + + Continue browsing + + + + + + + components/ui/Header.tsx + design.base + design.button + design.input + design.badge + + Create a responsive Header with logo text CommerceFrame, nav links Home, Catalog, Deals, About, Account, Admin, search field, cart link with count badge, and mobile menu toggle. Use Next Link for internal navigation. + Desktop layout: max-w-7xl mx-auto h-16 px-4 sm:px-6 lg:px-8, logo left, nav center, search plus account/cart actions right. Mobile layout: logo left, icon buttons right, nav menu appears below header when open with stacked links and full-width search. + Header must be sticky top-0 z-50 bg-white/90 backdrop-blur-sm border-b border-slate-200. Avoid heavy blur, glass panels, gradients, and shadow-xl. + All icon-only buttons must include aria-label. Mobile menu button must have aria-expanded tied to open state. Search input must have label or aria-label. + Header should look hand-built, not template spam: balanced columns, no useless decorative icons, clear CTA hierarchy, and steady spacing across mobile, tablet, desktop. + + Header exposes main nav routes + + GET + + + Catalog + + + + + + + components/ui/Footer.tsx + design.base + design.button + design.input + + Create a Footer with brand block, short product description, four sitemap columns named Shop, Support, Company, Legal, newsletter signup form, language selector, currency selector, and copyright line. Include realistic links: Products, New arrivals, Shipping, Returns, Contact, Privacy, Terms. + Footer grid must be responsive: grid-cols-1 on mobile, sm:grid-cols-2 on tablet, lg:grid-cols-5 on desktop with brand column spanning wider. Use bg-slate-50 border-t border-slate-200 text-slate-600. + Newsletter form must include email input, submit button, helper text about occasional updates, and accessible label. Submission behavior can be mock client alert or no-op with success text. + Footer must feel complete and credible. No lorem ipsum. Copy should sound like a practical commerce platform footer. + + Footer includes newsletter and legal links + + GET + + + Newsletter + + + + + + + components/ui/LayoutShell.tsx + design.base + design.header + design.footer + + Export LayoutShell component that wraps children with Header, main landmark, and Footer. Main must use id main-content, min-h-screen, and include a visually hidden skip link target for keyboard users. + LayoutShell accepts cartCount, userName, currentPath, and children props. Provide safe defaults so static pages can render without data fetching. + Layout shell should make every page feel consistent while staying invisible. It must not impose page-specific spacing that fights home, catalog, detail, cart, account, or admin screens. + + Layout shell includes main landmark + + GET + + + main-content + + + + diff --git a/examples/reusable-commerce-frame/flows/detail.xml b/examples/reusable-commerce-frame/flows/detail.xml new file mode 100644 index 0000000..7c41cc0 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/detail.xml @@ -0,0 +1,33 @@ + + + + app/catalog/[slug]/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.card + design:design.button + design:design.badge + api:api.mock-data + api:api.product-detail.route + + + + + Create product detail page at /catalog/[slug] using route params slug. If product does not exist, render a not-found-like empty state with CTA back to /catalog. Do not crash on missing params shape in tests. + Main layout must be two-column on desktop and single-column on mobile. Left: image gallery with one large preview and 3 smaller thumbnails stacked or inline. Right: category label, product title, subtitle, rating row, price block with compare-at price, inventory status badge, key bullet features, quantity stepper, add-to-cart CTA, save-for-later CTA, shipping promise box. + Below the fold add three sections: Detailed Description, Technical Specifications table, and Related Products grid. Specs table uses two columns Key / Value with zebra-like row dividers and muted labels. + Responsive rules: gallery must stay above content on mobile, sticky buy box only on desktop if easy to implement, buttons full width on mobile, trust badges stack cleanly. No carousel library; simple thumbnail selection or static gallery is enough. + Accessibility: all images require descriptive alt text, thumbnail buttons need aria-label, quantity stepper buttons need labels, spec table uses semantic table markup, review count links to reviews anchor even if review content is static. + The page must feel like a polished commerce PDP. The hierarchy should make buying easy without loud visual gimmicks. Use restrained shadow, clear borders, and professional spacing. + + Product detail renders pricing and specs + + GET + walnut-laptop-stand + + + Specifications + + + + diff --git a/examples/reusable-commerce-frame/flows/home.xml b/examples/reusable-commerce-frame/flows/home.xml new file mode 100644 index 0000000..2cbc333 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/home.xml @@ -0,0 +1,26 @@ + + + + app/page.tsx + setup:setup.nextjs.base + design:design.layout-shell + design:design.card + design:design.button + api:api.mock-data + + Create the main landing page at app/page.tsx using the LayoutShell. The page must render a Hero section, a Category Grid, a Featured Products horizontal row, a Promo Banner, and a trust credentials grid. + Hero section requirements: High-contrast typography with a bold title (e.g. Elevate Your Daily WorkSpace), a short paragraph describing premium accessories, and primary CTA pointing to '/catalog' and secondary CTA pointing to '/deals'. Do not use random stock image URLs; use solid backgrounds, neutral borders, or stylized SVG patterns. + Featured Products section: Fetch or import featured products from lib/mock-data.tsx and render them using the ProductCard component. Support up to 4 items in a responsive grid (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4). + Trust Grid: Show at least 3 cards highlighting Free Shipping, 2-Year Warranty, and Carbon Neutral delivery with tiny custom inline SVGs and slate descriptions. + The page must present a clean, premium, production-ready corporate brand. Keep whitespace generous: py-16 or py-24 between sections. Avoid dark neon colors, moving animations, excessive scroll reveals, or glow borders. Nền trắng/xám nhạt (bg-slate-50/50). + + Home renders hero CTA and featured products + + GET + + + WorkSpace + + + + diff --git a/examples/reusable-commerce-frame/flows/setup.xml b/examples/reusable-commerce-frame/flows/setup.xml new file mode 100644 index 0000000..754a329 --- /dev/null +++ b/examples/reusable-commerce-frame/flows/setup.xml @@ -0,0 +1,16 @@ + + + Base setup node used only for dependency ordering. It is extended by concrete setup nodes and must not generate a file by itself. + + + + + package.json + setup.base + + Initialize a Next.js application in the current directory non-interactively with TypeScript, App Router, ESLint, Tailwind CSS, no src directory, npm, and import alias at slash star. Reuse the default Next folder layout so generated files live under app, components, and lib without custom architecture layers. + Keep the dependency set intentionally small for a reusable frontend frame. Allow only the standard Next.js stack and at most one tiny utility dependency such as clsx if class composition becomes noisy. Do not install chart libraries, animation libraries, icon packs, database drivers, state managers, component mega-kits, or visual effect plugins. + Preserve Tailwind defaults and rely on utility classes rather than custom theme plugins. The generated website must feel like a clean commercial product shell that teams can restyle later without fighting gradients, glows, neon shadows, glassmorphism, or hidden magic config. + The setup outcome should feel boring in a good way: predictable folders, normal scripts, and a codebase a senior frontend developer would happily hand to another team for reuse. Prefer maintainability over novelty. + + diff --git a/examples/reusable-commerce-frame/project.xml b/examples/reusable-commerce-frame/project.xml new file mode 100644 index 0000000..ad18e27 --- /dev/null +++ b/examples/reusable-commerce-frame/project.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/fixtures/flows/blog.read.xml b/fixtures/flows/blog.read.xml index a610118..f4ce2bb 100644 --- a/fixtures/flows/blog.read.xml +++ b/fixtures/flows/blog.read.xml @@ -1,31 +1,31 @@ - - - - - - app/api/posts/route.ts - types:db.post - - - - - - - - - Sort by publishedAt descending - Maximum 20 items per page - - Get posts list default page size - - - 10 - - - - 200 - posts - - - - + + + + + + app/api/posts/route.ts + types:db.post + + + + + + + + + Sort by publishedAt descending + Maximum 20 items per page + + Get posts list default page size + + + 10 + + + + 200 + posts + + + + diff --git a/fixtures/project.xml b/fixtures/project.xml index 3f21baa..261c897 100644 --- a/fixtures/project.xml +++ b/fixtures/project.xml @@ -1,3 +1,3 @@ - - - + + + diff --git a/fixtures/shared/types.xml b/fixtures/shared/types.xml index 0454d78..b12cd7e 100644 --- a/fixtures/shared/types.xml +++ b/fixtures/shared/types.xml @@ -1,21 +1,21 @@ - - - - app/api/base.ts - - No sensitive data leakage in response or logs - - - - - prisma/schema.prisma - - - - - - - - - - + + + + app/api/base.ts + + No sensitive data leakage in response or logs + + + + + prisma/schema.prisma + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..208e976 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2406 @@ +{ + "name": "@two-tech-dev/pxml", + "version": "0.2.6", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@two-tech-dev/pxml", + "version": "0.2.6", + "dependencies": { + "@anthropic-ai/sdk": "^0.22.0", + "commander": "^12.1.0", + "fast-xml-parser": "^5.9.3", + "zod": "^3.23.8" + }, + "bin": { + "pxml": "dist/cli/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "tsx": "^4.16.0", + "typescript": "^5.5.2", + "vitest": "^4.1.10" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-dv4BCC6FZJw3w66WNLsHlUFjhu19fS1L/5jMPApwhZLa/Oy1j0A2i3RypmDtHEPp4Wwg3aZkSHksp7VzYWjzmw==", + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", + "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 0db5417..6c3181f 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,28 @@ -{ - "name": "@two-tech-dev/pxml", - "version": "0.2.6", - "description": "XML DSL and compiler for AI-driven project building", - "type": "module", - "main": "dist/index.js", - "bin": { - "pxml": "dist/cli/index.js" - }, - "scripts": { - "build": "tsc", - "test": "vitest run", - "test:watch": "vitest", - "cli": "tsx src/cli/index.ts" - }, - "dependencies": { - "@anthropic-ai/sdk": "^0.22.0", - "commander": "^12.1.0", - "fast-xml-parser": "^4.4.0", - "zod": "^3.23.8" - }, - "devDependencies": { - "@types/node": "^20.14.9", - "typescript": "^5.5.2", - "vitest": "^1.6.0", - "tsx": "^4.16.0" - } -} +{ + "name": "@two-tech-dev/pxml", + "version": "0.2.6", + "description": "XML DSL and compiler for AI-driven project building", + "type": "module", + "main": "dist/index.js", + "bin": { + "pxml": "dist/cli/index.js" + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "cli": "tsx src/cli/index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.22.0", + "commander": "^12.1.0", + "fast-xml-parser": "^5.9.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "tsx": "^4.16.0", + "typescript": "^5.5.2", + "vitest": "^4.1.10" + } +} diff --git a/packages/init-nextjs-project/project.xml b/packages/init-nextjs-project/project.xml new file mode 100644 index 0000000..2d5df04 --- /dev/null +++ b/packages/init-nextjs-project/project.xml @@ -0,0 +1,10 @@ + + + + package.json + + Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest + + \ No newline at end of file diff --git a/pxml.xsd b/pxml.xsd index b1d0bf1..c2ad5f6 100644 --- a/pxml.xsd +++ b/pxml.xsd @@ -1,102 +1,102 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/settings.local.json b/settings.local.json new file mode 100644 index 0000000..657be44 --- /dev/null +++ b/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "PowerShell(Get-ChildItem *)", + "Bash(tsx *)", + "PowerShell(npx tsx src/cli/index.ts validate)", + "PowerShell(npm install *)" + ] + } +} diff --git a/src/cache/index.ts b/src/cache/index.ts index fb5d50a..b864e1c 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,32 +1,32 @@ -import * as crypto from 'crypto'; -import { Node } from '../parser/schema.js'; - -export class PxmlCache { - /** - * Generates a stable hash for a node's configuration to verify if it has changed. - */ - static hashNode(node: Node): string { - const serialized = JSON.stringify({ - id: node.id, - type: node.type, - flow: node.flow, - extends: node.extends, - meta: node.meta, - input: node.input, - output: node.output, - constraints: node.constraints, - tests: node.tests - }); - return crypto.createHash('sha256').update(serialized).digest('hex'); - } - - static hashNodeTests(node: Node): string { - const serialized = JSON.stringify({ - input: node.input, - output: node.output, - constraints: node.constraints, - tests: node.tests - }); - return crypto.createHash('sha256').update(serialized).digest('hex'); - } -} +import * as crypto from 'crypto'; +import { Node } from '../parser/schema.js'; + +export class PxmlCache { + /** + * Generates a stable hash for a node's configuration to verify if it has changed. + */ + static hashNode(node: Node): string { + const serialized = JSON.stringify({ + id: node.id, + type: node.type, + flow: node.flow, + extends: node.extends, + meta: node.meta, + input: node.input, + output: node.output, + constraints: node.constraints, + tests: node.tests + }); + return crypto.createHash('sha256').update(serialized).digest('hex'); + } + + static hashNodeTests(node: Node): string { + const serialized = JSON.stringify({ + input: node.input, + output: node.output, + constraints: node.constraints, + tests: node.tests + }); + return crypto.createHash('sha256').update(serialized).digest('hex'); + } +} diff --git a/src/cli/fix.ts b/src/cli/fix.ts index f537af9..84fe29c 100644 --- a/src/cli/fix.ts +++ b/src/cli/fix.ts @@ -1,197 +1,197 @@ -import { Node } from '../parser/schema.js'; -import { PxmlCodegen } from '../codegen/index.js'; -import { PxmlRunner, getTestFilePath } from '../runner/index.js'; -import { PxmlPatcher } from '../patcher/index.js'; -import { FileWriter } from '../writer/index.js'; -import { PxmlManifest } from '../manifest/index.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -const colors = { - red: (text: string) => `\x1b[31m${text}\x1b[0m`, - green: (text: string) => `\x1b[32m${text}\x1b[0m`, - yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, - blue: (text: string) => `\x1b[34m${text}\x1b[0m`, - cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, - bold: (text: string) => `\x1b[1m${text}\x1b[0m` -}; - -export async function runFixLoop( - node: Node, - projectDir: string, - manifest: PxmlManifest, - codegen: PxmlCodegen, - runner: PxmlRunner, - writer: FileWriter, - mockFixResponse?: string, - bugContext?: string, - forceFirstRun?: boolean, - stack = 'nextjs' -): Promise { - const maxRetries = 3; - let attempt = 0; - - console.log(`${colors.yellow(colors.bold('[FIX]'))} Starting self-healing loop for node: ${node.id} (Max ${maxRetries} attempts)`); - - while (attempt < maxRetries) { - attempt++; - console.log(`${colors.yellow('[FIX]')} Attempt ${attempt}/${maxRetries}...`); - - // 1. Gather failure context - const currentCode = fs.existsSync(node.meta.path) ? fs.readFileSync(node.meta.path, 'utf-8') : ''; - const testResult = runner.runNodeTests(node, stack); - - // Bypass check only on attempt 1 if forceFirstRun is requested - const bypassCheck = forceFirstRun && attempt === 1; - - if (testResult.passed && !bypassCheck) { - console.log(`${colors.green(colors.bold('[FIX]'))} Success! Node ${node.id} tests passed on attempt ${attempt}.`); - - // Update manifest - const existing = manifest.getNode(node.id); - if (existing) { - manifest.setNode(node.id, { - ...existing, - last_test_run: testResult.results - }); - manifest.save(); - } - return true; - } - - // Identify failed cases - const failedCases = Object.entries(testResult.results) - .filter(([_, status]) => status === 'fail') - .map(([name]) => name); - - console.log(`${colors.red(colors.bold('[FIX]'))} Failed test cases: ${failedCases.join(', ')}`); - - const testFilePath = getTestFilePath(node.meta.path, stack); - const absTestFilePath = path.resolve(projectDir, testFilePath); - const currentTestCode = fs.existsSync(absTestFilePath) ? fs.readFileSync(absTestFilePath, 'utf-8') : ''; - - // 2. Formulate minimal fix-prompt - const patchPrompt = `You are a software repair AI. The following code or tests for node '${node.id}' have issues. -${testResult.output ? `Test Execution Failure Errors:\n${testResult.output}\n` : ''} -${bugContext ? `Raw Bug Context / Error Logs:\n${bugContext}\n` : ''} -Path: ${node.meta.path} -Test Path: ${testFilePath} -Failed Cases: ${failedCases.join(', ')} -Node XML spec: -- Input: ${JSON.stringify(node.input)} -- Output: ${JSON.stringify(node.output)} -- Constraints: ${JSON.stringify(node.constraints)} - -Current Code: -\`\`\`typescript -${currentCode} -\`\`\` - -Current Test Code: -\`\`\`typescript -${currentTestCode} -\`\`\` - -Analyze if the issue is in the implementation code or the test code (or both). -CRITICAL: Do not break the code's core business logic or violate the XML constraints. If the test fails due to incorrect test assertions, mock setups, or environment mismatches, patch the test file (${testFilePath}) instead of the implementation file (${node.meta.path}). -Generate SEARCH/REPLACE blocks to patch the files. You MUST prefix each file's search/replace blocks with the header "FILE: [file_path]" where [file_path] is the relative path (either ${node.meta.path} or ${testFilePath}). - -Format: -FILE: ${node.meta.path} -<<<<<<< SEARCH -[code to replace] -======= -[replacement code] ->>>>>>> REPLACE - -FILE: ${testFilePath} -<<<<<<< SEARCH -[code to replace] -======= -[replacement code] ->>>>>>> REPLACE`; - - // 3. Request diff/patch from AI (or use mock if provided) - let patch = ''; - if (mockFixResponse) { - patch = mockFixResponse; - } else { - try { - patch = await codegen.generateDirect(patchPrompt, "Generate only SEARCH/REPLACE patch block."); - } catch (err: any) { - console.error(`[FIX] AI call failed: ${err.message}. Escalating to user.`); - return false; - } - } - - // 4. Apply patch - try { - const filePatches = patch.split(/FILE:\s+/); - if (filePatches.length > 1) { - for (const fp of filePatches) { - if (!fp.trim()) continue; - const firstLineBreak = fp.indexOf('\n'); - if (firstLineBreak === -1) continue; - const relativePath = fp.slice(0, firstLineBreak).trim(); - const filePatchContent = fp.slice(firstLineBreak + 1); - - let targetFilePath = path.resolve(projectDir, relativePath); - if (!fs.existsSync(targetFilePath) && relativePath.startsWith('tests/')) { - const strippedPath = relativePath.replace(/^tests\//, ''); - const fallbackPath = path.resolve(projectDir, strippedPath); - if (fs.existsSync(fallbackPath)) { - targetFilePath = fallbackPath; - } - } - - if (fs.existsSync(targetFilePath)) { - const fileContent = fs.readFileSync(targetFilePath, 'utf-8'); - const patched = PxmlPatcher.applyPatch(fileContent, filePatchContent); - writer.write(targetFilePath, patched); - console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${path.relative(projectDir, targetFilePath)}.`); - } - } - } else { - const patchedCode = PxmlPatcher.applyPatch(currentCode, patch); - writer.write(node.meta.path, patchedCode); - console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${node.meta.path}.`); - } - console.log(`${colors.cyan(colors.bold('[FIX]'))} Patch details:\n${summarizePatch(patch)}\n`); - } catch (err: any) { - console.warn(`${colors.red(colors.bold('[FIX]'))} Failed to apply patch: ${err.message}`); - // If patch application failed, we retry or escalate - continue; - } - } - - console.error(`${colors.red(colors.bold('[FIX]'))} Failed to self-heal node ${node.id} after ${maxRetries} attempts. Escalating to user.`); - return false; -} - -function summarizePatch(patch: string): string { - const filePatches = patch.split(/FILE:\s+/); - if (filePatches.length <= 1) { - const blocks = PxmlPatcher.parsePatch(patch); - if (blocks.length > 0) { - return ` → Modified file: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).`; - } - return patch.trim(); - } - - let summary = ''; - for (const fp of filePatches) { - if (!fp.trim()) continue; - const firstLineBreak = fp.indexOf('\n'); - if (firstLineBreak === -1) continue; - const relativePath = fp.slice(0, firstLineBreak).trim(); - const filePatchContent = fp.slice(firstLineBreak + 1); - - const blocks = PxmlPatcher.parsePatch(filePatchContent); - if (blocks.length > 0) { - summary += ` → ${relativePath}: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).\n`; - } else { - summary += ` → ${relativePath}: replaced content entirely.\n`; - } - } - return summary.trim(); -} +import { Node } from '../parser/schema.js'; +import { PxmlCodegen } from '../codegen/index.js'; +import { PxmlRunner, getTestFilePath } from '../runner/index.js'; +import { PxmlPatcher } from '../patcher/index.js'; +import { FileWriter } from '../writer/index.js'; +import { PxmlManifest } from '../manifest/index.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + green: (text: string) => `\x1b[32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[34m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + bold: (text: string) => `\x1b[1m${text}\x1b[0m` +}; + +export async function runFixLoop( + node: Node, + projectDir: string, + manifest: PxmlManifest, + codegen: PxmlCodegen, + runner: PxmlRunner, + writer: FileWriter, + mockFixResponse?: string, + bugContext?: string, + forceFirstRun?: boolean, + stack = 'nextjs' +): Promise { + const maxRetries = 3; + let attempt = 0; + + console.log(`${colors.yellow(colors.bold('[FIX]'))} Starting self-healing loop for node: ${node.id} (Max ${maxRetries} attempts)`); + + while (attempt < maxRetries) { + attempt++; + console.log(`${colors.yellow('[FIX]')} Attempt ${attempt}/${maxRetries}...`); + + // 1. Gather failure context + const currentCode = fs.existsSync(node.meta.path) ? fs.readFileSync(node.meta.path, 'utf-8') : ''; + const testResult = runner.runNodeTests(node, stack); + + // Bypass check only on attempt 1 if forceFirstRun is requested + const bypassCheck = forceFirstRun && attempt === 1; + + if (testResult.passed && !bypassCheck) { + console.log(`${colors.green(colors.bold('[FIX]'))} Success! Node ${node.id} tests passed on attempt ${attempt}.`); + + // Update manifest + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: testResult.results + }); + manifest.save(); + } + return true; + } + + // Identify failed cases + const failedCases = Object.entries(testResult.results) + .filter(([_, status]) => status === 'fail') + .map(([name]) => name); + + console.log(`${colors.red(colors.bold('[FIX]'))} Failed test cases: ${failedCases.join(', ')}`); + + const testFilePath = getTestFilePath(node.meta.path, stack); + const absTestFilePath = path.resolve(projectDir, testFilePath); + const currentTestCode = fs.existsSync(absTestFilePath) ? fs.readFileSync(absTestFilePath, 'utf-8') : ''; + + // 2. Formulate minimal fix-prompt + const patchPrompt = `You are a software repair AI. The following code or tests for node '${node.id}' have issues. +${testResult.output ? `Test Execution Failure Errors:\n${testResult.output}\n` : ''} +${bugContext ? `Raw Bug Context / Error Logs:\n${bugContext}\n` : ''} +Path: ${node.meta.path} +Test Path: ${testFilePath} +Failed Cases: ${failedCases.join(', ')} +Node XML spec: +- Input: ${JSON.stringify(node.input)} +- Output: ${JSON.stringify(node.output)} +- Constraints: ${JSON.stringify(node.constraints)} + +Current Code: +\`\`\`typescript +${currentCode} +\`\`\` + +Current Test Code: +\`\`\`typescript +${currentTestCode} +\`\`\` + +Analyze if the issue is in the implementation code or the test code (or both). +CRITICAL: Do not break the code's core business logic or violate the XML constraints. If the test fails due to incorrect test assertions, mock setups, or environment mismatches, patch the test file (${testFilePath}) instead of the implementation file (${node.meta.path}). +Generate SEARCH/REPLACE blocks to patch the files. You MUST prefix each file's search/replace blocks with the header "FILE: [file_path]" where [file_path] is the relative path (either ${node.meta.path} or ${testFilePath}). + +Format: +FILE: ${node.meta.path} +<<<<<<< SEARCH +[code to replace] +======= +[replacement code] +>>>>>>> REPLACE + +FILE: ${testFilePath} +<<<<<<< SEARCH +[code to replace] +======= +[replacement code] +>>>>>>> REPLACE`; + + // 3. Request diff/patch from AI (or use mock if provided) + let patch = ''; + if (mockFixResponse) { + patch = mockFixResponse; + } else { + try { + patch = await codegen.generateDirect(patchPrompt, "Generate only SEARCH/REPLACE patch block."); + } catch (err: any) { + console.error(`[FIX] AI call failed: ${err.message}. Escalating to user.`); + return false; + } + } + + // 4. Apply patch + try { + const filePatches = patch.split(/FILE:\s+/); + if (filePatches.length > 1) { + for (const fp of filePatches) { + if (!fp.trim()) continue; + const firstLineBreak = fp.indexOf('\n'); + if (firstLineBreak === -1) continue; + const relativePath = fp.slice(0, firstLineBreak).trim(); + const filePatchContent = fp.slice(firstLineBreak + 1); + + let targetFilePath = path.resolve(projectDir, relativePath); + if (!fs.existsSync(targetFilePath) && relativePath.startsWith('tests/')) { + const strippedPath = relativePath.replace(/^tests\//, ''); + const fallbackPath = path.resolve(projectDir, strippedPath); + if (fs.existsSync(fallbackPath)) { + targetFilePath = fallbackPath; + } + } + + if (fs.existsSync(targetFilePath)) { + const fileContent = fs.readFileSync(targetFilePath, 'utf-8'); + const patched = PxmlPatcher.applyPatch(fileContent, filePatchContent); + writer.write(targetFilePath, patched); + console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${path.relative(projectDir, targetFilePath)}.`); + } + } + } else { + const patchedCode = PxmlPatcher.applyPatch(currentCode, patch); + writer.write(node.meta.path, patchedCode); + console.log(`${colors.green(colors.bold('[FIX]'))} Applied patch successfully to ${node.meta.path}.`); + } + console.log(`${colors.cyan(colors.bold('[FIX]'))} Patch details:\n${summarizePatch(patch)}\n`); + } catch (err: any) { + console.warn(`${colors.red(colors.bold('[FIX]'))} Failed to apply patch: ${err.message}`); + // If patch application failed, we retry or escalate + continue; + } + } + + console.error(`${colors.red(colors.bold('[FIX]'))} Failed to self-heal node ${node.id} after ${maxRetries} attempts. Escalating to user.`); + return false; +} + +function summarizePatch(patch: string): string { + const filePatches = patch.split(/FILE:\s+/); + if (filePatches.length <= 1) { + const blocks = PxmlPatcher.parsePatch(patch); + if (blocks.length > 0) { + return ` → Modified file: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).`; + } + return patch.trim(); + } + + let summary = ''; + for (const fp of filePatches) { + if (!fp.trim()) continue; + const firstLineBreak = fp.indexOf('\n'); + if (firstLineBreak === -1) continue; + const relativePath = fp.slice(0, firstLineBreak).trim(); + const filePatchContent = fp.slice(firstLineBreak + 1); + + const blocks = PxmlPatcher.parsePatch(filePatchContent); + if (blocks.length > 0) { + summary += ` → ${relativePath}: replaced ${blocks[0].search.split('\n').length} line(s) with ${blocks[0].replace.split('\n').length} line(s).\n`; + } else { + summary += ` → ${relativePath}: replaced content entirely.\n`; + } + } + return summary.trim(); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index daf50cc..e73e782 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,666 +1,666 @@ -#!/usr/bin/env node -import { Command } from 'commander'; -import { PxmlParser, validateProject } from '../parser/index.js'; -import { DependencyGraph } from '../graph/index.js'; -import { PxmlManifest } from '../manifest/index.js'; -import { PxmlCache } from '../cache/index.js'; -import { PxmlCodegen } from '../codegen/index.js'; -import { PxmlRunner, getTestFilePath } from '../runner/index.js'; -import { FileWriter } from '../writer/index.js'; -import { runFixLoop } from './fix.js'; -import { execSync } from 'child_process'; -import { XMLParser } from 'fast-xml-parser'; -import * as fs from 'fs'; -import * as path from 'path'; - -const colors = { - red: (text: string) => `\x1b[31m${text}\x1b[0m`, - green: (text: string) => `\x1b[32m${text}\x1b[0m`, - yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, - blue: (text: string) => `\x1b[34m${text}\x1b[0m`, - magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, - cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, - bold: (text: string) => `\x1b[1m${text}\x1b[0m` -}; - -const program = new Command(); - -program - .name('pxml') - .description('pxml compiler and build tool') - .version('0.1.0'); - -program - .command('init') - .description('Initialize sample pxml project structure') - .action(() => { - const cwd = process.cwd(); - const configPath = path.join(cwd, 'project.xml'); - - if (fs.existsSync(configPath)) { - console.log('Project already initialized.'); - return; - } - - // Create example directory structure for flows and packages - fs.mkdirSync(path.join(cwd, 'flows'), { recursive: true }); - fs.mkdirSync(path.join(cwd, 'shared'), { recursive: true }); - fs.mkdirSync(path.join(cwd, 'packages', 'init-nextjs-project'), { recursive: true }); - - const mainXml = ` - - - - - - - - app/page.tsx - setup.nextjs - - File exports default React component - Page contains a link with href="/posts" - Replace the entire homepage with a beautifully styled landing page (clean dark theme, tailwind classes). Do not call any dashboard APIs like /api/network or /api/ram. Show a hero section, and a prominent link/button pointing to the Posts page at '/posts'. - -`; - - const initNextjsProjectXml = ` - - - package.json - - Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest - -`; - - const blogXml = ` - - - app/api/posts/route.ts - - - - - - - - - Initialize a better-sqlite3 database named 'blog.db'. Create a 'posts' table with columns id (INTEGER PRIMARY KEY), title (TEXT), and content (TEXT) if it does not exist. Implement both POST (to insert a post) and GET (to select all posts) handlers in this route file. Ensure the route is dynamic and not cached by exporting: export const dynamic = 'force-dynamic'; - - Create post successful - - - Hello World - My first post - - - - 200 - success - - - - - - - app/posts/page.tsx - api.posts.create - - - - - Create a beautifully designed blog posts manager page at app/posts/page.tsx (clean dark layout, tailwind cards, inputs, and buttons). It must fetch posts from '/api/posts' on render/mount, display them, and show a form to submit new posts via a POST request to '/api/posts'. Refresh the posts list automatically on successful submission. - -`; - - const fileUrl = new URL(import.meta.url); - const sourceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../pxml.xsd'); - if (fs.existsSync(sourceXsd)) { - fs.copyFileSync(sourceXsd, path.join(cwd, 'pxml.xsd')); - } else { - const fallbackXsd = path.resolve(cwd, 'pxml.xsd'); - if (!fs.existsSync(fallbackXsd)) { - const workspaceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../pxml.xsd'); - if (fs.existsSync(workspaceXsd)) { - fs.copyFileSync(workspaceXsd, path.join(cwd, 'pxml.xsd')); - } - } - } - - const sourceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../bugs.xsd'); - if (fs.existsSync(sourceBugsXsd)) { - fs.copyFileSync(sourceBugsXsd, path.join(cwd, 'bugs.xsd')); - } else { - const fallbackBugsXsd = path.resolve(cwd, 'bugs.xsd'); - if (!fs.existsSync(fallbackBugsXsd)) { - const workspaceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../bugs.xsd'); - if (fs.existsSync(workspaceBugsXsd)) { - fs.copyFileSync(workspaceBugsXsd, path.join(cwd, 'bugs.xsd')); - } - } - } - - const bugsHistoryXml = ` - - SQLite database file locks when executing parallel write operations. Ensure connections are closed properly or run db queries sequentially. - -`; - - fs.writeFileSync(configPath, mainXml, 'utf-8'); - fs.writeFileSync(path.join(cwd, 'flows', 'blog.xml'), blogXml, 'utf-8'); - fs.writeFileSync(path.join(cwd, 'packages', 'init-nextjs-project', 'project.xml'), initNextjsProjectXml, 'utf-8'); - fs.writeFileSync(path.join(cwd, 'bugs_history.xml'), bugsHistoryXml, 'utf-8'); - console.log('Successfully initialized Next.js project with pxml templates.'); - }); - -program - .command('compile') - .description('Compile XML nodes to implementation code') - .option('--dry-run', 'Show execution plan without writing changes') - .option('--no-autogen-tests', 'Disable automatic test case generation') - .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') - .option('--apiKey ', 'API key') - .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') - .option('--model ', 'LLM model name', 'claude-3-5-sonnet') - .action(async (options) => { - const cwd = process.cwd(); - const projectXml = path.join(cwd, 'project.xml'); - if (!fs.existsSync(projectXml)) { - console.error('project.xml not found. Run pxml init first.'); - process.exit(1); - } - - const parser = new PxmlParser(); - const project = parser.parse(projectXml); - try { - validateProject(project); - } catch (err: any) { - console.error(err.message); - process.exit(1); - } - injectHistoricalBugs(project.nodes, cwd); - const graph = new DependencyGraph(project.nodes); - const order = graph.getSortOrder(); - - const manifest = new PxmlManifest(cwd, project.name, project.version); - const writer = new FileWriter(!!options.dryRun); - - const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); - - const codegen = new PxmlCodegen({ - provider: options.provider as any, - apiKey: apiKey, - baseUrl: options.baseUrl, - model: options.model - }); - - console.log(`Compiling project ${project.name} (stack: ${project.stack})...`); - - const extendedNodeIds = new Set(); - for (const node of project.nodes) { - if (node.extends) { - extendedNodeIds.add(node.extends); - } - } - - const compiledNodeIds: string[] = []; - - for (const nodeId of order) { - if (extendedNodeIds.has(nodeId)) { - continue; - } - const node = project.nodes.find(n => n.id === nodeId)!; - const xmlHash = PxmlCache.hashNode(node); - const cached = manifest.getNode(nodeId); - - if (cached && cached.xml_hash === xmlHash) { - console.log(`${colors.yellow('[SKIP]')} Node ${nodeId} has not changed.`); - continue; - } - - if (cached && cached.locked) { - console.log(`${colors.red(colors.bold('[LOCKED]'))} Node ${nodeId} is locked. Skipping codegen.`); - continue; - } - - // Build rich project context by loading contents of already generated files - let projectContext = project.nodes.map(n => `Node: ${n.id}, Path: ${n.meta.path}`).join('\n'); - projectContext += '\n\nAlready generated files contents:\n'; - - const manifestData = manifest.get(); - for (const [mNodeId, mNode] of Object.entries(manifestData.nodes)) { - for (const filePath of mNode.output_files) { - const absPath = path.resolve(cwd, filePath); - if (fs.existsSync(absPath)) { - const content = fs.readFileSync(absPath, 'utf-8'); - projectContext += `\n--- File: ${filePath} (Node: ${mNodeId}) ---\n${content}\n`; - } - } - } - - console.log(`${colors.cyan(colors.bold('[CODEGEN]'))} Generating code for node: ${nodeId}`); - try { - const code = await codegen.generateNodeCode(node, projectContext, writer, project.stack); - - const testFilePath = getTestFilePath(node.meta.path, project.stack); - const testXmlHash = PxmlCache.hashNodeTests(node); - const cachedTestHash = (cached as any)?.test_xml_hash; - const absTestFilePath = path.resolve(cwd, testFilePath); - const shouldAutogen = options.autogenTests && node.autogenTests; - - if (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') { - if (!cached || cached.xml_hash !== xmlHash || !fs.existsSync(absTestFilePath) || cachedTestHash !== testXmlHash) { - console.log(`${colors.magenta(colors.bold('[TESTGEN]'))} Generating/Updating test file at: ${testFilePath}`); - await codegen.generateNodeTest(node, absTestFilePath, code, project.stack, writer); - } - } - - manifest.setNode(nodeId, { - node_id: nodeId, - source_file: 'project.xml', - xml_hash: xmlHash, - test_xml_hash: testXmlHash, - output_files: (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') ? [node.meta.path, testFilePath] : [node.meta.path], - depends_on: node.meta.depends_on, - flow: node.flow, - generated_at: new Date().toISOString() - } as any); - manifest.save(); - compiledNodeIds.push(nodeId); - } catch (err: any) { - console.error(`[ERROR] Failed to compile node ${nodeId}: ${err.message}`); - writer.rollback(); - process.exit(1); - } - } - - if (compiledNodeIds.length > 0) { - console.log(colors.cyan(colors.bold('\nRunning tests for compiled nodes...'))); - const runner = new PxmlRunner(cwd, writer); - let allPassed = true; - for (const nodeId of compiledNodeIds) { - const node = project.nodes.find(n => n.id === nodeId)!; - if (node.type === 'setup-command' || node.type === 'config-file') { - continue; - } - console.log(`${colors.blue(colors.bold('[TEST]'))} Running tests for node: ${node.id}`); - const res = runner.runNodeTests(node, project.stack); - - const existing = manifest.getNode(node.id); - if (existing) { - manifest.setNode(node.id, { - ...existing, - last_test_run: res.results - }); - manifest.save(); - } - - if (!res.passed) { - console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed tests. Triggering self-healing...`); - - let bugContext = ''; - const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); - if (fs.existsSync(bugsHistoryPath)) { - try { - const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); - const optionsXml = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: true, - parseAttributeValue: true, - }; - const fastXml = new XMLParser(optionsXml); - const parsed = fastXml.parse(historyXml); - if (parsed.bugs && parsed.bugs.bug) { - const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; - let historyText = '\n--- Historical Bug Prevention Checklist ---\n'; - for (const bug of rawBugs) { - const flowAttr = bug['@_flow'] || 'general'; - const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); - historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; - } - bugContext = historyText; - } - } catch (err: any) {} - } - - const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, false, project.stack); - if (!success) { - allPassed = false; - console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed to self-heal.`); - } else { - console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} healed successfully.`); - } - } else { - console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} tests passed.`); - } - } - - if (!allPassed) { - console.error(colors.red(colors.bold('\n[ERROR] Some compiled nodes failed tests and could not self-heal.'))); - process.exit(1); - } - } - - const stats = codegen.getStats(); - if (stats.inputTokens > 0 || stats.outputTokens > 0) { - const cost = calculateEstimatedCost(options.model, stats); - console.log(`\nToken Usage & Cost Summary:`); - console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); - console.log(`- Output Tokens: ${stats.outputTokens}`); - console.log(`- Estimated Cost: $${cost.toFixed(4)}`); - } - - console.log('Compilation finished successfully.'); - }); - -program - .command('validate') - .description('Validate XML files against schema and rules') - .action(() => { - const cwd = process.cwd(); - const projectXml = path.join(cwd, 'project.xml'); - if (!fs.existsSync(projectXml)) { - console.error('project.xml not found. Run pxml init first.'); - process.exit(1); - } - - try { - const parser = new PxmlParser(); - const project = parser.parse(projectXml); - validateProject(project); - console.log('Project validation successful.'); - } catch (err: any) { - console.error(err.message); - process.exit(1); - } - }); - -program - .command('test') - .description('Run Vitest test suite on all compiled nodes') - .action(() => { - const cwd = process.cwd(); - const projectXml = path.join(cwd, 'project.xml'); - if (!fs.existsSync(projectXml)) { - console.error('project.xml not found. Run pxml init first.'); - process.exit(1); - } - - const parser = new PxmlParser(); - const project = parser.parse(projectXml); - const manifest = new PxmlManifest(cwd, project.name, project.version); - const writer = new FileWriter(); - const runner = new PxmlRunner(cwd, writer); - - let allPassed = true; - for (const node of project.nodes) { - console.log(`[TEST] Running tests for node: ${node.id}`); - const res = runner.runNodeTests(node, project.stack); - - const existing = manifest.getNode(node.id); - if (existing) { - manifest.setNode(node.id, { - ...existing, - last_test_run: res.results - }); - manifest.save(); - } - - if (!res.passed) { - allPassed = false; - console.log(`[FAIL] Node ${node.id} failed tests.`); - } else { - console.log(`[PASS] Node ${node.id} tests passed.`); - } - } - - if (allPassed) { - console.log('All tests passed successfully.'); - } else { - process.exit(1); - } - }); - -program - .command('diagnose') - .description('Diagnose logs and locate nodes with issues') - .option('--log ', 'Path to log file to parse') - .action((options) => { - const cwd = process.cwd(); - const logFilePath = options.log ? path.resolve(options.log) : null; - if (!logFilePath || !fs.existsSync(logFilePath)) { - console.error('Log file not found or not specified. Usage: pxml diagnose --log '); - process.exit(1); - } - - const logContent = fs.readFileSync(logFilePath, 'utf-8'); - const logs = logContent.split('\n').map(line => { - try { - return JSON.parse(line); - } catch (err) { - return { message: line }; - } - }); - - const PxmlDiagnostics = require('../diagnostics/index.js').PxmlDiagnostics; - console.log('Running diagnostics on logs...'); - - for (const log of logs) { - const diagnosis = PxmlDiagnostics.diagnoseHeuristic(log); - if (diagnosis) { - console.log(`[DIAGNOSIS] Suspected issue in flow: "${diagnosis.flow}" (Node type: "${diagnosis.suspectedType}")`); - console.log(` Reason: "${log.message}"`); - } - } - }); - -program - .command('fix') - .description('Invoke self-healing loop for failed nodes') - .option('--flow ', 'Fix specific flow') - .option('--node ', 'Fix specific node') - .option('--bug ', 'Path to raw error log or custom description text to aid the fix loop') - .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') - .option('--apiKey ', 'API key') - .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') - .option('--model ', 'LLM model name', 'claude-3-5-sonnet') - .action(async (options) => { - const cwd = process.cwd(); - const projectXml = path.join(cwd, 'project.xml'); - if (!fs.existsSync(projectXml)) { - console.error('project.xml not found. Run pxml init first.'); - process.exit(1); - } - - const parser = new PxmlParser(); - const project = parser.parse(projectXml); - const manifest = new PxmlManifest(cwd, project.name, project.version); - const writer = new FileWriter(); - const runner = new PxmlRunner(cwd, writer); - - const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); - - const codegen = new PxmlCodegen({ - provider: options.provider as any, - apiKey: apiKey, - baseUrl: options.baseUrl, - model: options.model - }); - - let targetNodes = project.nodes; - if (options.node) { - targetNodes = targetNodes.filter(n => n.id === options.node); - } else if (options.flow) { - targetNodes = targetNodes.filter(n => n.flow === options.flow); - } - - let bugContext = ''; - if (options.bug) { - if (fs.existsSync(options.bug)) { - bugContext = fs.readFileSync(options.bug, 'utf-8'); - } else { - bugContext = options.bug; - } - } - - // Load bugs_history.xml if it exists to add regression prevention checklist - const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); - if (fs.existsSync(bugsHistoryPath)) { - try { - const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); - const optionsXml = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: true, - parseAttributeValue: true, - }; - const fastXml = new XMLParser(optionsXml); - const parsed = fastXml.parse(historyXml); - if (parsed.bugs && parsed.bugs.bug) { - const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; - let historyText = '\n--- Historical Bug Prevention Checklist (Ensure these bugs do not exist in the code) ---\n'; - for (const bug of rawBugs) { - const flowAttr = bug['@_flow'] || 'general'; - const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); - historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; - } - bugContext = bugContext ? `${bugContext}\n${historyText}` : historyText; - } - } catch (err: any) { - console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); - } - } - - for (const node of targetNodes) { - // Check if node is failing tests, or force fix if a custom bug context is provided - console.log(`[FIX] Verifying node: ${node.id}`); - const testRes = runner.runNodeTests(node, project.stack); - if (!testRes.passed || bugContext) { - // If bugContext is provided, force run at least once by passing a flag or bypassing check - const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, !!bugContext, project.stack); - if (success) { - console.log(`[FIX] Node ${node.id} healed successfully.`); - } else { - console.error(`[FIX] Could not self-heal node ${node.id}.`); - } - } else { - console.log(`[FIX] Node ${node.id} is healthy.`); - } - } - - const stats = codegen.getStats(); - if (stats.inputTokens > 0 || stats.outputTokens > 0) { - const cost = calculateEstimatedCost(options.model, stats); - console.log(`\nToken Usage & Cost Summary (Fix Loop):`); - console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); - console.log(`- Output Tokens: ${stats.outputTokens}`); - console.log(`- Estimated Cost: $${cost.toFixed(4)}`); - } - }); - -program - .command('doctor') - .description('Diagnostics checklist (configurations, env keys, databases)') - .action(() => { - console.log('--- Doctor Check ---'); - console.log(`[x] Node version: ${process.version}`); - - const projectXml = path.join(process.cwd(), 'project.xml'); - if (fs.existsSync(projectXml)) { - console.log('[x] project.xml exists'); - } else { - console.log('[ ] project.xml is missing'); - } - - if (process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY) { - console.log('[x] AI API Key (ANTHROPIC_API_KEY or OPENAI_API_KEY) is configured'); - } else { - console.log('[ ] AI API Key is missing (needed for pxml compile/fix)'); - } - }); - -function injectHistoricalBugs(nodes: any[], cwd: string) { - const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); - if (!fs.existsSync(bugsHistoryPath)) return; - - try { - const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); - const optionsXml = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: true, - parseAttributeValue: true, - }; - const fastXml = new XMLParser(optionsXml); - const parsed = fastXml.parse(historyXml); - if (!parsed.bugs || !parsed.bugs.bug) return; - - const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; - for (const node of nodes) { - for (const bug of rawBugs) { - const bugId = bug['@_id']; - const bugFlow = bug['@_flow'] || 'general'; - const bugDesc = (typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug)).trim(); - - const matchesFlow = bugFlow !== 'general' && (node.flow === bugFlow || node.id.includes(bugFlow)); - const hasExplicitLearned = node.constraints.some((c: any) => c.learnedFrom === bugId); - - if (matchesFlow || hasExplicitLearned) { - const alreadyExists = node.constraints.some((c: any) => c.learnedFrom === bugId || c.description.includes(bugDesc)); - if (!alreadyExists) { - node.constraints.push({ - verify: 'static', - description: `Prevent regression of bug ${bugId}: ${bugDesc}`, - learnedFrom: bugId - }); - } - } - } - } - } catch (err: any) { - console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); - } -} - -function calculateEstimatedCost(model: string, stats: { inputTokens: number; outputTokens: number; cachedTokens: number }): number { - const modelLower = model.toLowerCase(); - let inputRate = 0.000003; // Default input rate per token ($3/M) - let outputRate = 0.000015; // Default output rate per token ($15/M) - let cacheReadRate = 0.0000003; // Default cache read rate ($0.30/M) - - if (modelLower.includes('gpt-4o-mini')) { - inputRate = 0.00000015; // $0.15/M - outputRate = 0.0000006; // $0.60/M - cacheReadRate = 0.000000075; - } else if (modelLower.includes('gpt-4o')) { - inputRate = 0.000005; // $5/M - outputRate = 0.000015; // $15/M - cacheReadRate = 0.0000025; - } else if (modelLower.includes('haiku')) { - inputRate = 0.00000025; // $0.25/M - outputRate = 0.00000125; // $1.25/M - cacheReadRate = 0.00000003; - } else if (modelLower.includes('sonnet')) { - inputRate = 0.000003; // $3/M - outputRate = 0.000015; // $15/M - cacheReadRate = 0.0000003; // $0.30/M - } else if (modelLower.includes('opus')) { - inputRate = 0.000015; // $15/M - outputRate = 0.000075; // $75/M - cacheReadRate = 0.0000015; - } - - const normalInput = Math.max(0, stats.inputTokens - stats.cachedTokens); - const cost = (normalInput * inputRate) + (stats.cachedTokens * cacheReadRate) + (stats.outputTokens * outputRate); - return cost; -} - -program.parse(process.argv); +#!/usr/bin/env node +import { Command } from 'commander'; +import { PxmlParser, validateProject } from '../parser/index.js'; +import { DependencyGraph } from '../graph/index.js'; +import { PxmlManifest } from '../manifest/index.js'; +import { PxmlCache } from '../cache/index.js'; +import { PxmlCodegen } from '../codegen/index.js'; +import { PxmlRunner, getTestFilePath } from '../runner/index.js'; +import { FileWriter } from '../writer/index.js'; +import { runFixLoop } from './fix.js'; +import { execSync } from 'child_process'; +import { XMLParser } from 'fast-xml-parser'; +import * as fs from 'fs'; +import * as path from 'path'; + +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + green: (text: string) => `\x1b[32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[34m${text}\x1b[0m`, + magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + bold: (text: string) => `\x1b[1m${text}\x1b[0m` +}; + +const program = new Command(); + +program + .name('pxml') + .description('pxml compiler and build tool') + .version('0.1.0'); + +program + .command('init') + .description('Initialize sample pxml project structure') + .action(() => { + const cwd = process.cwd(); + const configPath = path.join(cwd, 'project.xml'); + + if (fs.existsSync(configPath)) { + console.log('Project already initialized.'); + return; + } + + // Create example directory structure for flows and packages + fs.mkdirSync(path.join(cwd, 'flows'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'shared'), { recursive: true }); + fs.mkdirSync(path.join(cwd, 'packages', 'init-nextjs-project'), { recursive: true }); + + const mainXml = ` + + + + + + + + app/page.tsx + setup.nextjs + + File exports default React component + Page contains a link with href="/posts" + Replace the entire homepage with a beautifully styled landing page (clean dark theme, tailwind classes). Do not call any dashboard APIs like /api/network or /api/ram. Show a hero section, and a prominent link/button pointing to the Posts page at '/posts'. + +`; + + const initNextjsProjectXml = ` + + + package.json + + Initialize Next.js app in the current directory non-interactively. Run: npx create-next-app@latest . --typescript --eslint --tailwind --app --no-src-dir --import-alias "@/*" --use-npm --yes && npm install better-sqlite3 && npm install --save-dev @types/better-sqlite3 @testing-library/react @testing-library/jest-dom jsdom vitest + +`; + + const blogXml = ` + + + app/api/posts/route.ts + + + + + + + + + Initialize a better-sqlite3 database named 'blog.db'. Create a 'posts' table with columns id (INTEGER PRIMARY KEY), title (TEXT), and content (TEXT) if it does not exist. Implement both POST (to insert a post) and GET (to select all posts) handlers in this route file. Ensure the route is dynamic and not cached by exporting: export const dynamic = 'force-dynamic'; + + Create post successful + + + Hello World + My first post + + + + 200 + success + + + + + + + app/posts/page.tsx + api.posts.create + + + + + Create a beautifully designed blog posts manager page at app/posts/page.tsx (clean dark layout, tailwind cards, inputs, and buttons). It must fetch posts from '/api/posts' on render/mount, display them, and show a form to submit new posts via a POST request to '/api/posts'. Refresh the posts list automatically on successful submission. + +`; + + const fileUrl = new URL(import.meta.url); + const sourceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../pxml.xsd'); + if (fs.existsSync(sourceXsd)) { + fs.copyFileSync(sourceXsd, path.join(cwd, 'pxml.xsd')); + } else { + const fallbackXsd = path.resolve(cwd, 'pxml.xsd'); + if (!fs.existsSync(fallbackXsd)) { + const workspaceXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../pxml.xsd'); + if (fs.existsSync(workspaceXsd)) { + fs.copyFileSync(workspaceXsd, path.join(cwd, 'pxml.xsd')); + } + } + } + + const sourceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../bugs.xsd'); + if (fs.existsSync(sourceBugsXsd)) { + fs.copyFileSync(sourceBugsXsd, path.join(cwd, 'bugs.xsd')); + } else { + const fallbackBugsXsd = path.resolve(cwd, 'bugs.xsd'); + if (!fs.existsSync(fallbackBugsXsd)) { + const workspaceBugsXsd = path.resolve(path.dirname(fileUrl.pathname), '../../../bugs.xsd'); + if (fs.existsSync(workspaceBugsXsd)) { + fs.copyFileSync(workspaceBugsXsd, path.join(cwd, 'bugs.xsd')); + } + } + } + + const bugsHistoryXml = ` + + SQLite database file locks when executing parallel write operations. Ensure connections are closed properly or run db queries sequentially. + +`; + + fs.writeFileSync(configPath, mainXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'flows', 'blog.xml'), blogXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'packages', 'init-nextjs-project', 'project.xml'), initNextjsProjectXml, 'utf-8'); + fs.writeFileSync(path.join(cwd, 'bugs_history.xml'), bugsHistoryXml, 'utf-8'); + console.log('Successfully initialized Next.js project with pxml templates.'); + }); + +program + .command('compile') + .description('Compile XML nodes to implementation code') + .option('--dry-run', 'Show execution plan without writing changes') + .option('--no-autogen-tests', 'Disable automatic test case generation') + .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') + .option('--apiKey ', 'API key') + .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') + .option('--model ', 'LLM model name', 'claude-3-5-sonnet') + .action(async (options) => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + try { + validateProject(project); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + injectHistoricalBugs(project.nodes, cwd); + const graph = new DependencyGraph(project.nodes); + const order = graph.getSortOrder(); + + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(!!options.dryRun); + + const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); + + const codegen = new PxmlCodegen({ + provider: options.provider as any, + apiKey: apiKey, + baseUrl: options.baseUrl, + model: options.model + }); + + console.log(`Compiling project ${project.name} (stack: ${project.stack})...`); + + const extendedNodeIds = new Set(); + for (const node of project.nodes) { + if (node.extends) { + extendedNodeIds.add(node.extends); + } + } + + const compiledNodeIds: string[] = []; + + for (const nodeId of order) { + if (extendedNodeIds.has(nodeId)) { + continue; + } + const node = project.nodes.find(n => n.id === nodeId)!; + const xmlHash = PxmlCache.hashNode(node); + const cached = manifest.getNode(nodeId); + + if (cached && cached.xml_hash === xmlHash) { + console.log(`${colors.yellow('[SKIP]')} Node ${nodeId} has not changed.`); + continue; + } + + if (cached && cached.locked) { + console.log(`${colors.red(colors.bold('[LOCKED]'))} Node ${nodeId} is locked. Skipping codegen.`); + continue; + } + + // Build rich project context by loading contents of already generated files + let projectContext = project.nodes.map(n => `Node: ${n.id}, Path: ${n.meta.path}`).join('\n'); + projectContext += '\n\nAlready generated files contents:\n'; + + const manifestData = manifest.get(); + for (const [mNodeId, mNode] of Object.entries(manifestData.nodes)) { + for (const filePath of mNode.output_files) { + const absPath = path.resolve(cwd, filePath); + if (fs.existsSync(absPath)) { + const content = fs.readFileSync(absPath, 'utf-8'); + projectContext += `\n--- File: ${filePath} (Node: ${mNodeId}) ---\n${content}\n`; + } + } + } + + console.log(`${colors.cyan(colors.bold('[CODEGEN]'))} Generating code for node: ${nodeId}`); + try { + const code = await codegen.generateNodeCode(node, projectContext, writer, project.stack); + + const testFilePath = getTestFilePath(node.meta.path, project.stack); + const testXmlHash = PxmlCache.hashNodeTests(node); + const cachedTestHash = (cached as any)?.test_xml_hash; + const absTestFilePath = path.resolve(cwd, testFilePath); + const shouldAutogen = options.autogenTests && node.autogenTests; + + if (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') { + if (!cached || cached.xml_hash !== xmlHash || !fs.existsSync(absTestFilePath) || cachedTestHash !== testXmlHash) { + console.log(`${colors.magenta(colors.bold('[TESTGEN]'))} Generating/Updating test file at: ${testFilePath}`); + await codegen.generateNodeTest(node, absTestFilePath, code, project.stack, writer); + } + } + + manifest.setNode(nodeId, { + node_id: nodeId, + source_file: 'project.xml', + xml_hash: xmlHash, + test_xml_hash: testXmlHash, + output_files: (shouldAutogen && node.type !== 'setup-command' && node.type !== 'config-file') ? [node.meta.path, testFilePath] : [node.meta.path], + depends_on: node.meta.depends_on, + flow: node.flow, + generated_at: new Date().toISOString() + } as any); + manifest.save(); + compiledNodeIds.push(nodeId); + } catch (err: any) { + console.error(`[ERROR] Failed to compile node ${nodeId}: ${err.message}`); + writer.rollback(); + process.exit(1); + } + } + + if (compiledNodeIds.length > 0) { + console.log(colors.cyan(colors.bold('\nRunning tests for compiled nodes...'))); + const runner = new PxmlRunner(cwd, writer); + let allPassed = true; + for (const nodeId of compiledNodeIds) { + const node = project.nodes.find(n => n.id === nodeId)!; + if (node.type === 'setup-command' || node.type === 'config-file') { + continue; + } + console.log(`${colors.blue(colors.bold('[TEST]'))} Running tests for node: ${node.id}`); + const res = runner.runNodeTests(node, project.stack); + + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: res.results + }); + manifest.save(); + } + + if (!res.passed) { + console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed tests. Triggering self-healing...`); + + let bugContext = ''; + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (fs.existsSync(bugsHistoryPath)) { + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (parsed.bugs && parsed.bugs.bug) { + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + let historyText = '\n--- Historical Bug Prevention Checklist ---\n'; + for (const bug of rawBugs) { + const flowAttr = bug['@_flow'] || 'general'; + const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); + historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; + } + bugContext = historyText; + } + } catch (err: any) {} + } + + const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, false, project.stack); + if (!success) { + allPassed = false; + console.log(`${colors.red(colors.bold('[FAIL]'))} Node ${node.id} failed to self-heal.`); + } else { + console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} healed successfully.`); + } + } else { + console.log(`${colors.green(colors.bold('[PASS]'))} Node ${node.id} tests passed.`); + } + } + + if (!allPassed) { + console.error(colors.red(colors.bold('\n[ERROR] Some compiled nodes failed tests and could not self-heal.'))); + process.exit(1); + } + } + + const stats = codegen.getStats(); + if (stats.inputTokens > 0 || stats.outputTokens > 0) { + const cost = calculateEstimatedCost(options.model, stats); + console.log(`\nToken Usage & Cost Summary:`); + console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); + console.log(`- Output Tokens: ${stats.outputTokens}`); + console.log(`- Estimated Cost: $${cost.toFixed(4)}`); + } + + console.log('Compilation finished successfully.'); + }); + +program + .command('validate') + .description('Validate XML files against schema and rules') + .action(() => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + + try { + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + validateProject(project); + console.log('Project validation successful.'); + } catch (err: any) { + console.error(err.message); + process.exit(1); + } + }); + +program + .command('test') + .description('Run Vitest test suite on all compiled nodes') + .action(() => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(); + const runner = new PxmlRunner(cwd, writer); + + let allPassed = true; + for (const node of project.nodes) { + console.log(`[TEST] Running tests for node: ${node.id}`); + const res = runner.runNodeTests(node, project.stack); + + const existing = manifest.getNode(node.id); + if (existing) { + manifest.setNode(node.id, { + ...existing, + last_test_run: res.results + }); + manifest.save(); + } + + if (!res.passed) { + allPassed = false; + console.log(`[FAIL] Node ${node.id} failed tests.`); + } else { + console.log(`[PASS] Node ${node.id} tests passed.`); + } + } + + if (allPassed) { + console.log('All tests passed successfully.'); + } else { + process.exit(1); + } + }); + +program + .command('diagnose') + .description('Diagnose logs and locate nodes with issues') + .option('--log ', 'Path to log file to parse') + .action((options) => { + const cwd = process.cwd(); + const logFilePath = options.log ? path.resolve(options.log) : null; + if (!logFilePath || !fs.existsSync(logFilePath)) { + console.error('Log file not found or not specified. Usage: pxml diagnose --log '); + process.exit(1); + } + + const logContent = fs.readFileSync(logFilePath, 'utf-8'); + const logs = logContent.split('\n').map(line => { + try { + return JSON.parse(line); + } catch (err) { + return { message: line }; + } + }); + + const PxmlDiagnostics = require('../diagnostics/index.js').PxmlDiagnostics; + console.log('Running diagnostics on logs...'); + + for (const log of logs) { + const diagnosis = PxmlDiagnostics.diagnoseHeuristic(log); + if (diagnosis) { + console.log(`[DIAGNOSIS] Suspected issue in flow: "${diagnosis.flow}" (Node type: "${diagnosis.suspectedType}")`); + console.log(` Reason: "${log.message}"`); + } + } + }); + +program + .command('fix') + .description('Invoke self-healing loop for failed nodes') + .option('--flow ', 'Fix specific flow') + .option('--node ', 'Fix specific node') + .option('--bug ', 'Path to raw error log or custom description text to aid the fix loop') + .option('--provider ', 'AI Provider (anthropic, openai, or ollama)', 'anthropic') + .option('--apiKey ', 'API key') + .option('--baseUrl ', 'Base API URL for OpenAI compatible provider') + .option('--model ', 'LLM model name', 'claude-3-5-sonnet') + .action(async (options) => { + const cwd = process.cwd(); + const projectXml = path.join(cwd, 'project.xml'); + if (!fs.existsSync(projectXml)) { + console.error('project.xml not found. Run pxml init first.'); + process.exit(1); + } + + const parser = new PxmlParser(); + const project = parser.parse(projectXml); + const manifest = new PxmlManifest(cwd, project.name, project.version); + const writer = new FileWriter(); + const runner = new PxmlRunner(cwd, writer); + + const apiKey = options.apiKey || (options.provider === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY); + + const codegen = new PxmlCodegen({ + provider: options.provider as any, + apiKey: apiKey, + baseUrl: options.baseUrl, + model: options.model + }); + + let targetNodes = project.nodes; + if (options.node) { + targetNodes = targetNodes.filter(n => n.id === options.node); + } else if (options.flow) { + targetNodes = targetNodes.filter(n => n.flow === options.flow); + } + + let bugContext = ''; + if (options.bug) { + if (fs.existsSync(options.bug)) { + bugContext = fs.readFileSync(options.bug, 'utf-8'); + } else { + bugContext = options.bug; + } + } + + // Load bugs_history.xml if it exists to add regression prevention checklist + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (fs.existsSync(bugsHistoryPath)) { + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (parsed.bugs && parsed.bugs.bug) { + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + let historyText = '\n--- Historical Bug Prevention Checklist (Ensure these bugs do not exist in the code) ---\n'; + for (const bug of rawBugs) { + const flowAttr = bug['@_flow'] || 'general'; + const desc = typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug); + historyText += `- [Flow: ${flowAttr}] Bug ID ${bug['@_id']}: ${desc.trim()}\n`; + } + bugContext = bugContext ? `${bugContext}\n${historyText}` : historyText; + } + } catch (err: any) { + console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); + } + } + + for (const node of targetNodes) { + // Check if node is failing tests, or force fix if a custom bug context is provided + console.log(`[FIX] Verifying node: ${node.id}`); + const testRes = runner.runNodeTests(node, project.stack); + if (!testRes.passed || bugContext) { + // If bugContext is provided, force run at least once by passing a flag or bypassing check + const success = await runFixLoop(node, cwd, manifest, codegen, runner, writer, undefined, bugContext, !!bugContext, project.stack); + if (success) { + console.log(`[FIX] Node ${node.id} healed successfully.`); + } else { + console.error(`[FIX] Could not self-heal node ${node.id}.`); + } + } else { + console.log(`[FIX] Node ${node.id} is healthy.`); + } + } + + const stats = codegen.getStats(); + if (stats.inputTokens > 0 || stats.outputTokens > 0) { + const cost = calculateEstimatedCost(options.model, stats); + console.log(`\nToken Usage & Cost Summary (Fix Loop):`); + console.log(`- Input Tokens: ${stats.inputTokens} (Cached: ${stats.cachedTokens})`); + console.log(`- Output Tokens: ${stats.outputTokens}`); + console.log(`- Estimated Cost: $${cost.toFixed(4)}`); + } + }); + +program + .command('doctor') + .description('Diagnostics checklist (configurations, env keys, databases)') + .action(() => { + console.log('--- Doctor Check ---'); + console.log(`[x] Node version: ${process.version}`); + + const projectXml = path.join(process.cwd(), 'project.xml'); + if (fs.existsSync(projectXml)) { + console.log('[x] project.xml exists'); + } else { + console.log('[ ] project.xml is missing'); + } + + if (process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY) { + console.log('[x] AI API Key (ANTHROPIC_API_KEY or OPENAI_API_KEY) is configured'); + } else { + console.log('[ ] AI API Key is missing (needed for pxml compile/fix)'); + } + }); + +function injectHistoricalBugs(nodes: any[], cwd: string) { + const bugsHistoryPath = path.join(cwd, 'bugs_history.xml'); + if (!fs.existsSync(bugsHistoryPath)) return; + + try { + const historyXml = fs.readFileSync(bugsHistoryPath, 'utf-8'); + const optionsXml = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const fastXml = new XMLParser(optionsXml); + const parsed = fastXml.parse(historyXml); + if (!parsed.bugs || !parsed.bugs.bug) return; + + const rawBugs = Array.isArray(parsed.bugs.bug) ? parsed.bugs.bug : [parsed.bugs.bug]; + for (const node of nodes) { + for (const bug of rawBugs) { + const bugId = bug['@_id']; + const bugFlow = bug['@_flow'] || 'general'; + const bugDesc = (typeof bug === 'object' ? bug['#text'] || bug.description || '' : String(bug)).trim(); + + const matchesFlow = bugFlow !== 'general' && (node.flow === bugFlow || node.id.includes(bugFlow)); + const hasExplicitLearned = node.constraints.some((c: any) => c.learnedFrom === bugId); + + if (matchesFlow || hasExplicitLearned) { + const alreadyExists = node.constraints.some((c: any) => c.learnedFrom === bugId || c.description.includes(bugDesc)); + if (!alreadyExists) { + node.constraints.push({ + verify: 'static', + description: `Prevent regression of bug ${bugId}: ${bugDesc}`, + learnedFrom: bugId + }); + } + } + } + } + } catch (err: any) { + console.warn(`[WARNING] Failed to parse bugs_history.xml: ${err.message}`); + } +} + +function calculateEstimatedCost(model: string, stats: { inputTokens: number; outputTokens: number; cachedTokens: number }): number { + const modelLower = model.toLowerCase(); + let inputRate = 0.000003; // Default input rate per token ($3/M) + let outputRate = 0.000015; // Default output rate per token ($15/M) + let cacheReadRate = 0.0000003; // Default cache read rate ($0.30/M) + + if (modelLower.includes('gpt-4o-mini')) { + inputRate = 0.00000015; // $0.15/M + outputRate = 0.0000006; // $0.60/M + cacheReadRate = 0.000000075; + } else if (modelLower.includes('gpt-4o')) { + inputRate = 0.000005; // $5/M + outputRate = 0.000015; // $15/M + cacheReadRate = 0.0000025; + } else if (modelLower.includes('haiku')) { + inputRate = 0.00000025; // $0.25/M + outputRate = 0.00000125; // $1.25/M + cacheReadRate = 0.00000003; + } else if (modelLower.includes('sonnet')) { + inputRate = 0.000003; // $3/M + outputRate = 0.000015; // $15/M + cacheReadRate = 0.0000003; // $0.30/M + } else if (modelLower.includes('opus')) { + inputRate = 0.000015; // $15/M + outputRate = 0.000075; // $75/M + cacheReadRate = 0.0000015; + } + + const normalInput = Math.max(0, stats.inputTokens - stats.cachedTokens); + const cost = (normalInput * inputRate) + (stats.cachedTokens * cacheReadRate) + (stats.outputTokens * outputRate); + return cost; +} + +program.parse(process.argv); diff --git a/src/codegen.test.ts b/src/codegen.test.ts index 87fcc9b..73bb2c2 100644 --- a/src/codegen.test.ts +++ b/src/codegen.test.ts @@ -1,81 +1,81 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { PxmlCodegen } from '../src/codegen/index.ts'; -import { FileWriter } from '../src/writer/index.ts'; -import { Node } from '../src/parser/schema.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -const TMP_DIR = '/tmp/pxml-test-codegen'; - -describe('PxmlCodegen & FileWriter', () => { - beforeEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - afterEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - const mockNode: Node = { - id: 'api.posts.create', - type: 'api-route', - flow: 'blog.write', - meta: { - path: path.join(TMP_DIR, 'app/api/posts/route.ts'), - depends_on: [] - }, - input: [], - output: [], - constraints: [], - tests: [] - }; - - it('should write mock generated code and write files', async () => { - const writer = new FileWriter(); - const codegen = new PxmlCodegen({ - model: 'claude-3-5-sonnet', - mockResponse: (node) => `// generated code for ${node.id}` - }); - - const code = await codegen.generateNodeCode(mockNode, 'Context Info', writer); - expect(code).toBe('// generated code for api.posts.create'); - expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toBe(code); - }); - - it('should support dry-run without writing files', async () => { - const writer = new FileWriter(true); // dryRun = true - const codegen = new PxmlCodegen({ - model: 'claude-3-5-sonnet', - mockResponse: (node) => `// generated code for ${node.id}` - }); - - await codegen.generateNodeCode(mockNode, 'Context Info', writer); - expect(fs.existsSync(mockNode.meta.path)).toBe(false); - }); - - it('should support rollback to original content', async () => { - const writer = new FileWriter(); - const testFile = path.join(TMP_DIR, 'test-rollback.txt'); - fs.mkdirSync(path.dirname(testFile), { recursive: true }); - fs.writeFileSync(testFile, 'original content', 'utf-8'); - - writer.write(testFile, 'new content'); - expect(fs.readFileSync(testFile, 'utf-8')).toBe('new content'); - - writer.rollback(); - expect(fs.readFileSync(testFile, 'utf-8')).toBe('original content'); - }); - - it('should clean trailing annotation comments successfully', () => { - const codegen = new PxmlCodegen({ - model: 'claude-3-5-sonnet', - mockResponse: () => 'const a = 1;\n\n// skipped: database setup, add when schema is defined.' - }); - const cleaned = (codegen as any).cleanMarkdown('const a = 1;\n\n→ skipped: database setup, add when schema is defined.'); - expect(cleaned).toBe('const a = 1;'); - }); -}); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { PxmlCodegen } from '../src/codegen/index.ts'; +import { FileWriter } from '../src/writer/index.ts'; +import { Node } from '../src/parser/schema.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TMP_DIR = '/tmp/pxml-test-codegen'; + +describe('PxmlCodegen & FileWriter', () => { + beforeEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + const mockNode: Node = { + id: 'api.posts.create', + type: 'api-route', + flow: 'blog.write', + meta: { + path: path.join(TMP_DIR, 'app/api/posts/route.ts'), + depends_on: [] + }, + input: [], + output: [], + constraints: [], + tests: [] + }; + + it('should write mock generated code and write files', async () => { + const writer = new FileWriter(); + const codegen = new PxmlCodegen({ + model: 'claude-3-5-sonnet', + mockResponse: (node) => `// generated code for ${node.id}` + }); + + const code = await codegen.generateNodeCode(mockNode, 'Context Info', writer); + expect(code).toBe('// generated code for api.posts.create'); + expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toBe(code); + }); + + it('should support dry-run without writing files', async () => { + const writer = new FileWriter(true); // dryRun = true + const codegen = new PxmlCodegen({ + model: 'claude-3-5-sonnet', + mockResponse: (node) => `// generated code for ${node.id}` + }); + + await codegen.generateNodeCode(mockNode, 'Context Info', writer); + expect(fs.existsSync(mockNode.meta.path)).toBe(false); + }); + + it('should support rollback to original content', async () => { + const writer = new FileWriter(); + const testFile = path.join(TMP_DIR, 'test-rollback.txt'); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(testFile, 'original content', 'utf-8'); + + writer.write(testFile, 'new content'); + expect(fs.readFileSync(testFile, 'utf-8')).toBe('new content'); + + writer.rollback(); + expect(fs.readFileSync(testFile, 'utf-8')).toBe('original content'); + }); + + it('should clean trailing annotation comments successfully', () => { + const codegen = new PxmlCodegen({ + model: 'claude-3-5-sonnet', + mockResponse: () => 'const a = 1;\n\n// skipped: database setup, add when schema is defined.' + }); + const cleaned = (codegen as any).cleanMarkdown('const a = 1;\n\n→ skipped: database setup, add when schema is defined.'); + expect(cleaned).toBe('const a = 1;'); + }); +}); diff --git a/src/codegen/index.ts b/src/codegen/index.ts index 0ae02c9..0afeac2 100644 --- a/src/codegen/index.ts +++ b/src/codegen/index.ts @@ -1,506 +1,506 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { Node } from '../parser/schema.js'; -import { FileWriter } from '../writer/index.js'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -const colors = { - red: (text: string) => `\x1b[31m${text}\x1b[0m`, - green: (text: string) => `\x1b[32m${text}\x1b[0m`, - yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, - blue: (text: string) => `\x1b[34m${text}\x1b[0m`, - magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, - cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, - bold: (text: string) => `\x1b[1m${text}\x1b[0m` -}; - -function getStackInstructions(stack: string): { systemPrompt: string; promptNote: string } { - const stackLower = stack.toLowerCase(); - if (stackLower.includes('python')) { - return { - systemPrompt: `You are an expert Python developer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`python) or explanations. Only output code. -CRITICAL: Use idiomatic Python code, follow PEP 8 styling, and make sure dependencies are imported correctly.`, - promptNote: `Stack: Python. Use standard Python practices, requirements, and imports.` - }; - } else if (stackLower.includes('rust')) { - return { - systemPrompt: `You are an expert Rust developer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`rust) or explanations. Only output code. -CRITICAL: Write clean Rust code, manage lifetimes and ownership correctly, and follow Rust idioms.`, - promptNote: `Stack: Rust. Use standard Rust syntax and crate references.` - }; - } else if (stackLower.includes('go') || stackLower === 'golang') { - return { - systemPrompt: `You are an expert Go developer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`go) or explanations. Only output code. -CRITICAL: Write idiomatic Go code, ensure correct package declaration, and format using gofmt standards.`, - promptNote: `Stack: Go. Use standard Go packaging and syntax.` - }; - } else if (stackLower.includes('c#') || stackLower === 'csharp' || stackLower.includes('dotnet')) { - return { - systemPrompt: `You are an expert C# developer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`csharp) or explanations. Only output code. -CRITICAL: Use modern C# syntax, follow standard .NET conventions, and declare namespace/imports correctly.`, - promptNote: `Stack: C# / .NET. Use standard .NET namespace and architecture.` - }; - } else if (stackLower.includes('c++') || stackLower === 'cpp') { - return { - systemPrompt: `You are an expert C++ developer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`cpp) or explanations. Only output code. -CRITICAL: Use modern C++ standards (C++17/20), handle memory management correctly, and ensure clean header and source file separation.`, - promptNote: `Stack: C++. Use standard C++ library and syntax.` - }; - } else { - return { - systemPrompt: `You are an expert software engineer generating implementation code for a node specification. -Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output code. -CRITICAL: The codebase uses ES Modules (ESM). You must STRICTLY use 'import ... from ...' syntax. NEVER generate CommonJS 'require(...)' calls.`, - promptNote: `Stack: JS/TS (${stack}). Ensure ES Module format.` - }; - } -} - -export interface AIProvider { - generate(prompt: string, systemPrompt: string, model: string): Promise; -} - -export class AnthropicProvider implements AIProvider { - private client: Anthropic; - public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; - constructor(apiKey: string) { - this.client = new Anthropic({ apiKey }); - } - - async generate(prompt: string, systemPrompt: string, model: string): Promise { - const response = await this.client.messages.create({ - model, - max_tokens: 4000, - system: systemPrompt, - messages: [{ role: 'user', content: prompt }] - }); - if (response.usage) { - this.stats.inputTokens += response.usage.input_tokens || 0; - this.stats.outputTokens += response.usage.output_tokens || 0; - const cacheRead = (response.usage as any).cache_read_input_tokens || 0; - this.stats.cachedTokens += cacheRead; - } - return response.content[0].type === 'text' ? response.content[0].text : ''; - } -} - -export class OpenAICompatibleProvider implements AIProvider { - private apiKey: string; - private baseUrl: string; - public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; - - constructor(apiKey: string, baseUrl = 'https://api.openai.com/v1') { - this.apiKey = apiKey; - this.baseUrl = baseUrl; - } - - async generate(prompt: string, systemPrompt: string, model: string): Promise { - const maxRetries = 3; - let attempt = 0; - - while (attempt < maxRetries) { - attempt++; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout - - try { - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify({ - model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: prompt } - ], - temperature: 0.2, - stream: false - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`OpenAI Provider HTTP error! status: ${response.status}, details: ${errText}`); - } - - const data = await response.json() as any; - if (data.usage) { - this.stats.inputTokens += data.usage.prompt_tokens || 0; - this.stats.outputTokens += data.usage.completion_tokens || 0; - const cached = data.usage.prompt_tokens_details?.cached_tokens || 0; - this.stats.cachedTokens += cached; - } - return data.choices?.[0]?.message?.content || ''; - } catch (err: any) { - clearTimeout(timeoutId); - if (attempt >= maxRetries) { - throw new Error(`OpenAI API request failed after ${maxRetries} attempts: ${err.message}`); - } - console.warn(`[API WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); - // Backoff delay - await new Promise(res => setTimeout(res, 2000 * attempt)); - } - } - throw new Error('OpenAI API request failed.'); - } -} - -export class OllamaProvider implements AIProvider { - private baseUrl: string; - public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; - - constructor(baseUrl = 'http://localhost:11434') { - this.baseUrl = baseUrl; - } - - async generate(prompt: string, systemPrompt: string, model: string): Promise { - const maxRetries = 3; - let attempt = 0; - - while (attempt < maxRetries) { - attempt++; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout - - try { - const response = await fetch(`${this.baseUrl}/api/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - model, - prompt: `${systemPrompt}\n\nUser specifications:\n${prompt}`, - stream: false, - options: { - temperature: 0.2 - } - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`Ollama Provider HTTP error! status: ${response.status}, details: ${errText}`); - } - - const data = await response.json() as any; - if (data) { - this.stats.inputTokens += data.prompt_eval_count || 0; - this.stats.outputTokens += data.eval_count || 0; - } - return data.response || ''; - } catch (err: any) { - clearTimeout(timeoutId); - if (attempt >= maxRetries) { - throw new Error(`Ollama API request failed after ${maxRetries} attempts: ${err.message}`); - } - console.warn(`[OLLAMA WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); - await new Promise(res => setTimeout(res, 2000 * attempt)); - } - } - throw new Error('Ollama API request failed.'); - } -} - -export interface CodegenConfig { - provider?: 'anthropic' | 'openai' | 'ollama' | 'custom'; - apiKey?: string; - model: string; - baseUrl?: string; - customProvider?: AIProvider; - mockResponse?: (node: Node) => string; -} - -export class PxmlCodegen { - private config: CodegenConfig; - private provider?: AIProvider; - - constructor(config: CodegenConfig) { - this.config = config; - - if (config.mockResponse) { - return; - } - - if (config.customProvider) { - this.provider = config.customProvider; - } else if (config.provider === 'openai') { - if (!config.apiKey) throw new Error('API Key required for OpenAI provider'); - this.provider = new OpenAICompatibleProvider(config.apiKey, config.baseUrl); - } else if (config.provider === 'ollama') { - this.provider = new OllamaProvider(config.baseUrl); - } else { - // Default to anthropic - if (!config.apiKey) throw new Error('API Key required for Anthropic provider'); - this.provider = new AnthropicProvider(config.apiKey); - } - } - - getStats() { - if (this.provider && 'stats' in this.provider) { - return (this.provider as any).stats; - } - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; - } - - async generateDirect(prompt: string, systemPrompt: string): Promise { - if (!this.provider) { - throw new Error(`AI Provider is not configured.`); - } - return this.provider.generate(prompt, systemPrompt, this.config.model); - } - - async generateNodeCode(node: Node, projectContext: string, writer: FileWriter, stack = 'nextjs'): Promise { - const stackInfo = getStackInstructions(stack); - if (node.type === 'setup-command') { - if (this.config.mockResponse) { - const mockCmd = this.config.mockResponse(node); - console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Would execute: ${mockCmd}`); - return mockCmd; - } - - if (!this.provider) { - throw new Error(`AI Provider is not configured.`); - } - - const prompt = `Project Context: -${projectContext} - -Generate the exact terminal shell command to initialize/configure this project: -- ID: ${node.id} -- Type: ${node.type} -- Flow: ${node.flow} -- Stack: ${stack} -- Target: Run setup tasks (e.g. create project or install packages) -- Constraints: -${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} - -Generate ONLY the single-line shell command. Do not include explanation, comment, or markdown block wrapping.`; - - const systemPrompt = `You are a DevOps engineer generating setup shell commands. Generate ONLY the executable terminal command text. Do not wrap in markdown or backticks.`; - const commandText = (await this.provider.generate(prompt, systemPrompt, this.config.model)).trim(); - - console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Executing command: "${commandText}"`); - - // Conflict avoidance workaround for npx create-next-app . - const isCreateNextApp = commandText.includes('create-next-app'); - const tempDir = path.join(process.cwd(), '../.pxml-temp-init'); - const conflictItems = ['project.xml', 'pxml.xsd', 'flows', 'shared', 'packages', '.pxml', 'README.md', 'LICENSE', '.gitignore', 'bugs_history.xml', 'bugs.xsd', 'AGENTS.md', 'CLAUDE.md']; - const movedItems: { src: string; dest: string }[] = []; - - if (isCreateNextApp) { - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - for (const item of conflictItems) { - const itemPath = path.join(process.cwd(), item); - if (fs.existsSync(itemPath)) { - const destPath = path.join(tempDir, item); - if (fs.existsSync(destPath)) { - fs.rmSync(destPath, { recursive: true, force: true }); - } - fs.renameSync(itemPath, destPath); - movedItems.push({ src: destPath, dest: itemPath }); - } - } - } - - try { - execSync(commandText, { stdio: 'inherit', cwd: process.cwd() }); - } finally { - // Restore moved files - if (isCreateNextApp) { - for (const item of movedItems) { - if (fs.existsSync(item.src)) { - fs.renameSync(item.src, item.dest); - } - } - fs.rmSync(tempDir, { recursive: true, force: true }); - } - } - - return commandText; - } - - if (this.config.mockResponse) { - const mockCode = this.config.mockResponse(node); - writer.write(node.meta.path, mockCode); - this.logAIResponse(node.id, "MOCK PROMPT", mockCode); - return mockCode; - } - - if (!this.provider) { - throw new Error(`AI Provider is not configured.`); - } - - const prompt = this.buildPrompt(node, projectContext, stackInfo.promptNote); - const systemPrompt = stackInfo.systemPrompt; - - const code = await this.provider.generate(prompt, systemPrompt, this.config.model); - let cleanedCode = this.cleanMarkdown(code); - - // AI Code Verification & Self-Correction step - try { - const verificationPrompt = `Verify the correctness and deployment stability of the following generated code for node '${node.id}'. -Destination Path: ${node.meta.path} -Constraints: -${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} - -Generated Code: -${cleanedCode} - -Analyze the code. Are there any bugs, schema inconsistencies, or missing imports/exports? -If there are issues, output the corrected code. If the code is fully stable, output the word "STABLE".`; - - const verificationResponse = await this.provider.generate(verificationPrompt, "You are a senior code reviewer. Return ONLY the corrected code or the exact word 'STABLE'. Do not include markdown code blocks or explanations.", this.config.model); - const cleanedVerification = this.cleanMarkdown(verificationResponse); - - if (cleanedVerification.toUpperCase() !== 'STABLE' && cleanedVerification.length > 20) { - console.log(`${colors.green(colors.bold('[VERIFY]'))} AI self-corrected generated code for node: ${node.id}`); - cleanedCode = cleanedVerification; - } - } catch (err: any) { - console.warn(`[VERIFY WARNING] Self-verification step skipped: ${err.message}`); - } - - writer.write(node.meta.path, cleanedCode); - this.logAIResponse(node.id, prompt, cleanedCode); - return cleanedCode; - } - - async generateNodeTest(node: Node, testPath: string, implementationCode: string, stack = 'nextjs', writer: FileWriter): Promise { - if (this.config.mockResponse) { - const mockTest = `import { describe, it, expect } from 'vitest';\n// Mock test for ${node.id}\n`; - writer.write(testPath, mockTest); - return mockTest; - } - - if (!this.provider) { - throw new Error(`AI Provider is not configured.`); - } - - const testFileExists = fs.existsSync(testPath); - const currentTestCode = testFileExists ? fs.readFileSync(testPath, 'utf-8') : ''; - - const systemPrompt = `You are an expert QA and software testing engineer. -Generate ONLY the complete test file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output test code. -CRITICAL: The test framework matches the stack. For JS/TS, use Vitest. For Python, use pytest. For Go, use testing. For C#, use xUnit or NUnit. -CRITICAL: Never attempt to bind/start a live HTTP server or make real external network calls. Always mock inputs, mock requests, mock responses, and use virtual mock routing/internal test request objects (e.g., mock 'Request' in Next.js, 'httptest' in Go, 'responses' or mock frameworks in Python/C#). -CRITICAL: For Next.js page components where 'searchParams' is a Promise (Next.js 15/React 19), always wrap the rendered component in '' inside the test to prevent suspension boundary errors. -CRITICAL: In JS/TS component tests, always add '// @vitest-environment jsdom' at the very top of the test file. Tests are co-located in the same folder as code, so always use local relative paths (e.g. './page' or './route') for importing the implementation code. Never use path aliases (like '@/...'). -CRITICAL: When mocking constructors or classes (such as 'better-sqlite3' Database), always mock them using a standard JavaScript class (e.g., 'default: class { ... }') instead of an arrow function (e.g., 'default: () => ...') to prevent 'is not a constructor' TypeErrors. -CRITICAL: To ensure the DOM is cleared between tests when Vitest globals are disabled, always import 'cleanup' and call 'afterEach(cleanup)' explicitly in the test file (e.g. 'import { cleanup } from "@testing-library/react"; afterEach(cleanup);').`; - - const implExt = path.extname(node.meta.path); - const implBase = path.basename(node.meta.path, implExt); - let importStatement = ''; - const stackLower = stack.toLowerCase(); - - if (stackLower.includes('python')) { - importStatement = `from .${implBase} import ...`; - } else if (stackLower.includes('go') || stackLower === 'golang') { - importStatement = `// package matches other files in same directory`; - } else { - importStatement = `import ${node.type === 'api-route' ? '* as handlerModule' : 'Component'} from './${implBase}';`; - } - - let prompt = ''; - if (testFileExists && currentTestCode) { - prompt = `Improve and update the existing test file for this node to match the updated implementation and specifications. -Implementation File Path: ${node.meta.path} -Implementation Code: -\`\`\` -${implementationCode} -\`\`\` - -Test File Path: ${testPath} -Existing Test Code: -\`\`\` -${currentTestCode} -\`\`\` - -XML Specifications: -- Input Fields: ${JSON.stringify(node.input)} -- Output Fields: ${JSON.stringify(node.output)} -- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) -- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} - -Generate the updated complete test code. Do not include markdown wrapping or explanation.`; - } else { - prompt = `Generate a comprehensive test file for the following implementation node based on its specification and code. -Implementation File Path: ${node.meta.path} -Implementation Code: -\`\`\` -${implementationCode} -\`\`\` - -Target Test File Path: ${testPath} -XML Specifications: -- Input Fields: ${JSON.stringify(node.input)} -- Output Fields: ${JSON.stringify(node.output)} -- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) -- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} -- Defined Test Scenarios: ${JSON.stringify(node.tests)} - -Generate the complete test code. Do not include markdown wrapping or explanation.`; - } - - const testCode = await this.provider.generate(prompt, systemPrompt, this.config.model); - const cleaned = this.cleanMarkdown(testCode); - writer.write(testPath, cleaned); - this.logAIResponse(node.id + "_test", prompt, cleaned); - return cleaned; - } - - private buildPrompt(node: Node, projectContext: string, promptNote: string): string { - return `Project Context: -${projectContext} - -Generate implementation file for this node: -- ID: ${node.id} -- Type: ${node.type} -- Flow: ${node.flow} -- Destination Path: ${node.meta.path} -- Input Fields: ${JSON.stringify(node.input)} -- Output Fields: ${JSON.stringify(node.output)} -- ${promptNote} -- Constraints: -${node.constraints.map(c => ` - [${c.verify}] ${c.description}${c.learnedFrom ? ` (Learned from bug: ${c.learnedFrom})` : ''}`).join('\n')} - -Generate the cleanest code matching this specification. Do not include markdown wrapping or explanation.`; - } - - private cleanMarkdown(code: string): string { - let cleaned = code.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '').trim(); - // Remove any trailing AI skipped pattern comment or annotation - cleaned = cleaned.replace(/\s*→\s*skipped:.*$/gm, ''); - cleaned = cleaned.replace(/\s*\/\/\s*skipped:.*$/gm, ''); - return cleaned.trim(); - } - - private logAIResponse(nodeId: string, prompt: string, response: string) { - const logsDir = path.resolve('.pxml', 'logs'); - if (!fs.existsSync(logsDir)) { - fs.mkdirSync(logsDir, { recursive: true }); - } - const safeNodeId = nodeId.replace(/:/g, '_'); - const logPath = path.join(logsDir, `${safeNodeId}.log`); - const logContent = `--- PROMPT ---\n${prompt}\n\n--- RESPONSE ---\n${response}\n`; - fs.writeFileSync(logPath, logContent, 'utf-8'); - } -} +import Anthropic from '@anthropic-ai/sdk'; +import { Node } from '../parser/schema.js'; +import { FileWriter } from '../writer/index.js'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +const colors = { + red: (text: string) => `\x1b[31m${text}\x1b[0m`, + green: (text: string) => `\x1b[32m${text}\x1b[0m`, + yellow: (text: string) => `\x1b[33m${text}\x1b[0m`, + blue: (text: string) => `\x1b[34m${text}\x1b[0m`, + magenta: (text: string) => `\x1b[35m${text}\x1b[0m`, + cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, + bold: (text: string) => `\x1b[1m${text}\x1b[0m` +}; + +function getStackInstructions(stack: string): { systemPrompt: string; promptNote: string } { + const stackLower = stack.toLowerCase(); + if (stackLower.includes('python')) { + return { + systemPrompt: `You are an expert Python developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`python) or explanations. Only output code. +CRITICAL: Use idiomatic Python code, follow PEP 8 styling, and make sure dependencies are imported correctly.`, + promptNote: `Stack: Python. Use standard Python practices, requirements, and imports.` + }; + } else if (stackLower.includes('rust')) { + return { + systemPrompt: `You are an expert Rust developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`rust) or explanations. Only output code. +CRITICAL: Write clean Rust code, manage lifetimes and ownership correctly, and follow Rust idioms.`, + promptNote: `Stack: Rust. Use standard Rust syntax and crate references.` + }; + } else if (stackLower.includes('go') || stackLower === 'golang') { + return { + systemPrompt: `You are an expert Go developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`go) or explanations. Only output code. +CRITICAL: Write idiomatic Go code, ensure correct package declaration, and format using gofmt standards.`, + promptNote: `Stack: Go. Use standard Go packaging and syntax.` + }; + } else if (stackLower.includes('c#') || stackLower === 'csharp' || stackLower.includes('dotnet')) { + return { + systemPrompt: `You are an expert C# developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`csharp) or explanations. Only output code. +CRITICAL: Use modern C# syntax, follow standard .NET conventions, and declare namespace/imports correctly.`, + promptNote: `Stack: C# / .NET. Use standard .NET namespace and architecture.` + }; + } else if (stackLower.includes('c++') || stackLower === 'cpp') { + return { + systemPrompt: `You are an expert C++ developer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`cpp) or explanations. Only output code. +CRITICAL: Use modern C++ standards (C++17/20), handle memory management correctly, and ensure clean header and source file separation.`, + promptNote: `Stack: C++. Use standard C++ library and syntax.` + }; + } else { + return { + systemPrompt: `You are an expert software engineer generating implementation code for a node specification. +Generate ONLY the file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output code. +CRITICAL: The codebase uses ES Modules (ESM). You must STRICTLY use 'import ... from ...' syntax. NEVER generate CommonJS 'require(...)' calls.`, + promptNote: `Stack: JS/TS (${stack}). Ensure ES Module format.` + }; + } +} + +export interface AIProvider { + generate(prompt: string, systemPrompt: string, model: string): Promise; +} + +export class AnthropicProvider implements AIProvider { + private client: Anthropic; + public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + constructor(apiKey: string) { + this.client = new Anthropic({ apiKey }); + } + + async generate(prompt: string, systemPrompt: string, model: string): Promise { + const response = await this.client.messages.create({ + model, + max_tokens: 4000, + system: systemPrompt, + messages: [{ role: 'user', content: prompt }] + }); + if (response.usage) { + this.stats.inputTokens += response.usage.input_tokens || 0; + this.stats.outputTokens += response.usage.output_tokens || 0; + const cacheRead = (response.usage as any).cache_read_input_tokens || 0; + this.stats.cachedTokens += cacheRead; + } + return response.content[0].type === 'text' ? response.content[0].text : ''; + } +} + +export class OpenAICompatibleProvider implements AIProvider { + private apiKey: string; + private baseUrl: string; + public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + + constructor(apiKey: string, baseUrl = 'https://api.openai.com/v1') { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + async generate(prompt: string, systemPrompt: string, model: string): Promise { + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + attempt++; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout + + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ], + temperature: 0.2, + stream: false + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`OpenAI Provider HTTP error! status: ${response.status}, details: ${errText}`); + } + + const data = await response.json() as any; + if (data.usage) { + this.stats.inputTokens += data.usage.prompt_tokens || 0; + this.stats.outputTokens += data.usage.completion_tokens || 0; + const cached = data.usage.prompt_tokens_details?.cached_tokens || 0; + this.stats.cachedTokens += cached; + } + return data.choices?.[0]?.message?.content || ''; + } catch (err: any) { + clearTimeout(timeoutId); + if (attempt >= maxRetries) { + throw new Error(`OpenAI API request failed after ${maxRetries} attempts: ${err.message}`); + } + console.warn(`[API WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); + // Backoff delay + await new Promise(res => setTimeout(res, 2000 * attempt)); + } + } + throw new Error('OpenAI API request failed.'); + } +} + +export class OllamaProvider implements AIProvider { + private baseUrl: string; + public stats = { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + + constructor(baseUrl = 'http://localhost:11434') { + this.baseUrl = baseUrl; + } + + async generate(prompt: string, systemPrompt: string, model: string): Promise { + const maxRetries = 3; + let attempt = 0; + + while (attempt < maxRetries) { + attempt++; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 90000); // 90 seconds timeout + + try { + const response = await fetch(`${this.baseUrl}/api/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model, + prompt: `${systemPrompt}\n\nUser specifications:\n${prompt}`, + stream: false, + options: { + temperature: 0.2 + } + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Ollama Provider HTTP error! status: ${response.status}, details: ${errText}`); + } + + const data = await response.json() as any; + if (data) { + this.stats.inputTokens += data.prompt_eval_count || 0; + this.stats.outputTokens += data.eval_count || 0; + } + return data.response || ''; + } catch (err: any) { + clearTimeout(timeoutId); + if (attempt >= maxRetries) { + throw new Error(`Ollama API request failed after ${maxRetries} attempts: ${err.message}`); + } + console.warn(`[OLLAMA WARN] Attempt ${attempt} failed: ${err.message}. Retrying...`); + await new Promise(res => setTimeout(res, 2000 * attempt)); + } + } + throw new Error('Ollama API request failed.'); + } +} + +export interface CodegenConfig { + provider?: 'anthropic' | 'openai' | 'ollama' | 'custom'; + apiKey?: string; + model: string; + baseUrl?: string; + customProvider?: AIProvider; + mockResponse?: (node: Node) => string; +} + +export class PxmlCodegen { + private config: CodegenConfig; + private provider?: AIProvider; + + constructor(config: CodegenConfig) { + this.config = config; + + if (config.mockResponse) { + return; + } + + if (config.customProvider) { + this.provider = config.customProvider; + } else if (config.provider === 'openai') { + if (!config.apiKey) throw new Error('API Key required for OpenAI provider'); + this.provider = new OpenAICompatibleProvider(config.apiKey, config.baseUrl); + } else if (config.provider === 'ollama') { + this.provider = new OllamaProvider(config.baseUrl); + } else { + // Default to anthropic + if (!config.apiKey) throw new Error('API Key required for Anthropic provider'); + this.provider = new AnthropicProvider(config.apiKey); + } + } + + getStats() { + if (this.provider && 'stats' in this.provider) { + return (this.provider as any).stats; + } + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0 }; + } + + async generateDirect(prompt: string, systemPrompt: string): Promise { + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + return this.provider.generate(prompt, systemPrompt, this.config.model); + } + + async generateNodeCode(node: Node, projectContext: string, writer: FileWriter, stack = 'nextjs'): Promise { + const stackInfo = getStackInstructions(stack); + if (node.type === 'setup-command') { + if (this.config.mockResponse) { + const mockCmd = this.config.mockResponse(node); + console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Would execute: ${mockCmd}`); + return mockCmd; + } + + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + + const prompt = `Project Context: +${projectContext} + +Generate the exact terminal shell command to initialize/configure this project: +- ID: ${node.id} +- Type: ${node.type} +- Flow: ${node.flow} +- Stack: ${stack} +- Target: Run setup tasks (e.g. create project or install packages) +- Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} + +Generate ONLY the single-line shell command. Do not include explanation, comment, or markdown block wrapping.`; + + const systemPrompt = `You are a DevOps engineer generating setup shell commands. Generate ONLY the executable terminal command text. Do not wrap in markdown or backticks.`; + const commandText = (await this.provider.generate(prompt, systemPrompt, this.config.model)).trim(); + + console.log(`${colors.cyan(colors.bold('[SETUP-COMMAND]'))} Executing command: "${commandText}"`); + + // Conflict avoidance workaround for npx create-next-app . + const isCreateNextApp = commandText.includes('create-next-app'); + const tempDir = path.join(process.cwd(), '../.pxml-temp-init'); + const conflictItems = ['project.xml', 'pxml.xsd', 'flows', 'shared', 'packages', '.pxml', 'README.md', 'LICENSE', '.gitignore', 'bugs_history.xml', 'bugs.xsd', 'AGENTS.md', 'CLAUDE.md']; + const movedItems: { src: string; dest: string }[] = []; + + if (isCreateNextApp) { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + for (const item of conflictItems) { + const itemPath = path.join(process.cwd(), item); + if (fs.existsSync(itemPath)) { + const destPath = path.join(tempDir, item); + if (fs.existsSync(destPath)) { + fs.rmSync(destPath, { recursive: true, force: true }); + } + fs.renameSync(itemPath, destPath); + movedItems.push({ src: destPath, dest: itemPath }); + } + } + } + + try { + execSync(commandText, { stdio: 'inherit', cwd: process.cwd() }); + } finally { + // Restore moved files + if (isCreateNextApp) { + for (const item of movedItems) { + if (fs.existsSync(item.src)) { + fs.renameSync(item.src, item.dest); + } + } + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + + return commandText; + } + + if (this.config.mockResponse) { + const mockCode = this.config.mockResponse(node); + writer.write(node.meta.path, mockCode); + this.logAIResponse(node.id, "MOCK PROMPT", mockCode); + return mockCode; + } + + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + + const prompt = this.buildPrompt(node, projectContext, stackInfo.promptNote); + const systemPrompt = stackInfo.systemPrompt; + + const code = await this.provider.generate(prompt, systemPrompt, this.config.model); + let cleanedCode = this.cleanMarkdown(code); + + // AI Code Verification & Self-Correction step + try { + const verificationPrompt = `Verify the correctness and deployment stability of the following generated code for node '${node.id}'. +Destination Path: ${node.meta.path} +Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}`).join('\n')} + +Generated Code: +${cleanedCode} + +Analyze the code. Are there any bugs, schema inconsistencies, or missing imports/exports? +If there are issues, output the corrected code. If the code is fully stable, output the word "STABLE".`; + + const verificationResponse = await this.provider.generate(verificationPrompt, "You are a senior code reviewer. Return ONLY the corrected code or the exact word 'STABLE'. Do not include markdown code blocks or explanations.", this.config.model); + const cleanedVerification = this.cleanMarkdown(verificationResponse); + + if (cleanedVerification.toUpperCase() !== 'STABLE' && cleanedVerification.length > 20) { + console.log(`${colors.green(colors.bold('[VERIFY]'))} AI self-corrected generated code for node: ${node.id}`); + cleanedCode = cleanedVerification; + } + } catch (err: any) { + console.warn(`[VERIFY WARNING] Self-verification step skipped: ${err.message}`); + } + + writer.write(node.meta.path, cleanedCode); + this.logAIResponse(node.id, prompt, cleanedCode); + return cleanedCode; + } + + async generateNodeTest(node: Node, testPath: string, implementationCode: string, stack = 'nextjs', writer: FileWriter): Promise { + if (this.config.mockResponse) { + const mockTest = `import { describe, it, expect } from 'vitest';\n// Mock test for ${node.id}\n`; + writer.write(testPath, mockTest); + return mockTest; + } + + if (!this.provider) { + throw new Error(`AI Provider is not configured.`); + } + + const testFileExists = fs.existsSync(testPath); + const currentTestCode = testFileExists ? fs.readFileSync(testPath, 'utf-8') : ''; + + const systemPrompt = `You are an expert QA and software testing engineer. +Generate ONLY the complete test file contents. Do not include markdown code block syntax (like \`\`\`typescript) or explanations. Only output test code. +CRITICAL: The test framework matches the stack. For JS/TS, use Vitest. For Python, use pytest. For Go, use testing. For C#, use xUnit or NUnit. +CRITICAL: Never attempt to bind/start a live HTTP server or make real external network calls. Always mock inputs, mock requests, mock responses, and use virtual mock routing/internal test request objects (e.g., mock 'Request' in Next.js, 'httptest' in Go, 'responses' or mock frameworks in Python/C#). +CRITICAL: For Next.js page components where 'searchParams' is a Promise (Next.js 15/React 19), always wrap the rendered component in '' inside the test to prevent suspension boundary errors. +CRITICAL: In JS/TS component tests, always add '// @vitest-environment jsdom' at the very top of the test file. Tests are co-located in the same folder as code, so always use local relative paths (e.g. './page' or './route') for importing the implementation code. Never use path aliases (like '@/...'). +CRITICAL: When mocking constructors or classes (such as 'better-sqlite3' Database), always mock them using a standard JavaScript class (e.g., 'default: class { ... }') instead of an arrow function (e.g., 'default: () => ...') to prevent 'is not a constructor' TypeErrors. +CRITICAL: To ensure the DOM is cleared between tests when Vitest globals are disabled, always import 'cleanup' and call 'afterEach(cleanup)' explicitly in the test file (e.g. 'import { cleanup } from "@testing-library/react"; afterEach(cleanup);').`; + + const implExt = path.extname(node.meta.path); + const implBase = path.basename(node.meta.path, implExt); + let importStatement = ''; + const stackLower = stack.toLowerCase(); + + if (stackLower.includes('python')) { + importStatement = `from .${implBase} import ...`; + } else if (stackLower.includes('go') || stackLower === 'golang') { + importStatement = `// package matches other files in same directory`; + } else { + importStatement = `import ${node.type === 'api-route' ? '* as handlerModule' : 'Component'} from './${implBase}';`; + } + + let prompt = ''; + if (testFileExists && currentTestCode) { + prompt = `Improve and update the existing test file for this node to match the updated implementation and specifications. +Implementation File Path: ${node.meta.path} +Implementation Code: +\`\`\` +${implementationCode} +\`\`\` + +Test File Path: ${testPath} +Existing Test Code: +\`\`\` +${currentTestCode} +\`\`\` + +XML Specifications: +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) +- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} + +Generate the updated complete test code. Do not include markdown wrapping or explanation.`; + } else { + prompt = `Generate a comprehensive test file for the following implementation node based on its specification and code. +Implementation File Path: ${node.meta.path} +Implementation Code: +\`\`\` +${implementationCode} +\`\`\` + +Target Test File Path: ${testPath} +XML Specifications: +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- Import Directive: ${importStatement} (You MUST use exactly this relative import statement to import the code being tested. Do not use path aliases like '@/...' or other paths.) +- Constraints: ${node.constraints.map(c => `[${c.verify}] ${c.description}`).join('\n')} +- Defined Test Scenarios: ${JSON.stringify(node.tests)} + +Generate the complete test code. Do not include markdown wrapping or explanation.`; + } + + const testCode = await this.provider.generate(prompt, systemPrompt, this.config.model); + const cleaned = this.cleanMarkdown(testCode); + writer.write(testPath, cleaned); + this.logAIResponse(node.id + "_test", prompt, cleaned); + return cleaned; + } + + private buildPrompt(node: Node, projectContext: string, promptNote: string): string { + return `Project Context: +${projectContext} + +Generate implementation file for this node: +- ID: ${node.id} +- Type: ${node.type} +- Flow: ${node.flow} +- Destination Path: ${node.meta.path} +- Input Fields: ${JSON.stringify(node.input)} +- Output Fields: ${JSON.stringify(node.output)} +- ${promptNote} +- Constraints: +${node.constraints.map(c => ` - [${c.verify}] ${c.description}${c.learnedFrom ? ` (Learned from bug: ${c.learnedFrom})` : ''}`).join('\n')} + +Generate the cleanest code matching this specification. Do not include markdown wrapping or explanation.`; + } + + private cleanMarkdown(code: string): string { + let cleaned = code.replace(/^```[a-zA-Z]*\n/, '').replace(/\n```$/, '').trim(); + // Remove any trailing AI skipped pattern comment or annotation + cleaned = cleaned.replace(/\s*→\s*skipped:.*$/gm, ''); + cleaned = cleaned.replace(/\s*\/\/\s*skipped:.*$/gm, ''); + return cleaned.trim(); + } + + private logAIResponse(nodeId: string, prompt: string, response: string) { + const logsDir = path.resolve('.pxml', 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + const safeNodeId = nodeId.replace(/:/g, '_'); + const logPath = path.join(logsDir, `${safeNodeId}.log`); + const logContent = `--- PROMPT ---\n${prompt}\n\n--- RESPONSE ---\n${response}\n`; + fs.writeFileSync(logPath, logContent, 'utf-8'); + } +} diff --git a/src/diagnostics.test.ts b/src/diagnostics.test.ts index 5641b58..5b734b1 100644 --- a/src/diagnostics.test.ts +++ b/src/diagnostics.test.ts @@ -1,48 +1,48 @@ -import { describe, it, expect } from 'vitest'; -import { PxmlDiagnostics } from '../src/diagnostics/index.ts'; -import { PxmlPatcher } from '../src/patcher/index.ts'; - -describe('PxmlDiagnostics', () => { - it('should map errors to flow heuristics', () => { - const diag1 = PxmlDiagnostics.diagnoseHeuristic({ - message: 'Unauthorized access attempts', - statusCode: 401 - }); - expect(diag1?.flow).toBe('auth'); - expect(diag1?.suspectedType).toBe('middleware'); - - const diag2 = PxmlDiagnostics.diagnoseHeuristic({ - message: 'PrismaClientKnownRequestError: Unique constraint failed on the fields: (slug)', - }); - expect(diag2?.flow).toBe('db'); - expect(diag2?.suspectedType).toBe('db-model'); - }); -}); - -describe('PxmlPatcher', () => { - it('should apply search/replace patches cleanly', () => { - const original = `const a = 1;\nconst b = 2;\nconst c = 3;`; - const patch = ` -<<<<<<< SEARCH -const b = 2; -======= -const b = 20; -const d = 40; ->>>>>>> REPLACE -`; - const result = PxmlPatcher.applyPatch(original, patch); - expect(result).toBe(`const a = 1;\nconst b = 20;\nconst d = 40;\nconst c = 3;`); - }); - - it('should throw error when search block is not found', () => { - const original = `const a = 1;`; - const patch = ` -<<<<<<< SEARCH -const b = 2; -======= -const b = 20; ->>>>>>> REPLACE -`; - expect(() => PxmlPatcher.applyPatch(original, patch)).toThrow('search block not found'); - }); -}); +import { describe, it, expect } from 'vitest'; +import { PxmlDiagnostics } from '../src/diagnostics/index.ts'; +import { PxmlPatcher } from '../src/patcher/index.ts'; + +describe('PxmlDiagnostics', () => { + it('should map errors to flow heuristics', () => { + const diag1 = PxmlDiagnostics.diagnoseHeuristic({ + message: 'Unauthorized access attempts', + statusCode: 401 + }); + expect(diag1?.flow).toBe('auth'); + expect(diag1?.suspectedType).toBe('middleware'); + + const diag2 = PxmlDiagnostics.diagnoseHeuristic({ + message: 'PrismaClientKnownRequestError: Unique constraint failed on the fields: (slug)', + }); + expect(diag2?.flow).toBe('db'); + expect(diag2?.suspectedType).toBe('db-model'); + }); +}); + +describe('PxmlPatcher', () => { + it('should apply search/replace patches cleanly', () => { + const original = `const a = 1;\nconst b = 2;\nconst c = 3;`; + const patch = ` +<<<<<<< SEARCH +const b = 2; +======= +const b = 20; +const d = 40; +>>>>>>> REPLACE +`; + const result = PxmlPatcher.applyPatch(original, patch); + expect(result).toBe(`const a = 1;\nconst b = 20;\nconst d = 40;\nconst c = 3;`); + }); + + it('should throw error when search block is not found', () => { + const original = `const a = 1;`; + const patch = ` +<<<<<<< SEARCH +const b = 2; +======= +const b = 20; +>>>>>>> REPLACE +`; + expect(() => PxmlPatcher.applyPatch(original, patch)).toThrow('search block not found'); + }); +}); diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index b865acb..08cb902 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -1,30 +1,30 @@ -export interface DiagnosticsLog { - message: string; - statusCode?: number; - stack?: string; -} - -export class PxmlDiagnostics { - /** - * Evaluates runtime error logs/diagnostics and maps them heuristically to a node flow - */ - static diagnoseHeuristic(log: DiagnosticsLog): { flow: string; suspectedType: string } | null { - const msg = log.message.toLowerCase() + ' ' + (log.stack || '').toLowerCase(); - - // Heuristic mappings - if (log.statusCode === 401 || log.statusCode === 403 || msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('jwt') || msg.includes('token')) { - return { flow: 'auth', suspectedType: 'middleware' }; - } - if (msg.includes('cookie') || msg.includes('session')) { - return { flow: 'session', suspectedType: 'middleware' }; - } - if (msg.includes('cors') || msg.includes('origin') || log.statusCode === 405) { - return { flow: 'api', suspectedType: 'api-route' }; - } - if (msg.includes('prisma') || msg.includes('database') || msg.includes('db ') || msg.includes('query') || msg.includes('unique constraint')) { - return { flow: 'db', suspectedType: 'db-model' }; - } - - return null; - } -} +export interface DiagnosticsLog { + message: string; + statusCode?: number; + stack?: string; +} + +export class PxmlDiagnostics { + /** + * Evaluates runtime error logs/diagnostics and maps them heuristically to a node flow + */ + static diagnoseHeuristic(log: DiagnosticsLog): { flow: string; suspectedType: string } | null { + const msg = log.message.toLowerCase() + ' ' + (log.stack || '').toLowerCase(); + + // Heuristic mappings + if (log.statusCode === 401 || log.statusCode === 403 || msg.includes('unauthorized') || msg.includes('forbidden') || msg.includes('jwt') || msg.includes('token')) { + return { flow: 'auth', suspectedType: 'middleware' }; + } + if (msg.includes('cookie') || msg.includes('session')) { + return { flow: 'session', suspectedType: 'middleware' }; + } + if (msg.includes('cors') || msg.includes('origin') || log.statusCode === 405) { + return { flow: 'api', suspectedType: 'api-route' }; + } + if (msg.includes('prisma') || msg.includes('database') || msg.includes('db ') || msg.includes('query') || msg.includes('unique constraint')) { + return { flow: 'db', suspectedType: 'db-model' }; + } + + return null; + } +} diff --git a/src/fix.test.ts b/src/fix.test.ts index a1fb088..84d748a 100644 --- a/src/fix.test.ts +++ b/src/fix.test.ts @@ -1,164 +1,164 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { runFixLoop } from '../src/cli/fix.ts'; -import { PxmlManifest } from '../src/manifest/index.ts'; -import { PxmlCodegen } from '../src/codegen/index.ts'; -import { PxmlRunner } from '../src/runner/index.ts'; -import { FileWriter } from '../src/writer/index.ts'; -import { Node } from '../src/parser/schema.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -const TMP_DIR = '/tmp/pxml-test-fix'; - -describe('Fix self-healing loop', () => { - beforeEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - afterEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - const mockNode: Node = { - id: 'api.posts.create', - type: 'api-route', - flow: 'blog.write', - meta: { - path: path.join(TMP_DIR, 'app/api/posts/route.ts'), - depends_on: [] - }, - input: [], - output: [], - constraints: [], - tests: [ - { - name: 'Create post successful', - given: { query: { title: 'hello' } }, - expect: { - status: 200, - contains: 'success' - } - } - ] - }; - - it('should run fix loop successfully with AI patches', async () => { - const writer = new FileWriter(); - const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); - - // First, compile node which will fail initially (we write empty or broken code) - const initialCode = ` -export default async function handler(req) { - return { - status: 500, - json: async () => ({}) - }; -} -`; - writer.write(mockNode.meta.path, initialCode); - - manifest.setNode(mockNode.id, { - node_id: mockNode.id, - source_file: 'project.xml', - xml_hash: '123', - output_files: [mockNode.meta.path], - depends_on: [], - flow: mockNode.flow - }); - manifest.save(); - - const codegen = new PxmlCodegen({ - model: 'claude-3-5-sonnet', - mockResponse: () => '' - }); - - const runner = new PxmlRunner(TMP_DIR, writer); - - // Patch that AI is supposed to return: - const mockPatch = ` -<<<<<<< SEARCH -export default async function handler(req) { - return { - status: 500, - json: async () => ({}) - }; -} -======= -export default async function handler(req) { - return { - status: 200, - json: async () => ({ success: true }) - }; -} ->>>>>>> REPLACE -`; - - const success = await runFixLoop( - mockNode, - TMP_DIR, - manifest, - codegen, - runner, - writer, - mockPatch - ); - - expect(success).toBe(true); - expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toContain('success: true'); - }); - - it('should support multi-file patches targeting both code and tests', async () => { - const writer = new FileWriter(); - const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); - - const initialCode = `export default async function handler(req) { return { status: 500 }; }`; - writer.write(mockNode.meta.path, initialCode); - - const testPath = path.join(TMP_DIR, 'tests/app/api/posts/route.test.ts'); - const initialTestCode = `// Broken test`; - writer.write(testPath, initialTestCode); - - const codegen = new PxmlCodegen({ - model: 'claude', - mockResponse: () => '' - }); - const runner = new PxmlRunner(TMP_DIR, writer); - - const mockPatch = ` -FILE: app/api/posts/route.ts -<<<<<<< SEARCH -export default async function handler(req) { return { status: 500 }; } -======= -export default async function handler(req) { return { status: 200, json: async () => ({ success: true }) }; } ->>>>>>> REPLACE - -FILE: tests/app/api/posts/route.test.ts -<<<<<<< SEARCH -// Broken test -======= -import { describe, it, expect } from 'vitest'; -describe('api.posts.create', () => { - it('Create post successful', () => { expect(true).toBe(true); }); -}); ->>>>>>> REPLACE -`; - - const success = await runFixLoop( - mockNode, - TMP_DIR, - manifest, - codegen, - runner, - writer, - mockPatch - ); - - expect(success).toBe(true); - expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toContain('success: true'); - expect(fs.readFileSync(testPath, 'utf-8')).toContain('describe(\'api.posts.create\''); - }); -}); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { runFixLoop } from '../src/cli/fix.ts'; +import { PxmlManifest } from '../src/manifest/index.ts'; +import { PxmlCodegen } from '../src/codegen/index.ts'; +import { PxmlRunner } from '../src/runner/index.ts'; +import { FileWriter } from '../src/writer/index.ts'; +import { Node } from '../src/parser/schema.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TMP_DIR = '/tmp/pxml-test-fix'; + +describe('Fix self-healing loop', () => { + beforeEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + const mockNode: Node = { + id: 'api.posts.create', + type: 'api-route', + flow: 'blog.write', + meta: { + path: path.join(TMP_DIR, 'app/api/posts/route.ts'), + depends_on: [] + }, + input: [], + output: [], + constraints: [], + tests: [ + { + name: 'Create post successful', + given: { query: { title: 'hello' } }, + expect: { + status: 200, + contains: 'success' + } + } + ] + }; + + it('should run fix loop successfully with AI patches', async () => { + const writer = new FileWriter(); + const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); + + // First, compile node which will fail initially (we write empty or broken code) + const initialCode = ` +export default async function handler(req) { + return { + status: 500, + json: async () => ({}) + }; +} +`; + writer.write(mockNode.meta.path, initialCode); + + manifest.setNode(mockNode.id, { + node_id: mockNode.id, + source_file: 'project.xml', + xml_hash: '123', + output_files: [mockNode.meta.path], + depends_on: [], + flow: mockNode.flow + }); + manifest.save(); + + const codegen = new PxmlCodegen({ + model: 'claude-3-5-sonnet', + mockResponse: () => '' + }); + + const runner = new PxmlRunner(TMP_DIR, writer); + + // Patch that AI is supposed to return: + const mockPatch = ` +<<<<<<< SEARCH +export default async function handler(req) { + return { + status: 500, + json: async () => ({}) + }; +} +======= +export default async function handler(req) { + return { + status: 200, + json: async () => ({ success: true }) + }; +} +>>>>>>> REPLACE +`; + + const success = await runFixLoop( + mockNode, + TMP_DIR, + manifest, + codegen, + runner, + writer, + mockPatch + ); + + expect(success).toBe(true); + expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toContain('success: true'); + }); + + it('should support multi-file patches targeting both code and tests', async () => { + const writer = new FileWriter(); + const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); + + const initialCode = `export default async function handler(req) { return { status: 500 }; }`; + writer.write(mockNode.meta.path, initialCode); + + const testPath = path.join(TMP_DIR, 'tests/app/api/posts/route.test.ts'); + const initialTestCode = `// Broken test`; + writer.write(testPath, initialTestCode); + + const codegen = new PxmlCodegen({ + model: 'claude', + mockResponse: () => '' + }); + const runner = new PxmlRunner(TMP_DIR, writer); + + const mockPatch = ` +FILE: app/api/posts/route.ts +<<<<<<< SEARCH +export default async function handler(req) { return { status: 500 }; } +======= +export default async function handler(req) { return { status: 200, json: async () => ({ success: true }) }; } +>>>>>>> REPLACE + +FILE: tests/app/api/posts/route.test.ts +<<<<<<< SEARCH +// Broken test +======= +import { describe, it, expect } from 'vitest'; +describe('api.posts.create', () => { + it('Create post successful', () => { expect(true).toBe(true); }); +}); +>>>>>>> REPLACE +`; + + const success = await runFixLoop( + mockNode, + TMP_DIR, + manifest, + codegen, + runner, + writer, + mockPatch + ); + + expect(success).toBe(true); + expect(fs.readFileSync(mockNode.meta.path, 'utf-8')).toContain('success: true'); + expect(fs.readFileSync(testPath, 'utf-8')).toContain('describe(\'api.posts.create\''); + }); +}); diff --git a/src/graph/index.ts b/src/graph/index.ts index 0d36217..a9d423d 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1,57 +1,57 @@ -import { Node } from '../parser/schema.js'; - -export class DependencyGraph { - private adjacencyList = new Map(); - private nodes = new Map(); - - constructor(nodes: Node[]) { - for (const node of nodes) { - this.nodes.set(node.id, node); - this.adjacencyList.set(node.id, []); - } - - for (const node of nodes) { - for (const dep of node.meta.depends_on) { - if (!this.nodes.has(dep)) { - throw new Error(`Node ${node.id} depends on missing node: ${dep}`); - } - // dependency edge: dep -> node.id (dep must be built before node.id) - this.adjacencyList.get(dep)!.push(node.id); - } - } - } - - getSortOrder(): string[] { - const visited = new Map(); - const order: string[] = []; - - const visit = (nodeId: string) => { - const state = visited.get(nodeId); - if (state === 'VISITING') { - throw new Error(`Circular dependency detected involving node: ${nodeId}`); - } - if (state === 'VISITED') { - return; - } - - visited.set(nodeId, 'VISITING'); - - const neighbors = this.adjacencyList.get(nodeId) || []; - for (const neighbor of neighbors) { - visit(neighbor); - } - - visited.set(nodeId, 'VISITED'); - order.push(nodeId); - }; - - for (const nodeId of this.nodes.keys()) { - if (!visited.has(nodeId)) { - visit(nodeId); - } - } - - // Since we put dependencies first, we reverse the topological sort order - return order.reverse(); - } -} +import { Node } from '../parser/schema.js'; + +export class DependencyGraph { + private adjacencyList = new Map(); + private nodes = new Map(); + + constructor(nodes: Node[]) { + for (const node of nodes) { + this.nodes.set(node.id, node); + this.adjacencyList.set(node.id, []); + } + + for (const node of nodes) { + for (const dep of node.meta.depends_on) { + if (!this.nodes.has(dep)) { + throw new Error(`Node ${node.id} depends on missing node: ${dep}`); + } + // dependency edge: dep -> node.id (dep must be built before node.id) + this.adjacencyList.get(dep)!.push(node.id); + } + } + } + + getSortOrder(): string[] { + const visited = new Map(); + const order: string[] = []; + + const visit = (nodeId: string) => { + const state = visited.get(nodeId); + if (state === 'VISITING') { + throw new Error(`Circular dependency detected involving node: ${nodeId}`); + } + if (state === 'VISITED') { + return; + } + + visited.set(nodeId, 'VISITING'); + + const neighbors = this.adjacencyList.get(nodeId) || []; + for (const neighbor of neighbors) { + visit(neighbor); + } + + visited.set(nodeId, 'VISITED'); + order.push(nodeId); + }; + + for (const nodeId of this.nodes.keys()) { + if (!visited.has(nodeId)) { + visit(nodeId); + } + } + + // Since we put dependencies first, we reverse the topological sort order + return order.reverse(); + } +} diff --git a/src/manifest.test.ts b/src/manifest.test.ts index 2fde58a..bab739e 100644 --- a/src/manifest.test.ts +++ b/src/manifest.test.ts @@ -1,74 +1,74 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { PxmlCache } from '../src/cache/index.ts'; -import { PxmlManifest } from '../src/manifest/index.ts'; -import { Node } from '../src/parser/schema.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -const TMP_DIR = '/tmp/pxml-test-manifest'; - -describe('PxmlCache & Manifest', () => { - beforeEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - afterEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - const mockNode: Node = { - id: 'api.posts.create', - type: 'api-route', - flow: 'blog.write', - meta: { - path: 'app/api/posts/route.ts', - depends_on: [] - }, - input: [], - output: [], - constraints: [], - tests: [] - }; - - it('should generate stable hash for same node structure', () => { - const hash1 = PxmlCache.hashNode(mockNode); - const hash2 = PxmlCache.hashNode({ ...mockNode }); - expect(hash1).toBe(hash2); - - const changedNode = { ...mockNode, flow: 'blog.admin' }; - const hash3 = PxmlCache.hashNode(changedNode); - expect(hash1).not.toBe(hash3); - }); - - it('should load, update, save, and lock manifest states', () => { - const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); - const hash = PxmlCache.hashNode(mockNode); - - manifest.setNode(mockNode.id, { - node_id: mockNode.id, - source_file: 'blog.xml', - xml_hash: hash, - output_files: [mockNode.meta.path], - depends_on: mockNode.meta.depends_on, - flow: mockNode.flow, - generated_at: new Date().toISOString() - }); - manifest.save(); - - // Re-load - const manifest2 = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); - const node = manifest2.getNode(mockNode.id); - expect(node).toBeDefined(); - expect(node?.xml_hash).toBe(hash); - expect(node?.locked).toBe(false); - - // Lock node - manifest2.lockNode(mockNode.id, true); - const lockedNode = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0').getNode(mockNode.id); - expect(lockedNode?.locked).toBe(true); - }); -}); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { PxmlCache } from '../src/cache/index.ts'; +import { PxmlManifest } from '../src/manifest/index.ts'; +import { Node } from '../src/parser/schema.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TMP_DIR = '/tmp/pxml-test-manifest'; + +describe('PxmlCache & Manifest', () => { + beforeEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + const mockNode: Node = { + id: 'api.posts.create', + type: 'api-route', + flow: 'blog.write', + meta: { + path: 'app/api/posts/route.ts', + depends_on: [] + }, + input: [], + output: [], + constraints: [], + tests: [] + }; + + it('should generate stable hash for same node structure', () => { + const hash1 = PxmlCache.hashNode(mockNode); + const hash2 = PxmlCache.hashNode({ ...mockNode }); + expect(hash1).toBe(hash2); + + const changedNode = { ...mockNode, flow: 'blog.admin' }; + const hash3 = PxmlCache.hashNode(changedNode); + expect(hash1).not.toBe(hash3); + }); + + it('should load, update, save, and lock manifest states', () => { + const manifest = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); + const hash = PxmlCache.hashNode(mockNode); + + manifest.setNode(mockNode.id, { + node_id: mockNode.id, + source_file: 'blog.xml', + xml_hash: hash, + output_files: [mockNode.meta.path], + depends_on: mockNode.meta.depends_on, + flow: mockNode.flow, + generated_at: new Date().toISOString() + }); + manifest.save(); + + // Re-load + const manifest2 = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0'); + const node = manifest2.getNode(mockNode.id); + expect(node).toBeDefined(); + expect(node?.xml_hash).toBe(hash); + expect(node?.locked).toBe(false); + + // Lock node + manifest2.lockNode(mockNode.id, true); + const lockedNode = new PxmlManifest(TMP_DIR, 'test-project', '0.1.0').getNode(mockNode.id); + expect(lockedNode?.locked).toBe(true); + }); +}); diff --git a/src/manifest/index.ts b/src/manifest/index.ts index ab86afb..7998912 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -1,85 +1,85 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export interface ManifestNode { - node_id: string; - source_file: string; - xml_hash: string; - output_files: string[]; - depends_on: string[]; - flow: string; - locked: boolean; - last_test_run?: Record; - generated_at?: string; -} - -export interface Manifest { - project_name: string; - version: string; - nodes: Record; -} - -export class PxmlManifest { - private manifestPath: string; - private currentManifest: Manifest; - - constructor(projectDir: string, projectName: string, version: string) { - this.manifestPath = path.join(projectDir, '.pxml', 'manifest.json'); - this.currentManifest = this.loadOrCreate(projectName, version); - } - - private loadOrCreate(projectName: string, version: string): Manifest { - const dir = path.dirname(this.manifestPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - if (fs.existsSync(this.manifestPath)) { - try { - const content = fs.readFileSync(this.manifestPath, 'utf-8'); - return JSON.parse(content) as Manifest; - } catch (err) { - // Fallback to fresh if corrupted - } - } - - return { - project_name: projectName, - version: version, - nodes: {} - }; - } - - get(): Manifest { - return this.currentManifest; - } - - getNode(nodeId: string): ManifestNode | undefined { - return this.currentManifest.nodes[nodeId]; - } - - setNode(nodeId: string, nodeData: Omit & { locked?: boolean }) { - const existing = this.currentManifest.nodes[nodeId]; - this.currentManifest.nodes[nodeId] = { - ...nodeData, - locked: nodeData.locked ?? existing?.locked ?? false - }; - } - - save() { - fs.writeFileSync(this.manifestPath, JSON.stringify(this.currentManifest, null, 2), 'utf-8'); - } - - lockNode(nodeId: string, locked: boolean) { - const node = this.currentManifest.nodes[nodeId]; - if (node) { - node.locked = locked; - this.save(); - } - } - - clear() { - this.currentManifest.nodes = {}; - this.save(); - } -} +import * as fs from 'fs'; +import * as path from 'path'; + +export interface ManifestNode { + node_id: string; + source_file: string; + xml_hash: string; + output_files: string[]; + depends_on: string[]; + flow: string; + locked: boolean; + last_test_run?: Record; + generated_at?: string; +} + +export interface Manifest { + project_name: string; + version: string; + nodes: Record; +} + +export class PxmlManifest { + private manifestPath: string; + private currentManifest: Manifest; + + constructor(projectDir: string, projectName: string, version: string) { + this.manifestPath = path.join(projectDir, '.pxml', 'manifest.json'); + this.currentManifest = this.loadOrCreate(projectName, version); + } + + private loadOrCreate(projectName: string, version: string): Manifest { + const dir = path.dirname(this.manifestPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this.manifestPath)) { + try { + const content = fs.readFileSync(this.manifestPath, 'utf-8'); + return JSON.parse(content) as Manifest; + } catch (err) { + // Fallback to fresh if corrupted + } + } + + return { + project_name: projectName, + version: version, + nodes: {} + }; + } + + get(): Manifest { + return this.currentManifest; + } + + getNode(nodeId: string): ManifestNode | undefined { + return this.currentManifest.nodes[nodeId]; + } + + setNode(nodeId: string, nodeData: Omit & { locked?: boolean }) { + const existing = this.currentManifest.nodes[nodeId]; + this.currentManifest.nodes[nodeId] = { + ...nodeData, + locked: nodeData.locked ?? existing?.locked ?? false + }; + } + + save() { + fs.writeFileSync(this.manifestPath, JSON.stringify(this.currentManifest, null, 2), 'utf-8'); + } + + lockNode(nodeId: string, locked: boolean) { + const node = this.currentManifest.nodes[nodeId]; + if (node) { + node.locked = locked; + this.save(); + } + } + + clear() { + this.currentManifest.nodes = {}; + this.save(); + } +} diff --git a/src/parser.test.ts b/src/parser.test.ts index 68f5523..9166be6 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -1,207 +1,207 @@ -import { describe, it, expect } from 'vitest'; -import { PxmlParser, validateProject } from '../src/parser/index.ts'; -import { DependencyGraph } from '../src/graph/index.ts'; -import * as path from 'path'; - -describe('PxmlParser', () => { - it('should parse project, resolve imports, merge extends, and detect cycles', () => { - const parser = new PxmlParser(); - const projectXml = path.resolve(__dirname, '../fixtures/project.xml'); - const project = parser.parse(projectXml); - - expect(project.name).toBe('main-blog'); - expect(project.nodes.length).toBe(3); // base.api-route, db.post, and api.posts.list (with namespace prefixes) - - const listNode = project.nodes.find(n => n.id === 'read:api.posts.list'); - expect(listNode).toBeDefined(); - expect(listNode?.type).toBe('api-route'); - // Inherited metadata and constraints - expect(listNode?.meta.path).toBe('app/api/posts/route.ts'); - expect(listNode?.constraints.length).toBe(3); // 1 from parent, 2 from listNode - expect(listNode?.constraints[0].description).toBe('No sensitive data leakage in response or logs'); - expect(listNode?.constraints[1].description).toBe('Sort by publishedAt descending'); - }); - - it('should throw circular import error', () => { - const parser = new PxmlParser(); - const fs = require('fs'); - fs.writeFileSync('/tmp/a.xml', ``); - fs.writeFileSync('/tmp/b.xml', ``); - - expect(() => parser.parse('/tmp/a.xml')).toThrow('Circular import detected'); - }); - - it('should parse learned-from attribute on constraints', () => { - const parser = new PxmlParser(); - const fs = require('fs'); - fs.writeFileSync('/tmp/learned_test.xml', ` - - - - app/api/test.ts - - Do not block db connections - - - `); - const project = parser.parse('/tmp/learned_test.xml'); - const node = project.nodes.find(n => n.id === 'node.test'); - expect(node).toBeDefined(); - expect(node?.constraints[0].learnedFrom).toBe('bug.db_lock'); - expect(node?.constraints[0].verify).toBe('static'); - expect(node?.constraints[0].description).toBe('Do not block db connections'); - }); -}); - -describe('DependencyGraph', () => { - it('should sort nodes topologically and detect circular dependency', () => { - const parser = new PxmlParser(); - const projectXml = path.resolve(__dirname, '../fixtures/project.xml'); - const project = parser.parse(projectXml); - - const graph = new DependencyGraph(project.nodes); - const order = graph.getSortOrder(); - - // db.post should come before api.posts.list because api.posts.list depends on db.post - const dbIndex = order.indexOf('read:types:db.post'); - const apiIndex = order.indexOf('read:api.posts.list'); - expect(dbIndex).toBeGreaterThan(-1); - expect(apiIndex).toBeGreaterThan(-1); - expect(dbIndex).toBeLessThan(apiIndex); - }); -}); - -describe('validateProject', () => { - it('should throw if a node has output but no tests', () => { - const project = { - name: 'test', - stack: 'nextjs', - version: '0.1.0', - nodes: [ - { - id: 'api.test', - type: 'api-route', - flow: 'test', - meta: { path: 'app/api/test.ts', depends_on: [] }, - input: [], - output: [{ name: 'res', type: 'string', required: true }], - constraints: [], - tests: [] - } - ] - }; - expect(() => validateProject(project)).toThrow("has output fields defined, but is missing test cases"); - }); - - it('should throw if a node test is missing required input fields', () => { - const project = { - name: 'test', - stack: 'nextjs', - version: '0.1.0', - nodes: [ - { - id: 'api.test', - type: 'api-route', - flow: 'test', - meta: { path: 'app/api/test.ts', depends_on: [] }, - input: [{ name: 'title', type: 'string', required: true }], - output: [], - constraints: [], - tests: [ - { - name: 'Invalid Test', - given: { body: {} }, - expect: {} - } - ] - } - ] - }; - expect(() => validateProject(project)).toThrow("missing required input field 'title'"); - }); - - it('should throw if a node test contains field not declared in input', () => { - const project = { - name: 'test', - stack: 'nextjs', - version: '0.1.0', - nodes: [ - { - id: 'api.test', - type: 'api-route', - flow: 'test', - meta: { path: 'app/api/test.ts', depends_on: [] }, - input: [{ name: 'title', type: 'string', required: true }], - output: [], - constraints: [], - tests: [ - { - name: 'Extra Field Test', - given: { body: { title: 'hello', unknownField: 'extra' } }, - expect: {} - } - ] - } - ] - }; - expect(() => validateProject(project)).toThrow("specifies field 'unknownField' in given body which is not declared in node inputs"); - }); - - it('should not throw if valid', () => { - const project = { - name: 'test', - stack: 'nextjs', - version: '0.1.0', - nodes: [ - { - id: 'api.test', - type: 'api-route', - flow: 'test', - meta: { path: 'app/api/test.ts', depends_on: [] }, - input: [{ name: 'title', type: 'string', required: true }], - output: [{ name: 'id', type: 'string', required: true }], - constraints: [], - tests: [ - { - name: 'Valid Test', - given: { body: { title: 'hello' } }, - expect: {} - } - ] - } - ] - }; - expect(() => validateProject(project)).not.toThrow(); - }); - - it('should parse and resolve local packages', () => { - const parser = new PxmlParser(); - const fs = require('fs'); - - fs.mkdirSync('/tmp/packages/test-pack', { recursive: true }); - - fs.writeFileSync('/tmp/packages/test-pack/project.xml', ` - - - package.json - Mock setup command - - - `); - - fs.writeFileSync('/tmp/main_proj.xml', ` - - - - - `); - - const project = parser.parse('/tmp/main_proj.xml'); - expect(project.nodes.length).toBe(2); - - const setupNode = project.nodes.find(n => n.id === 'setup.nextjs'); - expect(setupNode).toBeDefined(); - expect(setupNode?.extends).toBe('tpl:base-node'); - expect(setupNode?.constraints[0].description).toBe('Mock setup command'); - }); -}); +import { describe, it, expect } from 'vitest'; +import { PxmlParser, validateProject } from '../src/parser/index.ts'; +import { DependencyGraph } from '../src/graph/index.ts'; +import * as path from 'path'; + +describe('PxmlParser', () => { + it('should parse project, resolve imports, merge extends, and detect cycles', () => { + const parser = new PxmlParser(); + const projectXml = path.resolve(__dirname, '../fixtures/project.xml'); + const project = parser.parse(projectXml); + + expect(project.name).toBe('main-blog'); + expect(project.nodes.length).toBe(3); // base.api-route, db.post, and api.posts.list (with namespace prefixes) + + const listNode = project.nodes.find(n => n.id === 'read:api.posts.list'); + expect(listNode).toBeDefined(); + expect(listNode?.type).toBe('api-route'); + // Inherited metadata and constraints + expect(listNode?.meta.path).toBe('app/api/posts/route.ts'); + expect(listNode?.constraints.length).toBe(3); // 1 from parent, 2 from listNode + expect(listNode?.constraints[0].description).toBe('No sensitive data leakage in response or logs'); + expect(listNode?.constraints[1].description).toBe('Sort by publishedAt descending'); + }); + + it('should throw circular import error', () => { + const parser = new PxmlParser(); + const fs = require('fs'); + fs.writeFileSync('/tmp/a.xml', ``); + fs.writeFileSync('/tmp/b.xml', ``); + + expect(() => parser.parse('/tmp/a.xml')).toThrow('Circular import detected'); + }); + + it('should parse learned-from attribute on constraints', () => { + const parser = new PxmlParser(); + const fs = require('fs'); + fs.writeFileSync('/tmp/learned_test.xml', ` + + + + app/api/test.ts + + Do not block db connections + + + `); + const project = parser.parse('/tmp/learned_test.xml'); + const node = project.nodes.find(n => n.id === 'node.test'); + expect(node).toBeDefined(); + expect(node?.constraints[0].learnedFrom).toBe('bug.db_lock'); + expect(node?.constraints[0].verify).toBe('static'); + expect(node?.constraints[0].description).toBe('Do not block db connections'); + }); +}); + +describe('DependencyGraph', () => { + it('should sort nodes topologically and detect circular dependency', () => { + const parser = new PxmlParser(); + const projectXml = path.resolve(__dirname, '../fixtures/project.xml'); + const project = parser.parse(projectXml); + + const graph = new DependencyGraph(project.nodes); + const order = graph.getSortOrder(); + + // db.post should come before api.posts.list because api.posts.list depends on db.post + const dbIndex = order.indexOf('read:types:db.post'); + const apiIndex = order.indexOf('read:api.posts.list'); + expect(dbIndex).toBeGreaterThan(-1); + expect(apiIndex).toBeGreaterThan(-1); + expect(dbIndex).toBeLessThan(apiIndex); + }); +}); + +describe('validateProject', () => { + it('should throw if a node has output but no tests', () => { + const project = { + name: 'test', + stack: 'nextjs', + version: '0.1.0', + nodes: [ + { + id: 'api.test', + type: 'api-route', + flow: 'test', + meta: { path: 'app/api/test.ts', depends_on: [] }, + input: [], + output: [{ name: 'res', type: 'string', required: true }], + constraints: [], + tests: [] + } + ] + }; + expect(() => validateProject(project)).toThrow("has output fields defined, but is missing test cases"); + }); + + it('should throw if a node test is missing required input fields', () => { + const project = { + name: 'test', + stack: 'nextjs', + version: '0.1.0', + nodes: [ + { + id: 'api.test', + type: 'api-route', + flow: 'test', + meta: { path: 'app/api/test.ts', depends_on: [] }, + input: [{ name: 'title', type: 'string', required: true }], + output: [], + constraints: [], + tests: [ + { + name: 'Invalid Test', + given: { body: {} }, + expect: {} + } + ] + } + ] + }; + expect(() => validateProject(project)).toThrow("missing required input field 'title'"); + }); + + it('should throw if a node test contains field not declared in input', () => { + const project = { + name: 'test', + stack: 'nextjs', + version: '0.1.0', + nodes: [ + { + id: 'api.test', + type: 'api-route', + flow: 'test', + meta: { path: 'app/api/test.ts', depends_on: [] }, + input: [{ name: 'title', type: 'string', required: true }], + output: [], + constraints: [], + tests: [ + { + name: 'Extra Field Test', + given: { body: { title: 'hello', unknownField: 'extra' } }, + expect: {} + } + ] + } + ] + }; + expect(() => validateProject(project)).toThrow("specifies field 'unknownField' in given body which is not declared in node inputs"); + }); + + it('should not throw if valid', () => { + const project = { + name: 'test', + stack: 'nextjs', + version: '0.1.0', + nodes: [ + { + id: 'api.test', + type: 'api-route', + flow: 'test', + meta: { path: 'app/api/test.ts', depends_on: [] }, + input: [{ name: 'title', type: 'string', required: true }], + output: [{ name: 'id', type: 'string', required: true }], + constraints: [], + tests: [ + { + name: 'Valid Test', + given: { body: { title: 'hello' } }, + expect: {} + } + ] + } + ] + }; + expect(() => validateProject(project)).not.toThrow(); + }); + + it('should parse and resolve local packages', () => { + const parser = new PxmlParser(); + const fs = require('fs'); + + fs.mkdirSync('/tmp/packages/test-pack', { recursive: true }); + + fs.writeFileSync('/tmp/packages/test-pack/project.xml', ` + + + package.json + Mock setup command + + + `); + + fs.writeFileSync('/tmp/main_proj.xml', ` + + + + + `); + + const project = parser.parse('/tmp/main_proj.xml'); + expect(project.nodes.length).toBe(2); + + const setupNode = project.nodes.find(n => n.id === 'setup.nextjs'); + expect(setupNode).toBeDefined(); + expect(setupNode?.extends).toBe('tpl:base-node'); + expect(setupNode?.constraints[0].description).toBe('Mock setup command'); + }); +}); diff --git a/src/parser/index.ts b/src/parser/index.ts index f02a911..78dfc90 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -1,390 +1,390 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { XMLParser } from 'fast-xml-parser'; -import { ProjectSchema, Project, Node, NodeSchema } from './schema.js'; - -interface RawNode { - '@_id': string; - '@_type': string; - '@_flow': string; - '@_extends'?: string; - '@_autogen-tests'?: string | boolean; - meta?: { - path?: string; - depends_on?: string | string[]; - }; - input?: { field?: any[] | any }; - output?: { field?: any[] | any }; - constraint?: any[] | any; - test?: any[] | any; -} - -interface RawImport { - '@_src'?: string; - '@_package'?: string; - '@_from'?: string; - '@_as': string; -} - -interface RawProject { - project: { - '@_name': string; - '@_stack': string; - '@_version': string; - '@_autogen-tests'?: string | boolean; - import?: RawImport[] | RawImport; - node?: RawNode[] | RawNode; - }; -} - -export class PxmlParser { - private visitedFiles = new Set(); - private loadedProjects = new Map(); - - parse(filePath: string): Project { - const absolutePath = path.resolve(filePath); - if (this.visitedFiles.has(absolutePath)) { - throw new Error(`Circular import detected: ${Array.from(this.visitedFiles).join(' -> ')} -> ${absolutePath}`); - } - - this.visitedFiles.add(absolutePath); - - if (!fs.existsSync(absolutePath)) { - throw new Error(`File not found: ${absolutePath}`); - } - - const xmlContent = fs.readFileSync(absolutePath, 'utf-8'); - const options = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: true, - parseAttributeValue: true, - }; - const parser = new XMLParser(options); - const parsedObj = parser.parse(xmlContent) as RawProject; - - if (!parsedObj.project) { - throw new Error(`Invalid pxml file: root element must be in ${absolutePath}`); - } - - const rawProj = parsedObj.project; - const name = String(rawProj['@_name'] || ''); - const stack = String(rawProj['@_stack'] || ''); - const version = String(rawProj['@_version'] || ''); - const autogenTestsProj = rawProj['@_autogen-tests'] !== undefined ? String(rawProj['@_autogen-tests']) === 'true' : true; - - const rawImports = rawProj.import - ? (Array.isArray(rawProj.import) ? rawProj.import : [rawProj.import]) - : []; - const parsedImports = rawImports.map(imp => ({ - src: imp['@_src'], - package: imp['@_package'], - from: imp['@_from'], - as: imp['@_as'] - })); - - const rawNodes = rawProj.node - ? (Array.isArray(rawProj.node) ? rawProj.node : [rawProj.node]) - : []; - - const nodes: Node[] = rawNodes.map(rn => { - const dependsRaw = rn.meta?.depends_on; - const dependsOn: string[] = []; - if (typeof dependsRaw === 'string') { - dependsOn.push(dependsRaw); - } else if (Array.isArray(dependsRaw)) { - dependsOn.push(...dependsRaw); - } - - const inputRaw = rn.input?.field; - const input = inputRaw - ? (Array.isArray(inputRaw) ? inputRaw : [inputRaw]).map(f => ({ - name: f['@_name'], - type: f['@_type'], - required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, - format: f['@_format'] - })) - : []; - - const outputRaw = rn.output?.field; - const output = outputRaw - ? (Array.isArray(outputRaw) ? outputRaw : [outputRaw]).map(f => ({ - name: f['@_name'], - type: f['@_type'], - required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, - format: f['@_format'] - })) - : []; - - const constraintRaw = rn.constraint; - const constraints = constraintRaw - ? (Array.isArray(constraintRaw) ? constraintRaw : [constraintRaw]).map(c => { - const verify = c['@_verify'] || 'static'; - const description = typeof c === 'object' ? c['#text'] || '' : String(c); - const learnedFrom = typeof c === 'object' ? c['@_learned-from'] : undefined; - return { verify, description, learnedFrom }; - }) - : []; - - const testRaw = rn.test; - const tests = testRaw - ? (Array.isArray(testRaw) ? testRaw : [testRaw]).map(t => { - const nameVal = t.name || ''; - const given = t.given || {}; - const expectRaw = t.expect || {}; - const expect = { - field: expectRaw.field, - status: expectRaw.status !== undefined ? Number(expectRaw.status) : undefined, - body: expectRaw.body, - contains: expectRaw.contains, - match: expectRaw.match - }; - return { name: nameVal, given, expect }; - }) - : []; - - const autogenTestsNode = rn['@_autogen-tests'] !== undefined ? String(rn['@_autogen-tests']) === 'true' : undefined; - const autogenTests = autogenTestsNode ?? autogenTestsProj; - - return NodeSchema.parse({ - id: rn['@_id'], - type: rn['@_type'], - flow: rn['@_flow'], - extends: rn['@_extends'], - autogenTests, - meta: { - path: rn.meta?.path || '', - depends_on: dependsOn - }, - input, - output, - constraints, - tests - }); - }); - - const currentProject = ProjectSchema.parse({ - name, - stack, - version, - autogenTests: autogenTestsProj, - imports: parsedImports, - nodes - }); - - this.loadedProjects.set(absolutePath, currentProject); - - // Resolve imports recursively and build final flattened project AST - const baseDir = path.dirname(absolutePath); - const resolvedNodes: Node[] = []; - - // Track imported files to avoid duplicate parsing/nodes if imported multiple times - const importedPaths = new Set(); - - const prefixNode = (node: Node, namespace: string): Node => { - const prefixId = (id: string) => { - if (id.includes(':')) { - // If it already has namespace, prepend new namespace - return `${namespace}:${id}`; - } - return `${namespace}:${id}`; - }; - - return { - ...node, - id: prefixId(node.id), - extends: node.extends ? prefixId(node.extends) : undefined, - meta: { - ...node.meta, - depends_on: node.meta.depends_on.map(prefixId) - } - }; - }; - - for (const imp of currentProject.imports) { - let importedPath = ''; - if (imp.src) { - importedPath = path.resolve(baseDir, imp.src); - } else if (imp.package && imp.from) { - if (imp.from.startsWith('github:')) { - const parts = imp.from.replace(/^github:/, '').split('/'); - const owner = parts[0]; - const repo = parts[1]; - const cacheDir = path.join(process.cwd(), '.pxml', 'packages', 'github', owner, repo); - if (!fs.existsSync(cacheDir)) { - fs.mkdirSync(cacheDir, { recursive: true }); - const gitUrl = `https://github.com/${owner}/${repo}.git`; - console.log(`[PACKAGE] Cloning package ${imp.package} from ${gitUrl}...`); - const { execSync } = require('child_process'); - execSync(`git clone --depth 1 ${gitUrl} ${cacheDir}`, { stdio: 'ignore' }); - } - importedPath = path.join(cacheDir, 'project.xml'); - } else { - importedPath = path.resolve(process.cwd(), imp.from, 'project.xml'); - } - } - - if (!importedPath || !fs.existsSync(importedPath)) { - throw new Error(`Import failed: package/src not found at ${importedPath || imp.src || imp.from}`); - } - - if (importedPaths.has(importedPath)) continue; - importedPaths.add(importedPath); - - const importedProj = this.parse(importedPath); - - // We only prefix nodes that were defined in the imported project, - // which includes nodes it has imported. Let's make sure we prefix all of them. - const prefixedNodes = importedProj.nodes.map(node => prefixNode(node, imp.as)); - resolvedNodes.push(...prefixedNodes); - } - - // Only include currentProject nodes if this is NOT an imported project, - // or include them and let the caller manage prefixing. - // Actually, to make a unified flat AST, the entry project XML (e.g. project.xml) has nodes of its own, - // and also brings in imported nodes. - // If we are parsing a nested import, we return its nodes, which get prefixed by the parent parser. - // So the parser call should just return the currentProject.nodes. - // But wait! If currentProject has nodes and imports, does currentProject.nodes already get returned? Yes. - // But does currentProject.nodes contain the resolved import nodes? No, they are only in resolvedNodes. - // So we should return resolvedNodes (which includes currentProject.nodes plus the prefixed imported nodes). - resolvedNodes.push(...currentProject.nodes); - - this.visitedFiles.delete(absolutePath); - - // Print resolved node IDs for debugging if needed - // console.log(resolvedNodes.map(n => n.id)); - - // Dedup nodes here to avoid extending issues or duplicate resolve calls - const resolvedNodesMap = new Map(); - for (const node of resolvedNodes) { - resolvedNodesMap.set(node.id, node); - } - const uniqueResolvedNodes = Array.from(resolvedNodesMap.values()); - - const finalNodes = this.resolveExtends(uniqueResolvedNodes); - - // Deduplicate nodes by id, taking the last defined (allows overrides) - const dedupedMap = new Map(); - for (const node of finalNodes) { - dedupedMap.set(node.id, node); - } - - return { - ...currentProject, - imports: [], // Empty imports as they are now flattened - nodes: Array.from(dedupedMap.values()) - }; - } - - private resolveExtends(nodes: Node[]): Node[] { - const nodeMap = new Map(nodes.map(n => [n.id, n])); - const resolvedMap = new Map(); - - const resolveNode = (id: string, depth = 0): Node => { - if (depth > 2) { - throw new Error(`Inheritance depth limit exceeded (max 2 levels) for node: ${id}`); - } - - if (resolvedMap.has(id)) { - return resolvedMap.get(id)!; - } - - const node = nodeMap.get(id); - if (!node) { - throw new Error(`Node not found to extend: ${id}`); - } - - if (!node.extends) { - resolvedMap.set(id, node); - return node; - } - - const parentNode = resolveNode(node.extends, depth + 1); - - // Merge constraints and tests - const mergedConstraints = [...parentNode.constraints]; - for (const childC of node.constraints) { - // Prevent duplicate constraints if merged already - if (!mergedConstraints.some(c => c.description === childC.description)) { - mergedConstraints.push(childC); - } - } - - const mergedTests = [...parentNode.tests]; - for (const childT of node.tests) { - if (!mergedTests.some(t => t.name === childT.name)) { - mergedTests.push(childT); - } - } - - const mergedNode: Node = { - ...node, - // If meta.path is not specified, inherit from parent - meta: { - path: node.meta.path || parentNode.meta.path, - depends_on: Array.from(new Set([...node.meta.depends_on, ...parentNode.meta.depends_on])) - }, - input: [...parentNode.input, ...node.input], - output: [...parentNode.output, ...node.output], - constraints: mergedConstraints, - tests: mergedTests - }; - - resolvedMap.set(id, mergedNode); - return mergedNode; - }; - - return nodes.map(node => resolveNode(node.id)); - } -} - -export function validateProject(project: Project): void { - for (const node of project.nodes) { - if (node.output.length > 0 && node.type !== 'db-model' && node.type !== 'setup-command' && node.tests.length === 0) { - throw new Error(`Validation Error: Node '${node.id}' has output fields defined, but is missing test cases.`); - } - - if (node.input.length > 0) { - for (const test of node.tests) { - const given = test.given || {}; - - for (const field of node.input) { - if (field.required) { - const inRoot = given[field.name] !== undefined; - const inBody = given.body && typeof given.body === 'object' && given.body[field.name] !== undefined; - const inQuery = given.query && typeof given.query === 'object' && given.query[field.name] !== undefined; - const inHeaders = given.headers && typeof given.headers === 'object' && given.headers[field.name] !== undefined; - - if (!inRoot && !inBody && !inQuery && !inHeaders) { - throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' is missing required input field '${field.name}' in 'given'.`); - } - } - } - - const allowedRootKeys = new Set(['method', 'headers', 'query', 'body']); - const inputFieldNames = new Set(node.input.map(f => f.name)); - - const checkKeys = (obj: any, locationName: string) => { - if (!obj || typeof obj !== 'object') return; - for (const key of Object.keys(obj)) { - if (key.startsWith('@_')) continue; - if (locationName === 'root' && allowedRootKeys.has(key)) continue; - - if (!inputFieldNames.has(key)) { - throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' specifies field '${key}' in given ${locationName} which is not declared in node inputs.`); - } - } - }; - - checkKeys(given, 'root'); - if (given.body && typeof given.body === 'object') { - checkKeys(given.body, 'body'); - } - if (given.query && typeof given.query === 'object') { - checkKeys(given.query, 'query'); - } - } - } - } -} +import * as fs from 'fs'; +import * as path from 'path'; +import { XMLParser } from 'fast-xml-parser'; +import { ProjectSchema, Project, Node, NodeSchema } from './schema.js'; + +interface RawNode { + '@_id': string; + '@_type': string; + '@_flow': string; + '@_extends'?: string; + '@_autogen-tests'?: string | boolean; + meta?: { + path?: string; + depends_on?: string | string[]; + }; + input?: { field?: any[] | any }; + output?: { field?: any[] | any }; + constraint?: any[] | any; + test?: any[] | any; +} + +interface RawImport { + '@_src'?: string; + '@_package'?: string; + '@_from'?: string; + '@_as': string; +} + +interface RawProject { + project: { + '@_name': string; + '@_stack': string; + '@_version': string; + '@_autogen-tests'?: string | boolean; + import?: RawImport[] | RawImport; + node?: RawNode[] | RawNode; + }; +} + +export class PxmlParser { + private visitedFiles = new Set(); + private loadedProjects = new Map(); + + parse(filePath: string): Project { + const absolutePath = path.resolve(filePath); + if (this.visitedFiles.has(absolutePath)) { + throw new Error(`Circular import detected: ${Array.from(this.visitedFiles).join(' -> ')} -> ${absolutePath}`); + } + + this.visitedFiles.add(absolutePath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`File not found: ${absolutePath}`); + } + + const xmlContent = fs.readFileSync(absolutePath, 'utf-8'); + const options = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseAttributeValue: true, + }; + const parser = new XMLParser(options); + const parsedObj = parser.parse(xmlContent) as RawProject; + + if (!parsedObj.project) { + throw new Error(`Invalid pxml file: root element must be in ${absolutePath}`); + } + + const rawProj = parsedObj.project; + const name = String(rawProj['@_name'] || ''); + const stack = String(rawProj['@_stack'] || ''); + const version = String(rawProj['@_version'] || ''); + const autogenTestsProj = rawProj['@_autogen-tests'] !== undefined ? String(rawProj['@_autogen-tests']) === 'true' : true; + + const rawImports = rawProj.import + ? (Array.isArray(rawProj.import) ? rawProj.import : [rawProj.import]) + : []; + const parsedImports = rawImports.map(imp => ({ + src: imp['@_src'], + package: imp['@_package'], + from: imp['@_from'], + as: imp['@_as'] + })); + + const rawNodes = rawProj.node + ? (Array.isArray(rawProj.node) ? rawProj.node : [rawProj.node]) + : []; + + const nodes: Node[] = rawNodes.map(rn => { + const dependsRaw = rn.meta?.depends_on; + const dependsOn: string[] = []; + if (typeof dependsRaw === 'string') { + dependsOn.push(dependsRaw); + } else if (Array.isArray(dependsRaw)) { + dependsOn.push(...dependsRaw); + } + + const inputRaw = rn.input?.field; + const input = inputRaw + ? (Array.isArray(inputRaw) ? inputRaw : [inputRaw]).map(f => ({ + name: f['@_name'], + type: f['@_type'], + required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, + format: f['@_format'] + })) + : []; + + const outputRaw = rn.output?.field; + const output = outputRaw + ? (Array.isArray(outputRaw) ? outputRaw : [outputRaw]).map(f => ({ + name: f['@_name'], + type: f['@_type'], + required: f['@_required'] !== undefined ? String(f['@_required']) === 'true' : true, + format: f['@_format'] + })) + : []; + + const constraintRaw = rn.constraint; + const constraints = constraintRaw + ? (Array.isArray(constraintRaw) ? constraintRaw : [constraintRaw]).map(c => { + const verify = c['@_verify'] || 'static'; + const description = typeof c === 'object' ? c['#text'] || '' : String(c); + const learnedFrom = typeof c === 'object' ? c['@_learned-from'] : undefined; + return { verify, description, learnedFrom }; + }) + : []; + + const testRaw = rn.test; + const tests = testRaw + ? (Array.isArray(testRaw) ? testRaw : [testRaw]).map(t => { + const nameVal = t.name || ''; + const given = t.given || {}; + const expectRaw = t.expect || {}; + const expect = { + field: expectRaw.field, + status: expectRaw.status !== undefined ? Number(expectRaw.status) : undefined, + body: expectRaw.body, + contains: expectRaw.contains, + match: expectRaw.match + }; + return { name: nameVal, given, expect }; + }) + : []; + + const autogenTestsNode = rn['@_autogen-tests'] !== undefined ? String(rn['@_autogen-tests']) === 'true' : undefined; + const autogenTests = autogenTestsNode ?? autogenTestsProj; + + return NodeSchema.parse({ + id: rn['@_id'], + type: rn['@_type'], + flow: rn['@_flow'], + extends: rn['@_extends'], + autogenTests, + meta: { + path: rn.meta?.path || '', + depends_on: dependsOn + }, + input, + output, + constraints, + tests + }); + }); + + const currentProject = ProjectSchema.parse({ + name, + stack, + version, + autogenTests: autogenTestsProj, + imports: parsedImports, + nodes + }); + + this.loadedProjects.set(absolutePath, currentProject); + + // Resolve imports recursively and build final flattened project AST + const baseDir = path.dirname(absolutePath); + const resolvedNodes: Node[] = []; + + // Track imported files to avoid duplicate parsing/nodes if imported multiple times + const importedPaths = new Set(); + + const prefixNode = (node: Node, namespace: string): Node => { + const prefixId = (id: string) => { + if (id.includes(':')) { + // If it already has namespace, prepend new namespace + return `${namespace}:${id}`; + } + return `${namespace}:${id}`; + }; + + return { + ...node, + id: prefixId(node.id), + extends: node.extends ? prefixId(node.extends) : undefined, + meta: { + ...node.meta, + depends_on: node.meta.depends_on.map(prefixId) + } + }; + }; + + for (const imp of currentProject.imports) { + let importedPath = ''; + if (imp.src) { + importedPath = path.resolve(baseDir, imp.src); + } else if (imp.package && imp.from) { + if (imp.from.startsWith('github:')) { + const parts = imp.from.replace(/^github:/, '').split('/'); + const owner = parts[0]; + const repo = parts[1]; + const cacheDir = path.join(process.cwd(), '.pxml', 'packages', 'github', owner, repo); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + const gitUrl = `https://github.com/${owner}/${repo}.git`; + console.log(`[PACKAGE] Cloning package ${imp.package} from ${gitUrl}...`); + const { execSync } = require('child_process'); + execSync(`git clone --depth 1 ${gitUrl} ${cacheDir}`, { stdio: 'ignore' }); + } + importedPath = path.join(cacheDir, 'project.xml'); + } else { + importedPath = path.resolve(process.cwd(), imp.from, 'project.xml'); + } + } + + if (!importedPath || !fs.existsSync(importedPath)) { + throw new Error(`Import failed: package/src not found at ${importedPath || imp.src || imp.from}`); + } + + if (importedPaths.has(importedPath)) continue; + importedPaths.add(importedPath); + + const importedProj = this.parse(importedPath); + + // We only prefix nodes that were defined in the imported project, + // which includes nodes it has imported. Let's make sure we prefix all of them. + const prefixedNodes = importedProj.nodes.map(node => prefixNode(node, imp.as)); + resolvedNodes.push(...prefixedNodes); + } + + // Only include currentProject nodes if this is NOT an imported project, + // or include them and let the caller manage prefixing. + // Actually, to make a unified flat AST, the entry project XML (e.g. project.xml) has nodes of its own, + // and also brings in imported nodes. + // If we are parsing a nested import, we return its nodes, which get prefixed by the parent parser. + // So the parser call should just return the currentProject.nodes. + // But wait! If currentProject has nodes and imports, does currentProject.nodes already get returned? Yes. + // But does currentProject.nodes contain the resolved import nodes? No, they are only in resolvedNodes. + // So we should return resolvedNodes (which includes currentProject.nodes plus the prefixed imported nodes). + resolvedNodes.push(...currentProject.nodes); + + this.visitedFiles.delete(absolutePath); + + // Print resolved node IDs for debugging if needed + // console.log(resolvedNodes.map(n => n.id)); + + // Dedup nodes here to avoid extending issues or duplicate resolve calls + const resolvedNodesMap = new Map(); + for (const node of resolvedNodes) { + resolvedNodesMap.set(node.id, node); + } + const uniqueResolvedNodes = Array.from(resolvedNodesMap.values()); + + const finalNodes = this.resolveExtends(uniqueResolvedNodes); + + // Deduplicate nodes by id, taking the last defined (allows overrides) + const dedupedMap = new Map(); + for (const node of finalNodes) { + dedupedMap.set(node.id, node); + } + + return { + ...currentProject, + imports: [], // Empty imports as they are now flattened + nodes: Array.from(dedupedMap.values()) + }; + } + + private resolveExtends(nodes: Node[]): Node[] { + const nodeMap = new Map(nodes.map(n => [n.id, n])); + const resolvedMap = new Map(); + + const resolveNode = (id: string, depth = 0): Node => { + if (depth > 2) { + throw new Error(`Inheritance depth limit exceeded (max 2 levels) for node: ${id}`); + } + + if (resolvedMap.has(id)) { + return resolvedMap.get(id)!; + } + + const node = nodeMap.get(id); + if (!node) { + throw new Error(`Node not found to extend: ${id}`); + } + + if (!node.extends) { + resolvedMap.set(id, node); + return node; + } + + const parentNode = resolveNode(node.extends, depth + 1); + + // Merge constraints and tests + const mergedConstraints = [...parentNode.constraints]; + for (const childC of node.constraints) { + // Prevent duplicate constraints if merged already + if (!mergedConstraints.some(c => c.description === childC.description)) { + mergedConstraints.push(childC); + } + } + + const mergedTests = [...parentNode.tests]; + for (const childT of node.tests) { + if (!mergedTests.some(t => t.name === childT.name)) { + mergedTests.push(childT); + } + } + + const mergedNode: Node = { + ...node, + // If meta.path is not specified, inherit from parent + meta: { + path: node.meta.path || parentNode.meta.path, + depends_on: Array.from(new Set([...node.meta.depends_on, ...parentNode.meta.depends_on])) + }, + input: [...parentNode.input, ...node.input], + output: [...parentNode.output, ...node.output], + constraints: mergedConstraints, + tests: mergedTests + }; + + resolvedMap.set(id, mergedNode); + return mergedNode; + }; + + return nodes.map(node => resolveNode(node.id)); + } +} + +export function validateProject(project: Project): void { + for (const node of project.nodes) { + if (node.output.length > 0 && node.type !== 'db-model' && node.type !== 'setup-command' && node.tests.length === 0) { + throw new Error(`Validation Error: Node '${node.id}' has output fields defined, but is missing test cases.`); + } + + if (node.input.length > 0) { + for (const test of node.tests) { + const given = test.given || {}; + + for (const field of node.input) { + if (field.required) { + const inRoot = given[field.name] !== undefined; + const inBody = given.body && typeof given.body === 'object' && given.body[field.name] !== undefined; + const inQuery = given.query && typeof given.query === 'object' && given.query[field.name] !== undefined; + const inHeaders = given.headers && typeof given.headers === 'object' && given.headers[field.name] !== undefined; + + if (!inRoot && !inBody && !inQuery && !inHeaders) { + throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' is missing required input field '${field.name}' in 'given'.`); + } + } + } + + const allowedRootKeys = new Set(['method', 'headers', 'query', 'body']); + const inputFieldNames = new Set(node.input.map(f => f.name)); + + const checkKeys = (obj: any, locationName: string) => { + if (!obj || typeof obj !== 'object') return; + for (const key of Object.keys(obj)) { + if (key.startsWith('@_')) continue; + if (locationName === 'root' && allowedRootKeys.has(key)) continue; + + if (!inputFieldNames.has(key)) { + throw new Error(`Validation Error: Node '${node.id}' test '${test.name}' specifies field '${key}' in given ${locationName} which is not declared in node inputs.`); + } + } + }; + + checkKeys(given, 'root'); + if (given.body && typeof given.body === 'object') { + checkKeys(given.body, 'body'); + } + if (given.query && typeof given.query === 'object') { + checkKeys(given.query, 'query'); + } + } + } + } +} diff --git a/src/parser/schema.ts b/src/parser/schema.ts index 3c3563f..c1872fc 100644 --- a/src/parser/schema.ts +++ b/src/parser/schema.ts @@ -1,68 +1,68 @@ -import { z } from 'zod'; - -export const ImportSchema = z.object({ - src: z.string().optional(), - package: z.string().optional(), - from: z.string().optional(), - as: z.string() -}); - -export const MetaSchema = z.object({ - path: z.string(), - depends_on: z.array(z.string()).default([]) -}); - -export const FieldSchema = z.object({ - name: z.string(), - type: z.string(), - required: z.boolean().default(true), - format: z.string().optional() -}); - -export const ConstraintSchema = z.object({ - verify: z.enum(['static', 'llm-judge']).default('static'), - description: z.string(), - learnedFrom: z.string().optional() -}); - -export const TestExpectSchema = z.object({ - field: z.string().optional(), - status: z.number().optional(), - body: z.any().optional(), - contains: z.string().optional(), - match: z.string().optional() -}); - -export const TestCaseSchema = z.object({ - name: z.string(), - given: z.any(), - expect: TestExpectSchema -}); - -export const NodeSchema = z.object({ - id: z.string(), - type: z.string(), // EXTENSION POINT: Expand backend/frontend stack types or generic custom node types - flow: z.string(), - extends: z.string().optional(), - autogenTests: z.boolean().default(true), - meta: MetaSchema, - input: z.array(FieldSchema).default([]), - output: z.array(FieldSchema).default([]), - constraints: z.array(ConstraintSchema).default([]), - tests: z.array(TestCaseSchema).default([]) -}); - -export const ProjectSchema = z.object({ - name: z.string(), - stack: z.string(), // EXTENSION POINT: Expand backend/frontend stack types - version: z.string(), - autogenTests: z.boolean().default(true), - imports: z.array(ImportSchema).default([]), - nodes: z.array(NodeSchema).default([]) -}); - -export type Project = z.infer; -export type Node = z.infer; -export type TestCase = z.infer; -export type Constraint = z.infer; -export type Field = z.infer; +import { z } from 'zod'; + +export const ImportSchema = z.object({ + src: z.string().optional(), + package: z.string().optional(), + from: z.string().optional(), + as: z.string() +}); + +export const MetaSchema = z.object({ + path: z.string(), + depends_on: z.array(z.string()).default([]) +}); + +export const FieldSchema = z.object({ + name: z.string(), + type: z.string(), + required: z.boolean().default(true), + format: z.string().optional() +}); + +export const ConstraintSchema = z.object({ + verify: z.enum(['static', 'llm-judge']).default('static'), + description: z.string(), + learnedFrom: z.string().optional() +}); + +export const TestExpectSchema = z.object({ + field: z.string().optional(), + status: z.number().optional(), + body: z.any().optional(), + contains: z.string().optional(), + match: z.string().optional() +}); + +export const TestCaseSchema = z.object({ + name: z.string(), + given: z.any(), + expect: TestExpectSchema +}); + +export const NodeSchema = z.object({ + id: z.string(), + type: z.string(), // EXTENSION POINT: Expand backend/frontend stack types or generic custom node types + flow: z.string(), + extends: z.string().optional(), + autogenTests: z.boolean().default(true), + meta: MetaSchema, + input: z.array(FieldSchema).default([]), + output: z.array(FieldSchema).default([]), + constraints: z.array(ConstraintSchema).default([]), + tests: z.array(TestCaseSchema).default([]) +}); + +export const ProjectSchema = z.object({ + name: z.string(), + stack: z.string(), // EXTENSION POINT: Expand backend/frontend stack types + version: z.string(), + autogenTests: z.boolean().default(true), + imports: z.array(ImportSchema).default([]), + nodes: z.array(NodeSchema).default([]) +}); + +export type Project = z.infer; +export type Node = z.infer; +export type TestCase = z.infer; +export type Constraint = z.infer; +export type Field = z.infer; diff --git a/src/patcher/index.ts b/src/patcher/index.ts index f3fde5d..909f6c3 100644 --- a/src/patcher/index.ts +++ b/src/patcher/index.ts @@ -1,67 +1,67 @@ -export class PxmlPatcher { - /** - * Applies a diff/patch securely to file contents. - * If it cannot apply cleanly, it throws an error. - * Expects patch format: - * <<<<<<< SEARCH - * original code - * ======= - * new code - * >>>>>>> REPLACE - */ - static applyPatch(originalContent: string, patch: string): string { - const searchBlocks = this.parsePatch(patch); - if (searchBlocks.length === 0) { - // If it is not in search/replace format, check if it's just raw code and replace entirely - if (patch.trim().length > 0 && !patch.includes('<<<<<<<')) { - return patch; - } - throw new Error('Invalid patch format: no SEARCH/REPLACE blocks found.'); - } - - let result = originalContent; - for (const block of searchBlocks) { - if (!result.includes(block.search)) { - throw new Error(`Failed to apply patch: search block not found in original file.\nSearch block:\n${block.search}`); - } - result = result.replace(block.search, block.replace); - } - - return result; - } - - static parsePatch(patch: string): { search: string; replace: string }[] { - const blocks: { search: string; replace: string }[] = []; - const lines = patch.split('\n'); - let i = 0; - - while (i < lines.length) { - if (lines[i].startsWith('<<<<<<< SEARCH')) { - const searchLines: string[] = []; - const replaceLines: string[] = []; - i++; - - while (i < lines.length && !lines[i].startsWith('=======')) { - searchLines.push(lines[i]); - i++; - } - i++; // skip ======= - - while (i < lines.length && !lines[i].startsWith('>>>>>>> REPLACE')) { - replaceLines.push(lines[i]); - i++; - } - i++; // skip >>>>>>> REPLACE - - blocks.push({ - search: searchLines.join('\n'), - replace: replaceLines.join('\n') - }); - } else { - i++; - } - } - - return blocks; - } -} +export class PxmlPatcher { + /** + * Applies a diff/patch securely to file contents. + * If it cannot apply cleanly, it throws an error. + * Expects patch format: + * <<<<<<< SEARCH + * original code + * ======= + * new code + * >>>>>>> REPLACE + */ + static applyPatch(originalContent: string, patch: string): string { + const searchBlocks = this.parsePatch(patch); + if (searchBlocks.length === 0) { + // If it is not in search/replace format, check if it's just raw code and replace entirely + if (patch.trim().length > 0 && !patch.includes('<<<<<<<')) { + return patch; + } + throw new Error('Invalid patch format: no SEARCH/REPLACE blocks found.'); + } + + let result = originalContent; + for (const block of searchBlocks) { + if (!result.includes(block.search)) { + throw new Error(`Failed to apply patch: search block not found in original file.\nSearch block:\n${block.search}`); + } + result = result.replace(block.search, block.replace); + } + + return result; + } + + static parsePatch(patch: string): { search: string; replace: string }[] { + const blocks: { search: string; replace: string }[] = []; + const lines = patch.split('\n'); + let i = 0; + + while (i < lines.length) { + if (lines[i].startsWith('<<<<<<< SEARCH')) { + const searchLines: string[] = []; + const replaceLines: string[] = []; + i++; + + while (i < lines.length && !lines[i].startsWith('=======')) { + searchLines.push(lines[i]); + i++; + } + i++; // skip ======= + + while (i < lines.length && !lines[i].startsWith('>>>>>>> REPLACE')) { + replaceLines.push(lines[i]); + i++; + } + i++; // skip >>>>>>> REPLACE + + blocks.push({ + search: searchLines.join('\n'), + replace: replaceLines.join('\n') + }); + } else { + i++; + } + } + + return blocks; + } +} diff --git a/src/providers.test.ts b/src/providers.test.ts index b2d1172..31f8c73 100644 --- a/src/providers.test.ts +++ b/src/providers.test.ts @@ -1,52 +1,52 @@ -import { describe, it, expect } from 'vitest'; -import { PxmlCodegen } from '../src/codegen/index.ts'; -import { FileWriter } from '../src/writer/index.ts'; -import { Node } from '../src/parser/schema.js'; -import * as path from 'path'; - -const TMP_DIR = '/tmp/pxml-test-providers'; - -describe('Multi-provider configuration', () => { - const mockNode: Node = { - id: 'api.posts.create', - type: 'api-route', - flow: 'blog.write', - meta: { - path: path.join(TMP_DIR, 'app/api/posts/route.ts'), - depends_on: [] - }, - input: [], - output: [], - constraints: [], - tests: [] - }; - - it('should allow custom providers to handle generation', async () => { - const writer = new FileWriter(); - - const customProvider = { - generate: async (prompt: string, systemPrompt: string, model: string) => { - return `// custom generated code using model ${model}`; - } - }; - - const codegen = new PxmlCodegen({ - provider: 'custom', - customProvider, - model: 'my-custom-model' - }); - - const code = await codegen.generateNodeCode(mockNode, 'Context Info', writer); - expect(code).toBe('// custom generated code using model my-custom-model'); - }); - - it('should support ollama provider configuration', () => { - const codegen = new PxmlCodegen({ - provider: 'ollama', - model: 'llama3', - baseUrl: 'http://localhost:11434' - }); - expect((codegen as any).provider).toBeDefined(); - expect((codegen as any).provider.baseUrl).toBe('http://localhost:11434'); - }); -}); +import { describe, it, expect } from 'vitest'; +import { PxmlCodegen } from '../src/codegen/index.ts'; +import { FileWriter } from '../src/writer/index.ts'; +import { Node } from '../src/parser/schema.js'; +import * as path from 'path'; + +const TMP_DIR = '/tmp/pxml-test-providers'; + +describe('Multi-provider configuration', () => { + const mockNode: Node = { + id: 'api.posts.create', + type: 'api-route', + flow: 'blog.write', + meta: { + path: path.join(TMP_DIR, 'app/api/posts/route.ts'), + depends_on: [] + }, + input: [], + output: [], + constraints: [], + tests: [] + }; + + it('should allow custom providers to handle generation', async () => { + const writer = new FileWriter(); + + const customProvider = { + generate: async (prompt: string, systemPrompt: string, model: string) => { + return `// custom generated code using model ${model}`; + } + }; + + const codegen = new PxmlCodegen({ + provider: 'custom', + customProvider, + model: 'my-custom-model' + }); + + const code = await codegen.generateNodeCode(mockNode, 'Context Info', writer); + expect(code).toBe('// custom generated code using model my-custom-model'); + }); + + it('should support ollama provider configuration', () => { + const codegen = new PxmlCodegen({ + provider: 'ollama', + model: 'llama3', + baseUrl: 'http://localhost:11434' + }); + expect((codegen as any).provider).toBeDefined(); + expect((codegen as any).provider.baseUrl).toBe('http://localhost:11434'); + }); +}); diff --git a/src/runner.test.ts b/src/runner.test.ts index 27dbb05..07806bb 100644 --- a/src/runner.test.ts +++ b/src/runner.test.ts @@ -1,103 +1,103 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { PxmlTestgen } from '../src/testgen/index.ts'; -import { PxmlRunner, getTestFilePath } from '../src/runner/index.ts'; -import { FileWriter } from '../src/writer/index.ts'; -import { Node } from '../src/parser/schema.js'; -import * as fs from 'fs'; -import * as path from 'path'; - -const TMP_DIR = '/tmp/pxml-test-runner'; - -describe('PxmlTestgen & PxmlRunner', () => { - beforeEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - afterEach(() => { - if (fs.existsSync(TMP_DIR)) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } - }); - - const mockNode: Node = { - id: 'api.posts.list', - type: 'api-route', - flow: 'blog.read', - meta: { - path: path.join(TMP_DIR, 'app/api/posts/route.ts'), - depends_on: [] - }, - input: [], - output: [], - constraints: [], - tests: [ - { - name: 'Get posts list default page size', - given: { query: { limit: 10 } }, - expect: { - status: 200, - contains: 'posts' - } - } - ] - }; - - it('should compile XML tests to Vitest format', () => { - const code = PxmlTestgen.generateTestFileContent(mockNode, path.join(TMP_DIR, '.pxml/tests/api.posts.list.test.ts')); - expect(code).toContain("describe(\"api.posts.list\""); - expect(code).toContain("it(\"Get posts list default page size\""); - expect(code).toContain("expect(res.status).toBe(200);"); - }); - - it('should run generated test successfully when file code exists', () => { - const writer = new FileWriter(); - const runner = new PxmlRunner(TMP_DIR, writer); - - // Write a mock implementation file that exports a default handler returning posts - const implCode = ` -export default async function handler(req) { - return { - status: 200, - json: async () => ({ posts: [] }) - }; -} -`; - writer.write(mockNode.meta.path, implCode); - - const result = runner.runNodeTests(mockNode); - expect(result.passed).toBe(true); - expect(result.results['Get posts list default page size']).toBe('pass'); - }); - - it('should report failure when test expectation fails', () => { - const writer = new FileWriter(); - const runner = new PxmlRunner(TMP_DIR, writer); - - // Mock implementation file returning 500 - const implCode = ` -export default async function handler(req) { - return { - status: 500, - json: async () => ({ error: 'internal server error' }) - }; -} -`; - writer.write(mockNode.meta.path, implCode); - - const result = runner.runNodeTests(mockNode); - expect(result.passed).toBe(false); - expect(result.results['Get posts list default page size']).toBe('fail'); - }); -}); - -describe('getTestFilePath', () => { - it('should map test file paths based on stack', () => { - expect(getTestFilePath('app/api/cart.ts', 'nextjs')).toBe('app/api/cart.test.ts'); - expect(getTestFilePath('app/api/cart.tsx', 'nextjs')).toBe('app/api/cart.test.tsx'); - expect(getTestFilePath('pkg/auth/login.go', 'go')).toBe('pkg/auth/login_test.go'); - expect(getTestFilePath('app/api/cart.py', 'python')).toBe('app/api/test_cart.py'); - expect(getTestFilePath('src/Services/AuthService.cs', 'csharp')).toBe('src/Services/AuthService.Tests.cs'); - }); -}); +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { PxmlTestgen } from '../src/testgen/index.ts'; +import { PxmlRunner, getTestFilePath } from '../src/runner/index.ts'; +import { FileWriter } from '../src/writer/index.ts'; +import { Node } from '../src/parser/schema.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TMP_DIR = '/tmp/pxml-test-runner'; + +describe('PxmlTestgen & PxmlRunner', () => { + beforeEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(TMP_DIR)) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } + }); + + const mockNode: Node = { + id: 'api.posts.list', + type: 'api-route', + flow: 'blog.read', + meta: { + path: path.join(TMP_DIR, 'app/api/posts/route.ts'), + depends_on: [] + }, + input: [], + output: [], + constraints: [], + tests: [ + { + name: 'Get posts list default page size', + given: { query: { limit: 10 } }, + expect: { + status: 200, + contains: 'posts' + } + } + ] + }; + + it('should compile XML tests to Vitest format', () => { + const code = PxmlTestgen.generateTestFileContent(mockNode, path.join(TMP_DIR, '.pxml/tests/api.posts.list.test.ts')); + expect(code).toContain("describe(\"api.posts.list\""); + expect(code).toContain("it(\"Get posts list default page size\""); + expect(code).toContain("expect(res.status).toBe(200);"); + }); + + it('should run generated test successfully when file code exists', () => { + const writer = new FileWriter(); + const runner = new PxmlRunner(TMP_DIR, writer); + + // Write a mock implementation file that exports a default handler returning posts + const implCode = ` +export default async function handler(req) { + return { + status: 200, + json: async () => ({ posts: [] }) + }; +} +`; + writer.write(mockNode.meta.path, implCode); + + const result = runner.runNodeTests(mockNode); + expect(result.passed).toBe(true); + expect(result.results['Get posts list default page size']).toBe('pass'); + }); + + it('should report failure when test expectation fails', () => { + const writer = new FileWriter(); + const runner = new PxmlRunner(TMP_DIR, writer); + + // Mock implementation file returning 500 + const implCode = ` +export default async function handler(req) { + return { + status: 500, + json: async () => ({ error: 'internal server error' }) + }; +} +`; + writer.write(mockNode.meta.path, implCode); + + const result = runner.runNodeTests(mockNode); + expect(result.passed).toBe(false); + expect(result.results['Get posts list default page size']).toBe('fail'); + }); +}); + +describe('getTestFilePath', () => { + it('should map test file paths based on stack', () => { + expect(getTestFilePath('app/api/cart.ts', 'nextjs')).toBe('app/api/cart.test.ts'); + expect(getTestFilePath('app/api/cart.tsx', 'nextjs')).toBe('app/api/cart.test.tsx'); + expect(getTestFilePath('pkg/auth/login.go', 'go')).toBe('pkg/auth/login_test.go'); + expect(getTestFilePath('app/api/cart.py', 'python')).toBe('app/api/test_cart.py'); + expect(getTestFilePath('src/Services/AuthService.cs', 'csharp')).toBe('src/Services/AuthService.Tests.cs'); + }); +}); diff --git a/src/runner/index.ts b/src/runner/index.ts index 06105b4..620c67a 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -1,108 +1,108 @@ -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Node } from '../parser/schema.js'; -import { PxmlTestgen } from '../testgen/index.js'; -import { FileWriter } from '../writer/index.js'; - -export interface TestResult { - passed: boolean; - results: Record; - output?: string; -} - -export function getTestFilePath(srcPath: string, stack: string): string { - const ext = path.extname(srcPath); - const base = srcPath.slice(0, -ext.length); - const stackLower = stack.toLowerCase(); - - if (stackLower.includes('python')) { - const dir = path.dirname(srcPath); - const filename = path.basename(srcPath); - return path.join(dir, `test_${filename}`); - } else if (stackLower.includes('go') || stackLower === 'golang') { - return `${base}_test${ext}`; - } else if (stackLower.includes('c#') || stackLower === 'csharp') { - return `${base}.Tests${ext}`; - } else { - const isTsx = ext === '.tsx' || ext === '.jsx'; - const testExt = isTsx ? `.test${ext}` : `.test${ext}`; - return `${base}${testExt}`; - } -} - -export class PxmlRunner { - private projectDir: string; - private writer: FileWriter; - - constructor(projectDir: string, writer: FileWriter) { - this.projectDir = path.resolve(projectDir); - this.writer = writer; - } - - runNodeTests(node: Node, stack = 'nextjs'): TestResult { - let testFilePath = path.resolve(this.projectDir, getTestFilePath(node.meta.path, stack)); - let testFileExisted = fs.existsSync(testFilePath); - - if (!testFileExisted) { - const testDir = path.join(this.projectDir, '.pxml', 'tests'); - const safeNodeId = node.id.replace(/:/g, '_'); - testFilePath = path.join(testDir, `${safeNodeId}.test.ts`); - - const testFileContent = PxmlTestgen.generateTestFileContent(node, testFilePath); - this.writer.write(testFilePath, testFileContent); - - if (this.writer.getHistory().some(h => h.filePath === testFilePath) && fs.existsSync(testFilePath) === false) { - const mockResults: Record = {}; - for (const t of node.tests) { - mockResults[t.name] = 'pass'; - } - return { passed: true, results: mockResults }; - } - } - - const stackLower = stack.toLowerCase(); - let testCmd = `npx vitest run ${testFilePath}`; - if (stackLower.includes('python')) { - testCmd = `pytest ${testFilePath}`; - } else if (stackLower.includes('go') || stackLower === 'golang') { - testCmd = `go test ${testFilePath}`; - } else if (stackLower.includes('c#') || stackLower === 'csharp') { - testCmd = `dotnet test --filter FullyQualifiedName~${node.id}`; - } - - let passed = false; - const results: Record = {}; - let output = ''; - - try { - const stdout = execSync(testCmd, { stdio: 'pipe', cwd: this.projectDir }); - passed = true; - output = stdout.toString(); - for (const t of node.tests) { - results[t.name] = 'pass'; - } - if (node.tests.length === 0) { - results['AI-Generated General Verification'] = 'pass'; - } - } catch (error: any) { - passed = false; - const stdout = error.stdout?.toString() || ''; - const stderr = error.stderr?.toString() || ''; - output = `${stdout}\n${stderr}`; - - for (const t of node.tests) { - if (stdout.includes(`× ${t.name}`) || stderr.includes(`× ${t.name}`) || stdout.includes(`fail`) || error.message.includes('fail')) { - results[t.name] = 'fail'; - } else { - results[t.name] = 'fail'; - } - } - if (node.tests.length === 0) { - results['AI-Generated General Verification'] = 'fail'; - } - } - - return { passed, results, output }; - } -} +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Node } from '../parser/schema.js'; +import { PxmlTestgen } from '../testgen/index.js'; +import { FileWriter } from '../writer/index.js'; + +export interface TestResult { + passed: boolean; + results: Record; + output?: string; +} + +export function getTestFilePath(srcPath: string, stack: string): string { + const ext = path.extname(srcPath); + const base = srcPath.slice(0, -ext.length); + const stackLower = stack.toLowerCase(); + + if (stackLower.includes('python')) { + const dir = path.dirname(srcPath); + const filename = path.basename(srcPath); + return path.join(dir, `test_${filename}`); + } else if (stackLower.includes('go') || stackLower === 'golang') { + return `${base}_test${ext}`; + } else if (stackLower.includes('c#') || stackLower === 'csharp') { + return `${base}.Tests${ext}`; + } else { + const isTsx = ext === '.tsx' || ext === '.jsx'; + const testExt = isTsx ? `.test${ext}` : `.test${ext}`; + return `${base}${testExt}`; + } +} + +export class PxmlRunner { + private projectDir: string; + private writer: FileWriter; + + constructor(projectDir: string, writer: FileWriter) { + this.projectDir = path.resolve(projectDir); + this.writer = writer; + } + + runNodeTests(node: Node, stack = 'nextjs'): TestResult { + let testFilePath = path.resolve(this.projectDir, getTestFilePath(node.meta.path, stack)); + let testFileExisted = fs.existsSync(testFilePath); + + if (!testFileExisted) { + const testDir = path.join(this.projectDir, '.pxml', 'tests'); + const safeNodeId = node.id.replace(/:/g, '_'); + testFilePath = path.join(testDir, `${safeNodeId}.test.ts`); + + const testFileContent = PxmlTestgen.generateTestFileContent(node, testFilePath); + this.writer.write(testFilePath, testFileContent); + + if (this.writer.getHistory().some(h => h.filePath === testFilePath) && fs.existsSync(testFilePath) === false) { + const mockResults: Record = {}; + for (const t of node.tests) { + mockResults[t.name] = 'pass'; + } + return { passed: true, results: mockResults }; + } + } + + const stackLower = stack.toLowerCase(); + let testCmd = `npx vitest run ${testFilePath}`; + if (stackLower.includes('python')) { + testCmd = `pytest ${testFilePath}`; + } else if (stackLower.includes('go') || stackLower === 'golang') { + testCmd = `go test ${testFilePath}`; + } else if (stackLower.includes('c#') || stackLower === 'csharp') { + testCmd = `dotnet test --filter FullyQualifiedName~${node.id}`; + } + + let passed = false; + const results: Record = {}; + let output = ''; + + try { + const stdout = execSync(testCmd, { stdio: 'pipe', cwd: this.projectDir }); + passed = true; + output = stdout.toString(); + for (const t of node.tests) { + results[t.name] = 'pass'; + } + if (node.tests.length === 0) { + results['AI-Generated General Verification'] = 'pass'; + } + } catch (error: any) { + passed = false; + const stdout = error.stdout?.toString() || ''; + const stderr = error.stderr?.toString() || ''; + output = `${stdout}\n${stderr}`; + + for (const t of node.tests) { + if (stdout.includes(`× ${t.name}`) || stderr.includes(`× ${t.name}`) || stdout.includes(`fail`) || error.message.includes('fail')) { + results[t.name] = 'fail'; + } else { + results[t.name] = 'fail'; + } + } + if (node.tests.length === 0) { + results['AI-Generated General Verification'] = 'fail'; + } + } + + return { passed, results, output }; + } +} diff --git a/src/testgen/index.ts b/src/testgen/index.ts index 2947eb2..7a553b6 100644 --- a/src/testgen/index.ts +++ b/src/testgen/index.ts @@ -1,115 +1,115 @@ -import { Node, TestCase } from '../parser/schema.js'; -import * as path from 'path'; - -export class PxmlTestgen { - static generateTestFileContent(node: Node, testFileAbsPath: string): string { - const relativeImplPath = path.relative(path.dirname(testFileAbsPath), node.meta.path); - let importPath = relativeImplPath.replace(/\.(ts|tsx|js|jsx)$/, ''); - if (!importPath.startsWith('.') && !importPath.startsWith('/')) { - importPath = './' + importPath; - } - - let testCasesCode = ''; - const tests: TestCase[] = node.tests.length > 0 ? node.tests : [this.createFallbackTestCase(node)]; - - for (const test of tests) { - const stringifiedGiven = JSON.stringify(test.given, null, 2); - - // Build assertions dynamically based on expected fields - let assertions = ''; - if (test.expect.status !== undefined) { - assertions += `expect(res.status).toBe(${test.expect.status});\n`; - } - if (test.expect.contains) { - assertions += `expect(JSON.stringify(body)).toContain(${JSON.stringify(test.expect.contains)});\n`; - } - if (test.expect.match) { - assertions += `expect(JSON.stringify(body)).toMatch(${test.expect.match});\n`; - } - if (node.type === 'setup-command') { - assertions += `expect(true).toBe(true);\n`; // Simple execution check - } - - testCasesCode += ` - it(${JSON.stringify(test.name)}, async () => { - const req = ${stringifiedGiven}; - let res = { status: 200, json: async () => ({}) }; - let body: any = {}; - - try { - if (nodeType === 'setup-command') { - // Just verify file module can be parsed or execution completed - body = { status: 'executed' }; - } else if (typeof handler === 'function') { - const response = await handler(req); - if (response && typeof response.json === 'function') { - res = response; - body = await response.json(); - } else { - body = response; - } - } else if (handler && typeof handler.GET === 'function' && req.method === 'GET') { - const response = await handler.GET(req); - res = response; - body = await response.json(); - } else if (handler && typeof handler.POST === 'function' && req.method === 'POST') { - const response = await handler.POST(req); - res = response; - body = await response.json(); - } else { - // Generic export verification fallback - body = handler; - } - } catch (err: any) { - res = { status: err.status || 500, json: async () => ({ error: err.message }) }; - body = { error: err.message }; - } - - ${assertions} - }); -`; - } - - return `import { describe, it, expect } from 'vitest'; -// @ts-ignore -import * as handlerModule from '${importPath}'; - -const handler = handlerModule.default || handlerModule; -const nodeType = ${JSON.stringify(node.type)}; - -describe(${JSON.stringify(node.id)}, () => { -${testCasesCode} -}); -`; - } - - private static createFallbackTestCase(node: Node): TestCase { - // Generate generic checks depending on node type - if (node.type === 'setup-command') { - return { - name: 'Verify setup execution finishes successfully', - given: {}, - expect: { - field: undefined, - status: undefined, - body: undefined, - contains: undefined, - match: undefined - } - }; - } - - // Default validation: loading modules shouldn't throw errors - return { - name: 'Verify module loads and exports valid elements', - given: { method: 'GET', query: {}, headers: {} }, - expect: { - field: undefined, - status: undefined, - body: undefined, - contains: undefined, - match: undefined - } - }; - } -} +import { Node, TestCase } from '../parser/schema.js'; +import * as path from 'path'; + +export class PxmlTestgen { + static generateTestFileContent(node: Node, testFileAbsPath: string): string { + const relativeImplPath = path.relative(path.dirname(testFileAbsPath), node.meta.path); + let importPath = relativeImplPath.replace(/\.(ts|tsx|js|jsx)$/, ''); + if (!importPath.startsWith('.') && !importPath.startsWith('/')) { + importPath = './' + importPath; + } + + let testCasesCode = ''; + const tests: TestCase[] = node.tests.length > 0 ? node.tests : [this.createFallbackTestCase(node)]; + + for (const test of tests) { + const stringifiedGiven = JSON.stringify(test.given, null, 2); + + // Build assertions dynamically based on expected fields + let assertions = ''; + if (test.expect.status !== undefined) { + assertions += `expect(res.status).toBe(${test.expect.status});\n`; + } + if (test.expect.contains) { + assertions += `expect(JSON.stringify(body)).toContain(${JSON.stringify(test.expect.contains)});\n`; + } + if (test.expect.match) { + assertions += `expect(JSON.stringify(body)).toMatch(${test.expect.match});\n`; + } + if (node.type === 'setup-command') { + assertions += `expect(true).toBe(true);\n`; // Simple execution check + } + + testCasesCode += ` + it(${JSON.stringify(test.name)}, async () => { + const req = ${stringifiedGiven}; + let res = { status: 200, json: async () => ({}) }; + let body: any = {}; + + try { + if (nodeType === 'setup-command') { + // Just verify file module can be parsed or execution completed + body = { status: 'executed' }; + } else if (typeof handler === 'function') { + const response = await handler(req); + if (response && typeof response.json === 'function') { + res = response; + body = await response.json(); + } else { + body = response; + } + } else if (handler && typeof handler.GET === 'function' && req.method === 'GET') { + const response = await handler.GET(req); + res = response; + body = await response.json(); + } else if (handler && typeof handler.POST === 'function' && req.method === 'POST') { + const response = await handler.POST(req); + res = response; + body = await response.json(); + } else { + // Generic export verification fallback + body = handler; + } + } catch (err: any) { + res = { status: err.status || 500, json: async () => ({ error: err.message }) }; + body = { error: err.message }; + } + + ${assertions} + }); +`; + } + + return `import { describe, it, expect } from 'vitest'; +// @ts-ignore +import * as handlerModule from '${importPath}'; + +const handler = handlerModule.default || handlerModule; +const nodeType = ${JSON.stringify(node.type)}; + +describe(${JSON.stringify(node.id)}, () => { +${testCasesCode} +}); +`; + } + + private static createFallbackTestCase(node: Node): TestCase { + // Generate generic checks depending on node type + if (node.type === 'setup-command') { + return { + name: 'Verify setup execution finishes successfully', + given: {}, + expect: { + field: undefined, + status: undefined, + body: undefined, + contains: undefined, + match: undefined + } + }; + } + + // Default validation: loading modules shouldn't throw errors + return { + name: 'Verify module loads and exports valid elements', + given: { method: 'GET', query: {}, headers: {} }, + expect: { + field: undefined, + status: undefined, + body: undefined, + contains: undefined, + match: undefined + } + }; + } +} diff --git a/src/writer/index.ts b/src/writer/index.ts index b2a061a..84c0778 100644 --- a/src/writer/index.ts +++ b/src/writer/index.ts @@ -1,61 +1,61 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export interface FileWriteOperation { - filePath: string; - originalContent: string | null; - newContent: string; -} - -export class FileWriter { - private dryRun: boolean; - private history: FileWriteOperation[] = []; - - constructor(dryRun = false) { - this.dryRun = dryRun; - } - - write(filePath: string, content: string) { - const absolutePath = path.resolve(filePath); - const originalContent = fs.existsSync(absolutePath) ? fs.readFileSync(absolutePath, 'utf-8') : null; - - this.history.push({ - filePath: absolutePath, - originalContent, - newContent: content - }); - - if (this.dryRun) { - console.log(`[DRY RUN] Would write to: ${absolutePath}`); - return; - } - - const dir = path.dirname(absolutePath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(absolutePath, content, 'utf-8'); - } - - rollback() { - if (this.dryRun) { - this.history = []; - return; - } - - for (const op of [...this.history].reverse()) { - if (op.originalContent === null) { - if (fs.existsSync(op.filePath)) { - fs.unlinkSync(op.filePath); - } - } else { - fs.writeFileSync(op.filePath, op.originalContent, 'utf-8'); - } - } - this.history = []; - } - - getHistory(): FileWriteOperation[] { - return this.history; - } -} +import * as fs from 'fs'; +import * as path from 'path'; + +export interface FileWriteOperation { + filePath: string; + originalContent: string | null; + newContent: string; +} + +export class FileWriter { + private dryRun: boolean; + private history: FileWriteOperation[] = []; + + constructor(dryRun = false) { + this.dryRun = dryRun; + } + + write(filePath: string, content: string) { + const absolutePath = path.resolve(filePath); + const originalContent = fs.existsSync(absolutePath) ? fs.readFileSync(absolutePath, 'utf-8') : null; + + this.history.push({ + filePath: absolutePath, + originalContent, + newContent: content + }); + + if (this.dryRun) { + console.log(`[DRY RUN] Would write to: ${absolutePath}`); + return; + } + + const dir = path.dirname(absolutePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(absolutePath, content, 'utf-8'); + } + + rollback() { + if (this.dryRun) { + this.history = []; + return; + } + + for (const op of [...this.history].reverse()) { + if (op.originalContent === null) { + if (fs.existsSync(op.filePath)) { + fs.unlinkSync(op.filePath); + } + } else { + fs.writeFileSync(op.filePath, op.originalContent, 'utf-8'); + } + } + this.history = []; + } + + getHistory(): FileWriteOperation[] { + return this.history; + } +} diff --git a/tsconfig.json b/tsconfig.json index 973e719..8190a2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,16 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts", "src/*.test.ts"] -} +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/*.test.ts"] +}