@nifrajs/cli 3.3.0 → 3.5.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 (118) hide show
  1. package/dist/check-diagnostics.d.ts +3 -15
  2. package/dist/check-diagnostics.d.ts.map +1 -1
  3. package/dist/check-diagnostics.js +149 -713
  4. package/dist/check-diagnostics.js.map +1 -1
  5. package/dist/check-scan.d.ts +9 -0
  6. package/dist/check-scan.d.ts.map +1 -1
  7. package/dist/check-scan.js +32 -0
  8. package/dist/check-scan.js.map +1 -1
  9. package/dist/check.d.ts +1 -0
  10. package/dist/check.d.ts.map +1 -1
  11. package/dist/check.js +63 -8
  12. package/dist/check.js.map +1 -1
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +46 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/command-catalog.d.ts +4 -1
  17. package/dist/command-catalog.d.ts.map +1 -1
  18. package/dist/command-catalog.js +28 -7
  19. package/dist/command-catalog.js.map +1 -1
  20. package/dist/contracts.d.ts +3 -2
  21. package/dist/contracts.d.ts.map +1 -1
  22. package/dist/contracts.js +4 -3
  23. package/dist/contracts.js.map +1 -1
  24. package/dist/diagnostics.d.ts +86 -0
  25. package/dist/diagnostics.d.ts.map +1 -1
  26. package/dist/diagnostics.js +85 -0
  27. package/dist/diagnostics.js.map +1 -1
  28. package/dist/introspect.d.ts +1 -3
  29. package/dist/introspect.d.ts.map +1 -1
  30. package/dist/introspect.js +1 -47
  31. package/dist/introspect.js.map +1 -1
  32. package/dist/load.d.ts +5 -0
  33. package/dist/load.d.ts.map +1 -1
  34. package/dist/load.js.map +1 -1
  35. package/dist/mcp-context.d.ts.map +1 -1
  36. package/dist/mcp-context.js +4 -5
  37. package/dist/mcp-context.js.map +1 -1
  38. package/dist/mcp-exec.d.ts +1 -0
  39. package/dist/mcp-exec.d.ts.map +1 -1
  40. package/dist/mcp-exec.js +133 -25
  41. package/dist/mcp-exec.js.map +1 -1
  42. package/dist/mcp-http.d.ts.map +1 -1
  43. package/dist/mcp-http.js +4 -1
  44. package/dist/mcp-http.js.map +1 -1
  45. package/dist/mcp-io.d.ts +12 -0
  46. package/dist/mcp-io.d.ts.map +1 -1
  47. package/dist/mcp-io.js +68 -0
  48. package/dist/mcp-io.js.map +1 -1
  49. package/dist/mcp-path.d.ts +10 -0
  50. package/dist/mcp-path.d.ts.map +1 -0
  51. package/dist/mcp-path.js +51 -0
  52. package/dist/mcp-path.js.map +1 -0
  53. package/dist/mcp-reflect.d.ts.map +1 -1
  54. package/dist/mcp-reflect.js +2 -2
  55. package/dist/mcp-reflect.js.map +1 -1
  56. package/dist/mcp-render.d.ts.map +1 -1
  57. package/dist/mcp-render.js +62 -43
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-root.d.ts.map +1 -1
  60. package/dist/mcp-root.js +14 -2
  61. package/dist/mcp-root.js.map +1 -1
  62. package/dist/mcp-run.d.ts.map +1 -1
  63. package/dist/mcp-run.js +82 -51
  64. package/dist/mcp-run.js.map +1 -1
  65. package/dist/mcp.d.ts +2 -2
  66. package/dist/mcp.d.ts.map +1 -1
  67. package/dist/mcp.js +84 -29
  68. package/dist/mcp.js.map +1 -1
  69. package/dist/project-facts.d.ts +23 -4
  70. package/dist/project-facts.d.ts.map +1 -1
  71. package/dist/project-facts.js +12 -2
  72. package/dist/project-facts.js.map +1 -1
  73. package/dist/review.d.ts +21 -0
  74. package/dist/review.d.ts.map +1 -0
  75. package/dist/review.js +819 -0
  76. package/dist/review.js.map +1 -0
  77. package/dist/rules/legacy.d.ts +3 -1
  78. package/dist/rules/legacy.d.ts.map +1 -1
  79. package/dist/rules/legacy.js +796 -34
  80. package/dist/rules/legacy.js.map +1 -1
  81. package/dist/scaffold.d.ts.map +1 -1
  82. package/dist/scaffold.js +23 -4
  83. package/dist/scaffold.js.map +1 -1
  84. package/dist/verification-plan.d.ts.map +1 -1
  85. package/dist/verification-plan.js +4 -0
  86. package/dist/verification-plan.js.map +1 -1
  87. package/dist/work-graph.d.ts +3 -0
  88. package/dist/work-graph.d.ts.map +1 -1
  89. package/dist/work-graph.js +4 -3
  90. package/dist/work-graph.js.map +1 -1
  91. package/docs/llms-full.txt +369 -21
  92. package/docs/types.json +1121 -80
  93. package/package.json +10 -9
  94. package/src/check-diagnostics.ts +181 -796
  95. package/src/check-scan.ts +36 -0
  96. package/src/check.ts +65 -12
  97. package/src/cli.ts +53 -7
  98. package/src/command-catalog.ts +33 -7
  99. package/src/contracts.ts +12 -3
  100. package/src/diagnostics.ts +179 -0
  101. package/src/introspect.ts +3 -55
  102. package/src/load.ts +5 -0
  103. package/src/mcp-context.ts +4 -6
  104. package/src/mcp-exec.ts +142 -30
  105. package/src/mcp-http.ts +4 -1
  106. package/src/mcp-io.ts +73 -0
  107. package/src/mcp-path.ts +50 -0
  108. package/src/mcp-reflect.ts +3 -2
  109. package/src/mcp-render.ts +70 -40
  110. package/src/mcp-root.ts +13 -2
  111. package/src/mcp-run.ts +90 -50
  112. package/src/mcp.ts +87 -27
  113. package/src/project-facts.ts +37 -8
  114. package/src/review.ts +983 -0
  115. package/src/rules/legacy.ts +915 -35
  116. package/src/scaffold.ts +29 -4
  117. package/src/verification-plan.ts +14 -0
  118. package/src/work-graph.ts +13 -3
package/src/check-scan.ts CHANGED
@@ -470,6 +470,42 @@ export function scanFetchText(
470
470
  return out
471
471
  }
472
472
 
473
+ // `new EventSource(` / `new WebSocket(` - a hand-rolled subscription to this app's own streaming
474
+ // surface. `new` is required so a local binding named `WebSocket` (a wrapper, a mock) stays quiet;
475
+ // The word boundary excludes member calls and identifiers such as `renew WebSocket(`; `new` keeps
476
+ // local wrapper bindings quiet while still matching the platform constructors.
477
+ const STREAM_CONSTRUCT = /(?<![\w$])new\s+(?:EventSource|WebSocket)\s*\(/g
478
+
479
+ /** Scan one file's text for hand-rolled own-API `EventSource`/`WebSocket` constructions. Pure +
480
+ * line-accurate. Same contract as {@link scanFetchText}: only a relative-path first argument
481
+ * (`"/…"` not `"//…"`, no scheme) is this app's API, so absolute `ws(s)://` / `http(s)://` URLs
482
+ * stay quiet exactly like absolute fetch URLs do. `externalMounts` and the relative-URL rule behave
483
+ * identically. A relative `new WebSocket("/…")` throws in browsers (the WS constructor needs an
484
+ * absolute URL), so a hit is either broken or bypassing `api.*.ws()` - either way the typed call
485
+ * (`api.*.subscribe()` for `app.sse()` routes, `api.*.ws()` for `app.ws()` routes) is the fix, and
486
+ * it keeps the compiler catching drift. */
487
+ export function scanStreamText(
488
+ file: string,
489
+ content: string,
490
+ externalMounts: readonly string[] = [],
491
+ ): SourceFinding[] {
492
+ const out: SourceFinding[] = []
493
+ const lines = content.split("\n")
494
+ const code = codePositionMask(content)
495
+ STREAM_CONSTRUCT.lastIndex = 0
496
+ for (let m = STREAM_CONSTRUCT.exec(code); m !== null; m = STREAM_CONSTRUCT.exec(code)) {
497
+ const argument = firstArgumentIndex(content, m.index + m[0].length)
498
+ const quote = content[argument]
499
+ if (quote !== "'" && quote !== '"' && quote !== "`") continue
500
+ if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
501
+ if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
502
+ continue
503
+ const line = lineAt(content, m.index)
504
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
505
+ }
506
+ return out
507
+ }
508
+
473
509
  /** Statically collect simple Nifra route registrations from source, without importing app code. */
474
510
  export function scanStaticRouteText(
475
511
  file: string,
package/src/check.ts CHANGED
@@ -26,11 +26,13 @@ import {
26
26
  collectCheckDiagnostics,
27
27
  type RuleOverride,
28
28
  } from "./check-diagnostics.ts"
29
+ import { checkContractsLock, DEFAULT_CONTRACTS_LOCK } from "./contracts.ts"
30
+ import { type Diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
29
31
  import { createSourceFacts } from "./internal/source-facts.ts"
30
32
  import { importProjectTypeScript, type TypeScriptApi } from "./internal/typescript-import.ts"
31
33
  // Type-only: `pipeline-report.ts` imports this module's source scanners, so a value import here would
32
34
  // close a cycle. Doctor is what actually runs the collector (see the `pipeline` rule below).
33
- import type { ProjectFactsSeed } from "./project-facts.ts"
35
+ import type { ContractCheckFacts, ProjectFactsSeed } from "./project-facts.ts"
34
36
  import { RULE_CODES } from "./rules/codes.ts"
35
37
  import { sourceIndex } from "./rules/index.ts"
36
38
 
@@ -59,6 +61,7 @@ import {
59
61
  scanResponseRoutes,
60
62
  scanServerManifestDrift,
61
63
  scanStaticRouteText,
64
+ scanStreamText,
62
65
  scanUntypedClient,
63
66
  type TransitiveServerImportFinding,
64
67
  walkSource,
@@ -230,10 +233,30 @@ interface CheckCollectionOptions {
230
233
 
231
234
  interface ProjectScan {
232
235
  readonly facts: ProjectFactsSeed
233
- readonly typecheck: TypecheckResult
234
- readonly sqlCompiler: TypeScriptApi | undefined
235
- readonly checkConfigError?: string
236
- readonly checkConfigWarnings: readonly string[]
236
+ }
237
+
238
+ async function collectContractFacts(cwd: string): Promise<ContractCheckFacts> {
239
+ const hasBackend = existsSync(join(cwd, "backend.ts"))
240
+ const hasLock = existsSync(join(cwd, DEFAULT_CONTRACTS_LOCK))
241
+ if (!hasBackend && !hasLock) return { present: false, vacuous: false, diagnostics: [] }
242
+ try {
243
+ const result = await checkContractsLock(cwd)
244
+ return {
245
+ present: result.present,
246
+ vacuous: result.vacuous,
247
+ diagnostics: result.diagnostics,
248
+ }
249
+ } catch (error) {
250
+ if (error instanceof Error && error.message === "contract source not configured") {
251
+ return { present: false, vacuous: false, diagnostics: [] }
252
+ }
253
+ return {
254
+ present: hasLock,
255
+ vacuous: false,
256
+ diagnostics: [],
257
+ error: error instanceof Error ? error.message : String(error),
258
+ }
259
+ }
237
260
  }
238
261
 
239
262
  /** Build the rule input from one source walk and the other check-wide scans. */
@@ -242,6 +265,7 @@ async function buildProjectScan(
242
265
  opts: Pick<CheckCollectionOptions, "lintsOnly" | "signal" | "loadTypeScript">,
243
266
  ): Promise<ProjectScan> {
244
267
  const fetches: SourceFinding[] = []
268
+ const streams: SourceFinding[] = []
245
269
  const staticRoutes: StaticRouteFinding[] = []
246
270
  const untypedClients: SourceFinding[] = []
247
271
  const removedImports: SourceFinding[] = []
@@ -259,13 +283,14 @@ async function buildProjectScan(
259
283
  const sourceFacts = sqlCompiler === undefined ? undefined : createSourceFacts(sqlCompiler)
260
284
  const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
261
285
 
262
- const [typecheckResult, _, doctor, manifestDrift] = await Promise.all([
286
+ const [typecheckResult, _, doctor, manifestDrift, contracts] = await Promise.all([
263
287
  opts.lintsOnly
264
288
  ? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "lints-only mode" })
265
289
  : typecheck(cwd, opts.signal),
266
290
  walkSource(cwd, (rel, content) => {
267
291
  sourceFiles.push({ file: rel, content })
268
292
  fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
293
+ streams.push(...scanStreamText(rel, content, checkConfig.externalMounts))
269
294
  staticRoutes.push(...scanStaticRouteText(rel, content, sourceFacts))
270
295
  untypedClients.push(...scanUntypedClient(rel, content))
271
296
  removedImports.push(...scanRemovedImports(rel, content))
@@ -276,6 +301,7 @@ async function buildProjectScan(
276
301
  }),
277
302
  import("./doctor.ts").then((m) => m.collectDoctorResult(cwd)),
278
303
  scanServerManifestDrift(cwd),
304
+ collectContractFacts(cwd),
279
305
  ])
280
306
 
281
307
  const resolveModule: ModuleResolver = (fromFile, specifier) => {
@@ -307,14 +333,24 @@ async function buildProjectScan(
307
333
  packages: { doctor, manifestDrift },
308
334
  ...(doctor.pipeline === undefined ? {} : { pipeline: doctor.pipeline }),
309
335
  policies: { checkConfig, rulePacks: [] },
310
- sourceFindings: { fetches, untypedClients, removedImports, responseRoutes, interpolatedSql },
336
+ check: {
337
+ typecheck: typecheckResult,
338
+ sqlCompilerAvailable: sqlCompiler !== undefined,
339
+ ...(checkConfigError === undefined ? {} : { checkConfigError }),
340
+ checkConfigWarnings,
341
+ contracts,
342
+ },
343
+ sourceFindings: {
344
+ fetches,
345
+ streams,
346
+ untypedClients,
347
+ removedImports,
348
+ responseRoutes,
349
+ interpolatedSql,
350
+ },
311
351
  }
312
352
  return {
313
353
  facts,
314
- typecheck: typecheckResult,
315
- sqlCompiler,
316
- ...(checkConfigError === undefined ? {} : { checkConfigError }),
317
- checkConfigWarnings,
318
354
  }
319
355
  }
320
356
 
@@ -334,7 +370,7 @@ export async function collectCheckResult(
334
370
  * its rule code, so a finding that can flip the exit code is never invisible in the default output. */
335
371
  const REPORT_SECTIONS = [
336
372
  ["typecheck", "typecheck"],
337
- ["typed-client", "hand-rolled fetch() to your own API"],
373
+ ["typed-client", "hand-rolled fetch()/EventSource/WebSocket to your own API"],
338
374
  ["untyped-client", 'client("…") missing its <typeof app> type argument'],
339
375
  ["server-only-import", "server-only import in a route module"],
340
376
  ["interpolated-sql", "SQL built by interpolating a value into the statement"],
@@ -468,12 +504,29 @@ export async function runCheck(
468
504
  readonly json?: boolean
469
505
  readonly lintsOnly?: boolean
470
506
  readonly structured?: boolean
507
+ readonly sarif?: boolean
471
508
  } = {},
472
509
  ): Promise<boolean> {
473
510
  // The check view over the one project verification: the same collector `assure` and `levels` read.
474
511
  const { collectProjectVerification } = await import("./verification.ts")
475
512
  const verification = await collectProjectVerification(cwd, { lintsOnly: opts.lintsOnly ?? false })
476
513
  const result = await verification.check()
514
+ if (opts.sarif) {
515
+ const diagnostics: readonly Diagnostic[] =
516
+ result.structuredDiagnostics ??
517
+ result.diagnostics.map((value) => ({
518
+ code: value.code ?? value.rule,
519
+ severity: normalizeSeverity(value.severity),
520
+ message: value.message,
521
+ ...(value.file === undefined ? {} : { file: value.file }),
522
+ ...(value.line === undefined ? {} : { line: value.line }),
523
+ ...(value.evidence === undefined ? {} : { evidence: value.evidence }),
524
+ ...(value.fix === undefined ? {} : { fix: { recipe: value.fix } }),
525
+ ...(value.verify === undefined ? {} : { verify: value.verify }),
526
+ }))
527
+ console.log(JSON.stringify(toSarifLog(diagnostics), null, 2))
528
+ return result.ok
529
+ }
477
530
  if (opts.json) {
478
531
  console.log(
479
532
  JSON.stringify(
package/src/cli.ts CHANGED
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"
15
15
  import { inProcessClient } from "@nifrajs/client"
16
16
  import {
17
17
  type CreateWebAppOptions,
18
+ type CssLoadingMode,
18
19
  createWebApp,
19
20
  DEFAULT_DEV_PORT,
20
21
  type RenderAdapter,
@@ -128,9 +129,11 @@ Usage:
128
129
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
129
130
  nifra learn [<step>] Print the guided build-an-app path (the human view of nifra_learn):
130
131
  no arg for the step index, a number for one step's goal/do/verify.
131
- nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
132
+ nifra check [--json] [--sarif] [--lints-only]
133
+ Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
132
134
  server-only imports in routes/). Run as "done"; --json for agents;
133
- --lints-only skips tsc for a near-instant inner-loop pass.
135
+ --sarif emits the same stable diagnostics as SARIF 2.1.0 for external
136
+ review tools; --lints-only skips tsc for a near-instant inner-loop pass.
134
137
  nifra verify [--release] [--json] Run the shared repository verification gate. --release runs the
135
138
  full build, test, coverage, corpus, consumer, and cross-runtime gate.
136
139
  nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
@@ -216,7 +219,7 @@ set in the process environment is never overwritten by a file.
216
219
  ${renderCommandCatalogHelp()}`
217
220
 
218
221
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
219
- const CLI_VERSION = "3.3.0"
222
+ const CLI_VERSION = "3.5.0"
220
223
 
221
224
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
222
225
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -562,20 +565,46 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
562
565
  if (flags.vite && flags.bun) {
563
566
  throw new Error("[nifra] `nifra build` takes `--vite` or `--bun`, not both.")
564
567
  }
568
+ const { framework: fw } = app
569
+ const cssCodeSplitRequested = fw.cssCodeSplit === false
570
+ const hasBunTransforms =
571
+ app.resolvedPlugins.clientPlugins.length > 0 || app.resolvedPlugins.serverPlugins.length > 0
572
+ if (cssCodeSplitRequested && flags.bun) {
573
+ throw new Error(
574
+ "[nifra] `cssCodeSplit: false` is a Vite-only production option, but `nifra build --bun` was requested. " +
575
+ "Drop `--bun` (Nifra will select Vite automatically) or remove the aggregate CSS setting.",
576
+ )
577
+ }
578
+ if (cssCodeSplitRequested && hasBunTransforms) {
579
+ throw new Error(
580
+ "[nifra] `cssCodeSplit: false` requires the Vite production pipeline, but this app also declares " +
581
+ "Bun client/server plugins. Use `--vite` with Vite equivalents, or remove the aggregate CSS setting; " +
582
+ "switching pipelines would silently drop the Bun transforms.",
583
+ )
584
+ }
565
585
  // Which bundler builds this app. Default is Bun; an app whose ONLY transforms are `vitePlugins` gets
566
586
  // Vite instead, because the Bun build cannot run them and would silently omit the work. `--vite`/`--bun`
567
587
  // force it, and the one forced combination that would drop transforms throws.
568
- const decision = chooseBuildPipeline(
588
+ let decision = chooseBuildPipeline(
569
589
  app.resolvedPlugins,
570
590
  flags.vite ? "vite" : flags.bun ? "bun" : undefined,
571
591
  )
592
+ if (cssCodeSplitRequested && decision.pipeline === "bun") {
593
+ // With no Bun transforms, aggregate CSS is a Vite-only request that can be honored safely by
594
+ // selecting the same pipeline the user would otherwise have to force explicitly.
595
+ decision = {
596
+ pipeline: "vite",
597
+ why: "auto",
598
+ reason: "auto: this app requested Vite's aggregate CSS output (`cssCodeSplit: false`)",
599
+ }
600
+ }
572
601
  const useVite = decision.pipeline === "vite"
573
602
  // Same deploy-dir output - buildTargetVite delegates to the same orchestrator as buildTarget - so only
574
603
  // the bundler and the plugin FORMAT (Vite plugins, not Bun) differ.
575
604
  const buildTarget = useVite
576
605
  ? (await import("@nifrajs/web/build-vite")).buildTargetVite
577
606
  : (await import("@nifrajs/web/build")).buildTarget
578
- const { framework: fw, routesDir, outDir, cwd, backend } = app
607
+ const { routesDir, outDir, cwd, backend } = app
579
608
  // The server entry must import the adapter from `framework.ts` (edge-safe), not the loaded config.
580
609
  // `loadApp` guarantees one of them exists; prefer framework.ts so a multi-target app's Vite-plugin
581
610
  // config never reaches the edge bundle (see load.ts module header).
@@ -611,6 +640,8 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
611
640
  ...(fw.define ? { define: fw.define } : {}),
612
641
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
613
642
  ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
643
+ ...(fw.cssCodeSplit !== undefined ? { cssCodeSplit: fw.cssCodeSplit } : {}),
644
+ ...(fw.cssLoading !== undefined ? { cssLoading: fw.cssLoading } : {}),
614
645
  // The static target needs a built app to drive prerendering - only build it when targeting static.
615
646
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
616
647
  })
@@ -647,6 +678,7 @@ async function buildPrerenderApp(
647
678
  ...(client.routes ? { routePreload: client.routes } : {}),
648
679
  ...(client.css ? { styles: client.css } : {}),
649
680
  ...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
681
+ ...(client.cssLoading !== undefined ? { cssLoading: client.cssLoading } : {}),
650
682
  ...apiOf(backend),
651
683
  })
652
684
  }
@@ -656,6 +688,7 @@ interface BuiltManifest {
656
688
  readonly routes?: Readonly<Record<string, readonly string[]>>
657
689
  readonly css?: readonly string[]
658
690
  readonly routeStyles?: Readonly<Record<string, readonly string[]>>
691
+ readonly cssLoading?: CssLoadingMode
659
692
  }
660
693
 
661
694
  async function start(app: LoadedApp, flags: Flags): Promise<void> {
@@ -836,14 +869,27 @@ async function main(): Promise<void> {
836
869
  } else {
837
870
  console.log(catalogSpec.render(output, input).join("\n"))
838
871
  }
839
- if (catalogSpec.success !== undefined && !catalogSpec.success(output, input))
872
+ if (catalogSpec.exitCode !== undefined) {
873
+ process.exitCode = catalogSpec.exitCode(output, input)
874
+ } else if (catalogSpec.success !== undefined && !catalogSpec.success(output, input)) {
840
875
  process.exitCode = 1
876
+ }
841
877
  } catch (err) {
842
878
  // A thrown error is nifra's own reporting path, not a silent abort: clear the sentinel so the
843
879
  // hint stays reserved for the case where the process died inside the app import.
844
880
  markReflecting(undefined)
845
881
  console.error(formatCliError(err))
846
- process.exitCode = 1
882
+ const candidate =
883
+ typeof err === "object" && err !== null && "exitCode" in err
884
+ ? (err as { readonly exitCode?: unknown }).exitCode
885
+ : undefined
886
+ process.exitCode =
887
+ typeof candidate === "number" &&
888
+ Number.isInteger(candidate) &&
889
+ candidate >= 0 &&
890
+ candidate <= 255
891
+ ? candidate
892
+ : 1
847
893
  }
848
894
  return
849
895
  }
@@ -15,12 +15,14 @@ import type {
15
15
  } from "./capabilities-tool.ts"
16
16
  import { type CheckResult, renderCheckReport } from "./check.ts"
17
17
  import type { ContractsLock } from "./contracts.ts"
18
+ import { type Diagnostic, diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
18
19
  import type { DoctorResult } from "./doctor.ts"
19
20
  import type { VerificationLevelsResult } from "./levels-tool.ts"
20
21
  import type { LoadedApp } from "./load.ts"
21
22
  import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
22
23
  import { collectPortResult, type PortResult, renderReport } from "./port.ts"
23
24
  import type { ReplayResult } from "./replay.ts"
25
+ import { reviewSpec } from "./review.ts"
24
26
  import type { StylexMigrationResult } from "./stylex-migrate.ts"
25
27
  import {
26
28
  collectProjectWorkGraph,
@@ -80,6 +82,8 @@ export interface CommandSpec<Input, Output> {
80
82
  readonly run: (input: Input, ctx: CommandCtx) => Promise<Output>
81
83
  readonly render: (out: Output, input?: Input) => readonly string[]
82
84
  readonly success?: (out: Output, input: Input) => boolean
85
+ /** Optional command-specific process status. Legacy commands retain the boolean fallback. */
86
+ readonly exitCode?: (out: Output, input: Input) => number
83
87
  /** JSON-compatible view. Defaults to the raw command result. */
84
88
  readonly json?: (out: Output, input: Input) => unknown
85
89
  }
@@ -240,6 +244,7 @@ function parseBooleanFlags(
240
244
  interface CheckInput {
241
245
  readonly lintsOnly?: boolean | undefined
242
246
  readonly json?: boolean | undefined
247
+ readonly sarif?: boolean | undefined
243
248
  readonly maxDiagnostics?: number | undefined
244
249
  readonly dir?: string | undefined
245
250
  }
@@ -429,6 +434,7 @@ const CHECK_SCHEMA = input<CheckInput>(
429
434
  objectSchema({
430
435
  lintsOnly: { type: "boolean" },
431
436
  json: { type: "boolean" },
437
+ sarif: { type: "boolean" },
432
438
  maxDiagnostics: { type: "integer", minimum: 1 },
433
439
  dir: { type: "string" },
434
440
  }),
@@ -438,7 +444,7 @@ const CHECK_SCHEMA = input<CheckInput>(
438
444
  if (maxDiagnostics !== undefined && (!Number.isInteger(maxDiagnostics) || maxDiagnostics < 1))
439
445
  throw new TypeError("maxDiagnostics must be a positive integer")
440
446
  return {
441
- ...parseBooleanFlags(raw, ["lintsOnly", "json"]),
447
+ ...parseBooleanFlags(raw, ["lintsOnly", "json", "sarif"]),
442
448
  ...(maxDiagnostics === undefined ? {} : { maxDiagnostics }),
443
449
  ...(raw.dir === undefined ? {} : { dir: raw.dir }),
444
450
  } as CheckInput
@@ -789,10 +795,25 @@ function loadAppFor(ctx: CommandCtx): Promise<LoadedApp> {
789
795
  return import("./load.ts").then(({ loadApp }) => loadApp(ctx.cwd))
790
796
  }
791
797
 
792
- function checkJson(out: CheckCommandOutput): unknown {
793
- return out.structuredDiagnostics === undefined
794
- ? out
795
- : { ...out, diagnostics: out.structuredDiagnostics }
798
+ function structuredDiagnostics(out: CheckCommandOutput): readonly Diagnostic[] {
799
+ if (out.structuredDiagnostics !== undefined) return out.structuredDiagnostics
800
+ return out.diagnostics.map((value) =>
801
+ diagnostic({
802
+ code: value.code ?? value.rule,
803
+ severity: normalizeSeverity(value.severity),
804
+ message: value.message,
805
+ ...(value.file === undefined ? {} : { file: value.file }),
806
+ ...(value.line === undefined ? {} : { line: value.line }),
807
+ ...(value.evidence === undefined ? {} : { evidence: value.evidence }),
808
+ ...(value.fix === undefined ? {} : { fix: { recipe: value.fix } }),
809
+ ...(value.verify === undefined ? {} : { verify: value.verify }),
810
+ }),
811
+ )
812
+ }
813
+
814
+ function checkJson(out: CheckCommandOutput, input?: CheckInput): unknown {
815
+ if (input?.sarif === true) return toSarifLog(structuredDiagnostics(out))
816
+ return { ...out, diagnostics: structuredDiagnostics(out) }
796
817
  }
797
818
 
798
819
  const checkSpec: CommandSpec<CheckInput, CheckCommandOutput> = {
@@ -810,6 +831,7 @@ const checkSpec: CommandSpec<CheckInput, CheckCommandOutput> = {
810
831
  flags: [
811
832
  { name: "lints-only", field: "lintsOnly", type: "boolean" },
812
833
  { name: "json", field: "json", type: "boolean" },
834
+ { name: "sarif", field: "sarif", type: "boolean" },
813
835
  ],
814
836
  },
815
837
  async run(value, ctx) {
@@ -822,9 +844,12 @@ const checkSpec: CommandSpec<CheckInput, CheckCommandOutput> = {
822
844
  })
823
845
  ).check()
824
846
  },
825
- render: (out) => renderCheckReport(out),
847
+ render: (out, input) =>
848
+ input?.sarif === true
849
+ ? JSON.stringify(checkJson(out, input), null, 2).split("\n")
850
+ : renderCheckReport(out),
826
851
  success: (out) => out.ok,
827
- json: (out) => checkJson(out),
852
+ json: (out, input) => checkJson(out, input),
828
853
  }
829
854
 
830
855
  const assureSpec: CommandSpec<AssureInput, AssureCommandOutput> = {
@@ -1512,6 +1537,7 @@ const portSpec: CommandSpec<PortInput, PortResult> = {
1512
1537
 
1513
1538
  export const commandSpecs = Object.freeze([
1514
1539
  checkSpec,
1540
+ reviewSpec,
1515
1541
  assureSpec,
1516
1542
  levelsSpec,
1517
1543
  capabilitiesSpec,
package/src/contracts.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
- import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
3
+ import {
4
+ type ProjectEvidenceSnapshot,
5
+ reflectedRoutesFromEvidence,
6
+ snapshotProjectEvidence,
7
+ } from "@nifrajs/core/evidence"
8
+ import type { ReflectedRoute } from "@nifrajs/core/reflection"
4
9
 
5
10
  export interface ContractDigest {
6
11
  readonly request: string
@@ -85,10 +90,14 @@ export async function isVacuousLock(lock: ContractsLock): Promise<boolean> {
85
90
  return digests.every((d) => d.request === empty && d.response === empty)
86
91
  }
87
92
 
88
- export async function buildContractsLock(source: unknown): Promise<ContractsLock> {
93
+ export async function buildContractsLock(
94
+ source: unknown,
95
+ evidence?: ProjectEvidenceSnapshot,
96
+ ): Promise<ContractsLock> {
97
+ const reflected = reflectedRoutesFromEvidence(evidence ?? snapshotProjectEvidence(source))
89
98
  const routes = Object.fromEntries(
90
99
  await Promise.all(
91
- reflectRoutes(source).map(
100
+ reflected.map(
92
101
  async (route) => [`${route.method} ${route.path}`, await digestRoute(route)] as const,
93
102
  ),
94
103
  ),
@@ -7,6 +7,32 @@ export interface DiagnosticFix {
7
7
  readonly command?: string
8
8
  }
9
9
 
10
+ /**
11
+ * The richer compatibility view used by the original `nifra check` renderer. It is deliberately
12
+ * kept out of the enumerable diagnostic shape: SARIF, MCP structured output, and JSON consumers get
13
+ * the small stable diagnostic contract, while the legacy text view can still explain an exact edit.
14
+ */
15
+ export interface DiagnosticCompatibility {
16
+ /** The pre-registry rule name used by the human/legacy check result. */
17
+ readonly rule: string
18
+ /** The old human-facing fix text, when the rule had one. */
19
+ readonly fix?: string
20
+ /** A richer legacy suggestion (diff, command, or manual steps). */
21
+ readonly suggestion?: DiagnosticSuggestion
22
+ /** The import path evidence used by the legacy server-only-import view. */
23
+ readonly chain?: readonly string[]
24
+ /** Preserve the old optional `CheckDiagnostic.code` field exactly where it existed. */
25
+ readonly includeCode?: boolean
26
+ }
27
+
28
+ export interface DiagnosticSuggestion {
29
+ readonly kind: "edit" | "command" | "manual"
30
+ readonly title: string
31
+ readonly diff?: string
32
+ readonly command?: readonly string[]
33
+ readonly steps?: readonly string[]
34
+ }
35
+
10
36
  export interface Diagnostic {
11
37
  /** Stable forever. Never renumber or reuse. */
12
38
  readonly code: string
@@ -21,6 +47,159 @@ export interface Diagnostic {
21
47
  readonly verify?: string
22
48
  }
23
49
 
50
+ const DIAGNOSTIC_COMPATIBILITY = Symbol("nifra.diagnosticCompatibility")
51
+
52
+ /** Attach legacy-only rendering metadata without widening the serialized diagnostic protocol. */
53
+ export function diagnosticWithCompatibility(
54
+ value: Diagnostic,
55
+ compatibility: DiagnosticCompatibility,
56
+ ): Diagnostic {
57
+ const result = { ...value }
58
+ Object.defineProperty(result, DIAGNOSTIC_COMPATIBILITY, {
59
+ value: Object.freeze({
60
+ ...compatibility,
61
+ ...(compatibility.chain === undefined
62
+ ? {}
63
+ : { chain: Object.freeze([...compatibility.chain]) }),
64
+ }),
65
+ enumerable: false,
66
+ })
67
+ return Object.freeze(result)
68
+ }
69
+
70
+ /** Read legacy-only rendering metadata. Undefined means the diagnostic has no compatibility view. */
71
+ export function diagnosticCompatibilityOf(value: Diagnostic): DiagnosticCompatibility | undefined {
72
+ return (
73
+ value as Diagnostic & {
74
+ readonly [DIAGNOSTIC_COMPATIBILITY]?: DiagnosticCompatibility
75
+ }
76
+ )[DIAGNOSTIC_COMPATIBILITY]
77
+ }
78
+
79
+ /** The subset of SARIF 2.1.0 emitted by {@link toSarifLog}. */
80
+ export interface SarifLog {
81
+ readonly $schema: "https://json.schemastore.org/sarif-2.1.0.json"
82
+ readonly version: "2.1.0"
83
+ readonly runs: readonly SarifRun[]
84
+ }
85
+
86
+ export interface SarifRun {
87
+ readonly tool: {
88
+ readonly driver: {
89
+ readonly name: string
90
+ readonly version?: string
91
+ readonly rules: readonly SarifRule[]
92
+ }
93
+ }
94
+ readonly results: readonly SarifResult[]
95
+ }
96
+
97
+ export interface SarifRule {
98
+ readonly id: string
99
+ readonly shortDescription: { readonly text: string }
100
+ }
101
+
102
+ export interface SarifResult {
103
+ readonly ruleId: string
104
+ readonly level: "error" | "warning" | "note"
105
+ readonly message: { readonly text: string }
106
+ readonly locations?: readonly SarifLocation[]
107
+ readonly properties?: Readonly<Record<string, string | readonly string[]>>
108
+ }
109
+
110
+ export interface SarifLocation {
111
+ readonly physicalLocation: {
112
+ readonly artifactLocation: {
113
+ readonly uri: string
114
+ readonly uriBaseId?: string
115
+ }
116
+ readonly region?: { readonly startLine: number }
117
+ }
118
+ }
119
+
120
+ export interface SarifProjectionOptions {
121
+ /** Name shown by external review tools in the SARIF run metadata. Defaults to `nifra`. */
122
+ readonly toolName?: string
123
+ /** Optional CLI/framework version shown by external review tools. */
124
+ readonly toolVersion?: string
125
+ /** Base URI identifier for relative diagnostic file paths. */
126
+ readonly uriBaseId?: string
127
+ }
128
+
129
+ function sarifLevel(severity: Severity): SarifResult["level"] {
130
+ return severity === "error" ? "error" : severity === "warn" ? "warning" : "note"
131
+ }
132
+
133
+ function sarifProperties(
134
+ value: Diagnostic,
135
+ ): Readonly<Record<string, string | readonly string[]>> | undefined {
136
+ const properties: Record<string, string | readonly string[]> = {}
137
+ if (value.evidence !== undefined && value.evidence.length > 0)
138
+ properties["nifra.evidence"] = [...value.evidence]
139
+ if (value.verify !== undefined) properties["nifra.verify"] = value.verify
140
+ if (value.fix !== undefined) {
141
+ properties["nifra.fix.recipe"] = value.fix.recipe
142
+ if (value.fix.command !== undefined) properties["nifra.fix.command"] = value.fix.command
143
+ }
144
+ return Object.keys(properties).length === 0 ? undefined : properties
145
+ }
146
+
147
+ /**
148
+ * Project stable Nifra diagnostics into SARIF for code-host and external review surfaces.
149
+ *
150
+ * This is deliberately a pure projection: it does not run checks, read files, or reinterpret policy.
151
+ * Relative file names remain relative so callers can choose the review tool's checkout base through
152
+ * {@link SarifProjectionOptions.uriBaseId}.
153
+ */
154
+ export function toSarifLog(
155
+ diagnostics: readonly Diagnostic[],
156
+ options: SarifProjectionOptions = {},
157
+ ): SarifLog {
158
+ const rules = new Map<string, SarifRule>()
159
+ const results: SarifResult[] = []
160
+ for (const value of diagnostics) {
161
+ if (!rules.has(value.code)) {
162
+ rules.set(value.code, {
163
+ id: value.code,
164
+ shortDescription: { text: value.message },
165
+ })
166
+ }
167
+ const location =
168
+ value.file === undefined
169
+ ? undefined
170
+ : {
171
+ physicalLocation: {
172
+ artifactLocation: {
173
+ uri: value.file.replaceAll("\\", "/"),
174
+ ...(options.uriBaseId === undefined ? {} : { uriBaseId: options.uriBaseId }),
175
+ },
176
+ ...(value.line !== undefined && Number.isInteger(value.line) && value.line >= 1
177
+ ? { region: { startLine: value.line } }
178
+ : {}),
179
+ },
180
+ }
181
+ const properties = sarifProperties(value)
182
+ results.push({
183
+ ruleId: value.code,
184
+ level: sarifLevel(value.severity),
185
+ message: { text: value.message },
186
+ ...(location === undefined ? {} : { locations: [location] }),
187
+ ...(properties === undefined ? {} : { properties }),
188
+ })
189
+ }
190
+
191
+ const driver = {
192
+ name: options.toolName ?? "nifra",
193
+ ...(options.toolVersion === undefined ? {} : { version: options.toolVersion }),
194
+ rules: [...rules.values()],
195
+ }
196
+ return {
197
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
198
+ version: "2.1.0",
199
+ runs: [{ tool: { driver }, results }],
200
+ }
201
+ }
202
+
24
203
  export const diagnostic = (value: Diagnostic): Diagnostic => Object.freeze(value)
25
204
 
26
205
  export function severityFails(severity: Severity, strict = false): boolean {