@lariocpt/flawless-react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { detectAppReact, mergePackageJson, repairReactPin } from "../packageJson.ts";
|
|
5
|
+
import { classifyStamped } from "../scaffold-tests/stamp.ts";
|
|
6
|
+
import type { ComponentTree, ScaffoldOptions } from "../types.ts";
|
|
7
|
+
import { extractRoutes, loadApiSpec } from "./openapi.ts";
|
|
8
|
+
import { renderSuite, toHostRelImport } from "./templates.ts";
|
|
9
|
+
|
|
10
|
+
export interface ScaffoldResult {
|
|
11
|
+
/** Absolute paths of the files this call wrote. */
|
|
12
|
+
written: string[];
|
|
13
|
+
/** writeOnce files that already existed and were left untouched. */
|
|
14
|
+
kept: string[];
|
|
15
|
+
status: "generated" | "refreshed" | "kept";
|
|
16
|
+
/**
|
|
17
|
+
* Version in the existing marker file's generated-by banner, on a "kept"
|
|
18
|
+
* result. Undefined for a pre-banner suite — the caller must say "unknown
|
|
19
|
+
* version", never substitute its own.
|
|
20
|
+
*/
|
|
21
|
+
existingVersion?: string;
|
|
22
|
+
/**
|
|
23
|
+
* host/main.tsx predates the registry split (no `from "./registry"` import),
|
|
24
|
+
* so the kept wrapper never sees newly analysed components. writeOnce is
|
|
25
|
+
* absolute — `--force` included — so the caller must name the escape:
|
|
26
|
+
* delete host/main.tsx and re-run, or port the hand wiring onto an import
|
|
27
|
+
* of ./registry. host/registry.ts is still written, inert beside it.
|
|
28
|
+
*/
|
|
29
|
+
preSplitMain?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Stale profile specs removed because their component is no longer in the
|
|
32
|
+
* host and the stamp proved the file was untouched tool output.
|
|
33
|
+
*/
|
|
34
|
+
staleRemoved: string[];
|
|
35
|
+
/** Stale profile specs kept (hand work or unprovable) — the owner's call. */
|
|
36
|
+
staleKept: StaleProfileSpec[];
|
|
37
|
+
/**
|
|
38
|
+
* The kept host/main.tsx imported a provider this run no longer resolves to,
|
|
39
|
+
* and the tool re-pointed that ONE import line (specifiers as the file had
|
|
40
|
+
* them and as it now does). Everything else in the file is untouched.
|
|
41
|
+
*/
|
|
42
|
+
providerImportRefresh?: { from: string; to: string };
|
|
43
|
+
/** The kept host/main.tsx names AppProvider in a shape the tool will not touch. */
|
|
44
|
+
providerImportWarning?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A stale profile spec the writer refused to remove, and why. */
|
|
48
|
+
export interface StaleProfileSpec {
|
|
49
|
+
abs: string;
|
|
50
|
+
/** "edited" = stamp present, body differs (hand work); "stampless" = no provenance at all. */
|
|
51
|
+
verdict: "edited" | "stampless";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The generated-by banner the marker file carries; parseExistingVersion reads it back. */
|
|
55
|
+
const BANNER_RE = /Generated by flawless-react (\S+)/;
|
|
56
|
+
|
|
57
|
+
export function parseExistingVersion(markerText: string): string | undefined {
|
|
58
|
+
return BANNER_RE.exec(markerText)?.[1];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function scaffold(
|
|
62
|
+
tree: ComponentTree,
|
|
63
|
+
opts: ScaffoldOptions,
|
|
64
|
+
): Promise<ScaffoldResult> {
|
|
65
|
+
const outDir = path.resolve(opts.outDir);
|
|
66
|
+
// Keyed on a suite already being here, not on the directory existing. The
|
|
67
|
+
// directory is not a usable proxy: ensureClientProviderWrapper mkdirs outDir
|
|
68
|
+
// at core.ts:215, before the task loop at :298, so on any --nextjs run outDir
|
|
69
|
+
// exists by the time this is reached and a bare existsSync would claim a
|
|
70
|
+
// suite on every FIRST run.
|
|
71
|
+
const marker = path.join(outDir, "playwright.config.ts");
|
|
72
|
+
const suiteExists = existsSync(marker);
|
|
73
|
+
|
|
74
|
+
// Changed-mode: the run is narrowed, generation is not churned — nothing is
|
|
75
|
+
// written over an existing suite, and the caller reports which version wrote
|
|
76
|
+
// it (from the marker's banner; pre-banner suites have no version to name).
|
|
77
|
+
if (suiteExists && opts.keepExisting) {
|
|
78
|
+
return {
|
|
79
|
+
written: [],
|
|
80
|
+
kept: [],
|
|
81
|
+
status: "kept",
|
|
82
|
+
existingVersion: parseExistingVersion(await Bun.file(marker).text()),
|
|
83
|
+
staleRemoved: [],
|
|
84
|
+
staleKept: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await mkdir(outDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
// Checked before the write loop only for clarity — the file is writeOnce, so
|
|
91
|
+
// the loop below could not change the verdict either way.
|
|
92
|
+
const mainPath = path.join(outDir, "host", "main.tsx");
|
|
93
|
+
const preSplitMain =
|
|
94
|
+
// Quote- and extension-tolerant: main.tsx is user-owned, so a prettier
|
|
95
|
+
// singleQuote pass or an explicit ./registry.ts import (the suite tsconfig
|
|
96
|
+
// allows importing .ts extensions) must not read as pre-split forever.
|
|
97
|
+
existsSync(mainPath) &&
|
|
98
|
+
!/from\s+['"]\.\/registry(\.ts)?['"]/.test(await Bun.file(mainPath).text());
|
|
99
|
+
|
|
100
|
+
const providerImportPath = opts.provider ? toHostRelImport(outDir, opts.provider) : undefined;
|
|
101
|
+
const mockRoutes = opts.apiSpec ? extractRoutes(await loadApiSpec(opts.apiSpec)) : undefined;
|
|
102
|
+
|
|
103
|
+
const files = renderSuite({
|
|
104
|
+
tree,
|
|
105
|
+
extraTrees: opts.extraTrees,
|
|
106
|
+
suiteName: opts.suiteName,
|
|
107
|
+
outDir,
|
|
108
|
+
variants: opts.variants,
|
|
109
|
+
...(opts.heaps ? { heaps: opts.heaps } : {}),
|
|
110
|
+
report: opts.report,
|
|
111
|
+
providerImportPath,
|
|
112
|
+
...(opts.provider ? { providerFile: opts.provider } : {}),
|
|
113
|
+
mockRoutes,
|
|
114
|
+
nextjsHost: opts.nextjsHost,
|
|
115
|
+
mswSetup: opts.mswSetup,
|
|
116
|
+
...(opts.providerWraps ? { providerWraps: opts.providerWraps } : {}),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const written: string[] = [];
|
|
120
|
+
const kept: string[] = [];
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
const abs = f.absPath ?? path.join(outDir, f.relPath);
|
|
123
|
+
// Not gated on --force, unlike the colocated specs: the point of these
|
|
124
|
+
// files is that no flag rebuilds a tuned budget or hand-wired provider.
|
|
125
|
+
// Delete the file to get a fresh one, same as provider.fixtures.ts.
|
|
126
|
+
if (f.writeOnce && existsSync(abs)) {
|
|
127
|
+
kept.push(abs);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
131
|
+
if (f.relPath === "package.json" && existsSync(abs)) {
|
|
132
|
+
await Bun.write(
|
|
133
|
+
abs,
|
|
134
|
+
repairReactPin(
|
|
135
|
+
mergePackageJson(await Bun.file(abs).text(), f.content),
|
|
136
|
+
detectAppReact(outDir),
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
written.push(abs);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
await Bun.write(abs, f.content);
|
|
143
|
+
if (f.executable) await chmod(abs, 0o755);
|
|
144
|
+
written.push(abs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Profile specs are rewritten every run ONLY while their component stays
|
|
148
|
+
// host-mountable. When it leaves the host set (a new guard the analyser
|
|
149
|
+
// cannot decide, a render-mode change, a deleted file) the spec is simply
|
|
150
|
+
// no longer rendered — and without this sweep it sits in profile/ forever,
|
|
151
|
+
// failing at mount with "unknown component": a suite red by the tool's own
|
|
152
|
+
// doing. Provenance decides, exactly as for the colocated and browser
|
|
153
|
+
// specs: an untouched stamp is pure tool output (removed and reported); a
|
|
154
|
+
// mismatched stamp is hand work (kept, reported); no stamp at all is
|
|
155
|
+
// unprovable (kept, reported — protects pre-stamp suites).
|
|
156
|
+
const staleRemoved: string[] = [];
|
|
157
|
+
const staleKept: StaleProfileSpec[] = [];
|
|
158
|
+
const renderedProfile = new Set(
|
|
159
|
+
files
|
|
160
|
+
.filter((f) => f.relPath.startsWith("profile/") && f.relPath.endsWith(".profile.spec.ts"))
|
|
161
|
+
.map((f) => f.relPath),
|
|
162
|
+
);
|
|
163
|
+
const profileDir = path.join(outDir, "profile");
|
|
164
|
+
if (existsSync(profileDir)) {
|
|
165
|
+
for (const entry of (await readdir(profileDir)).sort()) {
|
|
166
|
+
if (!entry.endsWith(".profile.spec.ts")) continue;
|
|
167
|
+
if (renderedProfile.has(`profile/${entry}`)) continue;
|
|
168
|
+
const abs = path.join(profileDir, entry);
|
|
169
|
+
const verdict = classifyStamped(await readFile(abs, "utf8"));
|
|
170
|
+
if (verdict === "untouched") {
|
|
171
|
+
await rm(abs);
|
|
172
|
+
staleRemoved.push(abs);
|
|
173
|
+
} else {
|
|
174
|
+
staleKept.push({ abs, verdict });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A kept host/main.tsx is user-owned — its ALL spread and hand wiring survive
|
|
180
|
+
// every run — but `import AppProvider from "…"` is not a tuned value: it names
|
|
181
|
+
// the provider THIS run resolved. When the provider moves, the kept import
|
|
182
|
+
// keeps pulling the old file and the whole host mounts the wrong tree. On the
|
|
183
|
+
// talkformai dogfood a pages `_app` that cannot mount began deriving to
|
|
184
|
+
// flawless/provider.tsx while main.tsx went on importing src/pages/_app, so
|
|
185
|
+
// all 18 browser mounts died as "mount: X did not render within 5000ms" — a
|
|
186
|
+
// timeout that never mentions the cause, which was a Supabase client
|
|
187
|
+
// constructor throwing inside _app. Re-point the one line and report it: an
|
|
188
|
+
// intentional repoint is then visible in git diff, not silently reverted.
|
|
189
|
+
let providerImportRefresh: { from: string; to: string } | undefined;
|
|
190
|
+
let providerImportWarning: string | undefined;
|
|
191
|
+
if (providerImportPath && kept.includes(mainPath)) {
|
|
192
|
+
const repair = await refreshProviderImport(mainPath, providerImportPath);
|
|
193
|
+
if (repair) {
|
|
194
|
+
providerImportRefresh = repair.refreshed;
|
|
195
|
+
providerImportWarning = repair.warning;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
written,
|
|
201
|
+
kept,
|
|
202
|
+
status: suiteExists ? "refreshed" : "generated",
|
|
203
|
+
preSplitMain,
|
|
204
|
+
staleRemoved,
|
|
205
|
+
staleKept,
|
|
206
|
+
...(providerImportRefresh ? { providerImportRefresh } : {}),
|
|
207
|
+
...(providerImportWarning ? { providerImportWarning } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Comparable module id for a specifier written inside `fromFile` (extension-blind). */
|
|
212
|
+
function specifierId(fromFile: string, specifier: string): string {
|
|
213
|
+
return path.resolve(path.dirname(fromFile), specifier).replace(/\.(tsx|jsx|ts|mjs|cjs|js)$/, "");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Re-point a kept host/main.tsx's AppProvider import when it disagrees with the
|
|
218
|
+
* provider this run resolved. Returns nothing when there is nothing to say.
|
|
219
|
+
*
|
|
220
|
+
* Only the exact line the tool emits is rewritten — quote style preserved — so
|
|
221
|
+
* a named import, a multi-line import, or an aliased default is left alone and
|
|
222
|
+
* reported instead. Those shapes mean a hand edit, and hand edits are why this
|
|
223
|
+
* file is write-once.
|
|
224
|
+
*/
|
|
225
|
+
async function refreshProviderImport(
|
|
226
|
+
mainPath: string,
|
|
227
|
+
want: string,
|
|
228
|
+
): Promise<{ refreshed?: { from: string; to: string }; warning?: string } | undefined> {
|
|
229
|
+
const text = await Bun.file(mainPath).text();
|
|
230
|
+
const wantNoExt = want.replace(/\.(tsx|jsx|ts)$/, "");
|
|
231
|
+
const match = /^import AppProvider from (['"])([^'"]+)\1;$/m.exec(text);
|
|
232
|
+
if (!match) {
|
|
233
|
+
return text.includes("AppProvider")
|
|
234
|
+
? {
|
|
235
|
+
warning:
|
|
236
|
+
"host/main.tsx references AppProvider through an import this tool does not " +
|
|
237
|
+
"recognise — its provider import was left alone and may not match --provider",
|
|
238
|
+
}
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
const [, quote, from] = match;
|
|
242
|
+
// The pattern has both groups whenever it matched at all, so this is the
|
|
243
|
+
// checker's guard, not a case the caller can reach: nothing to refresh.
|
|
244
|
+
if (quote === undefined || from === undefined) return undefined;
|
|
245
|
+
if (specifierId(mainPath, from) === specifierId(mainPath, want)) return undefined;
|
|
246
|
+
await Bun.write(
|
|
247
|
+
mainPath,
|
|
248
|
+
text.replace(match[0], `import AppProvider from ${quote}${wantNoExt}${quote};`),
|
|
249
|
+
);
|
|
250
|
+
return { refreshed: { from, to: wantNoExt } };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export { renderSuite } from "./templates.ts";
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { MockRoute } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
interface OpenApiSpec {
|
|
4
|
+
openapi?: string;
|
|
5
|
+
swagger?: string;
|
|
6
|
+
paths?: Record<string, Record<string, OperationObject> | undefined>;
|
|
7
|
+
components?: { schemas?: Record<string, unknown> };
|
|
8
|
+
[k: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface OperationObject {
|
|
12
|
+
responses?: Record<string, ResponseObject | undefined>;
|
|
13
|
+
[k: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ResponseObject {
|
|
17
|
+
content?: Record<string, MediaTypeObject | undefined>;
|
|
18
|
+
[k: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MediaTypeObject {
|
|
22
|
+
schema?: unknown;
|
|
23
|
+
example?: unknown;
|
|
24
|
+
examples?: Record<string, { value?: unknown } | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head"] as const;
|
|
28
|
+
|
|
29
|
+
export async function loadApiSpec(filePath: string): Promise<OpenApiSpec> {
|
|
30
|
+
const file = Bun.file(filePath);
|
|
31
|
+
if (!(await file.exists())) {
|
|
32
|
+
throw new Error(`--api: spec not found at ${filePath}`);
|
|
33
|
+
}
|
|
34
|
+
const text = await file.text();
|
|
35
|
+
const trimmed = text.trimStart();
|
|
36
|
+
const looksJson = trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
37
|
+
let parsed: unknown;
|
|
38
|
+
try {
|
|
39
|
+
parsed = looksJson ? JSON.parse(text) : parseYaml(text);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
throw new Error(`--api: failed to parse ${filePath}: ${(err as Error).message}`);
|
|
42
|
+
}
|
|
43
|
+
if (!parsed || typeof parsed !== "object") {
|
|
44
|
+
throw new Error(`--api: ${filePath} did not parse to an object`);
|
|
45
|
+
}
|
|
46
|
+
const spec = parsed as OpenApiSpec;
|
|
47
|
+
if (!spec.paths || typeof spec.paths !== "object") {
|
|
48
|
+
throw new Error(`--api: ${filePath} has no 'paths' object`);
|
|
49
|
+
}
|
|
50
|
+
return spec;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseYaml(text: string): unknown {
|
|
54
|
+
const yaml = (Bun as unknown as { YAML?: { parse: (s: string) => unknown } }).YAML;
|
|
55
|
+
if (!yaml) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
"YAML parsing requires Bun >= 1.2 (Bun.YAML.parse not found). Convert your spec to JSON first.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return yaml.parse(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function extractRoutes(spec: OpenApiSpec): MockRoute[] {
|
|
64
|
+
const out: MockRoute[] = [];
|
|
65
|
+
for (const [pathTpl, methods] of Object.entries(spec.paths ?? {})) {
|
|
66
|
+
if (!methods) continue;
|
|
67
|
+
for (const method of HTTP_METHODS) {
|
|
68
|
+
const op = methods[method];
|
|
69
|
+
if (!op) continue;
|
|
70
|
+
const picked = pickResponse(op);
|
|
71
|
+
const body = synthesizeBody(picked.media, spec);
|
|
72
|
+
out.push({
|
|
73
|
+
method: method.toUpperCase(),
|
|
74
|
+
path: pathTpl,
|
|
75
|
+
status: picked.status,
|
|
76
|
+
body,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pickResponse(op: OperationObject): { status: number; media: MediaTypeObject | null } {
|
|
84
|
+
const responses = op.responses ?? {};
|
|
85
|
+
const keys = Object.keys(responses);
|
|
86
|
+
const order: string[] = ["200", "201"];
|
|
87
|
+
for (const k of keys) if (/^2\d\d$/.test(k) && !order.includes(k)) order.push(k);
|
|
88
|
+
order.push("default");
|
|
89
|
+
for (const k of keys) if (!order.includes(k)) order.push(k);
|
|
90
|
+
for (const k of order) {
|
|
91
|
+
const r = responses[k];
|
|
92
|
+
if (!r) continue;
|
|
93
|
+
return { status: parseStatus(k), media: pickMedia(r) };
|
|
94
|
+
}
|
|
95
|
+
return { status: 200, media: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseStatus(key: string): number {
|
|
99
|
+
if (key === "default") return 200;
|
|
100
|
+
const n = parseInt(key, 10);
|
|
101
|
+
return Number.isFinite(n) ? n : 200;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pickMedia(res: ResponseObject): MediaTypeObject | null {
|
|
105
|
+
const content = res.content;
|
|
106
|
+
if (!content) return null;
|
|
107
|
+
if (content["application/json"]) return content["application/json"];
|
|
108
|
+
for (const v of Object.values(content)) if (v) return v;
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function synthesizeBody(media: MediaTypeObject | null, spec: OpenApiSpec): unknown {
|
|
113
|
+
if (!media) return null;
|
|
114
|
+
if ("example" in media && media.example !== undefined) return media.example;
|
|
115
|
+
if (media.examples) {
|
|
116
|
+
for (const ex of Object.values(media.examples)) {
|
|
117
|
+
if (ex && "value" in ex) return ex.value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (media.schema !== undefined) return generateFromSchema(media.schema, spec, new Set());
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function generateFromSchema(schema: unknown, spec: OpenApiSpec, visited: Set<string>): unknown {
|
|
125
|
+
if (!schema || typeof schema !== "object") return null;
|
|
126
|
+
const s = schema as Record<string, unknown>;
|
|
127
|
+
|
|
128
|
+
if (typeof s.$ref === "string") {
|
|
129
|
+
if (visited.has(s.$ref)) return null;
|
|
130
|
+
const resolved = resolveRef(s.$ref, spec);
|
|
131
|
+
if (!resolved) return null;
|
|
132
|
+
const next = new Set(visited);
|
|
133
|
+
next.add(s.$ref);
|
|
134
|
+
return generateFromSchema(resolved, spec, next);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if ("example" in s && s.example !== undefined) return s.example;
|
|
138
|
+
if (Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(s.allOf)) {
|
|
141
|
+
const merged: Record<string, unknown> = {};
|
|
142
|
+
for (const branch of s.allOf) {
|
|
143
|
+
const value = generateFromSchema(branch, spec, visited);
|
|
144
|
+
if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
|
|
145
|
+
}
|
|
146
|
+
return merged;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
switch (s.type) {
|
|
150
|
+
case "array":
|
|
151
|
+
return [generateFromSchema(s.items, spec, visited)].filter((v) => v !== null);
|
|
152
|
+
case "object": {
|
|
153
|
+
const obj: Record<string, unknown> = {};
|
|
154
|
+
const props = s.properties as Record<string, unknown> | undefined;
|
|
155
|
+
if (props) {
|
|
156
|
+
for (const [k, v] of Object.entries(props)) obj[k] = generateFromSchema(v, spec, visited);
|
|
157
|
+
}
|
|
158
|
+
return obj;
|
|
159
|
+
}
|
|
160
|
+
case "string":
|
|
161
|
+
return s.format === "date-time" ? new Date(0).toISOString() : "";
|
|
162
|
+
case "integer":
|
|
163
|
+
case "number":
|
|
164
|
+
return 0;
|
|
165
|
+
case "boolean":
|
|
166
|
+
return false;
|
|
167
|
+
case "null":
|
|
168
|
+
return null;
|
|
169
|
+
default:
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveRef(ref: string, spec: OpenApiSpec): unknown {
|
|
175
|
+
if (!ref.startsWith("#/")) return null;
|
|
176
|
+
const parts = ref.slice(2).split("/");
|
|
177
|
+
let cur: unknown = spec;
|
|
178
|
+
for (const p of parts) {
|
|
179
|
+
if (!cur || typeof cur !== "object") return null;
|
|
180
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
181
|
+
}
|
|
182
|
+
return cur;
|
|
183
|
+
}
|