@nifrajs/cli 3.2.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.
- package/dist/check-diagnostics.d.ts +3 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +149 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +9 -0
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +32 -0
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +63 -8
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -7
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +4 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +28 -7
- package/dist/command-catalog.js.map +1 -1
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +4 -3
- package/dist/contracts.js.map +1 -1
- package/dist/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/introspect.d.ts +1 -3
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +1 -47
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts +5 -0
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +4 -5
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +133 -25
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +4 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +12 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +68 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.js.map +1 -0
- package/dist/mcp-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +62 -43
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +14 -2
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +82 -51
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +84 -29
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +23 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- package/dist/project-facts.js.map +1 -1
- package/dist/review.d.ts +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +819 -0
- package/dist/review.js.map +1 -0
- package/dist/rules/legacy.d.ts +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +796 -34
- package/dist/rules/legacy.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +23 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +4 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/work-graph.d.ts +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +4 -3
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +10 -4
- package/docs/llms-full.txt +500 -46
- package/docs/types.json +1123 -82
- package/package.json +10 -9
- package/src/check-diagnostics.ts +181 -796
- package/src/check-scan.ts +36 -0
- package/src/check.ts +65 -12
- package/src/cli.ts +53 -7
- package/src/command-catalog.ts +33 -7
- package/src/contracts.ts +12 -3
- package/src/diagnostics.ts +179 -0
- package/src/introspect.ts +3 -55
- package/src/load.ts +5 -0
- package/src/mcp-context.ts +4 -6
- package/src/mcp-exec.ts +142 -30
- package/src/mcp-http.ts +4 -1
- package/src/mcp-io.ts +73 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +70 -40
- package/src/mcp-root.ts +13 -2
- package/src/mcp-run.ts +90 -50
- package/src/mcp.ts +87 -27
- package/src/project-facts.ts +37 -8
- package/src/review.ts +983 -0
- package/src/rules/legacy.ts +915 -35
- package/src/scaffold.ts +29 -4
- package/src/verification-plan.ts +14 -0
- 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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
--
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/command-catalog.ts
CHANGED
|
@@ -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
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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) =>
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
100
|
+
reflected.map(
|
|
92
101
|
async (route) => [`${route.method} ${route.path}`, await digestRoute(route)] as const,
|
|
93
102
|
),
|
|
94
103
|
),
|
package/src/diagnostics.ts
CHANGED
|
@@ -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 {
|