@nifrajs/cli 2.13.0 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/capabilities-tool.d.ts +54 -1
  2. package/dist/capabilities-tool.d.ts.map +1 -1
  3. package/dist/capabilities-tool.js +146 -18
  4. package/dist/capabilities-tool.js.map +1 -1
  5. package/dist/check.d.ts +11 -11
  6. package/dist/check.d.ts.map +1 -1
  7. package/dist/check.js +234 -43
  8. package/dist/check.js.map +1 -1
  9. package/dist/cli.d.ts +11 -0
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +91 -413
  12. package/dist/cli.js.map +1 -1
  13. package/dist/command-catalog.d.ts +244 -0
  14. package/dist/command-catalog.d.ts.map +1 -0
  15. package/dist/command-catalog.js +1014 -0
  16. package/dist/command-catalog.js.map +1 -0
  17. package/dist/contracts.d.ts +9 -0
  18. package/dist/contracts.d.ts.map +1 -1
  19. package/dist/contracts.js +22 -6
  20. package/dist/contracts.js.map +1 -1
  21. package/dist/doctor.d.ts +13 -0
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +18 -2
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/load.d.ts.map +1 -1
  26. package/dist/load.js +22 -5
  27. package/dist/load.js.map +1 -1
  28. package/dist/manifest-tool.d.ts +15 -1
  29. package/dist/manifest-tool.d.ts.map +1 -1
  30. package/dist/manifest-tool.js +23 -9
  31. package/dist/manifest-tool.js.map +1 -1
  32. package/dist/mcp-http.js +1 -1
  33. package/dist/mcp.d.ts +8 -0
  34. package/dist/mcp.d.ts.map +1 -1
  35. package/dist/mcp.js +59 -349
  36. package/dist/mcp.js.map +1 -1
  37. package/dist/project-facts.d.ts +40 -0
  38. package/dist/project-facts.d.ts.map +1 -0
  39. package/dist/project-facts.js +36 -0
  40. package/dist/project-facts.js.map +1 -0
  41. package/dist/rules/comment-markers.d.ts +8 -0
  42. package/dist/rules/comment-markers.d.ts.map +1 -1
  43. package/dist/rules/comment-markers.js +36 -0
  44. package/dist/rules/comment-markers.js.map +1 -1
  45. package/dist/rules/index.d.ts +2 -3
  46. package/dist/rules/index.d.ts.map +1 -1
  47. package/dist/rules/index.js.map +1 -1
  48. package/dist/rules/routes.d.ts.map +1 -1
  49. package/dist/rules/routes.js +12 -14
  50. package/dist/rules/routes.js.map +1 -1
  51. package/dist/rules/security.js +14 -14
  52. package/dist/rules/security.js.map +1 -1
  53. package/docs/types.json +27 -14
  54. package/package.json +9 -9
  55. package/src/capabilities-tool.ts +229 -21
  56. package/src/check.ts +285 -59
  57. package/src/cli.ts +100 -404
  58. package/src/command-catalog.ts +1415 -0
  59. package/src/contracts.ts +28 -7
  60. package/src/doctor.ts +35 -4
  61. package/src/load.ts +25 -6
  62. package/src/manifest-tool.ts +43 -12
  63. package/src/mcp-http.ts +1 -1
  64. package/src/mcp.ts +82 -414
  65. package/src/project-facts.ts +86 -0
  66. package/src/rules/comment-markers.ts +36 -0
  67. package/src/rules/index.ts +3 -4
  68. package/src/rules/routes.ts +13 -14
  69. package/src/rules/security.ts +14 -14
package/src/contracts.ts CHANGED
@@ -41,8 +41,7 @@ async function sha256(value: string): Promise<string> {
41
41
  return [...digest].map((item) => item.toString(16).padStart(2, "0")).join("")
42
42
  }
43
43
 
44
- function schemaValue(route: ReflectedRoute, side: "request" | "response"): unknown {
45
- const schema = route.schema
44
+ function schemaValue(schema: ReflectedRoute["schema"], side: "request" | "response"): unknown {
46
45
  if (schema === undefined) return {}
47
46
  if (side === "request") {
48
47
  return {
@@ -64,11 +63,28 @@ function schemaValue(route: ReflectedRoute, side: "request" | "response"): unkno
64
63
  }
65
64
 
66
65
  export async function digestRoute(route: ReflectedRoute): Promise<ContractDigest> {
67
- const request = JSON.stringify(canonical(schemaValue(route, "request")))
68
- const response = JSON.stringify(canonical(schemaValue(route, "response")))
66
+ const request = JSON.stringify(canonical(schemaValue(route.schema, "request")))
67
+ const response = JSON.stringify(canonical(schemaValue(route.schema, "response")))
69
68
  return { request: await sha256(request), response: await sha256(response) }
70
69
  }
71
70
 
71
+ /** The digest a route with NO declared schema produces on both sides - `sha256("{}")`. A lock whose
72
+ * every route hashes to this is guarding nothing: drift detection can only ever compare empty schema to
73
+ * empty schema. Computed through the same canonical/stringify path as {@link digestRoute} so it tracks
74
+ * that path rather than a hard-coded literal. */
75
+ export async function emptySchemaDigest(): Promise<string> {
76
+ return sha256(JSON.stringify(canonical(schemaValue(undefined, "request"))))
77
+ }
78
+
79
+ /** Whether every route in `lock` hashes to the empty-schema digest (so no route declares a schema). An
80
+ * empty `routes` map is NOT vacuous in this sense - there is no route whose contract went unguarded. */
81
+ export async function isVacuousLock(lock: ContractsLock): Promise<boolean> {
82
+ const digests = Object.values(lock.routes)
83
+ if (digests.length === 0) return false
84
+ const empty = await emptySchemaDigest()
85
+ return digests.every((d) => d.request === empty && d.response === empty)
86
+ }
87
+
72
88
  export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
73
89
  const routes = Object.fromEntries(
74
90
  await Promise.all(
@@ -131,11 +147,16 @@ export function parseContractsLock(value: unknown): ContractsLock {
131
147
  export async function checkContractsLock(
132
148
  cwd: string,
133
149
  file = DEFAULT_CONTRACTS_LOCK,
134
- ): Promise<{ present: boolean; diagnostics: Array<{ route?: string; message: string }> }> {
150
+ ): Promise<{
151
+ present: boolean
152
+ vacuous: boolean
153
+ diagnostics: Array<{ route?: string; message: string }>
154
+ }> {
135
155
  const path = resolve(cwd, file)
136
- if (!existsSync(path)) return { present: false, diagnostics: [] }
156
+ if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
137
157
  const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
138
158
  const current = await buildContractsLock(await loadBackend(cwd))
159
+ const vacuous = await isVacuousLock(current)
139
160
  const diagnostics: Array<{ route?: string; message: string }> = []
140
161
  const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
141
162
  for (const key of [...keys].sort()) {
@@ -153,5 +174,5 @@ export async function checkContractsLock(
153
174
  })
154
175
  }
155
176
  }
156
- return { present: true, diagnostics }
177
+ return { present: true, vacuous, diagnostics }
157
178
  }
package/src/doctor.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  resolvedInstalledCopy,
29
29
  } from "@nifrajs/web/internal/parity"
30
30
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
31
+ import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
31
32
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
32
33
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
33
34
 
@@ -144,6 +145,14 @@ export interface DoctorResult {
144
145
  * Advisory (never folded into `ok`): while actively editing a linked package its dist is always
145
146
  * momentarily behind - the finding matters when a dev server starts against it. */
146
147
  readonly staleDists: readonly StaleDistFinding[]
148
+ /**
149
+ * The running CLI is a different feature version than the `@nifrajs/cli` (or `@nifrajs/core`) the
150
+ * project installs. Advisory (never folded into `ok`): a stale global/bunx binary answering about a
151
+ * project it does not match is an environment problem, not a defect in the project - but every
152
+ * answer it gives (types, checks, docs) reads as authoritative. Present only when `cliVersion` was
153
+ * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
154
+ */
155
+ readonly toolingDrift?: ToolingDrift
147
156
  /** Static production-readiness evidence for the selected deploy target. */
148
157
  readonly readiness?: DoctorReadiness
149
158
  /**
@@ -672,7 +681,7 @@ async function inferDependencyFix(
672
681
  /** Run doctor against the project at `cwd`: diff source imports vs declared deps. */
673
682
  export async function collectDoctorResult(
674
683
  cwd: string,
675
- opts: { readonly target?: string; readonly strict?: boolean } = {},
684
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
676
685
  ): Promise<DoctorResult> {
677
686
  const pkg = await readJson(join(cwd, "package.json"))
678
687
  if (pkg === undefined)
@@ -700,7 +709,12 @@ export async function collectDoctorResult(
700
709
  const pipeline = await collectPipelineReport(cwd)
701
710
  const pipelineErrors = pipeline.findings.filter((f) => f.severity === "error")
702
711
  const readiness = await collectDoctorReadiness(cwd, opts)
703
- // `staleDists` is advisory (see DoctorResult): it never fails `ok`.
712
+ // A stale binary answering about a project it does not match is an environment condition, so like
713
+ // `staleDists` it is reported but never folded into `ok`. Skipped entirely when the caller did not
714
+ // pass its own version (e.g. the MCP server, which already annotates every result with the drift).
715
+ const toolingDrift =
716
+ opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
717
+ // `staleDists` and `toolingDrift` are advisory (see DoctorResult): they never fail `ok`.
704
718
  return {
705
719
  ok:
706
720
  findings.length === 0 &&
@@ -712,6 +726,7 @@ export async function collectDoctorResult(
712
726
  duplicateInstalls,
713
727
  staleDists,
714
728
  readiness,
729
+ ...(toolingDrift !== undefined ? { toolingDrift } : {}),
715
730
  ...(pipeline.ran ? { pipeline } : {}),
716
731
  }
717
732
  }
@@ -719,7 +734,7 @@ export async function collectDoctorResult(
719
734
  /** Safely add undeclared imports to package.json when the version can be inferred without network I/O. */
720
735
  export async function applyDoctorAutoFix(
721
736
  cwd: string,
722
- opts: { readonly target?: string; readonly strict?: boolean } = {},
737
+ opts: { readonly target?: string; readonly strict?: boolean; readonly cliVersion?: string } = {},
723
738
  ): Promise<DoctorResult> {
724
739
  const before = await collectDoctorResult(cwd, opts)
725
740
  if (!before.ran || before.findings.length === 0) return before
@@ -798,6 +813,8 @@ export async function runDoctor(
798
813
  readonly autoFix?: boolean
799
814
  readonly strict?: boolean
800
815
  readonly target?: string
816
+ /** The running CLI's own version, so doctor can flag a binary that mismatches the project. */
817
+ readonly cliVersion?: string
801
818
  } = {},
802
819
  ): Promise<boolean> {
803
820
  const result = opts.autoFix
@@ -872,6 +889,19 @@ export async function runDoctor(
872
889
  }
873
890
  console.log("")
874
891
  }
892
+ // Advisory, printed regardless of ok: the binary answering is a different feature version than the
893
+ // one the project builds with, so its types/checks/docs describe a surface the code does not have.
894
+ if (result.toolingDrift !== undefined) {
895
+ const drift = result.toolingDrift
896
+ console.log(
897
+ `⚠ this CLI is nifra ${drift.cli}, but the project installs ${drift.package} ${drift.project} - ` +
898
+ "its types, checks, and docs may describe a different version than your code builds with.",
899
+ )
900
+ console.log(
901
+ " fix: run the project's own CLI (`bunx --bun nifra doctor` from the project directory, " +
902
+ "or ./node_modules/.bin/nifra)\n",
903
+ )
904
+ }
875
905
  if (result.ok) {
876
906
  console.log(
877
907
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
@@ -897,7 +927,8 @@ export async function runDoctor(
897
927
  `${byPkg.size > 0 ? "\n" : ""}✗ identity-sensitive packages resolve to multiple physical copies:\n`,
898
928
  )
899
929
  for (const finding of result.duplicateInstalls) {
900
- console.log(` ${finding.package}`)
930
+ console.log(` ${finding.package} [${finding.cause}]`)
931
+ console.log(` versions: ${finding.versions.join(", ")}`)
901
932
  for (const copy of finding.copies) {
902
933
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
903
934
  }
package/src/load.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
14
14
  */
15
15
  import { existsSync } from "node:fs"
16
- import { resolve } from "node:path"
16
+ import { isAbsolute, resolve } from "node:path"
17
17
  import { checkPipelineSeparation } from "./pipeline-guard.ts"
18
18
 
19
19
  /**
@@ -210,11 +210,30 @@ export async function loadApp(
210
210
  // resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
211
211
  // entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
212
212
  // specifier would resolve against different bases and load in one phase but not the other. A bare or
213
- // package specifier (`@nifrajs/web-react/client`) is location-independent and passes through unchanged.
214
- const clientModule =
215
- fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")
216
- ? resolve(cwd, fw.clientModule)
217
- : fw.clientModule
213
+ // package specifier (`@nifrajs/web-react/client`) or an already-absolute path is location-independent
214
+ // and passes through unchanged.
215
+ let clientModule: string
216
+ if (fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")) {
217
+ clientModule = resolve(cwd, fw.clientModule)
218
+ } else {
219
+ // A specifier that is neither `./`-relative, scoped (`@…`), nor absolute is read as a BARE PACKAGE
220
+ // specifier - resolved against `node_modules`, not `cwd`. If a real local file sits at that path
221
+ // (a `./` was forgotten: `src/client.tsx` instead of `./src/client.tsx`), the bundler would ignore
222
+ // the file, fail to find a package, and surface an opaque "cannot resolve" deep in the build. Reject
223
+ // it here with the one-character fix rather than letting it fail silently.
224
+ if (
225
+ !isAbsolute(fw.clientModule) &&
226
+ !fw.clientModule.startsWith("@") &&
227
+ existsSync(resolve(cwd, fw.clientModule))
228
+ ) {
229
+ throw new Error(
230
+ `[nifra] clientModule "${fw.clientModule}" has no "./" prefix, so it is read as a bare package ` +
231
+ `specifier - but a local file exists at that path. Prefix it with "./" (e.g. ` +
232
+ `"./${fw.clientModule}") so \`nifra dev\` and \`nifra build\` resolve the same local entry.`,
233
+ )
234
+ }
235
+ clientModule = fw.clientModule
236
+ }
218
237
  const [vitePlugins, clientPlugins, serverPlugins] = await Promise.all([
219
238
  resolvePlugins(fw.vitePlugins),
220
239
  resolvePlugins(fw.clientPlugins),
@@ -5,6 +5,7 @@ import { resolve } from "node:path"
5
5
  import {
6
6
  buildNifraManifest,
7
7
  diffNifraManifests,
8
+ type NifraManifest,
8
9
  type NifraManifestDiff,
9
10
  parseNifraManifest,
10
11
  serializeNifraManifest,
@@ -38,11 +39,18 @@ export function formatManifestDiff(diff: NifraManifestDiff): string {
38
39
  return lines.join("\n")
39
40
  }
40
41
 
41
- /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
42
- export async function runManifestEmit(
42
+ export interface ManifestEmitCommandResult {
43
+ readonly ok: boolean
44
+ readonly path: string
45
+ readonly manifest?: NifraManifest
46
+ readonly signaturePath?: string
47
+ }
48
+
49
+ /** Emit the manifest without printing. This is the execution seam used by all adapters. */
50
+ export async function collectManifestEmit(
43
51
  cwd: string,
44
52
  options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
45
- ): Promise<boolean> {
53
+ ): Promise<ManifestEmitCommandResult> {
46
54
  const verification = await collectProjectVerification(cwd, {
47
55
  ...(options.config !== undefined ? { config: options.config } : {}),
48
56
  })
@@ -58,9 +66,9 @@ export async function runManifestEmit(
58
66
  if (assurance === undefined) {
59
67
  throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
60
68
  }
69
+ const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
61
70
  if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
62
- console.error("[nifra] refusing to emit a manifest from failing assurance")
63
- return false
71
+ return { ok: false, path }
64
72
  }
65
73
  const manifest = await buildNifraManifest({
66
74
  source: config.source,
@@ -68,9 +76,9 @@ export async function runManifestEmit(
68
76
  assurance,
69
77
  ...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
70
78
  })
71
- const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
72
79
  await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
73
80
 
81
+ let signaturePath: string | undefined
74
82
  if (options.sign !== undefined) {
75
83
  if (config.manifest?.signer === undefined) {
76
84
  throw new Error(
@@ -79,29 +87,52 @@ export async function runManifestEmit(
79
87
  }
80
88
  const signer = await config.manifest.signer(options.sign)
81
89
  const signature = await signNifraManifest(manifest, signer)
82
- await Bun.write(`${path}.sig`, `${serializeNifraManifestSignature(signature)}\n`)
90
+ signaturePath = `${path}.sig`
91
+ await Bun.write(signaturePath, `${serializeNifraManifestSignature(signature)}\n`)
92
+ }
93
+ return { ok: true, path, manifest, ...(signaturePath === undefined ? {} : { signaturePath }) }
94
+ }
95
+
96
+ /** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
97
+ export async function runManifestEmit(
98
+ cwd: string,
99
+ options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
100
+ ): Promise<boolean> {
101
+ const result = await collectManifestEmit(cwd, options)
102
+ if (!result.ok) {
103
+ console.error("[nifra] refusing to emit a manifest from failing assurance")
104
+ return false
83
105
  }
84
106
  console.log(
85
- `[nifra] wrote manifest to ${path}${options.sign === undefined ? "" : ` and ${path}.sig`}`,
107
+ `[nifra] wrote manifest to ${result.path}${result.signaturePath === undefined ? "" : ` and ${result.signaturePath}`}`,
86
108
  )
87
109
  return true
88
110
  }
89
111
 
90
112
  /** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
91
- export async function runManifestDiff(
113
+ export async function collectManifestDiff(
92
114
  cwd: string,
93
115
  beforePath: string,
94
116
  afterPath: string,
95
- options: { readonly json?: boolean } = {},
96
- ): Promise<boolean> {
117
+ ): Promise<NifraManifestDiff> {
97
118
  const before = resolve(cwd, beforePath)
98
119
  const after = resolve(cwd, afterPath)
99
120
  if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
100
121
  if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
101
- const diff = diffNifraManifests(
122
+ return diffNifraManifests(
102
123
  await parseNifraManifest(await Bun.file(before).text(), before),
103
124
  await parseNifraManifest(await Bun.file(after).text(), after),
104
125
  )
126
+ }
127
+
128
+ /** Diff two already-emitted, hash-verified artifacts and print the result. */
129
+ export async function runManifestDiff(
130
+ cwd: string,
131
+ beforePath: string,
132
+ afterPath: string,
133
+ options: { readonly json?: boolean } = {},
134
+ ): Promise<boolean> {
135
+ const diff = await collectManifestDiff(cwd, beforePath, afterPath)
105
136
  console.log(
106
137
  options.json === true
107
138
  ? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
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.13.0"
32
+ const VERSION = "2.14.1"
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 =>