@nifrajs/cli 1.12.0 → 2.0.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/README.md +17 -2
- package/dist/check.d.ts +17 -6
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +256 -15
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +58 -71
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +131 -61
- package/dist/doctor.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +42 -0
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/2.0.0.d.ts +9 -0
- package/dist/recipes/2.0.0.d.ts.map +1 -0
- package/dist/recipes/2.0.0.js +25 -0
- package/dist/recipes/2.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/sync-manifest.d.ts +33 -0
- package/dist/sync-manifest.d.ts.map +1 -0
- package/dist/sync-manifest.js +97 -0
- package/dist/sync-manifest.js.map +1 -0
- package/dist/upgrade.d.ts +23 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +144 -13
- package/dist/upgrade.js.map +1 -1
- package/docs/examples.json +69 -21
- package/docs/llms-full.txt +1065 -104
- package/docs/types.json +933 -181
- package/package.json +9 -9
- package/src/check.ts +251 -18
- package/src/cli.ts +57 -73
- package/src/doctor.ts +160 -61
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +52 -0
- package/src/recipes/2.0.0.ts +26 -0
- package/src/recipes/index.ts +10 -0
- package/src/sync-manifest.ts +111 -0
- package/src/upgrade.ts +163 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@nifrajs/client": "
|
|
45
|
-
"@nifrajs/core": "
|
|
46
|
-
"@nifrajs/mcp": "
|
|
47
|
-
"@nifrajs/runner": "
|
|
48
|
-
"@nifrajs/schema": "
|
|
49
|
-
"@nifrajs/testing": "
|
|
50
|
-
"@nifrajs/web": "
|
|
51
|
-
"create-nifra": "
|
|
44
|
+
"@nifrajs/client": "2.0.0",
|
|
45
|
+
"@nifrajs/core": "2.0.0",
|
|
46
|
+
"@nifrajs/mcp": "2.0.0",
|
|
47
|
+
"@nifrajs/runner": "2.0.0",
|
|
48
|
+
"@nifrajs/schema": "2.0.0",
|
|
49
|
+
"@nifrajs/testing": "2.0.0",
|
|
50
|
+
"@nifrajs/web": "2.0.0",
|
|
51
|
+
"create-nifra": "2.0.0"
|
|
52
52
|
},
|
|
53
53
|
"repository": {
|
|
54
54
|
"type": "git",
|
package/src/check.ts
CHANGED
|
@@ -25,9 +25,6 @@ export interface SourceFinding {
|
|
|
25
25
|
readonly line: number
|
|
26
26
|
readonly snippet: string
|
|
27
27
|
}
|
|
28
|
-
/** @deprecated kept for back-compat with existing tests; prefer {@link SourceFinding}. */
|
|
29
|
-
export type FetchFinding = SourceFinding
|
|
30
|
-
|
|
31
28
|
/** A server-only-import finding, carrying the offending module specifier on top of the base location so
|
|
32
29
|
* the diagnostic can show the import chain `routeFile → specifier` (the direct edge the regex scan sees). */
|
|
33
30
|
export interface ServerImportFinding extends SourceFinding {
|
|
@@ -39,7 +36,7 @@ export interface ServerImportFinding extends SourceFinding {
|
|
|
39
36
|
// i.e. same-origin = this app's own API. `(?<![.\w])` skips `.fetch(` (a method) and `prefetch(`; the
|
|
40
37
|
// `(?!\/)` skips protocol-relative `//host` (an external origin). A variable arg (`fetch(url)`) is left
|
|
41
38
|
// alone on purpose — undecidable from source, and flagging it would punish legitimate external calls.
|
|
42
|
-
const
|
|
39
|
+
const GLOBAL_FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
43
40
|
const FETCH_CALL = /(?<![.\w])fetch\s*\(/g
|
|
44
41
|
const HTTP_VERBS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
45
42
|
const SIMPLE_REWRITE_METHODS = new Set(["GET", "DELETE", "HEAD", "OPTIONS"])
|
|
@@ -279,12 +276,163 @@ export function stripComments(src: string): string {
|
|
|
279
276
|
return out.join("")
|
|
280
277
|
}
|
|
281
278
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
279
|
+
/** Blank comments and every quoted/template literal while preserving offsets. Unlike
|
|
280
|
+
* {@link stripComments}, this is a code-position mask: scanners use it to find a call expression in
|
|
281
|
+
* executable code, then inspect the original source for the call's literal argument. That prevents
|
|
282
|
+
* documentation strings such as `const example = 'client("/")'` from becoming diagnostics. */
|
|
283
|
+
export function codePositionMask(src: string): string {
|
|
284
|
+
const out = src.split("")
|
|
285
|
+
const n = src.length
|
|
286
|
+
let i = 0
|
|
287
|
+
const blank = (a: number, b: number): void => {
|
|
288
|
+
for (let k = a; k < b; k++) if (out[k] !== "\n") out[k] = " "
|
|
289
|
+
}
|
|
290
|
+
while (i < n) {
|
|
291
|
+
const c = src[i]
|
|
292
|
+
const d = src[i + 1]
|
|
293
|
+
if (c === "/" && d === "/") {
|
|
294
|
+
let j = i + 2
|
|
295
|
+
while (j < n && src[j] !== "\n") j++
|
|
296
|
+
blank(i, j)
|
|
297
|
+
i = j
|
|
298
|
+
} else if (c === "/" && d === "*") {
|
|
299
|
+
let j = i + 2
|
|
300
|
+
while (j < n && !(src[j] === "*" && src[j + 1] === "/")) j++
|
|
301
|
+
j = Math.min(j + 2, n)
|
|
302
|
+
blank(i, j)
|
|
303
|
+
i = j
|
|
304
|
+
} else if (c === "'" || c === '"' || c === "`") {
|
|
305
|
+
const quote = c
|
|
306
|
+
let j = i + 1
|
|
307
|
+
while (j < n && src[j] !== quote) {
|
|
308
|
+
if (src[j] === "\\") j++
|
|
309
|
+
j++
|
|
310
|
+
}
|
|
311
|
+
j = Math.min(j + 1, n)
|
|
312
|
+
blank(i, j)
|
|
313
|
+
i = j
|
|
314
|
+
} else {
|
|
315
|
+
i++
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out.join("")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const firstArgumentIndex = (content: string, callEnd: number): number => {
|
|
322
|
+
let index = callEnd
|
|
323
|
+
while (/\s/.test(content[index] ?? "")) index++
|
|
324
|
+
return index
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Read the literal URL path of a `fetch("/…")` call starting at `start` (the index of the leading `/`),
|
|
328
|
+
// stopping at the first quote / query / hash / template-expression boundary, and test it against the
|
|
329
|
+
// allowlist. A template like `/auth/${id}` matches on its literal head (`/auth`), so a dynamic sign-in URL
|
|
330
|
+
// is covered too. Match is segment-anchored - `/auth` blesses `/auth` and `/auth/**`, never `/authors`.
|
|
331
|
+
function matchesExternalMount(content: string, start: number, mounts: readonly string[]): boolean {
|
|
332
|
+
let end = start
|
|
333
|
+
while (end < content.length) {
|
|
334
|
+
const ch = content[end]
|
|
335
|
+
if (ch === "'" || ch === '"' || ch === "`" || ch === "?" || ch === "#") break
|
|
336
|
+
if (ch === "$" && content[end + 1] === "{") break
|
|
337
|
+
end++
|
|
338
|
+
}
|
|
339
|
+
const path = content.slice(start, end)
|
|
340
|
+
// A `..` segment means the runtime path escapes the literal prefix (`/auth/../api/admin` resolves to
|
|
341
|
+
// `/api/admin`), so it must NOT be blessed by an `/auth` mount - never let traversal hide a real own-API
|
|
342
|
+
// fetch. Match is otherwise segment-anchored (`/auth` blesses `/auth` and `/auth/**`, never `/authors`).
|
|
343
|
+
try {
|
|
344
|
+
if (path.split("/").some((segment) => decodeURIComponent(segment) === "..")) return false
|
|
345
|
+
} catch {
|
|
346
|
+
// A malformed escape is not a prefix we can prove stays inside the external mount.
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
return mounts.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function findTopLevelPropertyColon(src: string): number | undefined {
|
|
353
|
+
let depth = 0
|
|
354
|
+
let quote: '"' | "'" | "`" | undefined
|
|
355
|
+
for (let i = 0; i < src.length; i++) {
|
|
356
|
+
const c = src[i]
|
|
357
|
+
if (quote !== undefined) {
|
|
358
|
+
if (c === "\\") {
|
|
359
|
+
i++
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
if (c === quote) quote = undefined
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
366
|
+
quote = c
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
if (c === "(" || c === "{" || c === "[") depth++
|
|
370
|
+
else if (c === ")" || c === "}" || c === "]") depth--
|
|
371
|
+
else if (c === ":" && depth === 0) return i
|
|
372
|
+
}
|
|
373
|
+
return undefined
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function parseStaticPropertyName(raw: string): string | undefined {
|
|
377
|
+
const name = raw.trim()
|
|
378
|
+
if (IDENT.test(name)) return name
|
|
379
|
+
const quoted = parseQuotedLiteral(name)
|
|
380
|
+
if (quoted !== undefined) return quoted
|
|
381
|
+
if (name.startsWith("[") && name.endsWith("]")) {
|
|
382
|
+
return parseQuotedLiteral(name.slice(1, -1).trim())
|
|
383
|
+
}
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Return a statically named property from a plain object literal. Deliberately does not follow
|
|
388
|
+
* spreads, identifiers, or helper calls: only an inline literal proves this fetch is Nifra's page-data
|
|
389
|
+
* transport rather than an arbitrary same-origin API request. */
|
|
390
|
+
function staticObjectProperty(raw: string | undefined, name: string): string | undefined {
|
|
391
|
+
if (raw === undefined) return undefined
|
|
392
|
+
const object = raw.trim()
|
|
393
|
+
if (!object.startsWith("{") || !object.endsWith("}")) return undefined
|
|
394
|
+
const properties = splitTopLevelArgs(object.slice(1, -1))
|
|
395
|
+
if (properties === undefined) return undefined
|
|
396
|
+
for (const property of properties) {
|
|
397
|
+
const colon = findTopLevelPropertyColon(property)
|
|
398
|
+
if (colon === undefined || parseStaticPropertyName(property.slice(0, colon)) !== name) continue
|
|
399
|
+
return property.slice(colon + 1).trim()
|
|
400
|
+
}
|
|
401
|
+
return undefined
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function hasLiteralNifraDataHeader(content: string, openParen: number): boolean {
|
|
405
|
+
const closeParen = findMatchingParen(content, openParen)
|
|
406
|
+
if (closeParen === undefined) return false
|
|
407
|
+
const args = splitTopLevelArgs(content.slice(openParen + 1, closeParen))
|
|
408
|
+
if (args === undefined || args.length !== 2) return false
|
|
409
|
+
const headers = staticObjectProperty(args[1], "headers")
|
|
410
|
+
return staticObjectProperty(headers, "x-nifra-data") !== undefined
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Scan one file's text for hand-rolled own-API `fetch()` calls. Pure + line-accurate. `externalMounts`
|
|
414
|
+
* are intentional non-typed mount prefixes (from `nifra.check.json`, e.g. a mounted better-auth owning
|
|
415
|
+
* `/auth/**`) - a relative fetch into one is deliberate, not drift, so it's skipped. A fetch with an
|
|
416
|
+
* inline literal `x-nifra-data` header is Nifra's page-loader transport, not a backend API call, and is
|
|
417
|
+
* skipped too. */
|
|
418
|
+
export function scanFetchText(
|
|
419
|
+
file: string,
|
|
420
|
+
content: string,
|
|
421
|
+
externalMounts: readonly string[] = [],
|
|
422
|
+
): SourceFinding[] {
|
|
284
423
|
const out: SourceFinding[] = []
|
|
285
424
|
const lines = content.split("\n")
|
|
286
|
-
|
|
287
|
-
|
|
425
|
+
const code = codePositionMask(content)
|
|
426
|
+
GLOBAL_FETCH_CALL.lastIndex = 0
|
|
427
|
+
for (let m = GLOBAL_FETCH_CALL.exec(code); m !== null; m = GLOBAL_FETCH_CALL.exec(code)) {
|
|
428
|
+
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
429
|
+
const quote = content[argument]
|
|
430
|
+
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
431
|
+
if (content[argument + 1] !== "/" || content[argument + 2] === "/") continue
|
|
432
|
+
const openParen = m.index + m[0].lastIndexOf("(")
|
|
433
|
+
if (hasLiteralNifraDataHeader(content, openParen)) continue
|
|
434
|
+
if (externalMounts.length > 0 && matchesExternalMount(content, argument + 1, externalMounts))
|
|
435
|
+
continue
|
|
288
436
|
const line = lineAt(content, m.index)
|
|
289
437
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
290
438
|
}
|
|
@@ -305,14 +453,18 @@ export function scanStaticRouteText(file: string, content: string): StaticRouteF
|
|
|
305
453
|
// the compiler has nothing to derive types from, so the anti-drift guarantee silently vanishes.
|
|
306
454
|
// `client<typeof app>("…")` has `<…>` between the name and `(` so it never matches; the published-
|
|
307
455
|
// contract form `client(contract, url)` starts with an identifier, not a quote — also unmatched.
|
|
308
|
-
const
|
|
456
|
+
const CLIENT_CALL = /(?<![.\w])client\s*\(/g
|
|
309
457
|
|
|
310
458
|
/** Scan one file's text for untyped `client("…")` calls. Pure + line-accurate. */
|
|
311
459
|
export function scanUntypedClient(file: string, content: string): SourceFinding[] {
|
|
312
460
|
const out: SourceFinding[] = []
|
|
313
461
|
const lines = content.split("\n")
|
|
314
|
-
|
|
315
|
-
|
|
462
|
+
const code = codePositionMask(content)
|
|
463
|
+
CLIENT_CALL.lastIndex = 0
|
|
464
|
+
for (let m = CLIENT_CALL.exec(code); m !== null; m = CLIENT_CALL.exec(code)) {
|
|
465
|
+
const argument = firstArgumentIndex(content, m.index + m[0].length)
|
|
466
|
+
const quote = content[argument]
|
|
467
|
+
if (quote !== "'" && quote !== '"' && quote !== "`") continue
|
|
316
468
|
const line = lineAt(content, m.index)
|
|
317
469
|
out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
|
|
318
470
|
}
|
|
@@ -326,8 +478,11 @@ export function scanServerOnlyImports(file: string, content: string): ServerImpo
|
|
|
326
478
|
if (!ROUTE_FILE.test(file)) return []
|
|
327
479
|
const out: ServerImportFinding[] = []
|
|
328
480
|
const lines = content.split("\n")
|
|
481
|
+
const code = stripComments(content)
|
|
482
|
+
const positions = codePositionMask(content)
|
|
329
483
|
STATIC_IMPORT.lastIndex = 0
|
|
330
|
-
for (let m = STATIC_IMPORT.exec(
|
|
484
|
+
for (let m = STATIC_IMPORT.exec(code); m !== null; m = STATIC_IMPORT.exec(code)) {
|
|
485
|
+
if (positions[m.index] === " ") continue
|
|
331
486
|
const specifier = m[1] ?? ""
|
|
332
487
|
if (!SERVER_ONLY.test(specifier)) continue
|
|
333
488
|
const line = lineAt(content, m.index)
|
|
@@ -379,7 +534,10 @@ const staticImportRegex = (): RegExp => new RegExp(STATIC_IMPORT.source, STATIC_
|
|
|
379
534
|
function staticImportEdges(content: string): Array<{ specifier: string; index: number }> {
|
|
380
535
|
const edges: Array<{ specifier: string; index: number }> = []
|
|
381
536
|
const re = staticImportRegex()
|
|
382
|
-
|
|
537
|
+
const code = stripComments(content)
|
|
538
|
+
const positions = codePositionMask(content)
|
|
539
|
+
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
540
|
+
if (positions[m.index] === " ") continue
|
|
383
541
|
if (m[1] !== undefined) edges.push({ specifier: m[1], index: m.index })
|
|
384
542
|
}
|
|
385
543
|
return edges
|
|
@@ -574,11 +732,24 @@ export function scanResponseRoutes(file: string, content: string): SourceFinding
|
|
|
574
732
|
RESPONSE_RETURN.lastIndex = 0
|
|
575
733
|
for (let m = RESPONSE_RETURN.exec(code); m !== null; m = RESPONSE_RETURN.exec(code)) {
|
|
576
734
|
const line = lineAt(content, m.index)
|
|
577
|
-
|
|
735
|
+
// A `// nifra-expect raw-response` pragma marks an intentional raw Response (a file/redirect that can't
|
|
736
|
+
// be a typed route), silencing this advisory. Honor it on the return line itself, OR on the line above
|
|
737
|
+
// ONLY when that line is a standalone comment - so a TRAILING pragma on one route's return line can't
|
|
738
|
+
// leak down and suppress the very next route's genuine advisory. `stripComments` blanks the pragma in
|
|
739
|
+
// `code` (so it never affects the match); the raw `lines` still carry it.
|
|
740
|
+
const thisLine = lines[line - 1] ?? ""
|
|
741
|
+
const aboveLine = lines[line - 2] ?? ""
|
|
742
|
+
const aboveSuppresses =
|
|
743
|
+
aboveLine.trimStart().startsWith("//") && aboveLine.includes(RAW_RESPONSE_PRAGMA)
|
|
744
|
+
if (thisLine.includes(RAW_RESPONSE_PRAGMA) || aboveSuppresses) continue
|
|
745
|
+
out.push({ file, line, snippet: thisLine.trim() })
|
|
578
746
|
}
|
|
579
747
|
return out
|
|
580
748
|
}
|
|
581
749
|
|
|
750
|
+
/** The opt-out pragma that suppresses the {@link scanResponseRoutes} advisory for an intentional raw Response. */
|
|
751
|
+
const RAW_RESPONSE_PRAGMA = "nifra-expect raw-response"
|
|
752
|
+
|
|
582
753
|
/** Walk the project's `.ts`/`.tsx` source (skipping deps/build/tests), calling `visit` per file.
|
|
583
754
|
* Exported so `nifra doctor` ({@link ./doctor.ts}) scans the same source surface as `nifra check`. */
|
|
584
755
|
/**
|
|
@@ -634,7 +805,7 @@ export async function walkSource(
|
|
|
634
805
|
const bySite = (a: SourceFinding, b: SourceFinding): number =>
|
|
635
806
|
a.file.localeCompare(b.file) || a.line - b.line
|
|
636
807
|
|
|
637
|
-
/** Collect own-API `fetch()` findings across the project
|
|
808
|
+
/** Collect own-API `fetch()` findings across the project. */
|
|
638
809
|
export async function scanProject(cwd: string): Promise<SourceFinding[]> {
|
|
639
810
|
const out: SourceFinding[] = []
|
|
640
811
|
await walkSource(cwd, (rel, content) => out.push(...scanFetchText(rel, content)))
|
|
@@ -775,6 +946,7 @@ export interface CheckDiagnostic {
|
|
|
775
946
|
| "manifest-drift"
|
|
776
947
|
| "capability-assurance"
|
|
777
948
|
| "capability-config"
|
|
949
|
+
| "check-config"
|
|
778
950
|
/** `error` fails the gate (a real contract break); `warning` is advisory — surfaced to the agent but
|
|
779
951
|
* does NOT fail `nifra check`, for patterns that are sometimes intentional (a route returning a raw
|
|
780
952
|
* `Response`, which silently drops the typed client to `data: never` but is valid for files/redirects). */
|
|
@@ -817,12 +989,43 @@ export interface CheckResult {
|
|
|
817
989
|
readonly ok: boolean
|
|
818
990
|
readonly typecheck: "pass" | "fail" | "skipped"
|
|
819
991
|
readonly diagnostics: readonly CheckDiagnostic[]
|
|
992
|
+
/** Intentional non-typed mount prefixes declared in `nifra.check.json` (e.g. `/auth` for a mounted
|
|
993
|
+
* better-auth). Echoed here so `--json` / the MCP tool / the report can show what the typed-client scan
|
|
994
|
+
* deliberately skipped - a suppressed prefix stays auditable instead of silently hiding real drift. */
|
|
995
|
+
readonly externalMounts?: readonly string[]
|
|
820
996
|
/** Set only when the caller passed `maxDiagnostics` and there were more — `diagnostics` then holds the
|
|
821
997
|
* first `shown` of `total`. It caps the serialized size so the `nifra_check` MCP tool can't emit a
|
|
822
998
|
* message large enough to break the stdio transport; fix the shown diagnostics and re-run for the rest. */
|
|
823
999
|
readonly truncated?: { readonly shown: number; readonly total: number }
|
|
824
1000
|
}
|
|
825
1001
|
|
|
1002
|
+
/** Optional per-project `nifra.check.json` - pure data (no code execution), so it's safe to read before
|
|
1003
|
+
* the app is built or even importable, preserving check's pre-`loadApp` invariant. */
|
|
1004
|
+
interface CheckConfig {
|
|
1005
|
+
readonly externalMounts: readonly string[]
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Load `nifra.check.json` if present. Fail-open + total: absent → empty; malformed → empty plus a parse
|
|
1009
|
+
* `error` the caller surfaces as a `warning` diagnostic (never throws, never blocks the gate). Mount
|
|
1010
|
+
* entries are normalized to bare path prefixes (`/auth/**` and `/auth/` both become `/auth`). */
|
|
1011
|
+
async function loadCheckConfig(cwd: string): Promise<{ config: CheckConfig; error?: string }> {
|
|
1012
|
+
const path = join(cwd, "nifra.check.json")
|
|
1013
|
+
if (!existsSync(path)) return { config: { externalMounts: [] } }
|
|
1014
|
+
try {
|
|
1015
|
+
const parsed = JSON.parse(await Bun.file(path).text()) as { externalMounts?: unknown }
|
|
1016
|
+
const raw = Array.isArray(parsed.externalMounts) ? parsed.externalMounts : []
|
|
1017
|
+
const externalMounts = raw
|
|
1018
|
+
.filter((m): m is string => typeof m === "string" && m.startsWith("/"))
|
|
1019
|
+
.map((m) => m.replace(/\/\*+$/, "").replace(/\/+$/, "") || "/")
|
|
1020
|
+
return { config: { externalMounts } }
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
return {
|
|
1023
|
+
config: { externalMounts: [] },
|
|
1024
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
826
1029
|
const UNTYPED_CLIENT_HINT =
|
|
827
1030
|
'client("…") without a type argument — write client<typeof app>("…") (or client(contract, url)) so the compiler can catch drift'
|
|
828
1031
|
const FETCH_HINT =
|
|
@@ -830,7 +1033,7 @@ const FETCH_HINT =
|
|
|
830
1033
|
const SERVER_IMPORT_HINT =
|
|
831
1034
|
"server-only import in a route module (bundled for the browser) — reach it via c.db / ctx.api inside a loader, never a top-level import"
|
|
832
1035
|
const RESPONSE_ROUTE_HINT =
|
|
833
|
-
"route handler returns a raw Response
|
|
1036
|
+
"route handler returns a raw Response - the typed client infers `data: never`, so drift detection is lost for this route. Return a plain object (it's serialized for you); for a stream use a typed SSE route (`app.sse(...)`), which keeps typed events; or, if a raw Response is intended (file/redirect), add `{ response: t.… }` or a `// nifra-expect raw-response` comment to mark it and silence this"
|
|
834
1037
|
const UNDECLARED_DEP_HINT =
|
|
835
1038
|
"imported package is not declared in package.json dependencies — run bun add to declare it"
|
|
836
1039
|
const MANIFEST_DRIFT_HINT =
|
|
@@ -965,7 +1168,8 @@ function responseRouteSuggestion(): CheckSuggestion {
|
|
|
965
1168
|
title: "Preserve typed-client response inference",
|
|
966
1169
|
steps: [
|
|
967
1170
|
"Prefer returning a plain object from JSON routes; nifra serializes it for you.",
|
|
968
|
-
"
|
|
1171
|
+
"For a stream, use a typed SSE route - `app.sse(...)` (or `sse(c, run)` from `@nifrajs/core/server`) - which keeps typed events instead of collapsing the client to `data: never`.",
|
|
1172
|
+
"If this route must return a raw Response (redirect, file), declare an explicit response schema, or add a `// nifra-expect raw-response` comment above the return to mark it intentional and silence this advisory.",
|
|
969
1173
|
],
|
|
970
1174
|
}
|
|
971
1175
|
}
|
|
@@ -990,6 +1194,9 @@ export async function collectCheckResult(
|
|
|
990
1194
|
// Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
|
|
991
1195
|
// (#4.4) — which needs fs-backed import resolution, not just per-file text — runs after the walk.
|
|
992
1196
|
const routeModules: Array<{ rel: string; content: string }> = []
|
|
1197
|
+
// Load the optional check config first - the typed-client scan needs the external-mount allowlist as it
|
|
1198
|
+
// walks. It's a tiny pure-JSON read; a malformed file surfaces as a warning below, never blocking.
|
|
1199
|
+
const { config: checkConfig, error: checkConfigError } = await loadCheckConfig(cwd)
|
|
993
1200
|
// lintsOnly: skip the tsc pass (seconds on a big project) and run just the near-instant source
|
|
994
1201
|
// lints — the agent inner-loop mode; the full gate stays the definition of done.
|
|
995
1202
|
const [tc, _, dr, manifestDrift] = await Promise.all([
|
|
@@ -997,7 +1204,7 @@ export async function collectCheckResult(
|
|
|
997
1204
|
? Promise.resolve<TypecheckResult>({ ran: false, ok: true, note: "skipped (lintsOnly)" })
|
|
998
1205
|
: typecheck(cwd, opts.signal),
|
|
999
1206
|
walkSource(cwd, (rel, content) => {
|
|
1000
|
-
fetches.push(...scanFetchText(rel, content))
|
|
1207
|
+
fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
|
|
1001
1208
|
staticRoutes.push(...scanStaticRouteText(rel, content))
|
|
1002
1209
|
untypedClients.push(...scanUntypedClient(rel, content))
|
|
1003
1210
|
if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
|
|
@@ -1034,6 +1241,15 @@ export async function collectCheckResult(
|
|
|
1034
1241
|
}
|
|
1035
1242
|
|
|
1036
1243
|
const diagnostics: CheckDiagnostic[] = []
|
|
1244
|
+
if (checkConfigError !== undefined) {
|
|
1245
|
+
diagnostics.push({
|
|
1246
|
+
rule: "check-config",
|
|
1247
|
+
severity: "warning",
|
|
1248
|
+
file: "nifra.check.json",
|
|
1249
|
+
message: `nifra.check.json could not be parsed (${checkConfigError}) - its external-mount allowlist was ignored`,
|
|
1250
|
+
fix: "Fix the JSON syntax in nifra.check.json",
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1037
1253
|
if (tc.ran && !tc.ok) {
|
|
1038
1254
|
const lines = (tc.output ?? "").split("\n")
|
|
1039
1255
|
let matched = false
|
|
@@ -1304,6 +1520,9 @@ export async function collectCheckResult(
|
|
|
1304
1520
|
ok: !diagnostics.some((diagnostic) => diagnostic.severity === "error"),
|
|
1305
1521
|
typecheck: tc.ran ? (tc.ok ? "pass" : "fail") : "skipped",
|
|
1306
1522
|
diagnostics: shown,
|
|
1523
|
+
...(checkConfig.externalMounts.length > 0
|
|
1524
|
+
? { externalMounts: checkConfig.externalMounts }
|
|
1525
|
+
: {}),
|
|
1307
1526
|
...(shown.length < total ? { truncated: { shown: shown.length, total } } : {}),
|
|
1308
1527
|
}
|
|
1309
1528
|
}
|
|
@@ -1327,6 +1546,11 @@ export async function runCheck(
|
|
|
1327
1546
|
? "✗ typecheck failed — the frontend/backend contract is broken"
|
|
1328
1547
|
: "• typecheck skipped (no tsconfig / typescript not installed)",
|
|
1329
1548
|
)
|
|
1549
|
+
if (result.externalMounts !== undefined && result.externalMounts.length > 0) {
|
|
1550
|
+
console.log(
|
|
1551
|
+
`• intentional external mounts (not typed-client checked): ${result.externalMounts.join(", ")}`,
|
|
1552
|
+
)
|
|
1553
|
+
}
|
|
1330
1554
|
const counts = (rule: CheckDiagnostic["rule"]): CheckDiagnostic[] =>
|
|
1331
1555
|
result.diagnostics.filter((d) => d.rule === rule)
|
|
1332
1556
|
for (const [rule, label] of [
|
|
@@ -1341,6 +1565,7 @@ export async function runCheck(
|
|
|
1341
1565
|
["manifest-drift", "versioned trust manifest drift"],
|
|
1342
1566
|
["capability-assurance", "effect/capability assurance"],
|
|
1343
1567
|
["capability-config", "capability assurance config"],
|
|
1568
|
+
["check-config", "nifra.check.json"],
|
|
1344
1569
|
] as const) {
|
|
1345
1570
|
const ds = counts(rule)
|
|
1346
1571
|
if (rule === "response-route") {
|
|
@@ -1369,5 +1594,13 @@ export async function runCheck(
|
|
|
1369
1594
|
}
|
|
1370
1595
|
}
|
|
1371
1596
|
console.log(result.ok ? "\n✓ check passed" : "\n✗ check failed")
|
|
1597
|
+
// Discoverability nudge: a project with no `.mcp.json` hasn't wired its nifra MCP for coding agents.
|
|
1598
|
+
// `nifra init-agents` writes it (+ .cursor/mcp.json + a CLAUDE.md preamble), no-clobber. A non-fatal
|
|
1599
|
+
// one-line tip in the human report only (the `--json` path returns above, unaffected).
|
|
1600
|
+
if (!existsSync(join(cwd, ".mcp.json"))) {
|
|
1601
|
+
console.log(
|
|
1602
|
+
"\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).",
|
|
1603
|
+
)
|
|
1604
|
+
}
|
|
1372
1605
|
return result.ok
|
|
1373
1606
|
}
|