@nifrajs/cli 2.1.0 → 2.3.0
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/README.md +25 -25
- package/dist/assure.d.ts +2 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +17 -10
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +7 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +68 -16
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +64 -22
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +400 -83
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +37 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +291 -53
- package/dist/cli.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.js +7 -7
- package/dist/docs-search.d.ts +3 -3
- package/dist/docs-search.js +6 -6
- package/dist/doctor.d.ts +8 -4
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +74 -14
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +5 -5
- package/dist/examples.js +5 -5
- package/dist/init-agents.d.ts +14 -14
- package/dist/init-agents.js +14 -14
- package/dist/internal/typescript-import.d.ts +24 -0
- package/dist/internal/typescript-import.d.ts.map +1 -0
- package/dist/internal/typescript-import.js +32 -0
- package/dist/internal/typescript-import.js.map +1 -0
- package/dist/introspect.d.ts +12 -12
- package/dist/introspect.js +24 -24
- package/dist/levels-tool.d.ts +8 -8
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -30
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +17 -6
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +45 -7
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +4 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +14 -10
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +5 -5
- package/dist/mcp-http.js +7 -7
- package/dist/mcp-protocol.d.ts +2 -2
- package/dist/mcp-protocol.js +2 -2
- package/dist/mcp-render.d.ts +3 -3
- package/dist/mcp-render.js +15 -7
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-run.d.ts +2 -2
- package/dist/mcp-run.js +2 -2
- package/dist/mcp.d.ts +14 -14
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +119 -47
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +1 -1
- package/dist/openapi-tool.js +2 -2
- package/dist/pipeline-guard.d.ts +83 -0
- package/dist/pipeline-guard.d.ts.map +1 -0
- package/dist/pipeline-guard.js +136 -0
- package/dist/pipeline-guard.js.map +1 -0
- package/dist/port.d.ts +9 -9
- package/dist/port.js +27 -27
- package/dist/recipes/1.8.0.d.ts +2 -2
- package/dist/recipes/1.8.0.js +5 -5
- package/dist/recipes/index.d.ts +2 -2
- package/dist/recipes/index.js +1 -1
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +8 -8
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.d.ts.map +1 -0
- package/dist/sync-routes.js +55 -0
- package/dist/sync-routes.js.map +1 -0
- package/dist/types-search.d.ts +6 -6
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +53 -8
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +7 -7
- package/dist/upgrade.js +12 -12
- package/dist/verification.d.ts +51 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +80 -0
- package/dist/verification.js.map +1 -0
- package/docs/examples.json +178 -88
- package/docs/llms-full.txt +1766 -766
- package/docs/types.json +2171 -2807
- package/package.json +22 -11
- package/src/assure.ts +17 -18
- package/src/capabilities-tool.ts +82 -16
- package/src/check.ts +493 -100
- package/src/cli.ts +306 -54
- package/src/diff-tool.ts +8 -8
- package/src/docs-search.ts +6 -6
- package/src/doctor.ts +81 -16
- package/src/examples.ts +7 -7
- package/src/init-agents.ts +17 -17
- package/src/internal/typescript-import.ts +33 -0
- package/src/introspect.ts +28 -28
- package/src/levels-tool.ts +37 -31
- package/src/load.ts +65 -12
- package/src/manifest-tool.ts +4 -2
- package/src/mcp-docs-tools.ts +20 -10
- package/src/mcp-http.ts +7 -7
- package/src/mcp-protocol.ts +2 -2
- package/src/mcp-render.ts +15 -7
- package/src/mcp-run.ts +2 -2
- package/src/mcp.ts +127 -47
- package/src/openapi-tool.ts +2 -2
- package/src/pipeline-guard.ts +186 -0
- package/src/port.ts +31 -31
- package/src/recipes/1.8.0.ts +5 -5
- package/src/recipes/index.ts +2 -2
- package/src/scaffold.ts +8 -8
- package/src/sync-routes.ts +69 -0
- package/src/types-search.ts +57 -8
- package/src/upgrade.ts +12 -12
- package/src/verification.ts +123 -0
package/src/port.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra port`
|
|
2
|
+
* `nifra port` - a portability linter. It scans an app's source for features whose runtime guarantees
|
|
3
3
|
* differ across deploy targets, prints a feature × target capability matrix, and (in CI mode) fails
|
|
4
4
|
* when a *detected* feature is unsupported on the app's resolved deploy target.
|
|
5
5
|
*
|
|
6
6
|
* The premise: nifra runs the same app on five targets (Bun, Node, Deno, Cloudflare Pages, Vercel), but
|
|
7
7
|
* a per-instance in-memory store, a long-lived in-process cron loop, or a `Bun.*` global is portable
|
|
8
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
|
|
9
|
+
* `MemorySessionStore` throws under `NODE_ENV=production`); `port` is the *build-time* counterpart - it
|
|
10
10
|
* tells you before you deploy, and gates CI so the regression can't ship.
|
|
11
11
|
*
|
|
12
12
|
* Detection works on reliable import/symbol signals only (a real `@nifrajs/*` import paired with the
|
|
@@ -24,7 +24,7 @@ export type Target = (typeof TARGETS)[number]
|
|
|
24
24
|
|
|
25
25
|
const isTarget = (value: string): value is Target => (TARGETS as readonly string[]).includes(value)
|
|
26
26
|
|
|
27
|
-
/** The portability hazards `port` detects. Stable ids
|
|
27
|
+
/** The portability hazards `port` detects. Stable ids - they're part of the `--json` contract. */
|
|
28
28
|
export const FEATURE_IDS = [
|
|
29
29
|
"in-memory-session-store",
|
|
30
30
|
"in-memory-isr-cache",
|
|
@@ -46,12 +46,12 @@ interface FeatureSpec {
|
|
|
46
46
|
/** Short explanation of WHY this feature is a portability hazard at all (printed in the legend/report). */
|
|
47
47
|
readonly summary: string
|
|
48
48
|
readonly verdicts: Readonly<Record<Target, Verdict>>
|
|
49
|
-
/** Per-target reason
|
|
49
|
+
/** Per-target reason - why this verdict, and (for caveat/unsupported) the fix. */
|
|
50
50
|
readonly reasons: Readonly<Record<Target, string>>
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// In-memory stores (session/ISR/rate-limit) share a verdict shape: fine single-instance on a long-lived
|
|
54
|
-
// runtime (caveat
|
|
54
|
+
// runtime (caveat - lost on restart, not shared across instances), unsupported on the distributed edge.
|
|
55
55
|
const inMemoryVerdicts: Readonly<Record<Target, Verdict>> = {
|
|
56
56
|
bun: "caveat",
|
|
57
57
|
node: "caveat",
|
|
@@ -79,7 +79,7 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
79
79
|
summary: "MemorySessionStore keeps sessions per-instance (@nifrajs/auth)",
|
|
80
80
|
verdicts: inMemoryVerdicts,
|
|
81
81
|
reasons: inMemoryReasons(
|
|
82
|
-
"distributed edge has no shared per-instance memory
|
|
82
|
+
"distributed edge has no shared per-instance memory - use KVSessionStore (Workers KV) or another shared store",
|
|
83
83
|
),
|
|
84
84
|
},
|
|
85
85
|
"in-memory-isr-cache": {
|
|
@@ -87,7 +87,7 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
87
87
|
summary: "MemoryCacheStore caches rendered pages per-instance (@nifrajs/web)",
|
|
88
88
|
verdicts: inMemoryVerdicts,
|
|
89
89
|
reasons: inMemoryReasons(
|
|
90
|
-
"each edge instance caches separately and revalidation won't propagate
|
|
90
|
+
"each edge instance caches separately and revalidation won't propagate - use KVCacheStore (Workers KV) or the platform Cache API",
|
|
91
91
|
),
|
|
92
92
|
},
|
|
93
93
|
"in-memory-rate-limit": {
|
|
@@ -95,7 +95,7 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
95
95
|
summary: "MemoryStore counts hits per-instance (@nifrajs/middleware)",
|
|
96
96
|
verdicts: inMemoryVerdicts,
|
|
97
97
|
reasons: inMemoryReasons(
|
|
98
|
-
"a per-instance limiter doesn't hold across edge instances
|
|
98
|
+
"a per-instance limiter doesn't hold across edge instances - use a shared store (Redis / Durable Object)",
|
|
99
99
|
),
|
|
100
100
|
},
|
|
101
101
|
"in-process-cron": {
|
|
@@ -113,8 +113,8 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
113
113
|
node: "long-lived process runs the scheduler loop",
|
|
114
114
|
deno: "long-lived process runs the scheduler loop",
|
|
115
115
|
"cf-pages":
|
|
116
|
-
"no long-lived loop on Workers
|
|
117
|
-
vercel: "no long-lived process
|
|
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
118
|
},
|
|
119
119
|
},
|
|
120
120
|
"in-process-websocket": {
|
|
@@ -132,7 +132,7 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
132
132
|
node: "all sockets live in one process; app.publish broadcasts to every client",
|
|
133
133
|
deno: "all sockets live in one process; app.publish broadcasts to every client",
|
|
134
134
|
"cf-pages":
|
|
135
|
-
"a stateless fetch can't broadcast across isolates
|
|
135
|
+
"a stateless fetch can't broadcast across isolates - wrap the app in createWebSocketHub (a Durable Object) from @nifrajs/workers",
|
|
136
136
|
vercel: "Vercel's serverless functions don't hold long-lived WebSocket connections",
|
|
137
137
|
},
|
|
138
138
|
},
|
|
@@ -148,8 +148,8 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
148
148
|
},
|
|
149
149
|
reasons: {
|
|
150
150
|
bun: "the Bun runtime provides the Bun global",
|
|
151
|
-
node: "no Bun global on Node
|
|
152
|
-
deno: "no Bun global on Deno
|
|
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
153
|
"cf-pages": "no Bun global in the Workers runtime",
|
|
154
154
|
vercel: "no Bun global in the Vercel runtime",
|
|
155
155
|
},
|
|
@@ -165,8 +165,8 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
165
165
|
vercel: "unsupported",
|
|
166
166
|
},
|
|
167
167
|
reasons: {
|
|
168
|
-
bun: "no Deno global on Bun
|
|
169
|
-
node: "no Deno global on Node
|
|
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
170
|
deno: "the Deno runtime provides the Deno global",
|
|
171
171
|
"cf-pages": "no Deno global in the Workers runtime",
|
|
172
172
|
vercel: "no Deno global in the Vercel runtime",
|
|
@@ -197,7 +197,7 @@ export const FEATURES: Readonly<Record<FeatureId, FeatureSpec>> = {
|
|
|
197
197
|
export interface Evidence {
|
|
198
198
|
readonly file: string
|
|
199
199
|
readonly line: number
|
|
200
|
-
/** The trimmed source line
|
|
200
|
+
/** The trimmed source line - the signal in context, for the human report. */
|
|
201
201
|
readonly snippet: string
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -233,9 +233,9 @@ const ISR_CACHE = /(?<![.\w$])MemoryCacheStore\b/g
|
|
|
233
233
|
const RATE_LIMIT = /(?<![.\w$])MemoryStore\b/g
|
|
234
234
|
// In-process cron: the scheduler factory. Paired with the @nifrajs/cron import.
|
|
235
235
|
const CRON_SCHEDULER = /(?<![.\w$])createScheduler\s*\(/g
|
|
236
|
-
// `.ws(` method call
|
|
236
|
+
// `.ws(` method call - a WebSocket route registration on a nifra app. `(?<![.\w$])` keeps it from
|
|
237
237
|
// matching identifiers that merely END in `ws` (e.g. `rows(`, `views(`). It DOES match `app.ws(`,
|
|
238
|
-
// `server().ws(`, `x.ws(`
|
|
238
|
+
// `server().ws(`, `x.ws(` - all the chained forms - because the dot is consumed by the call, and the
|
|
239
239
|
// negative lookbehind only guards the char BEFORE `ws`, which is the dot here. We exclude the dot from
|
|
240
240
|
// the lookbehind set on purpose: a method call is exactly what we want.
|
|
241
241
|
const WS_ROUTE = /\.ws\s*\(/g
|
|
@@ -268,7 +268,7 @@ const pushHits = (
|
|
|
268
268
|
*/
|
|
269
269
|
interface ScanState {
|
|
270
270
|
readonly evidence: Map<FeatureId, Evidence[]>
|
|
271
|
-
/** True once any file references `createWebSocketHub`
|
|
271
|
+
/** True once any file references `createWebSocketHub` - suppresses the in-process-websocket finding. */
|
|
272
272
|
hasWebSocketHub: boolean
|
|
273
273
|
}
|
|
274
274
|
|
|
@@ -303,7 +303,7 @@ export function scanFileForFeatures(file: string, content: string, state: ScanSt
|
|
|
303
303
|
}
|
|
304
304
|
|
|
305
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)
|
|
306
|
+
// PLUS *.config.* and obvious build scripts (build*.ts) - `port` scans request/app source, not tooling.
|
|
307
307
|
const IGNORED =
|
|
308
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
309
|
|
|
@@ -334,7 +334,7 @@ export async function detectFeatures(cwd: string): Promise<DetectedFeature[]> {
|
|
|
334
334
|
const evidence = state.evidence.get(id)
|
|
335
335
|
if (evidence === undefined || evidence.length === 0) continue
|
|
336
336
|
// in-process WebSocket is only a portability hazard when the app does NOT use createWebSocketHub
|
|
337
|
-
// (the Workers Durable Object hub)
|
|
337
|
+
// (the Workers Durable Object hub) - its presence means the WS routes are already edge-ready.
|
|
338
338
|
if (id === "in-process-websocket" && state.hasWebSocketHub) continue
|
|
339
339
|
const spec = FEATURES[id]
|
|
340
340
|
detected.push({
|
|
@@ -351,7 +351,7 @@ export async function detectFeatures(cwd: string): Promise<DetectedFeature[]> {
|
|
|
351
351
|
|
|
352
352
|
export interface ResolvedTarget {
|
|
353
353
|
readonly target: Target
|
|
354
|
-
/** How the target was determined
|
|
354
|
+
/** How the target was determined - surfaced so the report explains itself. */
|
|
355
355
|
readonly source: "flag" | "package-json-build" | "package-json-deploy" | "wrangler" | "vercel"
|
|
356
356
|
}
|
|
357
357
|
|
|
@@ -466,7 +466,7 @@ export interface PortResult {
|
|
|
466
466
|
|
|
467
467
|
/**
|
|
468
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
|
|
469
|
+
* A finding blocks when the detected feature is `unsupported` on the resolved target - always - and
|
|
470
470
|
* (with `strict`) also when it's a `caveat`. With no resolved target there's nothing to gate against,
|
|
471
471
|
* so `blocked` is empty and `ok` reflects "are there hazards at all" only when the caller asks.
|
|
472
472
|
*/
|
|
@@ -523,19 +523,19 @@ const padCell = (s: string, width: number): string => {
|
|
|
523
523
|
/** Render the feature × target matrix + evidence + summary as a readable report. */
|
|
524
524
|
export function renderReport(result: PortResult, opts: { readonly strict?: boolean }): string {
|
|
525
525
|
const { detected, resolved, json } = result
|
|
526
|
-
const out: string[] = ["nifra port
|
|
526
|
+
const out: string[] = ["nifra port - portability matrix", ""]
|
|
527
527
|
|
|
528
528
|
if (resolved !== undefined) {
|
|
529
529
|
out.push(`target: ${resolved.target} (detected from ${describeSource(resolved.source)})`)
|
|
530
530
|
} else {
|
|
531
531
|
out.push(
|
|
532
|
-
"target: not detected
|
|
532
|
+
"target: not detected - pass --target <bun|node|deno|cf-pages|vercel> to gate against one",
|
|
533
533
|
)
|
|
534
534
|
}
|
|
535
535
|
out.push("")
|
|
536
536
|
|
|
537
537
|
if (detected.length === 0) {
|
|
538
|
-
out.push("✓ no portability hazards detected
|
|
538
|
+
out.push("✓ no portability hazards detected - this app is portable across all targets.")
|
|
539
539
|
return out.join("\n")
|
|
540
540
|
}
|
|
541
541
|
|
|
@@ -583,9 +583,9 @@ export function renderReport(result: PortResult, opts: { readonly strict?: boole
|
|
|
583
583
|
`✓ no blockers for ${resolved.target}, but ${caveatHere.length} feature(s) need a change there:`,
|
|
584
584
|
)
|
|
585
585
|
for (const f of caveatHere) {
|
|
586
|
-
out.push(` ⚠ ${f.label}
|
|
586
|
+
out.push(` ⚠ ${f.label} - ${FEATURES[f.id].reasons[resolved.target]}`)
|
|
587
587
|
}
|
|
588
|
-
out.push("\n(caveats don't fail the gate
|
|
588
|
+
out.push("\n(caveats don't fail the gate - run with --strict to treat them as blockers)")
|
|
589
589
|
}
|
|
590
590
|
return out.join("\n")
|
|
591
591
|
}
|
|
@@ -595,12 +595,12 @@ export function renderReport(result: PortResult, opts: { readonly strict?: boole
|
|
|
595
595
|
out.push(`✗ ${json.blocked.length} feature(s) block deploying to ${resolved.target}:`)
|
|
596
596
|
for (const b of [...unsupported, ...caveats]) {
|
|
597
597
|
const mark = b.verdict === "unsupported" ? "✗" : "⚠"
|
|
598
|
-
out.push(` ${mark} ${FEATURES[b.feature].label}
|
|
598
|
+
out.push(` ${mark} ${FEATURES[b.feature].label} - ${b.verdict} on ${resolved.target}`)
|
|
599
599
|
out.push(` fix: ${b.reason}`)
|
|
600
600
|
out.push(` at: ${b.evidence.join(", ")}`)
|
|
601
601
|
}
|
|
602
602
|
if (!opts.strict && unsupported.length === 0) {
|
|
603
|
-
out.push("\n(only caveats
|
|
603
|
+
out.push("\n(only caveats - run with --strict to fail on these)")
|
|
604
604
|
}
|
|
605
605
|
return out.join("\n")
|
|
606
606
|
}
|
|
@@ -630,7 +630,7 @@ export interface RunPortOptions {
|
|
|
630
630
|
}
|
|
631
631
|
|
|
632
632
|
/**
|
|
633
|
-
* Run `nifra port`: detect, resolve, render. Returns `true` when the run "passes"
|
|
633
|
+
* Run `nifra port`: detect, resolve, render. Returns `true` when the run "passes" - meaning either the
|
|
634
634
|
* gate is off, or the gate is on and nothing blocks. The caller maps `false` → exit 1.
|
|
635
635
|
*
|
|
636
636
|
* The gate fires when `--ci` is passed OR a `--target` is set (an explicit target signals intent to
|
package/src/recipes/1.8.0.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { UpgradeRecipe } from "./index.ts"
|
|
|
5
5
|
*
|
|
6
6
|
* 1.8.0 is a fixed-group bump: every `@nifrajs/*` package moves in lockstep. The pin sweep sets them
|
|
7
7
|
* all to 1.8.0 (preserving each spec's `^`/`~`/exact style). 1.8.0 introduced no breaking import moves
|
|
8
|
-
* in the public packages
|
|
9
|
-
* are additive
|
|
8
|
+
* in the public packages - the new surfaces (route assurance, adversarial testing, `@nifrajs/events`)
|
|
9
|
+
* are additive - so `importMoves` is empty. The notes flag the additive follow-ups a bump can't do for
|
|
10
10
|
* you (wiring the new assurance config), keeping the runner honest about what it did and didn't touch.
|
|
11
11
|
*/
|
|
12
12
|
export const recipe: UpgradeRecipe = {
|
|
@@ -14,8 +14,8 @@ export const recipe: UpgradeRecipe = {
|
|
|
14
14
|
pins: [{ match: "@nifrajs/", to: "1.8.0" }],
|
|
15
15
|
importMoves: [],
|
|
16
16
|
notes: [
|
|
17
|
-
"1.8.0 adds route assurance (@nifrajs/core + middleware)
|
|
18
|
-
"New package @nifrajs/events (portable versioned event contracts)
|
|
19
|
-
"@nifrajs/testing gained the adversarial contract lab
|
|
17
|
+
"1.8.0 adds route assurance (@nifrajs/core + middleware) - optional. To enforce it in CI, add a `nifra.assurance.ts` policy and run `nifra assure`.",
|
|
18
|
+
"New package @nifrajs/events (portable versioned event contracts) - add it to package.json only if you adopt it; the pin sweep won't introduce a dependency you don't already declare.",
|
|
19
|
+
"@nifrajs/testing gained the adversarial contract lab - no migration needed; existing tests are unchanged.",
|
|
20
20
|
],
|
|
21
21
|
}
|
package/src/recipes/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-release upgrade recipes, as data. Each recipe declares the mechanical edits a target version
|
|
3
|
-
* needs
|
|
3
|
+
* needs - dependency pins and exact import-specifier moves - so `nifra upgrade <version>` can apply
|
|
4
4
|
* them deterministically. Recipes are static imports (they bundle) and keyed by target version.
|
|
5
5
|
*/
|
|
6
6
|
|
|
@@ -33,7 +33,7 @@ export interface UpgradeRecipe {
|
|
|
33
33
|
readonly pins: readonly PinRule[]
|
|
34
34
|
readonly dependencyMoves?: readonly DependencyMove[]
|
|
35
35
|
readonly importMoves: readonly ImportMove[]
|
|
36
|
-
/** Human notes printed after the plan
|
|
36
|
+
/** Human notes printed after the plan - e.g. structural changes the runner deliberately can't do. */
|
|
37
37
|
readonly notes?: readonly string[]
|
|
38
38
|
}
|
|
39
39
|
|
package/src/scaffold.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra_scaffold`
|
|
2
|
+
* `nifra_scaffold` - turn a URL path into the correct `routes/` file (the convention an agent most often
|
|
3
3
|
* gets wrong) + a minimal, contract-correct stub. The mapping is the inverse of @nifrajs/web's
|
|
4
4
|
* `filePathToPatterns`: `:id`/`[id]` → `[id]`, `*rest`/`[...rest]` → `[...rest]`, `[[lang]]` optional,
|
|
5
5
|
* `/` → `index`. The framework (→ file extension) comes from the project's `clientModule`.
|
|
6
6
|
*
|
|
7
|
-
* Page stubs are emitted only for the JSX family (react/preact/solid
|
|
7
|
+
* Page stubs are emitted only for the JSX family (react/preact/solid - one shared, verified shape);
|
|
8
8
|
* for vue/svelte/vanilla we return the correct PATH + the route-module contract and point at
|
|
9
9
|
* `nifra_example` for the body, rather than hand-writing an SFC we can't typecheck here.
|
|
10
10
|
*/
|
|
@@ -53,10 +53,10 @@ export function routePathToFile(urlPath: string, ext: string): string {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const ROUTE_CONTRACT = `A route module may export:
|
|
56
|
-
- \`export default function Page(props: { data: LoaderData<typeof loader> }) { … }\`
|
|
57
|
-
- \`export async function loader({ params, request, api }: LoaderArgs<typeof backend>) { … }\`
|
|
58
|
-
- \`export async function action({ request, api }: ActionArgs<typeof backend>) { … }\`
|
|
59
|
-
- \`export const meta = { title, meta:[…] }\`
|
|
56
|
+
- \`export default function Page(props: { data: LoaderData<typeof loader> }) { … }\` - the page component.
|
|
57
|
+
- \`export async function loader({ params, request, api }: LoaderArgs<typeof backend>) { … }\` - server-only; data for SSR. Reach the backend via \`api\` (typed) / DB via the backend, NEVER a top-level server-only import.
|
|
58
|
+
- \`export async function action({ request, api }: ActionArgs<typeof backend>) { … }\` - server-only; handles the form POST.
|
|
59
|
+
- \`export const meta = { title, meta:[…] }\` - head tags.
|
|
60
60
|
Path params are typed on \`params\`.`
|
|
61
61
|
|
|
62
62
|
function jsxStub(file: string, params: string[]): string {
|
|
@@ -66,7 +66,7 @@ function jsxStub(file: string, params: string[]): string {
|
|
|
66
66
|
params.length > 0
|
|
67
67
|
? `// export async function loader({ params, api }: LoaderArgs<typeof backend>) { return { /* fetch by ${params[0]} */ } }`
|
|
68
68
|
: `// export async function loader({ api }: LoaderArgs<typeof backend>) { return {} }`
|
|
69
|
-
return `// ${file}
|
|
69
|
+
return `// ${file} - server-only loader/action allowed; never top-level-import server-only code (DB/secrets).
|
|
70
70
|
// Available here: ${paramsNote}. Fetch data in a loader via the typed \`api\`; see nifra_example("loader").
|
|
71
71
|
${loaderLine}
|
|
72
72
|
export default function Page() {
|
|
@@ -151,7 +151,7 @@ export async function writeScaffoldRoute(
|
|
|
151
151
|
return { ...result, written: true }
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/** Render the tool result as markdown
|
|
154
|
+
/** Render the tool result as markdown - the file path, the stub (if any), and the contract note. */
|
|
155
155
|
export function renderScaffold(urlPath: string, framework: Framework): string {
|
|
156
156
|
let r: ScaffoldResult
|
|
157
157
|
try {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nifra sync-routes` - regenerate the route-search types (`nifra-routes.d.ts`) from the current `routes/`
|
|
3
|
+
* tree, so `navigate({ to, search })` is typed against each static route's `searchSchema`. It runs
|
|
4
|
+
* `@nifrajs/web`'s `generateRouteSearchTypes` over the discovered routes and writes the `.d.ts` at the
|
|
5
|
+
* project root; include it in your tsconfig's `include`. Because it is regenerated from the route files, a
|
|
6
|
+
* stale navigate search shape becomes a `tsc` error - run it after adding/renaming a route or changing a
|
|
7
|
+
* `searchSchema` (a pure file write, no build). A dynamic route (`:param`/catch-all) is omitted, since its
|
|
8
|
+
* pattern is not a concrete `to`; those keep the loose object form.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from "node:fs"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
|
|
14
|
+
/** The committed route-types file `sync-routes` writes, so `nifra check` can recognise + drift-check it. */
|
|
15
|
+
export const ROUTE_TYPES_FILE = "nifra-routes.d.ts"
|
|
16
|
+
|
|
17
|
+
export interface SyncRoutesResult {
|
|
18
|
+
/** The written `.d.ts` (relative to the project root). */
|
|
19
|
+
readonly file: string
|
|
20
|
+
/** True when the file's content changed and it was rewritten. */
|
|
21
|
+
readonly changed: boolean
|
|
22
|
+
/** How many static routes got a typed entry. */
|
|
23
|
+
readonly typedRoutes: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns `null` when
|
|
28
|
+
* there is no `routes/` directory (an API-only project - nothing to type). Never loads `framework.ts`: it
|
|
29
|
+
* needs only the route tree, so it works before a build and on a broken framework config.
|
|
30
|
+
*/
|
|
31
|
+
export async function syncRouteTypes(cwd: string): Promise<SyncRoutesResult | null> {
|
|
32
|
+
const routesDir = join(cwd, "routes")
|
|
33
|
+
if (!existsSync(routesDir)) return null
|
|
34
|
+
const { discoverRoutes } = await import("@nifrajs/web/fs")
|
|
35
|
+
const { generateRouteSearchTypes } = await import("@nifrajs/web")
|
|
36
|
+
const manifest = discoverRoutes(routesDir)
|
|
37
|
+
const code = generateRouteSearchTypes(manifest, {
|
|
38
|
+
// A route file is relative to routesDir (`reports.tsx`, `users/index.tsx`); the `.d.ts` sits at the
|
|
39
|
+
// project root, so the `typeof import(...)` specifier is `./routes/<file без extension>`.
|
|
40
|
+
resolve: (file) => `./routes/${file.replace(/\.[^./]+$/, "")}`,
|
|
41
|
+
})
|
|
42
|
+
const outFile = join(cwd, ROUTE_TYPES_FILE)
|
|
43
|
+
const existing = await Bun.file(outFile)
|
|
44
|
+
.text()
|
|
45
|
+
.catch(() => "")
|
|
46
|
+
const changed = existing !== code
|
|
47
|
+
if (changed) await Bun.write(outFile, code)
|
|
48
|
+
return { file: ROUTE_TYPES_FILE, changed, typedRoutes: (code.match(/: SearchOf</g) ?? []).length }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** CLI entry: write/refresh the route-types file and print a one-line summary + the tsconfig reminder. */
|
|
52
|
+
export async function runSyncRoutes(cwd: string): Promise<boolean> {
|
|
53
|
+
const result = await syncRouteTypes(cwd)
|
|
54
|
+
if (result === null) {
|
|
55
|
+
console.log("nifra sync-routes: no routes/ directory found. Run from your project root.")
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
const n = result.typedRoutes
|
|
59
|
+
const routes = `${n} typed route${n === 1 ? "" : "s"}`
|
|
60
|
+
console.log(
|
|
61
|
+
result.changed
|
|
62
|
+
? `✓ wrote ${result.file} (${routes})`
|
|
63
|
+
: `• ${result.file} already in sync (${routes})`,
|
|
64
|
+
)
|
|
65
|
+
console.log(
|
|
66
|
+
" Include it in your tsconfig's `include` so `navigate({ to, search })` is typed per route.",
|
|
67
|
+
)
|
|
68
|
+
return true
|
|
69
|
+
}
|
package/src/types-search.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra_types`
|
|
2
|
+
* `nifra_types` - the EXACT TypeScript of every exported `@nifrajs/*` symbol. The corpus is `types.json`
|
|
3
3
|
* (written into this package by `bun run gen:llms`): each entry's `signature` is the literal declaration
|
|
4
|
-
* parsed from the package's BUILT `.d.ts` with the TS compiler
|
|
4
|
+
* parsed from the package's BUILT `.d.ts` with the TS compiler - a clean signature, never prose, never
|
|
5
5
|
* truncated. So an agent that needs the precise shape of a type (`RateLimitStore`, `RouteSchema`, a
|
|
6
6
|
* function signature) gets the authoritative source here and never has to read a `.d.ts` file.
|
|
7
7
|
*
|
|
@@ -15,7 +15,7 @@ export interface TypeEntry {
|
|
|
15
15
|
readonly name: string
|
|
16
16
|
readonly kind: "interface" | "type" | "class" | "function" | "enum" | "const"
|
|
17
17
|
readonly package: string
|
|
18
|
-
/** The literal declaration text from the `.d.ts`
|
|
18
|
+
/** The literal declaration text from the `.d.ts` - a clean signature, no implementation. */
|
|
19
19
|
readonly signature: string
|
|
20
20
|
/** The declaration's JSDoc block, if any. */
|
|
21
21
|
readonly doc?: string
|
|
@@ -25,7 +25,7 @@ export interface ScoredType extends TypeEntry {
|
|
|
25
25
|
readonly score: number
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** Exact (case-insensitive) name lookup
|
|
28
|
+
/** Exact (case-insensitive) name lookup - returns every declaration with that name (a name can exist in
|
|
29
29
|
* more than one package). */
|
|
30
30
|
export function lookupType(types: readonly TypeEntry[], name: string): TypeEntry[] {
|
|
31
31
|
const wanted = name.trim().toLowerCase()
|
|
@@ -55,21 +55,69 @@ export function searchTypes(
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function renderEntry(t: TypeEntry): string {
|
|
58
|
-
const head = `## ${t.name}
|
|
58
|
+
const head = `## ${t.name} - ${t.kind}, \`${t.package}\``
|
|
59
59
|
const doc = t.doc ? `${t.doc}\n` : ""
|
|
60
60
|
return `${head}\n\n\`\`\`ts\n${doc}${t.signature}\n\`\`\``
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Longest signature rendered whole in a SEARCH result.
|
|
65
|
+
*
|
|
66
|
+
* Search is "which symbol did I mean?", not "show me everything about it" - and the corpus is wildly
|
|
67
|
+
* uneven: the median declaration is small, while `Server` alone is ~32,000 characters, five times the
|
|
68
|
+
* next largest. One broad query that happened to match it returned the entire class for near-zero
|
|
69
|
+
* value. Above this, a search result shows the head plus a member count and says how to get the rest;
|
|
70
|
+
* an exact `name` lookup is never collapsed, because there the caller asked for that symbol.
|
|
71
|
+
*/
|
|
72
|
+
const SEARCH_SIGNATURE_MAX = 600
|
|
73
|
+
|
|
74
|
+
/** First sentence of the doc block, flattened to one line - enough to pick between candidates. */
|
|
75
|
+
function summarize(doc: string | undefined): string {
|
|
76
|
+
if (doc === undefined) return ""
|
|
77
|
+
const text = doc
|
|
78
|
+
.replace(/\/\*\*|\*\/|^\s*\*/gm, " ")
|
|
79
|
+
.replace(/\s+/g, " ")
|
|
80
|
+
.trim()
|
|
81
|
+
if (text === "") return ""
|
|
82
|
+
const end = text.search(/\.\s|\.$/)
|
|
83
|
+
const sentence = end === -1 ? text : text.slice(0, end + 1)
|
|
84
|
+
return sentence.length > 200 ? `${sentence.slice(0, 197)}...` : sentence
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Keep the declaration head; replace a long body with a member count. */
|
|
88
|
+
function collapseSignature(signature: string): string {
|
|
89
|
+
const open = signature.indexOf("{")
|
|
90
|
+
if (open === -1) return `${signature.slice(0, SEARCH_SIGNATURE_MAX)}…`
|
|
91
|
+
const body = signature.slice(open)
|
|
92
|
+
// Rough but honest: count top-level-looking member lines rather than parsing TypeScript here.
|
|
93
|
+
const members = body.split("\n").filter((line) => /^\s{2,4}\S/.test(line)).length
|
|
94
|
+
return `${signature.slice(0, open).trim()} { … ${members} members … }`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A search hit: enough to choose, not the whole declaration. */
|
|
98
|
+
function renderSearchHit(t: TypeEntry): string {
|
|
99
|
+
const head = `## ${t.name} - ${t.kind}, \`${t.package}\``
|
|
100
|
+
const summary = summarize(t.doc)
|
|
101
|
+
const signature =
|
|
102
|
+
t.signature.length <= SEARCH_SIGNATURE_MAX ? t.signature : collapseSignature(t.signature)
|
|
103
|
+
const note =
|
|
104
|
+
t.signature.length <= SEARCH_SIGNATURE_MAX
|
|
105
|
+
? ""
|
|
106
|
+
: `\n_Collapsed (${t.signature.length} chars). Call \`nifra_types({ name: "${t.name}" })\` for the full declaration._`
|
|
107
|
+
return `${head}\n${summary ? `\n${summary}\n` : ""}\n\`\`\`ts\n${signature}\n\`\`\`${note}`
|
|
108
|
+
}
|
|
109
|
+
|
|
63
110
|
/**
|
|
64
111
|
* Render the tool result. `name` → the exact declaration(s); else `query` → the top matches; else a
|
|
65
112
|
* per-package index of type names. The signatures are authoritative (generated from the built `.d.ts`),
|
|
66
|
-
* so this is the complete answer
|
|
113
|
+
* so this is the complete answer - there is no need to read a `.d.ts` file.
|
|
67
114
|
*/
|
|
68
115
|
export function renderTypesResult(
|
|
69
116
|
types: readonly TypeEntry[],
|
|
70
117
|
name: string | undefined,
|
|
71
118
|
query: string | undefined,
|
|
72
119
|
limit: number,
|
|
120
|
+
full = false,
|
|
73
121
|
): string {
|
|
74
122
|
if (name !== undefined && name.trim() !== "") {
|
|
75
123
|
const hits = lookupType(types, name)
|
|
@@ -83,7 +131,8 @@ export function renderTypesResult(
|
|
|
83
131
|
if (matches.length === 0) {
|
|
84
132
|
return `No types matched ${JSON.stringify(query)}. Try a broader term, pass an exact \`name\`, or call with no args for the index.`
|
|
85
133
|
}
|
|
86
|
-
|
|
134
|
+
// Search results are collapsed by default; `full` opts back into whole declarations.
|
|
135
|
+
return matches.map(full ? renderEntry : renderSearchHit).join("\n\n---\n\n")
|
|
87
136
|
}
|
|
88
137
|
const byPkg = new Map<string, string[]>()
|
|
89
138
|
for (const t of types) {
|
|
@@ -95,7 +144,7 @@ export function renderTypesResult(
|
|
|
95
144
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
96
145
|
.map(([pkg, names]) => `- **${pkg}**: ${names.sort().join(", ")}`)
|
|
97
146
|
.join("\n")
|
|
98
|
-
return `# nifra types
|
|
147
|
+
return `# nifra types - index (${types.length} exported symbols; pass \`name\` for the exact declaration, or \`query\` to search)\n\nSignatures are generated from the built \`.d.ts\` - authoritative + complete. Use this instead of reading any \`.d.ts\` file.\n\n${index}`
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
/** Load the bundled type corpus (`docs/types.json`), resolved relative to this module so it works from
|
package/src/upgrade.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra upgrade <version>`
|
|
2
|
+
* `nifra upgrade <version>` - an executable, per-release upgrade runner. Framework and shared-package
|
|
3
3
|
* bumps otherwise spread the same mechanical edits across every consuming app by hand; a recipe turns
|
|
4
4
|
* them into `detect → transform → verify`:
|
|
5
5
|
*
|
|
6
|
-
* 1. **pin sweep**
|
|
6
|
+
* 1. **pin sweep** - set every matching dependency to the target version across the workspace's
|
|
7
7
|
* package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
|
|
8
|
-
* 2. **dependency moves**
|
|
8
|
+
* 2. **dependency moves** - replace removed packages with their supported successor without
|
|
9
9
|
* reserializing package.json or leaving duplicate dependency keys.
|
|
10
|
-
* 3. **import moves**
|
|
11
|
-
* 4. **verify**
|
|
10
|
+
* 3. **import moves** - rewrite exact import specifiers to their updated module paths.
|
|
11
|
+
* 4. **verify** - reuse the existing `nifra check` gate; no new verification surface.
|
|
12
12
|
*
|
|
13
13
|
* Dry-run by default (prints the plan, writes nothing); `--write` applies. Fail-closed on an unknown
|
|
14
14
|
* target version or a missing package.json, and deterministic (same repo + target → same edits).
|
|
15
15
|
*
|
|
16
16
|
* Deliberately NOT a codemod engine: transforms are string/specifier-level only. Structural (AST)
|
|
17
|
-
* transforms are a future addition
|
|
17
|
+
* transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync, writeFileSync } from "node:fs"
|
|
@@ -75,8 +75,8 @@ const DEP_FIELDS = [
|
|
|
75
75
|
"optionalDependencies",
|
|
76
76
|
] as const
|
|
77
77
|
|
|
78
|
-
// Only a bare semver spec is rewritten. Anything else
|
|
79
|
-
// urls, "*", "latest", or a multi-part range
|
|
78
|
+
// Only a bare semver spec is rewritten. Anything else - workspace:*, link:/file:, npm: aliases, git
|
|
79
|
+
// urls, "*", "latest", or a multi-part range - is intentionally left untouched (skipped, not guessed).
|
|
80
80
|
const SEMVER_SPEC =
|
|
81
81
|
/^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
82
82
|
|
|
@@ -95,7 +95,7 @@ export function rewriteVersionSpec(spec: string, toVersion: string): string | nu
|
|
|
95
95
|
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
|
-
* Apply pin rules to one package.json's TEXT (format-preserving
|
|
98
|
+
* Apply pin rules to one package.json's TEXT (format-preserving - targeted string replaces, never a
|
|
99
99
|
* JSON re-serialize that would reorder keys or drop comments-as-formatting). Returns the new text and
|
|
100
100
|
* the changes made.
|
|
101
101
|
*/
|
|
@@ -336,7 +336,7 @@ function renderPlan(plan: UpgradePlan, write: boolean): string {
|
|
|
336
336
|
plan.dependencyMoves.length === 0 &&
|
|
337
337
|
plan.importMoves.length === 0
|
|
338
338
|
) {
|
|
339
|
-
lines.push("Already up to date
|
|
339
|
+
lines.push("Already up to date - no changes.")
|
|
340
340
|
return lines.join("\n")
|
|
341
341
|
}
|
|
342
342
|
if (plan.dependencyMoves.length > 0) {
|
|
@@ -393,7 +393,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
|
|
|
393
393
|
readFileSync(join(cwd, "package.json"), "utf8")
|
|
394
394
|
} catch {
|
|
395
395
|
console.error(
|
|
396
|
-
`[nifra] no package.json in ${relative(process.cwd(), cwd) || "."}
|
|
396
|
+
`[nifra] no package.json in ${relative(process.cwd(), cwd) || "."} - run from a project root`,
|
|
397
397
|
)
|
|
398
398
|
return false
|
|
399
399
|
}
|
|
@@ -413,7 +413,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
|
|
|
413
413
|
const ok = await runCheck(cwd, { json: false })
|
|
414
414
|
if (!ok) {
|
|
415
415
|
if (!options.json)
|
|
416
|
-
console.error("\n[nifra] upgrade applied but `nifra check` failed
|
|
416
|
+
console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
|
|
417
417
|
return false
|
|
418
418
|
}
|
|
419
419
|
}
|