@nifrajs/cli 0.1.0-alpha.1 → 0.1.0-alpha.2

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/src/port.ts ADDED
@@ -0,0 +1,663 @@
1
+ /**
2
+ * `nifra port` — a portability linter. It scans an app's source for features whose runtime guarantees
3
+ * differ across deploy targets, prints a feature × target capability matrix, and (in CI mode) fails
4
+ * when a *detected* feature is unsupported on the app's resolved deploy target.
5
+ *
6
+ * The premise: nifra runs the same app on five targets (Bun, Node, Deno, Cloudflare Pages, Vercel), but
7
+ * a per-instance in-memory store, a long-lived in-process cron loop, or a `Bun.*` global is portable
8
+ * across some and a silent footgun on others. The framework's stores already *runtime*-guard this (e.g.
9
+ * `MemorySessionStore` throws under `NODE_ENV=production`); `port` is the *build-time* counterpart — it
10
+ * tells you before you deploy, and gates CI so the regression can't ship.
11
+ *
12
+ * Detection works on reliable import/symbol signals only (a real `@nifrajs/*` import paired with the
13
+ * exact exported symbol, a `node:` specifier, a `Bun.`/`Deno.` global), recording `file:line` evidence
14
+ * per hit. The pure scanners are unit-tested; correctness of detection IS the product for a linter.
15
+ */
16
+
17
+ import { join } from "node:path"
18
+ import { Glob } from "bun"
19
+ import { stripComments } from "./check.ts"
20
+
21
+ /** The five deploy targets a nifra `site` app can build for (mirrors create-nifra's DEPLOY presets). */
22
+ export const TARGETS = ["bun", "node", "deno", "cf-pages", "vercel"] as const
23
+ export type Target = (typeof TARGETS)[number]
24
+
25
+ const isTarget = (value: string): value is Target => (TARGETS as readonly string[]).includes(value)
26
+
27
+ /** The portability hazards `port` detects. Stable ids — they're part of the `--json` contract. */
28
+ export const FEATURE_IDS = [
29
+ "in-memory-session-store",
30
+ "in-memory-isr-cache",
31
+ "in-memory-rate-limit",
32
+ "in-process-cron",
33
+ "in-process-websocket",
34
+ "bun-runtime-api",
35
+ "deno-runtime-api",
36
+ "node-builtin",
37
+ ] as const
38
+ export type FeatureId = (typeof FEATURE_IDS)[number]
39
+
40
+ /** A target's verdict for one feature: fully fine, works-with-a-caveat, or won't work at all. */
41
+ export type Verdict = "ok" | "caveat" | "unsupported"
42
+
43
+ /** A single feature's capability row: a human label, the per-target verdicts, and a reason per verdict. */
44
+ interface FeatureSpec {
45
+ readonly label: string
46
+ /** Short explanation of WHY this feature is a portability hazard at all (printed in the legend/report). */
47
+ readonly summary: string
48
+ readonly verdicts: Readonly<Record<Target, Verdict>>
49
+ /** Per-target reason — why this verdict, and (for caveat/unsupported) the fix. */
50
+ readonly reasons: Readonly<Record<Target, string>>
51
+ }
52
+
53
+ // In-memory stores (session/ISR/rate-limit) share a verdict shape: fine single-instance on a long-lived
54
+ // runtime (caveat — lost on restart, not shared across instances), unsupported on the distributed edge.
55
+ const inMemoryVerdicts: Readonly<Record<Target, Verdict>> = {
56
+ bun: "caveat",
57
+ node: "caveat",
58
+ deno: "caveat",
59
+ "cf-pages": "unsupported",
60
+ vercel: "unsupported",
61
+ }
62
+ const inMemoryReasons = (shared: string): Readonly<Record<Target, string>> => ({
63
+ bun: "ok single-instance; state is lost on restart and not shared across instances",
64
+ node: "ok single-instance; state is lost on restart and not shared across instances",
65
+ deno: "ok single-instance; state is lost on restart and not shared across instances",
66
+ "cf-pages": shared,
67
+ vercel: shared,
68
+ })
69
+
70
+ /**
71
+ * The capability matrix. The source of truth for every verdict + reason `port` prints. Verdicts mirror
72
+ * the framework's own runtime guards: the in-memory stores all `throw` under production multi-instance,
73
+ * cron documents the Workers `scheduled` trigger as its edge replacement, and the WS hub is the Workers
74
+ * cross-connection broadcast story.
75
+ */
76
+ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
77
+ "in-memory-session-store": {
78
+ label: "in-memory session store",
79
+ summary: "MemorySessionStore keeps sessions per-instance (@nifrajs/auth)",
80
+ verdicts: inMemoryVerdicts,
81
+ reasons: inMemoryReasons(
82
+ "distributed edge has no shared per-instance memory — use KVSessionStore (Workers KV) or another shared store",
83
+ ),
84
+ },
85
+ "in-memory-isr-cache": {
86
+ label: "in-memory ISR cache",
87
+ summary: "MemoryCacheStore caches rendered pages per-instance (@nifrajs/web)",
88
+ verdicts: inMemoryVerdicts,
89
+ reasons: inMemoryReasons(
90
+ "each edge instance caches separately and revalidation won't propagate — use KVCacheStore (Workers KV) or the platform Cache API",
91
+ ),
92
+ },
93
+ "in-memory-rate-limit": {
94
+ label: "in-memory rate limit",
95
+ summary: "MemoryStore counts hits per-instance (@nifrajs/middleware)",
96
+ verdicts: inMemoryVerdicts,
97
+ reasons: inMemoryReasons(
98
+ "a per-instance limiter doesn't hold across edge instances — use a shared store (Redis / Durable Object)",
99
+ ),
100
+ },
101
+ "in-process-cron": {
102
+ label: "in-process cron",
103
+ summary: "createScheduler runs a long-lived in-process loop (@nifrajs/cron)",
104
+ verdicts: {
105
+ bun: "ok",
106
+ node: "ok",
107
+ deno: "ok",
108
+ "cf-pages": "caveat",
109
+ vercel: "unsupported",
110
+ },
111
+ reasons: {
112
+ bun: "long-lived process runs the scheduler loop",
113
+ node: "long-lived process runs the scheduler loop",
114
+ deno: "long-lived process runs the scheduler loop",
115
+ "cf-pages":
116
+ "no long-lived loop on Workers — use the platform `scheduled` trigger via toFetchHandler(app, { scheduled }) and a wrangler [triggers] cron",
117
+ vercel: "no long-lived process — use a Vercel Cron Job hitting an endpoint",
118
+ },
119
+ },
120
+ "in-process-websocket": {
121
+ label: "in-process WebSocket",
122
+ summary: "app.ws() without createWebSocketHub broadcasts only within one process",
123
+ verdicts: {
124
+ bun: "ok",
125
+ node: "ok",
126
+ deno: "ok",
127
+ "cf-pages": "caveat",
128
+ vercel: "unsupported",
129
+ },
130
+ reasons: {
131
+ bun: "all sockets live in one process; app.publish broadcasts to every client",
132
+ node: "all sockets live in one process; app.publish broadcasts to every client",
133
+ deno: "all sockets live in one process; app.publish broadcasts to every client",
134
+ "cf-pages":
135
+ "a stateless fetch can't broadcast across isolates — wrap the app in createWebSocketHub (a Durable Object) from @nifrajs/workers",
136
+ vercel: "Vercel's serverless functions don't hold long-lived WebSocket connections",
137
+ },
138
+ },
139
+ "bun-runtime-api": {
140
+ label: "Bun.* runtime API",
141
+ summary: "Bun.* globals (Bun.serve, Bun.file, …) exist only on the Bun runtime",
142
+ verdicts: {
143
+ bun: "ok",
144
+ node: "unsupported",
145
+ deno: "unsupported",
146
+ "cf-pages": "unsupported",
147
+ vercel: "unsupported",
148
+ },
149
+ reasons: {
150
+ bun: "the Bun runtime provides the Bun global",
151
+ node: "no Bun global on Node — use the node: / Web equivalents",
152
+ deno: "no Bun global on Deno — use the Deno / Web equivalents",
153
+ "cf-pages": "no Bun global in the Workers runtime",
154
+ vercel: "no Bun global in the Vercel runtime",
155
+ },
156
+ },
157
+ "deno-runtime-api": {
158
+ label: "Deno.* runtime API",
159
+ summary: "Deno.* globals exist only on the Deno runtime",
160
+ verdicts: {
161
+ bun: "unsupported",
162
+ node: "unsupported",
163
+ deno: "ok",
164
+ "cf-pages": "unsupported",
165
+ vercel: "unsupported",
166
+ },
167
+ reasons: {
168
+ bun: "no Deno global on Bun — use the Bun / Web equivalents",
169
+ node: "no Deno global on Node — use the node: / Web equivalents",
170
+ deno: "the Deno runtime provides the Deno global",
171
+ "cf-pages": "no Deno global in the Workers runtime",
172
+ vercel: "no Deno global in the Vercel runtime",
173
+ },
174
+ },
175
+ "node-builtin": {
176
+ label: "node: builtin",
177
+ summary: "node:* builtins (node:fs, node:crypto, …) need a Node-compatible runtime",
178
+ verdicts: {
179
+ bun: "ok",
180
+ node: "ok",
181
+ deno: "ok",
182
+ "cf-pages": "caveat",
183
+ vercel: "caveat",
184
+ },
185
+ reasons: {
186
+ bun: "Bun implements the node: builtins",
187
+ node: "native node: builtins",
188
+ deno: "Deno implements the node: builtins",
189
+ "cf-pages":
190
+ 'Workers needs `compatibility_flags = ["nodejs_compat"]` in wrangler.toml (and not every builtin is covered)',
191
+ vercel: "needs the Node.js runtime (not the Edge runtime) so node: builtins resolve",
192
+ },
193
+ },
194
+ }
195
+
196
+ /** A `file:line` evidence site for a detected feature. */
197
+ export interface Evidence {
198
+ readonly file: string
199
+ readonly line: number
200
+ /** The trimmed source line — the signal in context, for the human report. */
201
+ readonly snippet: string
202
+ }
203
+
204
+ /** A detected feature: which one, where the evidence is, and its per-target verdicts. */
205
+ export interface DetectedFeature {
206
+ readonly id: FeatureId
207
+ readonly label: string
208
+ readonly evidence: readonly Evidence[]
209
+ readonly verdicts: Readonly<Record<Target, Verdict>>
210
+ }
211
+
212
+ /** Line number (1-based) of a string index. */
213
+ const lineAt = (content: string, index: number): number =>
214
+ content.slice(0, index).split("\n").length
215
+
216
+ // --- Detection signals (all matched against comment-stripped source so doc examples don't false-positive) ---
217
+
218
+ // A non-type import (static, side-effect, dynamic, or re-export) from one of these `@nifrajs/*` packages.
219
+ // Used to confirm a symbol like `MemoryStore` is the framework's, not a same-named local class.
220
+ const importsFrom = (code: string, pkg: string): boolean => {
221
+ const escaped = pkg.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")
222
+ // `from "@nifrajs/x"` | `from "@nifrajs/x/sub"` | `import("@nifrajs/x")` | `import "@nifrajs/x"`
223
+ const rx = new RegExp(`(?:from|import)\\s*\\(?\\s*["'\`]${escaped}(?:/[^"'\`]*)?["'\`]`)
224
+ return rx.test(code)
225
+ }
226
+
227
+ // `new MemorySessionStore(` / `MemorySessionStore` referenced as a value. We require the @nifrajs/auth
228
+ // import in the same file (importsFrom) so a same-named user class never trips it.
229
+ const SESSION_STORE = /(?<![.\w$])MemorySessionStore\b/g
230
+ const ISR_CACHE = /(?<![.\w$])MemoryCacheStore\b/g
231
+ // Rate-limit store. The exported symbol is the generic `MemoryStore`; require the @nifrajs/middleware
232
+ // import so it can't collide with an unrelated `MemoryStore` from elsewhere.
233
+ const RATE_LIMIT = /(?<![.\w$])MemoryStore\b/g
234
+ // In-process cron: the scheduler factory. Paired with the @nifrajs/cron import.
235
+ const CRON_SCHEDULER = /(?<![.\w$])createScheduler\s*\(/g
236
+ // `.ws(` method call — a WebSocket route registration on a nifra app. `(?<![.\w$])` keeps it from
237
+ // matching identifiers that merely END in `ws` (e.g. `rows(`, `views(`). It DOES match `app.ws(`,
238
+ // `server().ws(`, `x.ws(` — all the chained forms — because the dot is consumed by the call, and the
239
+ // negative lookbehind only guards the char BEFORE `ws`, which is the dot here. We exclude the dot from
240
+ // the lookbehind set on purpose: a method call is exactly what we want.
241
+ const WS_ROUTE = /\.ws\s*\(/g
242
+ const WS_HUB = /(?<![.\w$])createWebSocketHub\b/
243
+ // `Bun.` / `Deno.` runtime globals. `(?<![.\w$])` rejects `myBun.x`, `globalThis.Bun` stays a real use
244
+ // (we want it), and `obj.Bun.` won't match because the char before `Bun` would be `.`.
245
+ const BUN_GLOBAL = /(?<![.\w$])Bun\s*\./g
246
+ const DENO_GLOBAL = /(?<![.\w$])Deno\s*\./g
247
+ // `node:` builtin specifier in any import/require/export-from form.
248
+ const NODE_BUILTIN = /(?:from|import|require)\s*\(?\s*["'`](node:[A-Za-z0-9/_-]+)["'`]/g
249
+
250
+ const pushHits = (
251
+ out: Evidence[],
252
+ file: string,
253
+ content: string,
254
+ code: string,
255
+ rx: RegExp,
256
+ ): void => {
257
+ rx.lastIndex = 0
258
+ const lines = content.split("\n")
259
+ for (let m = rx.exec(code); m !== null; m = rx.exec(code)) {
260
+ const line = lineAt(code, m.index)
261
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The per-file scan accumulator. Each feature's evidence is collected across the whole project before
267
+ * the WS post-pass (in-process WS is only a hazard when NO `createWebSocketHub` exists anywhere).
268
+ */
269
+ interface ScanState {
270
+ readonly evidence: Map<FeatureId, Evidence[]>
271
+ /** True once any file references `createWebSocketHub` — suppresses the in-process-websocket finding. */
272
+ hasWebSocketHub: boolean
273
+ }
274
+
275
+ /**
276
+ * Scan one file's source for every feature signal, mutating `state`. Pure w.r.t. the filesystem (takes
277
+ * the already-read `content`). Comment- and template-literal-stripped first, so a usage example in a
278
+ * doc comment or a code-as-text string never false-positives (shared with `nifra check`/`doctor`).
279
+ */
280
+ export function scanFileForFeatures(file: string, content: string, state: ScanState): void {
281
+ const code = stripComments(content)
282
+ const add = (id: FeatureId): Evidence[] => {
283
+ const list = state.evidence.get(id) ?? []
284
+ if (!state.evidence.has(id)) state.evidence.set(id, list)
285
+ return list
286
+ }
287
+
288
+ if (WS_HUB.test(code)) state.hasWebSocketHub = true
289
+
290
+ if (importsFrom(code, "@nifrajs/auth"))
291
+ pushHits(add("in-memory-session-store"), file, content, code, SESSION_STORE)
292
+ if (importsFrom(code, "@nifrajs/web"))
293
+ pushHits(add("in-memory-isr-cache"), file, content, code, ISR_CACHE)
294
+ if (importsFrom(code, "@nifrajs/middleware"))
295
+ pushHits(add("in-memory-rate-limit"), file, content, code, RATE_LIMIT)
296
+ if (importsFrom(code, "@nifrajs/cron"))
297
+ pushHits(add("in-process-cron"), file, content, code, CRON_SCHEDULER)
298
+ // WS routes are collected unconditionally; the hub-presence post-pass decides whether they're a hazard.
299
+ pushHits(add("in-process-websocket"), file, content, code, WS_ROUTE)
300
+ pushHits(add("bun-runtime-api"), file, content, code, BUN_GLOBAL)
301
+ pushHits(add("deno-runtime-api"), file, content, code, DENO_GLOBAL)
302
+ pushHits(add("node-builtin"), file, content, code, NODE_BUILTIN)
303
+ }
304
+
305
+ // Mirror nifra check/doctor's ignore set: deps, build output, generated/per-runtime dist dirs, VCS.
306
+ // PLUS *.config.* and obvious build scripts (build*.ts) — `port` scans request/app source, not tooling.
307
+ const IGNORED =
308
+ /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)[^/]*\.config\.[cm]?[jt]sx?$|(^|\/)build[^/]*\.[cm]?[jt]sx?$/
309
+
310
+ /** Walk the project's source (same surface as `nifra check`, minus config + build scripts). */
311
+ async function walkPortSource(
312
+ cwd: string,
313
+ visit: (rel: string, content: string) => void,
314
+ ): Promise<void> {
315
+ for await (const rel of new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}").scan({
316
+ cwd,
317
+ dot: false,
318
+ })) {
319
+ if (IGNORED.test(rel)) continue
320
+ visit(rel, await Bun.file(join(cwd, rel)).text())
321
+ }
322
+ }
323
+
324
+ const byEvidence = (a: Evidence, b: Evidence): number =>
325
+ a.file.localeCompare(b.file) || a.line - b.line
326
+
327
+ /** Run every detector over the project and return the detected features (sorted, stable). */
328
+ export async function detectFeatures(cwd: string): Promise<DetectedFeature[]> {
329
+ const state: ScanState = { evidence: new Map(), hasWebSocketHub: false }
330
+ await walkPortSource(cwd, (rel, content) => scanFileForFeatures(rel, content, state))
331
+
332
+ const detected: DetectedFeature[] = []
333
+ for (const id of FEATURE_IDS) {
334
+ const evidence = state.evidence.get(id)
335
+ if (evidence === undefined || evidence.length === 0) continue
336
+ // in-process WebSocket is only a portability hazard when the app does NOT use createWebSocketHub
337
+ // (the Workers Durable Object hub) — its presence means the WS routes are already edge-ready.
338
+ if (id === "in-process-websocket" && state.hasWebSocketHub) continue
339
+ const spec = FEATURES[id]
340
+ detected.push({
341
+ id,
342
+ label: spec.label,
343
+ evidence: [...evidence].sort(byEvidence),
344
+ verdicts: spec.verdicts,
345
+ })
346
+ }
347
+ return detected
348
+ }
349
+
350
+ // --- Target auto-detection ----------------------------------------------------------------------------
351
+
352
+ export interface ResolvedTarget {
353
+ readonly target: Target
354
+ /** How the target was determined — surfaced so the report explains itself. */
355
+ readonly source: "flag" | "package-json-build" | "package-json-deploy" | "wrangler" | "vercel"
356
+ }
357
+
358
+ const BUILD_SCRIPT_TARGET: ReadonlyArray<readonly [RegExp, Target]> = [
359
+ // create-nifra's canonical per-target build scripts (build-bun.ts / build-node.ts / build-deno.ts /
360
+ // build-vercel.ts), plus the bare `build.ts` the cf-pages preset uses.
361
+ [/build-vercel\.[cm]?[jt]s\b/, "vercel"],
362
+ [/build-deno\.[cm]?[jt]s\b/, "deno"],
363
+ [/build-node\.[cm]?[jt]s\b/, "node"],
364
+ [/build-bun\.[cm]?[jt]s\b/, "bun"],
365
+ ]
366
+ const DEPLOY_SCRIPT_TARGET: ReadonlyArray<readonly [RegExp, Target]> = [
367
+ [/\bvercel\s+deploy\b/, "vercel"],
368
+ [/\bdeployctl\b/, "deno"],
369
+ [/wrangler\s+pages\s+deploy\b/, "cf-pages"],
370
+ [/\bdocker\s+build\b/, "node"],
371
+ ]
372
+
373
+ interface PackageJsonScripts {
374
+ readonly scripts?: Readonly<Record<string, string>>
375
+ }
376
+
377
+ /**
378
+ * Resolve the app's deploy target. `--target` (validated) always wins. Otherwise infer it, in priority
379
+ * order, from: the canonical `build` script (create-nifra's `build-<target>.ts`), the `deploy` script
380
+ * (the vendor CLI it shells out to), a `wrangler.toml` (Cloudflare Pages), or a `vercel.json`. Returns
381
+ * `undefined` when nothing signals a target (the matrix still prints; CI gating needs an explicit one).
382
+ */
383
+ export async function resolveTarget(
384
+ cwd: string,
385
+ override?: string,
386
+ ): Promise<ResolvedTarget | undefined> {
387
+ if (override !== undefined) {
388
+ if (!isTarget(override)) {
389
+ throw new Error(`[nifra] invalid --target "${override}". options: ${TARGETS.join(", ")}`)
390
+ }
391
+ return { target: override, source: "flag" }
392
+ }
393
+
394
+ const pkg = await readJson<PackageJsonScripts>(join(cwd, "package.json"))
395
+ const scripts = pkg?.scripts
396
+ if (scripts !== undefined) {
397
+ const build = scripts.build ?? ""
398
+ for (const [rx, target] of BUILD_SCRIPT_TARGET) {
399
+ if (rx.test(build)) return { target, source: "package-json-build" }
400
+ }
401
+ const deploy = scripts.deploy ?? ""
402
+ for (const [rx, target] of DEPLOY_SCRIPT_TARGET) {
403
+ if (rx.test(deploy)) return { target, source: "package-json-deploy" }
404
+ }
405
+ }
406
+
407
+ if (await Bun.file(join(cwd, "wrangler.toml")).exists()) {
408
+ return { target: "cf-pages", source: "wrangler" }
409
+ }
410
+ if (
411
+ (await Bun.file(join(cwd, "vercel.json")).exists()) ||
412
+ (await Bun.file(join(cwd, ".vercel", "project.json")).exists())
413
+ ) {
414
+ return { target: "vercel", source: "vercel" }
415
+ }
416
+ return undefined
417
+ }
418
+
419
+ async function readJson<T>(path: string): Promise<T | undefined> {
420
+ try {
421
+ const parsed = JSON.parse(await Bun.file(path).text()) as unknown
422
+ return typeof parsed === "object" && parsed !== null ? (parsed as T) : undefined
423
+ } catch {
424
+ return undefined
425
+ }
426
+ }
427
+
428
+ // --- Result shaping (the `--json` contract) -----------------------------------------------------------
429
+
430
+ /** One feature in the `--json` output: its id, evidence as `file:line` strings, and per-target verdicts. */
431
+ export interface PortJsonFeature {
432
+ readonly id: FeatureId
433
+ readonly label: string
434
+ readonly evidence: readonly string[]
435
+ readonly verdicts: Readonly<Record<Target, Verdict>>
436
+ }
437
+
438
+ /** One blocking finding: a detected feature unsupported on the resolved target, with its fix. */
439
+ export interface PortBlock {
440
+ readonly feature: FeatureId
441
+ readonly verdict: Extract<Verdict, "unsupported" | "caveat">
442
+ readonly reason: string
443
+ readonly evidence: readonly string[]
444
+ }
445
+
446
+ /** The stable machine-readable result `--json` prints. No `any`; every field is shaped. */
447
+ export interface PortJson {
448
+ /** The resolved deploy target, or `null` when none could be determined and none was given. */
449
+ readonly target: Target | null
450
+ readonly targetSource: ResolvedTarget["source"] | null
451
+ readonly features: readonly PortJsonFeature[]
452
+ /** Findings that fail the gate (unsupported), plus caveats when `--strict`. Empty ⇒ portable. */
453
+ readonly blocked: readonly PortBlock[]
454
+ /** True when the app is portable to the resolved target (or to all targets when none is resolved). */
455
+ readonly ok: boolean
456
+ }
457
+
458
+ const evidenceStrings = (evidence: readonly Evidence[]): string[] =>
459
+ evidence.map((e) => `${e.file}:${e.line}`)
460
+
461
+ export interface PortResult {
462
+ readonly resolved: ResolvedTarget | undefined
463
+ readonly detected: readonly DetectedFeature[]
464
+ readonly json: PortJson
465
+ }
466
+
467
+ /**
468
+ * Compute the full `port` result: detect features, resolve the target, and derive the blocking findings.
469
+ * A finding blocks when the detected feature is `unsupported` on the resolved target — always — and
470
+ * (with `strict`) also when it's a `caveat`. With no resolved target there's nothing to gate against,
471
+ * so `blocked` is empty and `ok` reflects "are there hazards at all" only when the caller asks.
472
+ */
473
+ export async function collectPortResult(
474
+ cwd: string,
475
+ opts: { readonly target?: string; readonly strict?: boolean } = {},
476
+ ): Promise<PortResult> {
477
+ const [detected, resolved] = await Promise.all([
478
+ detectFeatures(cwd),
479
+ resolveTarget(cwd, opts.target),
480
+ ])
481
+
482
+ const blocked: PortBlock[] = []
483
+ if (resolved !== undefined) {
484
+ for (const feature of detected) {
485
+ const verdict = feature.verdicts[resolved.target]
486
+ const blocks = verdict === "unsupported" || (verdict === "caveat" && opts.strict === true)
487
+ if (!blocks) continue
488
+ blocked.push({
489
+ feature: feature.id,
490
+ verdict,
491
+ reason: FEATURES[feature.id].reasons[resolved.target],
492
+ evidence: evidenceStrings(feature.evidence),
493
+ })
494
+ }
495
+ }
496
+
497
+ const json: PortJson = {
498
+ target: resolved?.target ?? null,
499
+ targetSource: resolved?.source ?? null,
500
+ features: detected.map((f) => ({
501
+ id: f.id,
502
+ label: f.label,
503
+ evidence: evidenceStrings(f.evidence),
504
+ verdicts: f.verdicts,
505
+ })),
506
+ blocked,
507
+ ok: blocked.length === 0,
508
+ }
509
+ return { resolved, detected, json }
510
+ }
511
+
512
+ // --- Rendering ----------------------------------------------------------------------------------------
513
+
514
+ const CELL: Readonly<Record<Verdict, string>> = { ok: "✓", caveat: "⚠", unsupported: "✗" }
515
+ const COL_WIDTH = 9 // widest target header ("cf-pages") + padding
516
+
517
+ const padCell = (s: string, width: number): string => {
518
+ // Pad accounting for the symbol's display width (✓/⚠/✗ render as 1 cell in a monospace terminal).
519
+ const len = [...s].length
520
+ return s + " ".repeat(Math.max(0, width - len))
521
+ }
522
+
523
+ /** Render the feature × target matrix + evidence + summary as a readable report. */
524
+ export function renderReport(result: PortResult, opts: { readonly strict?: boolean }): string {
525
+ const { detected, resolved, json } = result
526
+ const out: string[] = ["nifra port — portability matrix", ""]
527
+
528
+ if (resolved !== undefined) {
529
+ out.push(`target: ${resolved.target} (detected from ${describeSource(resolved.source)})`)
530
+ } else {
531
+ out.push(
532
+ "target: not detected — pass --target <bun|node|deno|cf-pages|vercel> to gate against one",
533
+ )
534
+ }
535
+ out.push("")
536
+
537
+ if (detected.length === 0) {
538
+ out.push("✓ no portability hazards detected — this app is portable across all targets.")
539
+ return out.join("\n")
540
+ }
541
+
542
+ // Header row.
543
+ const labelWidth = Math.max(22, ...detected.map((f) => f.label.length)) + 2
544
+ const header = padCell("feature", labelWidth) + TARGETS.map((t) => padCell(t, COL_WIDTH)).join("")
545
+ out.push(header)
546
+ out.push("-".repeat(header.length))
547
+ for (const feature of detected) {
548
+ const cells = TARGETS.map((t) => padCell(CELL[feature.verdicts[t]], COL_WIDTH)).join("")
549
+ out.push(padCell(feature.label, labelWidth) + cells)
550
+ }
551
+ out.push("")
552
+ out.push("legend: ✓ ok ⚠ caveat (works with a change) ✗ unsupported")
553
+ out.push("")
554
+
555
+ // Evidence per detected feature.
556
+ out.push("detected features + evidence:")
557
+ for (const feature of detected) {
558
+ out.push(` • ${feature.label} (${feature.id})`)
559
+ out.push(` why: ${FEATURES[feature.id].summary}`)
560
+ for (const e of feature.evidence) {
561
+ out.push(` ${e.file}:${e.line} ${e.snippet}`)
562
+ }
563
+ }
564
+ out.push("")
565
+
566
+ // Summary + blocking detail.
567
+ if (resolved === undefined) {
568
+ out.push(
569
+ `• ${detected.length} portability hazard(s) detected; no target resolved, so nothing is gated. ` +
570
+ "Pass --target to check one.",
571
+ )
572
+ return out.join("\n")
573
+ }
574
+
575
+ if (json.blocked.length === 0) {
576
+ // With the gate passing, surface any caveats for the resolved target so the user still sees the
577
+ // "works, but with a change" cases (they don't fail without --strict, but they're not free either).
578
+ const caveatHere = detected.filter((f) => f.verdicts[resolved.target] === "caveat")
579
+ if (caveatHere.length === 0) {
580
+ out.push(`✓ portable to ${resolved.target}: every detected feature is supported there.`)
581
+ } else {
582
+ out.push(
583
+ `✓ no blockers for ${resolved.target}, but ${caveatHere.length} feature(s) need a change there:`,
584
+ )
585
+ for (const f of caveatHere) {
586
+ out.push(` ⚠ ${f.label} — ${FEATURES[f.id].reasons[resolved.target]}`)
587
+ }
588
+ out.push("\n(caveats don't fail the gate — run with --strict to treat them as blockers)")
589
+ }
590
+ return out.join("\n")
591
+ }
592
+
593
+ const unsupported = json.blocked.filter((b) => b.verdict === "unsupported")
594
+ const caveats = json.blocked.filter((b) => b.verdict === "caveat")
595
+ out.push(`✗ ${json.blocked.length} feature(s) block deploying to ${resolved.target}:`)
596
+ for (const b of [...unsupported, ...caveats]) {
597
+ const mark = b.verdict === "unsupported" ? "✗" : "⚠"
598
+ out.push(` ${mark} ${FEATURES[b.feature].label} — ${b.verdict} on ${resolved.target}`)
599
+ out.push(` fix: ${b.reason}`)
600
+ out.push(` at: ${b.evidence.join(", ")}`)
601
+ }
602
+ if (!opts.strict && unsupported.length === 0) {
603
+ out.push("\n(only caveats — run with --strict to fail on these)")
604
+ }
605
+ return out.join("\n")
606
+ }
607
+
608
+ function describeSource(source: ResolvedTarget["source"]): string {
609
+ switch (source) {
610
+ case "flag":
611
+ return "--target"
612
+ case "package-json-build":
613
+ return "the package.json build script"
614
+ case "package-json-deploy":
615
+ return "the package.json deploy script"
616
+ case "wrangler":
617
+ return "wrangler.toml"
618
+ case "vercel":
619
+ return "vercel config"
620
+ }
621
+ }
622
+
623
+ export interface RunPortOptions {
624
+ readonly target?: string
625
+ readonly json?: boolean
626
+ /** Fail (exit non-zero) on a detected unsupported feature for the resolved target. Implied by --target. */
627
+ readonly ci?: boolean
628
+ /** With --ci, also fail on caveats (not just unsupported). */
629
+ readonly strict?: boolean
630
+ }
631
+
632
+ /**
633
+ * Run `nifra port`: detect, resolve, render. Returns `true` when the run "passes" — meaning either the
634
+ * gate is off, or the gate is on and nothing blocks. The caller maps `false` → exit 1.
635
+ *
636
+ * The gate fires when `--ci` is passed OR a `--target` is set (an explicit target signals intent to
637
+ * gate). With no resolved target and `--ci`, the run is a hard error: there's nothing to gate against.
638
+ */
639
+ export async function runPort(cwd: string, opts: RunPortOptions = {}): Promise<boolean> {
640
+ const result = await collectPortResult(cwd, {
641
+ ...(opts.target !== undefined ? { target: opts.target } : {}),
642
+ ...(opts.strict ? { strict: true } : {}),
643
+ })
644
+ // The gate is on when --ci or an explicit --target was given.
645
+ const gating = opts.ci === true || opts.target !== undefined
646
+
647
+ if (opts.json) {
648
+ console.log(JSON.stringify(result.json, null, 2))
649
+ } else {
650
+ console.log(renderReport(result, { strict: opts.strict ?? false }))
651
+ }
652
+
653
+ if (!gating) return true
654
+ if (result.resolved === undefined) {
655
+ if (!opts.json) {
656
+ console.error(
657
+ "\n[nifra] --ci needs a deploy target to gate against, and none was detected. Pass --target <bun|node|deno|cf-pages|vercel>.",
658
+ )
659
+ }
660
+ return false
661
+ }
662
+ return result.json.blocked.length === 0
663
+ }