diff --git a/README.md b/README.md index be81e81..ef02581 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ It will: 3. **Rename the skeleton** — `Shop → Clinic`, `Shopkeeper → Vet`, etc. — consistently across Ruby migrations, Swift models, Kotlin data classes, policies, tests, and localized copy. 4. **Adapt or replace the domain module** — keep `ItemTag` for walk-in queue variants; strip and insert a new resource for non-queue SaaS. 5. **Drive the build green** — `bin/rails test`, `xcodebuild test`, `./gradlew test` must all pass before the agent exits. -6. **Validate the output** across three layers (structural, runtime, semantic). Details in [`docs/SPEC.md`](./docs/SPEC.md) section 6. +6. **Validate the output** across three layers (structural, runtime, semantic) and write a self-contained HTML + JSON [validation report](#validation-report). Details in [`docs/SPEC.md`](./docs/SPEC.md) section 6. ## Demo @@ -107,9 +107,11 @@ npx nativeapptemplate-agent "a personal task tracker with due dates" # Generated output appears under ./out// tree ./out/clinic-queue/ -# ├── rails/ ← Rails 8.1 API, git-initialized, buildable -# ├── ios/ ← SwiftUI iOS project, buildable -# └── android/ ← Jetpack Compose Android project, buildable +# ├── rails/ ← Rails 8.1 API, git-initialized, buildable +# ├── ios/ ← SwiftUI iOS project, buildable +# ├── android/ ← Jetpack Compose Android project, buildable +# ├── report.json ← machine-readable validation result +# └── validation-report.html ← self-contained visual report (open in a browser) ``` The agent will also be available as a Claude Code plugin. @@ -154,6 +156,29 @@ The agent doesn't just generate code and exit — it validates the output. See [`docs/SPEC.md`](./docs/SPEC.md) for the full design. +## Validation report + +Every run writes a report of the validation results to the output directory: + +- **`out//validation-report.html`** — a self-contained HTML report (screenshots base64-embedded, no external assets, no JavaScript) you can open in a browser, attach to a PR, or drop into a demo. It shows the overall verdict, a platform×layer matrix, Layer 1 leftover-token findings, Layer 2 build commands + `stderr`, Layer 3 home-screen screenshots with the vision judge's per-criterion rationales (plus the Stage 2 filmstrip when `NATIVEAPPTEMPLATE_VISUAL=2`), the reviewer's contract diff, and the domain rename plan. +- **`out//report.json`** — the same data, machine-readable, for CI gating or programmatic use. The full schema lives in [`docs/validation-report.md`](./docs/validation-report.md). + +The CLI **exits non-zero when validation fails**, so a shell `&&` chain or CI step catches it: + +```bash +npx nativeapptemplate-agent "a walk-in clinic queue" && echo "validation passed" +``` + +Report flags: + +| Flag | Default | Effect | +|---|---|---| +| `--no-report` | — | Skip writing the report | +| `--report-format=html\|json\|both` | `both` | Which artifact(s) to write | +| `--report-embed=true\|false` | `true` | Embed screenshots as `data:` URIs (single portable file) vs. copy to `report-assets/` | +| `--report-open` | — | Open the HTML in your browser when the run finishes (macOS) | +| `--exit-zero` | — | Always exit 0, even on validation failure (e.g. when you only want the report) | + ## Security `ANTHROPIC_API_KEY` is the only sensitive secret the agent needs. diff --git a/src/index.ts b/src/index.ts index 71c2cc2..abe97d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,15 +7,17 @@ import { loadDotenvIfPresent } from "./env.js"; loadDotenvIfPresent(); -type ParsedArgs = { spec: string; report: DispatchReportOptions; open: boolean }; +export type ParsedArgs = { spec: string; report: DispatchReportOptions; open: boolean; exitZero: boolean }; -function parseArgs(argv: readonly string[]): ParsedArgs { +export function parseArgs(argv: readonly string[]): ParsedArgs { const specParts: string[] = []; const report: DispatchReportOptions = {}; let open = false; + let exitZero = false; for (const arg of argv) { if (arg === "--no-report") report.enabled = false; else if (arg === "--report-open") open = true; + else if (arg === "--exit-zero") exitZero = true; else if (arg.startsWith("--report-format=")) { const value = arg.slice("--report-format=".length); if (value === "html" || value === "json" || value === "both") report.format = value; @@ -25,7 +27,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs { specParts.push(arg); } } - return { spec: specParts.join(" ").trim(), report, open }; + return { spec: specParts.join(" ").trim(), report, open, exitZero }; } export async function main(spec?: string): Promise { @@ -33,7 +35,7 @@ export async function main(spec?: string): Promise { const input = (spec ?? parsed.spec).trim(); if (!input) { console.error( - 'Usage: nativeapptemplate-agent "your spec here" [--no-report] [--report-format=html|json|both] [--report-embed=true|false] [--report-open]', + 'Usage: nativeapptemplate-agent "your spec here" [--no-report] [--report-format=html|json|both] [--report-embed=true|false] [--report-open] [--exit-zero]', ); process.exitCode = 1; return; @@ -56,6 +58,12 @@ export async function main(spec?: string): Promise { } else if (result.reportPaths.jsonPath) { console.log(`report: ${result.reportPaths.jsonPath}`); } + + // Non-zero exit on validation failure so CI / shell `&&` chains catch + // it. --exit-zero opts out (e.g. when you only want the report). + if (!result.overallPass && !parsed.exitZero) { + process.exitCode = 1; + } } // Entry guard: run main() when this file is the program entry point. Resolve diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index fa7193a..756815e 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1296,3 +1296,30 @@ test("writeReport with embed=false externalizes screenshots to report-assets/", // The copied asset exists on disk. assert.ok(readFileSync(join(dir, "report-assets", "ios-home.png")).length > 0); }); + +test("parseArgs splits the spec from report + exit flags", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["a", "walk-in", "queue", "--no-report", "--report-format=json", "--report-embed=false", "--report-open", "--exit-zero"]); + assert.equal(parsed.spec, "a walk-in queue"); + assert.equal(parsed.report.enabled, false); + assert.equal(parsed.report.format, "json"); + assert.equal(parsed.report.embed, false); + assert.equal(parsed.open, true); + assert.equal(parsed.exitZero, true); +}); + +test("parseArgs defaults: a bare spec leaves report options unset", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["just", "a", "spec"]); + assert.equal(parsed.spec, "just a spec"); + assert.deepEqual(parsed.report, {}); + assert.equal(parsed.open, false); + assert.equal(parsed.exitZero, false); +}); + +test("parseArgs ignores an invalid --report-format value", async () => { + const { parseArgs } = await import("../src/index.js"); + const parsed = parseArgs(["spec", "--report-format=xml"]); + assert.equal(parsed.spec, "spec"); + assert.equal(parsed.report.format, undefined); +});