From 58d33cf688df8bb2e5f067f9b8e7a0ce362c6ef5 Mon Sep 17 00:00:00 2001 From: pitechae Date: Sun, 19 Jul 2026 01:57:23 +0400 Subject: [PATCH] feat: add local OpenAPI change check --- ...7-19-public-evidence-product-slice-plan.md | 26 ++ ...2026-07-19-public-openapi-pain-evidence.md | 20 + ...07-19-TSK-8-contract-change-check-TASKS.md | 37 ++ bun.lock | 3 + package.json | 3 +- .../2026-07-19-contract-change-check/spec.md | 53 +++ src/core/openapi/compare.test.ts | 109 ++++++ src/core/openapi/compare.ts | 352 ++++++++++++++++++ .../contract-change/contract-change-check.tsx | 215 +++++++++++ .../contract-change/parse-contract.test.ts | 16 + .../contract-change/parse-contract.ts | 31 ++ src/features/marketing/marketing-home.tsx | 35 +- src/routeTree.gen.ts | 24 +- src/routes/compare.tsx | 16 + src/styles.css | 203 ++++++++++ tests/browser/compare.test.ts | 62 +++ tests/browser/marketing.test.ts | 12 +- 17 files changed, 1185 insertions(+), 32 deletions(-) create mode 100644 .docs/2026-07-19-public-evidence-product-slice-plan.md create mode 100644 .docs/2026-07-19-public-openapi-pain-evidence.md create mode 100644 .tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md create mode 100644 specs/features/2026-07-19-contract-change-check/spec.md create mode 100644 src/core/openapi/compare.test.ts create mode 100644 src/core/openapi/compare.ts create mode 100644 src/features/contract-change/contract-change-check.tsx create mode 100644 src/features/contract-change/parse-contract.test.ts create mode 100644 src/features/contract-change/parse-contract.ts create mode 100644 src/routes/compare.tsx create mode 100644 tests/browser/compare.test.ts diff --git a/.docs/2026-07-19-public-evidence-product-slice-plan.md b/.docs/2026-07-19-public-evidence-product-slice-plan.md new file mode 100644 index 0000000..a03094e --- /dev/null +++ b/.docs/2026-07-19-public-evidence-product-slice-plan.md @@ -0,0 +1,26 @@ +# Public Evidence Product Slice Plan + +## Objective + +Use current public evidence from Reddit, X, and GitHub to select, build, validate, and ship one working OpenAPI painkiller before customer interviews. + +## Constraints + +- Build one complete vertical slice, not an editor platform. +- Use public complaints, issue discussions, and active repositories as directional evidence. Do not treat engagement as willingness to pay. +- Keep uploads local to the browser unless persistence is required by the selected workflow. +- Do not add authentication, billing, workspaces, collaboration, or speculative infrastructure. +- Preserve OpenAPI semantics and report uncertainty honestly. + +## Execution + +1. Collect recent pain signals from Reddit and X plus recurring issues and workflows from GitHub. +2. Rank pains by frequency, urgency, buyer relevance, implementation cost, and fit with the current positioning. +3. Write the selected feature specification, validation contract, and task ledger before implementation. +4. Build the smallest end-to-end workflow on a feature branch. +5. Test valid, invalid, breaking, non-breaking, no-JavaScript, mobile, privacy, and accessibility behavior as applicable. +6. Deploy a preview, inspect it in a browser, open a pull request, pass CI, and ship only the validated slice. + +## Decision Rule + +Prefer a workflow that produces an immediate decision from two real OpenAPI documents. Reject ideas that require accounts, persistent customer data, third-party integrations, or broad editing before value is visible. diff --git a/.docs/2026-07-19-public-openapi-pain-evidence.md b/.docs/2026-07-19-public-openapi-pain-evidence.md new file mode 100644 index 0000000..37a8698 --- /dev/null +++ b/.docs/2026-07-19-public-openapi-pain-evidence.md @@ -0,0 +1,20 @@ +# Public OpenAPI Pain Evidence + +## Decision + +Ship a browser-local OpenAPI change check. A user selects a baseline and revised JSON or YAML contract, then receives a concise report of high-confidence breaking changes. + +## Evidence + +- A June 2026 FastAPI discussion describes accidental response-field changes becoming more likely as AI tooling increases development pace. The recommended control is comparing generated OpenAPI contracts in CI: https://www.reddit.com/r/FastAPI/comments/1tvfwxr/prevent_unintentional_breaking_api_changes_in/ +- A February 2026 discussion reports repeated review work caused by inconsistent OpenAPI documents and SDK generator failures: https://www.reddit.com/r/node/comments/1r76obi/i_was_tired_of_fixing_inconsistent_openapi_specs/ +- A January 2026 discussion repeatedly identifies synchronization between implementation and OpenAPI as the central requirement: https://www.reddit.com/r/node/comments/1q5imqi/openapi_static_or_dynamic/ +- GitHub documents removed operations, renamed parameters or response fields, newly required parameters, type changes, enum removals, validation changes, and authentication changes as consumer-breaking categories: https://docs.github.com/en/rest/about-the-rest-api/breaking-changes +- The active oasdiff issue backlog shows that exhaustive semantic comparison is difficult, including OpenAPI 3.1 composition, multi-type schemas, formats, defaults, and semantic wording: https://github.com/oasdiff/oasdiff/issues +- Current oasdiff documentation separates definite errors, potential warnings, and informational changes, supporting a severity-based decision surface: https://github.com/oasdiff/oasdiff/blob/main/docs/BREAKING-CHANGES.md + +X search produced weak direct pain evidence. It showed OpenAPI being used as agent tooling input, but did not produce a stronger buyer signal than Reddit and GitHub. Do not inflate that channel's contribution. + +## Scope Choice + +Start with high-confidence structural breaks that can be explained deterministically. Do not claim exhaustive compatibility analysis. Keep contracts in memory and avoid accounts, persistence, remote URLs, GitHub installation, or CI integration. diff --git a/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md b/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md new file mode 100644 index 0000000..e16281b --- /dev/null +++ b/.tasks/2026-07-19-TSK-8-contract-change-check-TASKS.md @@ -0,0 +1,37 @@ +# TSK-8: Contract Change Check + +**Overall status:** IN_PROGRESS + +## TG-001: Evidence and contract + +- **Owner:** Main agent +- **Status:** DONE +- **Acceptance:** Public evidence, scope, privacy boundary, supported checks, limitations, and validation contract are recorded. +- **Evidence:** `.docs/2026-07-19-public-openapi-pain-evidence.md` and the approved feature specification. + +## TG-002: Comparison core + +- **Owner:** Main agent +- **Status:** DONE +- **Dependencies:** TG-001 +- **Acceptance:** JSON and YAML parsing plus supported change classification pass focused unit tests without network access. +- **Validation:** `bun run test` +- **Evidence:** Seven test files and 18 unit tests pass, including focused parser and comparison coverage. + +## TG-003: Browser workflow + +- **Owner:** Main agent +- **Status:** DONE +- **Dependencies:** TG-002 +- **Acceptance:** `/compare` delivers local file selection, samples, decision-oriented results, privacy disclosure, responsive layout, and accessible states. +- **Validation:** `bun run test:e2e` +- **Evidence:** Six browser tests pass across the marketing and comparison routes, including accessibility, narrow viewport, malformed input, oversized input, and no-JavaScript coverage. + +## TG-004: Release validation + +- **Owner:** Main agent +- **Status:** IN_PROGRESS +- **Dependencies:** TG-003 +- **Acceptance:** Full checks, preview deployment, browser inspection, pull request, CI, and rollback record pass. +- **Validation:** `bun run check` +- **Evidence:** The local full gate passes. Preview version `c4b254a2-b80a-445c-baff-822e5cba705f` serves `/compare` with the new route and assets. Pull request and CI remain. diff --git a/bun.lock b/bun.lock index 673d9d4..9483f3a 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "drizzle-orm": "0.45.2", "solid-js": "1.9.14", "valibot": "1.4.2", + "yaml": "2.9.0", }, "devDependencies": { "@axe-core/playwright": "4.12.1", @@ -730,6 +731,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], diff --git a/package.json b/package.json index 8150c0a..e4f7061 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "@tanstack/solid-start": "1.168.30", "drizzle-orm": "0.45.2", "solid-js": "1.9.14", - "valibot": "1.4.2" + "valibot": "1.4.2", + "yaml": "2.9.0" }, "devDependencies": { "@axe-core/playwright": "4.12.1", diff --git a/specs/features/2026-07-19-contract-change-check/spec.md b/specs/features/2026-07-19-contract-change-check/spec.md new file mode 100644 index 0000000..5b62e27 --- /dev/null +++ b/specs/features/2026-07-19-contract-change-check/spec.md @@ -0,0 +1,53 @@ +# Contract Change Check + +**Status:** APPROVED +**Owner:** Main agent +**Roadmap:** Public-evidence product slice before Phase 2 + +## Outcome + +A visitor can compare two local OpenAPI 3.x JSON or YAML documents and understand whether the revision contains a supported high-confidence breaking change before sharing any contract with a server. + +## Requirements + +1. Provide a dedicated `/compare` route linked from the marketing page. +2. Accept one baseline file and one revised file, each limited to 2 MB and `.json`, `.yaml`, or `.yml`. +3. Parse files entirely in the browser. Do not transmit, persist, log, or analyze contract contents server-side. +4. Require an OpenAPI 3.x root version and a paths object in both documents. +5. Detect and explain these high-confidence changes: + - operation removed + - required request parameter added + - successful response removed + - schema or property type changed + - response property removed + - request property made required + - supported security requirement removed from an operation +6. Group results into breaking changes and review notes, with method, path, location, and plain-language impact. +7. Show a clear no-supported-breaking-changes result without claiming full compatibility. +8. Provide sample contracts so the workflow is useful without user data. +9. Work with keyboard navigation, mobile layouts, reduced motion, and without client-side JavaScript for the explanatory shell. Comparison requires JavaScript and must say so. + +## Non-Goals + +- Full OpenAPI validation or exhaustive compatibility analysis +- External `$ref` retrieval or dereferencing +- Swagger 2.0, AsyncAPI, callbacks, webhooks, links, discriminator analysis, or JSON Schema composition semantics +- Accounts, saved comparisons, GitHub integration, CI configuration, billing, or AI-generated judgments + +## Privacy and Failure Behavior + +- File contents remain in browser memory and are cleared on reload. +- Reject unsupported extensions, files over 2 MB, malformed JSON or YAML, missing OpenAPI 3.x versions, and missing paths with actionable messages. +- Do not fetch external references. Report unresolved `$ref` coverage as a review note. +- Never render specification text as HTML. + +## Validation + +- Unit tests cover every supported breaking-change category and direction. +- Browser tests cover sample comparison, malformed input, privacy copy, keyboard use, mobile layout, accessibility, and the no-JavaScript shell. +- The production bundle contains no server upload path for contract contents. +- `bun run check` passes and the Cloudflare preview is inspected before merge. + +## Rollout + +Deploy to preview first. Roll back by reverting the route, feature slice, marketing link, and YAML dependency. No data migration is required. diff --git a/src/core/openapi/compare.test.ts b/src/core/openapi/compare.test.ts new file mode 100644 index 0000000..110cac1 --- /dev/null +++ b/src/core/openapi/compare.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { compareOpenApi } from './compare' + +const operation = { + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' }, dueDate: { type: 'string' } }, + }, + }, + }, + }, + }, +} + +function document(nextOperation: Record = operation) { + return { openapi: '3.1.0', paths: { '/invoices/{id}': { get: nextOperation } } } +} + +describe('compareOpenApi', () => { + it('reports the supported high-confidence breaking changes', () => { + const revision = document({ + parameters: [{ in: 'query', name: 'expand', required: true, schema: { type: 'string' } }], + security: [{ oauth: [] }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['email'], + properties: { email: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'object', properties: { id: { type: 'number' } } }, + }, + }, + }, + }, + }) + const base = document({ + ...operation, + security: [{ apiKey: [] }, { oauth: [] }], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { email: { type: 'string' } }, + }, + }, + }, + }, + }) + + expect(compareOpenApi(base, revision).breaking.map(({ code }) => code)).toEqual([ + 'required-parameter-added', + 'response-property-removed', + 'schema-type-changed', + 'request-property-required', + 'security-scheme-removed', + ]) + }) + + it('reports removed operations and successful responses', () => { + expect(compareOpenApi(document(), { openapi: '3.1.0', paths: {} }).breaking[0]?.code).toBe( + 'operation-removed', + ) + expect( + compareOpenApi(document(), document({ responses: { '404': { description: 'Missing' } } })) + .breaking[0]?.code, + ).toBe('success-response-removed') + }) + + it('reports removed response properties', () => { + const result = compareOpenApi( + document(), + document({ + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'object', properties: { id: { type: 'string' } } }, + }, + }, + }, + }, + }), + ) + + expect(result.breaking.map(({ code }) => code)).toContain('response-property-removed') + }) + + it('does not claim a breaking change for identical contracts', () => { + expect(compareOpenApi(document(), document())).toEqual({ breaking: [], review: [] }) + }) +}) diff --git a/src/core/openapi/compare.ts b/src/core/openapi/compare.ts new file mode 100644 index 0000000..d903c8d --- /dev/null +++ b/src/core/openapi/compare.ts @@ -0,0 +1,352 @@ +export type ChangeSeverity = 'breaking' | 'review' + +export interface ContractChange { + severity: ChangeSeverity + code: string + method?: string + path?: string + location: string + message: string +} + +export interface ComparisonResult { + breaking: ContractChange[] + review: ContractChange[] +} + +type ObjectValue = Record + +const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'] as const + +function object(value: unknown): ObjectValue | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as ObjectValue) + : undefined +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +function localRef(document: ObjectValue, value: unknown): ObjectValue | undefined { + const schema = object(value) + const ref = schema?.['$ref'] + if (typeof ref !== 'string') return schema + if (!ref.startsWith('#/')) return undefined + + let current: unknown = document + for (const segment of ref.slice(2).split('/')) { + current = object(current)?.[segment.replaceAll('~1', '/').replaceAll('~0', '~')] + } + return object(current) +} + +function mediaSchemas(value: unknown): ObjectValue[] { + const content = object(object(value)?.['content']) + if (!content) return [] + return Object.values(content) + .map((media) => object(media)?.['schema']) + .map(object) + .filter((schema): schema is ObjectValue => Boolean(schema)) +} + +function change( + severity: ChangeSeverity, + code: string, + message: string, + location: string, + path?: string, + method?: string, +): ContractChange { + return { + severity, + code, + message, + location, + ...(path ? { path } : {}), + ...(method ? { method } : {}), + } +} + +function compareSchema( + baseDocument: ObjectValue, + revisionDocument: ObjectValue, + baseValue: unknown, + revisionValue: unknown, + context: 'request' | 'response', + location: string, + path: string, + method: string, + changes: ContractChange[], + seen: Set, +) { + const key = `${context}:${method}:${path}:${location}` + if (seen.has(key)) return + seen.add(key) + + const base = localRef(baseDocument, baseValue) + const revision = localRef(revisionDocument, revisionValue) + if (!base || !revision) { + changes.push( + change( + 'review', + 'unresolved-ref', + 'This schema uses a reference outside the supported local comparison boundary.', + location, + path, + method, + ), + ) + return + } + + const baseType = base['type'] + const revisionType = revision['type'] + if (baseType !== undefined && revisionType !== undefined && baseType !== revisionType) { + changes.push( + change( + 'breaking', + 'schema-type-changed', + `Type changed from ${String(baseType)} to ${String(revisionType)}.`, + location, + path, + method, + ), + ) + return + } + + const baseProperties = object(base['properties']) ?? {} + const revisionProperties = object(revision['properties']) ?? {} + if (context === 'response') { + for (const property of Object.keys(baseProperties)) { + if (!(property in revisionProperties)) { + changes.push( + change( + 'breaking', + 'response-property-removed', + `Response property "${property}" was removed.`, + `${location}.properties.${property}`, + path, + method, + ), + ) + } + } + } else { + const baseRequired = new Set(strings(base['required'])) + for (const property of strings(revision['required'])) { + if (!baseRequired.has(property)) { + changes.push( + change( + 'breaking', + 'request-property-required', + `Request property "${property}" is now required.`, + `${location}.required`, + path, + method, + ), + ) + } + } + } + + for (const property of Object.keys(baseProperties)) { + if (property in revisionProperties) { + compareSchema( + baseDocument, + revisionDocument, + baseProperties[property], + revisionProperties[property], + context, + `${location}.properties.${property}`, + path, + method, + changes, + seen, + ) + } + } + + if (base['items'] && revision['items']) { + compareSchema( + baseDocument, + revisionDocument, + base['items'], + revision['items'], + context, + `${location}.items`, + path, + method, + changes, + seen, + ) + } +} + +function parameters(pathItem: ObjectValue, operation: ObjectValue): ObjectValue[] { + return [ + ...(Array.isArray(pathItem['parameters']) ? pathItem['parameters'] : []), + ...(Array.isArray(operation['parameters']) ? operation['parameters'] : []), + ] + .map(object) + .filter((item): item is ObjectValue => Boolean(item)) +} + +function securityKeys(document: ObjectValue, operation: ObjectValue): Set { + const value = operation['security'] ?? document['security'] + if (!Array.isArray(value)) return new Set() + return new Set(value.flatMap((requirement) => Object.keys(object(requirement) ?? {}))) +} + +function compareOperation( + baseDocument: ObjectValue, + revisionDocument: ObjectValue, + basePathItem: ObjectValue, + revisionPathItem: ObjectValue, + baseOperation: ObjectValue, + revisionOperation: ObjectValue, + path: string, + method: string, + changes: ContractChange[], +) { + const baseParameters = new Map( + parameters(basePathItem, baseOperation).map((parameter) => [ + `${String(parameter['in'])}:${String(parameter['name'])}`, + parameter, + ]), + ) + for (const parameter of parameters(revisionPathItem, revisionOperation)) { + const key = `${String(parameter['in'])}:${String(parameter['name'])}` + const previous = baseParameters.get(key) + if (parameter['required'] === true && previous?.['required'] !== true) { + changes.push( + change( + 'breaking', + 'required-parameter-added', + `Required ${String(parameter['in'])} parameter "${String(parameter['name'])}" was added.`, + `paths.${path}.${method}.parameters`, + path, + method, + ), + ) + } + } + + const baseResponses = object(baseOperation['responses']) ?? {} + const revisionResponses = object(revisionOperation['responses']) ?? {} + for (const status of Object.keys(baseResponses).filter((code) => /^2\d\d$/.test(code))) { + if (!(status in revisionResponses)) { + changes.push( + change( + 'breaking', + 'success-response-removed', + `Successful response ${status} was removed.`, + `paths.${path}.${method}.responses.${status}`, + path, + method, + ), + ) + continue + } + const baseSchemas = mediaSchemas(baseResponses[status]) + const revisionSchemas = mediaSchemas(revisionResponses[status]) + if (baseSchemas[0] && revisionSchemas[0]) { + compareSchema( + baseDocument, + revisionDocument, + baseSchemas[0], + revisionSchemas[0], + 'response', + `paths.${path}.${method}.responses.${status}`, + path, + method, + changes, + new Set(), + ) + } + } + + const baseRequestSchemas = mediaSchemas(baseOperation['requestBody']) + const revisionRequestSchemas = mediaSchemas(revisionOperation['requestBody']) + if (baseRequestSchemas[0] && revisionRequestSchemas[0]) { + compareSchema( + baseDocument, + revisionDocument, + baseRequestSchemas[0], + revisionRequestSchemas[0], + 'request', + `paths.${path}.${method}.requestBody`, + path, + method, + changes, + new Set(), + ) + } + + const revisionSecurity = securityKeys(revisionDocument, revisionOperation) + for (const scheme of securityKeys(baseDocument, baseOperation)) { + if (!revisionSecurity.has(scheme)) { + changes.push( + change( + 'breaking', + 'security-scheme-removed', + `Supported security scheme "${scheme}" was removed.`, + `paths.${path}.${method}.security`, + path, + method, + ), + ) + } + } +} + +export function compareOpenApi( + baseDocument: ObjectValue, + revisionDocument: ObjectValue, +): ComparisonResult { + const changes: ContractChange[] = [] + const basePaths = object(baseDocument['paths']) ?? {} + const revisionPaths = object(revisionDocument['paths']) ?? {} + + for (const [path, basePathValue] of Object.entries(basePaths)) { + const basePathItem = object(basePathValue) ?? {} + const revisionPathItem = object(revisionPaths[path]) + for (const method of methods) { + const baseOperation = object(basePathItem[method]) + if (!baseOperation) continue + const revisionOperation = object(revisionPathItem?.[method]) + if (!revisionOperation) { + changes.push( + change( + 'breaking', + 'operation-removed', + `${method.toUpperCase()} ${path} was removed.`, + `paths.${path}.${method}`, + path, + method, + ), + ) + continue + } + compareOperation( + baseDocument, + revisionDocument, + basePathItem, + revisionPathItem ?? {}, + baseOperation, + revisionOperation, + path, + method, + changes, + ) + } + } + + return { + breaking: changes.filter(({ severity }) => severity === 'breaking'), + review: changes.filter(({ severity }) => severity === 'review'), + } +} diff --git a/src/features/contract-change/contract-change-check.tsx b/src/features/contract-change/contract-change-check.tsx new file mode 100644 index 0000000..50cbdba --- /dev/null +++ b/src/features/contract-change/contract-change-check.tsx @@ -0,0 +1,215 @@ +import { For, Show, createSignal } from 'solid-js' + +import { compareOpenApi, type ComparisonResult } from '../../core/openapi/compare' +import { ContractParseError, parseContract } from './parse-contract' + +const maxFileBytes = 2 * 1024 * 1024 +const allowedExtensions = ['.json', '.yaml', '.yml'] + +const sampleBaseline = `openapi: 3.1.0 +info: + title: Billing API + version: 1.0.0 +paths: + /invoices/{invoiceId}: + get: + responses: + "200": + description: Invoice + content: + application/json: + schema: + type: object + properties: + invoiceId: + type: string + dueDate: + type: string +` + +const sampleRevision = sampleBaseline.replace( + ` dueDate: + type: string`, + ` paymentDueAt: + type: string`, +) + +interface SelectedContract { + name: string + source: string +} + +function errorMessage(error: unknown): string { + return error instanceof ContractParseError || error instanceof Error + ? error.message + : 'The comparison could not be completed.' +} + +async function readContract(file: File): Promise { + const lowerName = file.name.toLowerCase() + if (!allowedExtensions.some((extension) => lowerName.endsWith(extension))) { + throw new Error('Choose a .json, .yaml, or .yml file.') + } + if (file.size > maxFileBytes) throw new Error('Keep each contract under 2 MB.') + return { name: file.name, source: await file.text() } +} + +export function ContractChangeCheck() { + const [baseline, setBaseline] = createSignal() + const [revision, setRevision] = createSignal() + const [result, setResult] = createSignal() + const [error, setError] = createSignal('') + + async function selectFile(kind: 'baseline' | 'revision', file?: File) { + if (!file) return + setError('') + setResult() + try { + const selected = await readContract(file) + if (kind === 'baseline') setBaseline(selected) + else setRevision(selected) + } catch (cause) { + setError(errorMessage(cause)) + } + } + + function compare() { + const base = baseline() + const next = revision() + setError('') + if (!base || !next) { + setError('Choose both a baseline and a revised contract.') + return + } + try { + setResult(compareOpenApi(parseContract(base.source), parseContract(next.source))) + } catch (cause) { + setResult() + setError(errorMessage(cause)) + } + } + + function loadSample() { + setBaseline({ name: 'billing-api-baseline.yaml', source: sampleBaseline }) + setRevision({ name: 'billing-api-revision.yaml', source: sampleRevision }) + setError('') + setResult() + } + + return ( +
+
+ + + OpenAPI Studio + + + Back to overview + +
+ +
+ +

Check a contract change before your users do.

+

+ Compare two OpenAPI 3.x files for a focused set of high-confidence breaking changes. Your + files stay in this browser and disappear on reload. +

+
+ +
+
+
+ +

Baseline against revision

+
+ +
+ +
+ + +
+ +
+ +

OpenAPI 3.x · JSON or YAML · 2 MB per file · no upload

+
+ +

Comparison requires JavaScript to read both files locally.

+ {(message) =>

{message()}

}
+ + + {(comparison) => ( +
+
+
+ +

+ {comparison().breaking.length > 0 + ? 'Breaking change detected.' + : 'No supported breaking changes detected.'} +

+
+ {comparison().breaking.length} +
+ + 0}> +
    + + {(item) => ( +
  1. +
    + {item.method?.toUpperCase()} + {item.path} +
    + {item.message} + {item.location} +
  2. + )} +
    +
+
+ + 0}> +
+

Review notes

+ {(item) =>

{item.message}

}
+
+
+ +

+ This preview checks a focused subset of structural changes. It does not prove full + behavioral compatibility or resolve external references. +

+
+ )} +
+
+
+ ) +} diff --git a/src/features/contract-change/parse-contract.test.ts b/src/features/contract-change/parse-contract.test.ts new file mode 100644 index 0000000..a911567 --- /dev/null +++ b/src/features/contract-change/parse-contract.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +import { ContractParseError, parseContract } from './parse-contract' + +describe('parseContract', () => { + it('parses JSON and YAML OpenAPI 3 documents', () => { + expect(parseContract('{"openapi":"3.1.0","paths":{}}')['openapi']).toBe('3.1.0') + expect(parseContract('openapi: 3.0.4\npaths: {}')['openapi']).toBe('3.0.4') + }) + + it('rejects malformed and unsupported documents', () => { + expect(() => parseContract('openapi: [')).toThrow(ContractParseError) + expect(() => parseContract('swagger: "2.0"\npaths: {}')).toThrow('OpenAPI 3.x') + expect(() => parseContract('openapi: 3.1.0')).toThrow('paths object') + }) +}) diff --git a/src/features/contract-change/parse-contract.ts b/src/features/contract-change/parse-contract.ts new file mode 100644 index 0000000..572f494 --- /dev/null +++ b/src/features/contract-change/parse-contract.ts @@ -0,0 +1,31 @@ +import { parseDocument } from 'yaml' + +export type OpenApiDocument = Record + +export class ContractParseError extends Error {} + +export function parseContract(source: string): OpenApiDocument { + const parsed = parseDocument(source, { logLevel: 'error' }) + if (parsed.errors.length > 0) { + throw new ContractParseError( + parsed.errors[0]?.message ?? 'The contract is not valid JSON or YAML.', + ) + } + + const value: unknown = parsed.toJS({ maxAliasCount: 50 }) + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new ContractParseError('The contract root must be an object.') + } + const document = value as OpenApiDocument + if (typeof document['openapi'] !== 'string' || !/^3\.\d+\.\d+/.test(document['openapi'])) { + throw new ContractParseError('Use an OpenAPI 3.x document with an openapi version field.') + } + if ( + typeof document['paths'] !== 'object' || + document['paths'] === null || + Array.isArray(document['paths']) + ) { + throw new ContractParseError('The contract must contain a paths object.') + } + return document +} diff --git a/src/features/marketing/marketing-home.tsx b/src/features/marketing/marketing-home.tsx index 2b640aa..9d0599f 100644 --- a/src/features/marketing/marketing-home.tsx +++ b/src/features/marketing/marketing-home.tsx @@ -42,12 +42,8 @@ export function MarketingHome() { Evidence - recordConversion('design_partner_clicked')} - > - Join research + + Try comparison @@ -59,31 +55,26 @@ export function MarketingHome() {

Catch the breaking change before your users do.

- We are testing whether small API teams need a clearer way to review OpenAPI contract - changes before we build the product workflow. + Compare a baseline and revised OpenAPI contract for high-confidence consumer breaks. + Files stay in your browser.

- Interviews are free. A product pilot and $29/workspace/month price are hypotheses. - They are not available yet. + Free public preview. No account, upload, persistence, or claim of exhaustive analysis.

-

Illustrative product concept · not a live editor

+

Product workflow · live comparison available

billing-api.yaml PR #184

@@ -175,12 +166,14 @@ export function MarketingHome() {

- OpenAPI Studio is in demand validation, not a working editor. Today’s repository - contains this website, delivery checks, preview infrastructure, and identifier-free - conversion measurement. OpenAPI 3.0/3.1 import, validation, diff review, and - publishing are roadmap work. They are not implemented yet. No invented customers. No + OpenAPI Studio now includes a browser-local comparison preview for a focused set of + high-confidence OpenAPI changes. Exhaustive validation, editing, saved projects, + publishing, collaboration, and billing are not implemented. No invented customers. No fake screenshots. No checkout.

+ + Try the local comparison + recordConversion('repository_clicked')}> Follow the public build diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4776246..f2c6050 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,33 +10,43 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as CompareRouteImport } from './routes/compare' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const CompareRoute = CompareRouteImport.update({ + id: '/compare', + path: '/compare', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/compare': typeof CompareRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/compare': typeof CompareRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/compare': typeof CompareRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/compare' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/compare' + id: '__root__' | '/' | '/compare' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CompareRoute: typeof CompareRoute } declare module '@tanstack/solid-router' { @@ -48,11 +58,19 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/compare': { + id: '/compare' + path: '/compare' + fullPath: '/compare' + preLoaderRoute: typeof CompareRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CompareRoute: CompareRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/compare.tsx b/src/routes/compare.tsx new file mode 100644 index 0000000..f8e32a7 --- /dev/null +++ b/src/routes/compare.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/solid-router' + +import { ContractChangeCheck } from '../features/contract-change/contract-change-check' + +export const Route = createFileRoute('/compare')({ + component: ContractChangeCheck, + head: () => ({ + meta: [ + { title: 'OpenAPI change check | OpenAPI Studio' }, + { + name: 'description', + content: 'Compare two local OpenAPI contracts for high-confidence breaking changes.', + }, + ], + }), +}) diff --git a/src/styles.css b/src/styles.css index bb0cb06..7c715dd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -146,6 +146,11 @@ a:focus-visible { .site-footer a:focus-visible { outline-color: #cf2f1b; } +button:focus-visible, +input:focus-visible { + outline: 3px solid #cf2f1b; + outline-offset: 4px; +} .hero { display: grid; @@ -154,6 +159,204 @@ a:focus-visible { align-items: center; padding-block: clamp(2.5rem, 5vw, 4.5rem) clamp(4rem, 7vw, 6rem); } + +.compare-page { + min-height: 100vh; + padding-bottom: 6rem; + background: #f5f5f3; +} +.compare-header { + display: flex; + min-height: 5rem; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #d4d0ca; +} +.compare-mark { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + color: #fff; + background: #cf2f1b; + font-size: 0.62rem; + letter-spacing: -0.04em; +} +.compare-intro { + display: grid; + gap: 1.25rem; + padding-block: clamp(3rem, 7vw, 7rem); +} +.compare-intro h1 { + max-width: 14ch; +} +.compare-intro > p:last-child { + max-width: 48rem; + font-size: clamp(1rem, 1.4vw, 1.25rem); + line-height: 1.65; +} +.compare-workspace { + min-width: 0; + padding: clamp(1.5rem, 3vw, 3rem); + border: 1px solid #bdb8b1; + background: #fff; +} +.compare-workspace-head, +.result-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 2rem; +} +.compare-workspace h2 { + margin-top: 0.5rem; + font-size: clamp(1.7rem, 3vw, 2.7rem); + letter-spacing: -0.035em; +} +.text-button { + padding: 0; + border: 0; + border-bottom: 1px solid currentColor; + color: #171310; + background: transparent; + font: inherit; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} +.file-grid { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 2.5rem; +} +.file-card { + display: grid; + min-width: 0; + min-height: 15rem; + align-content: start; + gap: 0.8rem; + padding: 1.5rem; + border: 1px solid #171310; + cursor: pointer; +} +.file-card:hover { + background: #f2f0ec; +} +.file-number { + margin-bottom: 1.5rem; + color: #cf2f1b; + font-size: 2rem; + font-weight: 750; +} +.file-card strong { + font-size: 1.1rem; +} +.file-card > span:not(.file-number) { + min-height: 2.5rem; + color: #655f59; + font-size: 0.8rem; + line-height: 1.5; + overflow-wrap: anywhere; +} +.file-card input { + width: 100%; + min-width: 0; + max-width: 100%; + margin-top: auto; + font: inherit; + font-size: 0.76rem; +} +.compare-action { + display: flex; + align-items: center; + gap: 1.5rem; + margin-top: 1.5rem; +} +.compare-action button { + cursor: pointer; +} +.compare-action p, +.js-note, +.coverage-note { + color: #655f59; + font-size: 0.75rem; + line-height: 1.6; +} +.js-note { + margin-top: 0.75rem; +} +.error-banner { + margin-top: 1.5rem; + padding: 1rem 1.25rem; + border-left: 4px solid #cf2f1b; + background: #f5e5e2; + font-size: 0.85rem; + line-height: 1.5; +} +.results { + margin-top: 3rem; + padding-top: 2.5rem; + border-top: 3px solid #171310; +} +.result-count { + color: #cf2f1b; + font-size: clamp(4rem, 9vw, 8rem); + line-height: 0.8; +} +.change-list { + display: grid; + gap: 1px; + margin: 2.5rem 0 0; + padding: 0; + background: #bdb8b1; + list-style: none; +} +.change-list li { + display: grid; + gap: 0.8rem; + padding: 1.4rem; + background: #f5f5f3; +} +.change-list li > code { + color: #655f59; + font-size: 0.7rem; + overflow-wrap: anywhere; +} +.change-context { + display: flex; + align-items: center; + gap: 0.8rem; +} +.change-context span { + padding: 0.25rem 0.4rem; + color: #fff; + background: #171310; + font-size: 0.68rem; + font-weight: 750; +} +.review-notes { + margin-top: 1.5rem; + padding: 1.25rem; + border: 1px solid #bdb8b1; +} +.review-notes p, +.coverage-note { + margin-top: 0.75rem; +} + +@media (max-width: 720px) { + .compare-workspace-head, + .result-summary, + .compare-action { + align-items: stretch; + flex-direction: column; + } + .file-grid { + grid-template-columns: 1fr; + } +} .hero-copy { display: flex; flex-direction: column; diff --git a/tests/browser/compare.test.ts b/tests/browser/compare.test.ts new file mode 100644 index 0000000..c3f2d2c --- /dev/null +++ b/tests/browser/compare.test.ts @@ -0,0 +1,62 @@ +import AxeBuilder from '@axe-core/playwright' +import { expect, test } from '@playwright/test' + +test('turns two local sample contracts into a breaking-change decision', async ({ page }) => { + await page.goto('/compare') + + await expect( + page.getByRole('heading', { level: 1, name: 'Check a contract change before your users do.' }), + ).toBeVisible() + await expect(page.getByText('Your files stay in this browser')).toBeVisible() + + await page.getByRole('button', { name: 'Load breaking-change sample' }).click() + await page.getByRole('button', { name: 'Check contract impact' }).click() + + await expect(page.getByRole('heading', { name: 'Breaking change detected.' })).toBeVisible() + await expect(page.getByText('Response property "dueDate" was removed.')).toBeVisible() + await expect(page.getByText('This preview checks a focused subset')).toBeVisible() + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]) +}) + +test('rejects malformed and oversized contract inputs', async ({ page }) => { + await page.goto('/compare') + const inputs = page.locator('input[type="file"]') + + await inputs.nth(0).setInputFiles({ + name: 'baseline.yaml', + mimeType: 'text/yaml', + buffer: Buffer.from('openapi: ['), + }) + await inputs.nth(1).setInputFiles({ + name: 'revision.yaml', + mimeType: 'text/yaml', + buffer: Buffer.from('openapi: 3.1.0\npaths: {}'), + }) + await page.getByRole('button', { name: 'Check contract impact' }).click() + await expect(page.getByText(/Unexpected flow-seq-end|Flow sequence/)).toBeVisible() + + await inputs.nth(0).setInputFiles({ + name: 'too-large.yaml', + mimeType: 'text/yaml', + buffer: Buffer.alloc(2 * 1024 * 1024 + 1), + }) + await expect(page.getByText('Keep each contract under 2 MB.')).toBeVisible() +}) + +test('keeps the explanatory shell usable on mobile without JavaScript', async ({ browser }) => { + const context = await browser.newContext({ + javaScriptEnabled: false, + viewport: { width: 360, height: 800 }, + }) + const page = await context.newPage() + + await page.goto('/compare') + + await expect(page.getByRole('heading', { name: 'Baseline against revision' })).toBeVisible() + await expect( + page.getByText('Comparison requires JavaScript to read both files locally.'), + ).toBeVisible() + expect((await page.locator('body').evaluate((body) => body.scrollWidth)) <= 360).toBe(true) + + await context.close() +}) diff --git a/tests/browser/marketing.test.ts b/tests/browser/marketing.test.ts index 890643e..db8c4c6 100644 --- a/tests/browser/marketing.test.ts +++ b/tests/browser/marketing.test.ts @@ -28,7 +28,7 @@ test('presents a truthful design-partner conversion path', async ({ page }) => { }), ).toBeVisible() - const application = page.getByRole('link', { name: 'Email the founder about the research' }) + const application = page.getByRole('link', { name: 'Apply by email' }) const applicationUrl = new URL((await application.getAttribute('href')) ?? '') expect(applicationUrl.protocol).toBe('mailto:') expect(applicationUrl.pathname).toBe('pitechae@gmail.com') @@ -41,8 +41,8 @@ test('presents a truthful design-partner conversion path', async ({ page }) => { expect(applicationUrl.searchParams.get('body')).toContain( 'Would we pay $29/workspace/month if this worked? Why or why not?', ) - await expect(page.getByText('not a working editor')).toBeVisible() - await expect(page.getByText('roadmap work. They are not implemented yet')).toBeVisible() + await expect(page.getByText('browser-local comparison preview')).toBeVisible() + await expect(page.getByText('saved projects, publishing')).toBeVisible() await expect(page.getByText('Illustrative prototype. This is not a screenshot')).toBeVisible() await expect(page.getByText('Do not upload a contract or confidential material')).toBeVisible() const renderedContent = await page @@ -71,7 +71,7 @@ test('has no detectable accessibility violations and a usable tab order', async await page.keyboard.press('Tab') await expect(page.getByRole('link', { name: 'Evidence' })).toBeFocused() await page.keyboard.press('Tab') - await expect(page.getByRole('link', { name: 'Join research' })).toBeFocused() + await expect(page.getByRole('link', { name: 'Try comparison' })).toBeFocused() }) test('keeps the offer usable on a narrow screen without JavaScript', async ({ browser }) => { @@ -84,9 +84,7 @@ test('keeps the offer usable on a narrow screen without JavaScript', async ({ br await page.goto('/') await expect(page.getByRole('main')).toBeVisible() - await expect( - page.getByRole('link', { name: 'Email the founder about the research' }), - ).toBeVisible() + await expect(page.getByRole('link', { name: 'Check a contract change' })).toBeVisible() await expect(page.getByText('price hypothesis')).toBeVisible() expect((await page.locator('body').evaluate((body) => body.scrollWidth)) <= 360).toBe(true)