@nifrajs/cli 2.4.0 → 2.6.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/src/doctor.ts CHANGED
@@ -10,9 +10,10 @@
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
11
  * the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
12
12
  */
13
- import { realpath, stat } from "node:fs/promises"
13
+ import type { Dirent } from "node:fs"
14
+ import { readdir, realpath, stat } from "node:fs/promises"
14
15
  import { builtinModules } from "node:module"
15
- import { dirname, join, relative } from "node:path"
16
+ import { dirname, join, relative, sep } from "node:path"
16
17
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
17
18
 
18
19
  // Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
@@ -124,6 +125,10 @@ export interface DoctorResult {
124
125
  readonly findings: readonly DoctorFinding[]
125
126
  /** Packages that resolve to more than one physical install across this workspace. */
126
127
  readonly duplicateInstalls: readonly DuplicateInstallFinding[]
128
+ /** Workspace-linked deps whose `default`-condition artifact lags their `bun`-condition source.
129
+ * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
130
+ * momentarily behind - the finding matters when a dev server starts against it. */
131
+ readonly staleDists: readonly StaleDistFinding[]
127
132
  /** Dependencies written by `--auto-fix` / MCP `autoFix:true`. */
128
133
  readonly fixed?: readonly DoctorAppliedFix[]
129
134
  /** Findings that were safe to report but not safe to write automatically. */
@@ -460,6 +465,156 @@ export async function collectDuplicateInstalls(
460
465
  return findings
461
466
  }
462
467
 
468
+ /** A workspace-linked dependency whose build artifact is older than its source. */
469
+ export interface StaleDistFinding {
470
+ readonly package: string
471
+ /** The `default`-condition artifact (what Vite's SSR runner, vitest, and node consumers load),
472
+ * relative to the doctor root when possible. `missing: true` means it was never built at all. */
473
+ readonly distFile: string
474
+ readonly missing: boolean
475
+ /** The newest source file under the package's `bun`-condition tree - the proof of staleness. */
476
+ readonly sourceFile: string
477
+ /** How far the artifact lags the newest source, in whole seconds (0 when `missing`). */
478
+ readonly behindSeconds: number
479
+ }
480
+
481
+ /**
482
+ * Resolve one condition through an export-map entry: a string is terminal; an object takes the first
483
+ * key that is the wanted condition or `default` (insertion order, like the runtime), skipping `types`.
484
+ */
485
+ function exportTarget(entry: unknown, condition: "bun" | "default"): string | undefined {
486
+ if (typeof entry === "string") return entry
487
+ if (!isRecord(entry)) return undefined
488
+ for (const [key, value] of Object.entries(entry)) {
489
+ if (key === "types") continue
490
+ if (key === condition || key === "default") return exportTarget(value, condition)
491
+ }
492
+ return undefined
493
+ }
494
+
495
+ // Bounded recursive walk for the newest file under a package's source tree. The cap is a runaway
496
+ // guard, not a tuning knob - a real `src/` is a few hundred files.
497
+ const MAX_SOURCE_FILES = 4_096
498
+ async function newestFileUnder(
499
+ dir: string,
500
+ ): Promise<{ readonly file: string; readonly mtimeMs: number } | undefined> {
501
+ let newest: { file: string; mtimeMs: number } | undefined
502
+ let seen = 0
503
+ const walk = async (current: string): Promise<void> => {
504
+ const entries: Dirent[] = await readdir(current, { withFileTypes: true }).catch(
505
+ () => [] as Dirent[],
506
+ )
507
+ for (const entry of entries) {
508
+ if (seen >= MAX_SOURCE_FILES) return
509
+ const path = join(current, entry.name)
510
+ if (entry.isDirectory()) {
511
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith("."))
512
+ continue
513
+ await walk(path)
514
+ continue
515
+ }
516
+ if (!entry.isFile()) continue
517
+ seen++
518
+ const s = await stat(path).catch(() => undefined)
519
+ if (s !== undefined && (newest === undefined || s.mtimeMs > newest.mtimeMs))
520
+ newest = { file: path, mtimeMs: s.mtimeMs }
521
+ }
522
+ }
523
+ await walk(dir)
524
+ return newest
525
+ }
526
+
527
+ /**
528
+ * Find workspace-linked dependencies whose `default`-condition artifact (`dist/…`) has fallen behind
529
+ * their `bun`-condition source (`src/…`).
530
+ *
531
+ * The split export map is what makes this a trap: Bun (the build, the tests, the prod binary) reads
532
+ * live source, while Vite's SSR runner and any node consumer read a build artifact nothing in the dev
533
+ * loop regenerates. `dist/` is gitignored, so the drift never shows in a diff - it surfaces as a 500
534
+ * INSIDE framework/shared-package code and reads like an upstream regression. npm-installed copies are
535
+ * immutable and skipped; only symlinked (workspace) installs can drift.
536
+ */
537
+ export async function collectStaleWorkspaceDists(
538
+ cwd: string,
539
+ rootPackage: Record<string, unknown>,
540
+ ): Promise<StaleDistFinding[]> {
541
+ const findings: StaleDistFinding[] = []
542
+ const seen = new Set<string>()
543
+ for (const field of DEPENDENCY_FIELDS) {
544
+ for (const name of depNames(rootPackage, field)) {
545
+ if (seen.has(name)) continue
546
+ seen.add(name)
547
+ // Boundary "" - never matches a real dir, so the upward walk runs to the filesystem root and
548
+ // finds a hoisted copy wherever the install put it.
549
+ const copy = await resolvedInstalledCopy(cwd, "", name)
550
+ // A real (tarball) install lives under node_modules and cannot drift; only a symlink out of the
551
+ // tree - a workspace/link install whose realpath escapes node_modules - has a live `src`.
552
+ if (copy === undefined || copy.path.includes(`${sep}node_modules${sep}`)) continue
553
+ const meta = await readJson(join(copy.path, "package.json"))
554
+ const exportsMap = meta?.exports
555
+ if (!isRecord(exportsMap)) continue
556
+ // Every subpath with a bun→src / default→dist split; the root "." alone would miss the common
557
+ // "deep subpaths only" layout.
558
+ const pairs: Array<{ readonly bun: string; readonly dist: string }> = []
559
+ const entries = Object.keys(exportsMap).some((k) => k.startsWith("."))
560
+ ? Object.entries(exportsMap)
561
+ : ([[".", exportsMap]] as Array<[string, unknown]>)
562
+ for (const [, entry] of entries) {
563
+ const bun = exportTarget(entry, "bun")
564
+ const dist = exportTarget(entry, "default")
565
+ if (bun !== undefined && dist !== undefined && bun !== dist) pairs.push({ bun, dist })
566
+ }
567
+ if (pairs.length === 0) continue
568
+ // One newest-source probe per package: the bun targets share a source tree (its top directory).
569
+ const sourceDirs = new Set(
570
+ pairs
571
+ .map((p) => p.bun.replace(/^\.\//, "").split("/")[0])
572
+ .filter((d): d is string => d !== undefined && d.length > 0),
573
+ )
574
+ let newest: { file: string; mtimeMs: number } | undefined
575
+ for (const dir of sourceDirs) {
576
+ const candidate = await newestFileUnder(join(copy.path, dir))
577
+ if (candidate !== undefined && (newest === undefined || candidate.mtimeMs > newest.mtimeMs))
578
+ newest = candidate
579
+ }
580
+ if (newest === undefined) continue
581
+ // Report the package once, anchored on its stalest (or missing) artifact.
582
+ let worst: StaleDistFinding | undefined
583
+ for (const pair of pairs) {
584
+ const distPath = join(copy.path, pair.dist.replace(/^\.\//, ""))
585
+ const distStat = await stat(distPath).catch(() => undefined)
586
+ const finding: StaleDistFinding | undefined =
587
+ distStat === undefined
588
+ ? {
589
+ package: name,
590
+ distFile: displayPath(cwd, distPath),
591
+ missing: true,
592
+ sourceFile: displayPath(cwd, newest.file),
593
+ behindSeconds: 0,
594
+ }
595
+ : distStat.mtimeMs < newest.mtimeMs
596
+ ? {
597
+ package: name,
598
+ distFile: displayPath(cwd, distPath),
599
+ missing: false,
600
+ sourceFile: displayPath(cwd, newest.file),
601
+ behindSeconds: Math.round((newest.mtimeMs - distStat.mtimeMs) / 1000),
602
+ }
603
+ : undefined
604
+ if (finding === undefined) continue
605
+ if (
606
+ worst === undefined ||
607
+ (finding.missing && !worst.missing) ||
608
+ finding.behindSeconds > worst.behindSeconds
609
+ )
610
+ worst = finding
611
+ }
612
+ if (worst !== undefined) findings.push(worst)
613
+ }
614
+ }
615
+ return findings.sort((a, b) => a.package.localeCompare(b.package))
616
+ }
617
+
463
618
  function depRecord(
464
619
  pkg: Record<string, unknown>,
465
620
  field: string,
@@ -518,7 +673,8 @@ async function inferDependencyFix(
518
673
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
519
674
  export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
520
675
  const pkg = await readJson(join(cwd, "package.json"))
521
- if (pkg === undefined) return { ok: true, ran: false, findings: [], duplicateInstalls: [] }
676
+ if (pkg === undefined)
677
+ return { ok: true, ran: false, findings: [], duplicateInstalls: [], staleDists: [] }
522
678
 
523
679
  const scopes = await doctorPackageScopes(cwd, pkg)
524
680
 
@@ -531,11 +687,14 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
531
687
  })
532
688
  findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
533
689
  const duplicateInstalls = await collectDuplicateInstalls(cwd, pkg)
690
+ const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
691
+ // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
534
692
  return {
535
693
  ok: findings.length === 0 && duplicateInstalls.length === 0,
536
694
  ran: true,
537
695
  findings,
538
696
  duplicateInstalls,
697
+ staleDists,
539
698
  }
540
699
  }
541
700
 
@@ -639,6 +798,22 @@ export async function runDoctor(
639
798
  }
640
799
  console.log("")
641
800
  }
801
+ // Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
802
+ // it costs an hour of misdiagnosis when the dev server 500s inside the package.
803
+ if (result.staleDists.length > 0) {
804
+ console.log(
805
+ `⚠ ${result.staleDists.length} workspace-linked package(s) with a build artifact older than their source (Bun reads src, Vite SSR/node consumers read dist):\n`,
806
+ )
807
+ for (const f of result.staleDists) {
808
+ console.log(
809
+ f.missing
810
+ ? ` ${f.package} - ${f.distFile} was never built`
811
+ : ` ${f.package} - ${f.distFile} is ${f.behindSeconds}s behind ${f.sourceFile}`,
812
+ )
813
+ console.log(` rebuild ${f.package}`)
814
+ }
815
+ console.log("")
816
+ }
642
817
  if (result.ok) {
643
818
  console.log(
644
819
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
@@ -51,6 +51,7 @@ export const examplesWidget = defineMcpWidget({
51
51
  export function examplesAppTool(loadExamples: () => Promise<Example[] | undefined>): McpTool {
52
52
  return defineMcpTool({
53
53
  name: "nifra_examples_app",
54
+ annotations: { title: "Browse nifra examples", readOnlyHint: true, openWorldHint: false },
54
55
  description:
55
56
  "Browse nifra's verified code examples as an interactive, filterable list (MCP Apps widget). Pass query to pre-filter; the widget also filters client-side.",
56
57
  inputSchema: {
@@ -23,8 +23,11 @@ export function docsTools(
23
23
  loadTypes: () => Promise<TypeEntry[] | undefined>,
24
24
  ): McpTool[] {
25
25
  return [
26
+ // Every tool here reads the package's bundled corpus and nothing else: read-only, closed-world.
27
+ // The annotations state that for MCP hosts (and directory reviews check for them).
26
28
  {
27
29
  name: "nifra_docs",
30
+ annotations: { title: "Search nifra docs", readOnlyHint: true, openWorldHint: false },
28
31
  description:
29
32
  "Search nifra's framework documentation and get back ONLY the matching sections - auth, uploads, ISR, WebSockets, loaders, deployment, anything. Call with no query for the cheap section index; pass query (e.g. \"isr revalidate\") for the top matching sections. Use this instead of reading llms-full.txt (~150 KB) whole. For the EXACT TypeScript of a type/interface/function, call nifra_types instead (don't read .d.ts files).",
30
33
  inputSchema: {
@@ -49,6 +52,11 @@ export function docsTools(
49
52
  },
50
53
  {
51
54
  name: "nifra_example",
55
+ annotations: {
56
+ title: "Get a verified nifra example",
57
+ readOnlyHint: true,
58
+ openWorldHint: false,
59
+ },
52
60
  description:
53
61
  'Get a VERIFIED, copy-pasteable nifra code example for a task - auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
54
62
  inputSchema: {
@@ -73,6 +81,7 @@ export function docsTools(
73
81
  },
74
82
  {
75
83
  name: "nifra_types",
84
+ annotations: { title: "Look up nifra API types", readOnlyHint: true, openWorldHint: false },
76
85
  description:
77
86
  'Get the EXACT TypeScript declaration of any exported @nifrajs/* symbol - interface, type, class, function, const. Each signature is generated from the package\'s built .d.ts, so it is the LITERAL declaration: complete, authoritative, never prose and never truncated. THIS IS THE SOURCE OF TRUTH for nifra\'s types - do NOT read node_modules/@nifrajs/**/*.d.ts; call this instead. Pass `name` for an exact symbol (e.g. "RateLimitStore", "RouteSchema", "Context", "rateLimit"); pass `query` to search by keyword; omit both for the per-package index of names. A `name` lookup is always the complete declaration; a `query` returns a one-line summary plus the signature, collapsing an oversized body - pass `full: true` to override.',
78
87
  inputSchema: {
@@ -116,6 +125,11 @@ export function docsTools(
116
125
  },
117
126
  {
118
127
  name: "nifra_learn",
128
+ annotations: {
129
+ title: "Guided nifra learning path",
130
+ readOnlyHint: true,
131
+ openWorldHint: false,
132
+ },
119
133
  description:
120
134
  "Walk the guided path to build a nifra app end to end - an ORDERED sequence (create → page route → loader → typed API → typed client → auth → background jobs → deploy), not the random-access search of nifra_docs/nifra_example. Call with no args for the step index; pass `step: N` for that step's goal, how to do it (which tool emits the correct artifact), and how to verify it. Use it when scaffolding a new app or learning nifra's flow - each step composes the other nifra_* tools.",
121
135
  inputSchema: {
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "2.4.0"
32
+ const VERSION = "2.6.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>
@@ -63,7 +63,12 @@ export function respondMcpHttp(
63
63
  * resource so MCP Apps hosts can render `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */
64
64
  export function handleMcpHttp(request: Request): Promise<Response> {
65
65
  return respondMcpHttp(request, publicDocsTools(), {
66
- features: { resources: [examplesWidget.resource], ui: { mimeTypes: [UI_MIME] } },
66
+ features: {
67
+ resources: [examplesWidget.resource],
68
+ ui: { mimeTypes: [UI_MIME] },
69
+ instructions:
70
+ "nifra's own documentation, runnable examples, and API types - for building with the nifra framework. Call nifra_docs / nifra_example / nifra_types / nifra_learn to learn it without a local checkout.",
71
+ },
67
72
  })
68
73
  }
69
74