@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.
Files changed (126) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +64 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +400 -83
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +37 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -53
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +8 -4
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +74 -14
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/levels-tool.d.ts +8 -8
  37. package/dist/levels-tool.d.ts.map +1 -1
  38. package/dist/levels-tool.js +33 -30
  39. package/dist/levels-tool.js.map +1 -1
  40. package/dist/load.d.ts +17 -6
  41. package/dist/load.d.ts.map +1 -1
  42. package/dist/load.js +45 -7
  43. package/dist/load.js.map +1 -1
  44. package/dist/manifest-tool.d.ts +1 -1
  45. package/dist/manifest-tool.d.ts.map +1 -1
  46. package/dist/manifest-tool.js +4 -2
  47. package/dist/manifest-tool.js.map +1 -1
  48. package/dist/mcp-docs-tools.d.ts +1 -1
  49. package/dist/mcp-docs-tools.d.ts.map +1 -1
  50. package/dist/mcp-docs-tools.js +14 -10
  51. package/dist/mcp-docs-tools.js.map +1 -1
  52. package/dist/mcp-http.d.ts +5 -5
  53. package/dist/mcp-http.js +7 -7
  54. package/dist/mcp-protocol.d.ts +2 -2
  55. package/dist/mcp-protocol.js +2 -2
  56. package/dist/mcp-render.d.ts +3 -3
  57. package/dist/mcp-render.js +15 -7
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-run.d.ts +2 -2
  60. package/dist/mcp-run.js +2 -2
  61. package/dist/mcp.d.ts +14 -14
  62. package/dist/mcp.d.ts.map +1 -1
  63. package/dist/mcp.js +119 -47
  64. package/dist/mcp.js.map +1 -1
  65. package/dist/openapi-tool.d.ts +1 -1
  66. package/dist/openapi-tool.js +2 -2
  67. package/dist/pipeline-guard.d.ts +83 -0
  68. package/dist/pipeline-guard.d.ts.map +1 -0
  69. package/dist/pipeline-guard.js +136 -0
  70. package/dist/pipeline-guard.js.map +1 -0
  71. package/dist/port.d.ts +9 -9
  72. package/dist/port.js +27 -27
  73. package/dist/recipes/1.8.0.d.ts +2 -2
  74. package/dist/recipes/1.8.0.js +5 -5
  75. package/dist/recipes/index.d.ts +2 -2
  76. package/dist/recipes/index.js +1 -1
  77. package/dist/scaffold.d.ts +3 -3
  78. package/dist/scaffold.js +8 -8
  79. package/dist/sync-routes.d.ts +28 -0
  80. package/dist/sync-routes.d.ts.map +1 -0
  81. package/dist/sync-routes.js +55 -0
  82. package/dist/sync-routes.js.map +1 -0
  83. package/dist/types-search.d.ts +6 -6
  84. package/dist/types-search.d.ts.map +1 -1
  85. package/dist/types-search.js +53 -8
  86. package/dist/types-search.js.map +1 -1
  87. package/dist/upgrade.d.ts +7 -7
  88. package/dist/upgrade.js +12 -12
  89. package/dist/verification.d.ts +51 -0
  90. package/dist/verification.d.ts.map +1 -0
  91. package/dist/verification.js +80 -0
  92. package/dist/verification.js.map +1 -0
  93. package/docs/examples.json +178 -88
  94. package/docs/llms-full.txt +1766 -766
  95. package/docs/types.json +2171 -2807
  96. package/package.json +22 -11
  97. package/src/assure.ts +17 -18
  98. package/src/capabilities-tool.ts +82 -16
  99. package/src/check.ts +493 -100
  100. package/src/cli.ts +306 -54
  101. package/src/diff-tool.ts +8 -8
  102. package/src/docs-search.ts +6 -6
  103. package/src/doctor.ts +81 -16
  104. package/src/examples.ts +7 -7
  105. package/src/init-agents.ts +17 -17
  106. package/src/internal/typescript-import.ts +33 -0
  107. package/src/introspect.ts +28 -28
  108. package/src/levels-tool.ts +37 -31
  109. package/src/load.ts +65 -12
  110. package/src/manifest-tool.ts +4 -2
  111. package/src/mcp-docs-tools.ts +20 -10
  112. package/src/mcp-http.ts +7 -7
  113. package/src/mcp-protocol.ts +2 -2
  114. package/src/mcp-render.ts +15 -7
  115. package/src/mcp-run.ts +2 -2
  116. package/src/mcp.ts +127 -47
  117. package/src/openapi-tool.ts +2 -2
  118. package/src/pipeline-guard.ts +186 -0
  119. package/src/port.ts +31 -31
  120. package/src/recipes/1.8.0.ts +5 -5
  121. package/src/recipes/index.ts +2 -2
  122. package/src/scaffold.ts +8 -8
  123. package/src/sync-routes.ts +69 -0
  124. package/src/types-search.ts +57 -8
  125. package/src/upgrade.ts +12 -12
  126. package/src/verification.ts +123 -0
package/src/port.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * `nifra port` — a portability linter. It scans an app's source for features whose runtime guarantees
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 — it
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 — they're part of the `--json` contract. */
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 — why this verdict, and (for caveat/unsupported) the fix. */
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 — lost on restart, not shared across instances), unsupported on the distributed edge.
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 — use KVSessionStore (Workers KV) or another shared store",
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 — use KVCacheStore (Workers KV) or the platform Cache API",
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 — use a shared store (Redis / Durable Object)",
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 — 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",
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 — wrap the app in createWebSocketHub (a Durable Object) from @nifrajs/workers",
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 — use the node: / Web equivalents",
152
- deno: "no Bun global on Deno — use the Deno / Web equivalents",
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 — use the Bun / Web equivalents",
169
- node: "no Deno global on Node — use the node: / Web equivalents",
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 — the signal in context, for the human report. */
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 — a WebSocket route registration on a nifra app. `(?<![.\w$])` keeps it from
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(` — all the chained forms — because the dot is consumed by the call, and the
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` — suppresses the in-process-websocket finding. */
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) — `port` scans request/app source, not tooling.
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) — its presence means the WS routes are already edge-ready.
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 — surfaced so the report explains itself. */
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 — always — and
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 — portability matrix", ""]
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 — pass --target <bun|node|deno|cf-pages|vercel> to gate against one",
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 — this app is portable across all targets.")
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} — ${FEATURES[f.id].reasons[resolved.target]}`)
586
+ out.push(` ⚠ ${f.label} - ${FEATURES[f.id].reasons[resolved.target]}`)
587
587
  }
588
- out.push("\n(caveats don't fail the gate — run with --strict to treat them as blockers)")
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} — ${b.verdict} on ${resolved.target}`)
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 — run with --strict to fail on these)")
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" — meaning either the
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
@@ -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 — 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
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) — 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.",
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
  }
@@ -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 — dependency pins and exact import-specifier moves — so `nifra upgrade <version>` can apply
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 — e.g. structural changes the runner deliberately can't do. */
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` — turn a URL path into the correct `routes/` file (the convention an agent most often
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 — one shared, verified shape);
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> }) { … }\` — 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.
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} — server-only loader/action allowed; never top-level-import server-only code (DB/secrets).
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 — the file path, the stub (if any), and the contract note. */
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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `nifra_types` — the EXACT TypeScript of every exported `@nifrajs/*` symbol. The corpus is `types.json`
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 — a clean signature, never prose, never
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` — a clean signature, no implementation. */
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 — returns every declaration with that name (a name can exist in
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} — ${t.kind}, \`${t.package}\``
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 — there is no need to read a `.d.ts` file.
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
- return matches.map(renderEntry).join("\n\n---\n\n")
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 — 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}`
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>` — an executable, per-release upgrade runner. Framework and shared-package
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** — set every matching dependency to the target version across the workspace's
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** — replace removed packages with their supported successor without
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** — rewrite exact import specifiers to their updated module paths.
11
- * 4. **verify** — reuse the existing `nifra check` gate; no new verification surface.
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 — a recipe that needs one is the signal to add the engine, not before.
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 — workspace:*, link:/file:, npm: aliases, git
79
- // urls, "*", "latest", or a multi-part range — is intentionally left untouched (skipped, not guessed).
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 — targeted string replaces, never a
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 — no changes.")
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) || "."} — run from a project root`,
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 — review above.")
416
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
417
417
  return false
418
418
  }
419
419
  }