@nifrajs/cli 4.0.0 → 5.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 +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +28 -3
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.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/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/rules/data-guard.ts
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
* The server enforces both at runtime, on every request: data a loader or action returns without an
|
|
5
5
|
* output schema fails the request, and an output schema naming a sensitive field without
|
|
6
6
|
* `t.declassified` fails the route when it loads. These report what source alone shows, before a
|
|
7
|
-
* request ever does. NF-C030 is
|
|
7
|
+
* request ever does. NF-C030 is an error when the loader's own body returns a literal the runtime
|
|
8
|
+
* refuses (an object, array, string, number or boolean) and a warning otherwise, since only running
|
|
9
|
+
* the loader proves what an expression evaluates to.
|
|
8
10
|
*/
|
|
11
|
+
import { realpathSync } from "node:fs"
|
|
12
|
+
import { isAbsolute, join, relative } from "node:path"
|
|
9
13
|
import { isSensitiveFieldName } from "@nifrajs/web/zones"
|
|
10
14
|
import { codePositionMask } from "../check-scan.ts"
|
|
11
15
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
12
16
|
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
17
|
+
import { createSpecifierResolver } from "../internal/project-specifiers.ts"
|
|
13
18
|
import type { CheckRule } from "./index.ts"
|
|
14
19
|
import { zonedFiles } from "./zones.ts"
|
|
15
20
|
|
|
@@ -18,7 +23,8 @@ const lineAt = (text: string, index: number): number => text.slice(0, index).spl
|
|
|
18
23
|
const byPlace = (a: Diagnostic, b: Diagnostic): number =>
|
|
19
24
|
codeUnitOrder(a.file ?? "", b.file ?? "") || (a.line ?? 0) - (b.line ?? 0)
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
/** The export statement of `name` in a code-position mask: a declaration or an `export { }` list. */
|
|
27
|
+
export const exported = (mask: string, name: string): RegExpExecArray | null =>
|
|
22
28
|
new RegExp(
|
|
23
29
|
`\\bexport\\s+(?:(?:async\\s+)?function\\s*\\*?\\s*|(?:const|let|var)\\s+)${name}\\b|\\bexport\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`,
|
|
24
30
|
).exec(mask)
|
|
@@ -40,37 +46,129 @@ const closing = (mask: string, open: number): number => {
|
|
|
40
46
|
return mask.length
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
const opening = (mask: string, close: number): number => {
|
|
50
|
+
let depth = 0
|
|
51
|
+
for (let i = close; i >= 0; i--) {
|
|
52
|
+
const c = mask[i]
|
|
53
|
+
if (c === ")" || c === "}" || c === "]") depth++
|
|
54
|
+
else if (c === "(" || c === "{" || c === "[") {
|
|
55
|
+
depth--
|
|
56
|
+
if (depth === 0) return i
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const CONTROL_HEAD = /(?:^|[^\w$.])(?:if|for(?:\s+await)?|while|catch|switch|with)\s*$/
|
|
63
|
+
const BLOCK_WORD = /(?:^|[^\w$.])(?:else|try|finally|do)$/
|
|
64
|
+
|
|
43
65
|
/**
|
|
44
|
-
* Whether the
|
|
45
|
-
*
|
|
66
|
+
* Whether the `{` at `open` starts a statement block of the enclosing function: after a control
|
|
67
|
+
* head, `else`/`try`/`finally`/`do`, or a statement boundary. A function, method, class, object or
|
|
68
|
+
* `case` body is not one, so a `return` inside it is never counted as the outer function's.
|
|
46
69
|
*/
|
|
47
|
-
function
|
|
70
|
+
function isStatementBlock(mask: string, open: number): boolean {
|
|
71
|
+
let p = open - 1
|
|
72
|
+
while (p >= 0 && /\s/.test(mask[p] ?? "")) p--
|
|
73
|
+
const prev = mask[p]
|
|
74
|
+
if (prev === undefined || prev === ";" || prev === "{" || prev === "}") return true
|
|
75
|
+
if (prev === ")") {
|
|
76
|
+
const head = opening(mask, p)
|
|
77
|
+
return CONTROL_HEAD.test(mask.slice(Math.max(0, head - 16), head))
|
|
78
|
+
}
|
|
79
|
+
return BLOCK_WORD.test(mask.slice(Math.max(0, p - 8), p + 1))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Offsets of the `return`s that belong to the block at `open` itself, not to a nested function. */
|
|
83
|
+
function ownReturns(mask: string, open: number, close: number): number[] {
|
|
84
|
+
const own: boolean[] = []
|
|
85
|
+
const found: number[] = []
|
|
86
|
+
for (let i = open; i < close; i++) {
|
|
87
|
+
const c = mask[i]
|
|
88
|
+
if (c === "{") own.push(i === open || (own.at(-1) === true && isStatementBlock(mask, i)))
|
|
89
|
+
else if (c === "}") own.pop()
|
|
90
|
+
else if (
|
|
91
|
+
c === "r" &&
|
|
92
|
+
own.at(-1) === true &&
|
|
93
|
+
mask.startsWith("return", i) &&
|
|
94
|
+
!/[\w$.]/.test(mask[i - 1] ?? "") &&
|
|
95
|
+
!/[\w$]/.test(mask[i + 6] ?? "")
|
|
96
|
+
) {
|
|
97
|
+
found.push(i)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const NUMBER = /^-?(?:0[xob][\da-f_]+|(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:e[+-]?\d+)?)n?$/i
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the expression at `start` is one literal the runtime refuses without an output schema: an
|
|
107
|
+
* object, array, string, template, number or boolean, alone, not part of a larger expression.
|
|
108
|
+
*/
|
|
109
|
+
function isDataLiteral(js: string, mask: string, start: number): boolean {
|
|
110
|
+
let from = start
|
|
111
|
+
let to = expressionEnd(mask, start)
|
|
112
|
+
for (;;) {
|
|
113
|
+
// Skip on the source: the mask blanks a string literal, quotes included.
|
|
114
|
+
while (from < to && /\s/.test(js[from] ?? "")) from++
|
|
115
|
+
while (to > from && /\s/.test(js[to - 1] ?? "")) to--
|
|
116
|
+
if (from >= to) return false
|
|
117
|
+
if (mask[from] !== "(" || closing(mask, from) !== to - 1) break
|
|
118
|
+
from++
|
|
119
|
+
to--
|
|
120
|
+
}
|
|
121
|
+
const first = mask[from]
|
|
122
|
+
if (first === "{" || first === "[") return closing(mask, from) === to - 1
|
|
123
|
+
const text = mask.slice(from, to)
|
|
124
|
+
if (text.trim() === "") {
|
|
125
|
+
const quote = js[from] ?? ""
|
|
126
|
+
return `"'\``.includes(quote) && js[to - 1] === quote
|
|
127
|
+
}
|
|
128
|
+
return NUMBER.test(text) || text === "true" || text === "false"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type DataReturn = "none" | "possible" | "proven"
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What the function exported as `name` returns, read from its type-stripped source. `proven` when
|
|
135
|
+
* its expression body, or a `return` of its own body, is a data literal; `possible` when an
|
|
136
|
+
* expression body or some `return <value>` is not a redirect or a status; `none` otherwise.
|
|
137
|
+
*/
|
|
138
|
+
function dataReturn(js: string, name: string): DataReturn {
|
|
48
139
|
const mask = codePositionMask(js)
|
|
49
140
|
const found = exported(mask, name)
|
|
50
141
|
if (found === null || found[0].startsWith("export {") || found[0].startsWith("export{")) {
|
|
51
|
-
return
|
|
142
|
+
return "possible" // declared elsewhere in the file and re-exported: assume it does
|
|
52
143
|
}
|
|
53
144
|
const params = mask.indexOf("(", found.index + found[0].length)
|
|
54
|
-
if (params === -1) return
|
|
145
|
+
if (params === -1) return "possible"
|
|
55
146
|
let at = closing(mask, params) + 1
|
|
56
147
|
while (/\s/.test(mask[at] ?? "")) at++
|
|
57
148
|
if (mask.startsWith("=>", at)) {
|
|
58
149
|
at += 2
|
|
59
|
-
while (/\s/.test(
|
|
60
|
-
if (mask[at] !== "{")
|
|
150
|
+
while (/\s/.test(js[at] ?? "")) at++
|
|
151
|
+
if (mask[at] !== "{") {
|
|
152
|
+
if (CONTROL_FLOW.test(js.slice(at, at + 40))) return "none"
|
|
153
|
+
return isDataLiteral(js, mask, at) ? "proven" : "possible"
|
|
154
|
+
}
|
|
61
155
|
}
|
|
62
|
-
if (mask[at] !== "{") return
|
|
63
|
-
const
|
|
156
|
+
if (mask[at] !== "{") return "possible"
|
|
157
|
+
const end = closing(mask, at)
|
|
158
|
+
for (const index of ownReturns(mask, at, end)) {
|
|
159
|
+
if (isDataLiteral(js, mask, index + "return".length)) return "proven"
|
|
160
|
+
}
|
|
161
|
+
const body = mask.slice(at, end)
|
|
64
162
|
for (const match of body.matchAll(/\breturn\b/g)) {
|
|
65
163
|
const from = at + match.index + "return".length
|
|
66
164
|
const rest = js.slice(from, from + 40).replace(/^[ \t]+/, "")
|
|
67
|
-
if (!CONTROL_FLOW.test(rest)) return
|
|
165
|
+
if (!CONTROL_FLOW.test(rest)) return "possible"
|
|
68
166
|
}
|
|
69
|
-
return
|
|
167
|
+
return "none"
|
|
70
168
|
}
|
|
71
169
|
|
|
72
170
|
/** Where an expression starting at `start` ends: a `,`, `;` or closer at its own depth. */
|
|
73
|
-
function expressionEnd(mask: string, start: number): number {
|
|
171
|
+
export function expressionEnd(mask: string, start: number): number {
|
|
74
172
|
let depth = 0
|
|
75
173
|
for (let i = start; i < mask.length; i++) {
|
|
76
174
|
const c = mask[i]
|
|
@@ -135,6 +233,8 @@ export interface MissingOutputSchema {
|
|
|
135
233
|
readonly name: "loader" | "action"
|
|
136
234
|
readonly schema: "loaderOutput" | "actionOutput"
|
|
137
235
|
readonly line: number
|
|
236
|
+
/** Its own body returns a data literal, so every request that reaches that return fails. */
|
|
237
|
+
readonly proven: boolean
|
|
138
238
|
}
|
|
139
239
|
|
|
140
240
|
let transpiler: Bun.Transpiler | undefined
|
|
@@ -156,7 +256,10 @@ export function missingOutputSchemas(content: string): MissingOutputSchema[] {
|
|
|
156
256
|
} catch {
|
|
157
257
|
js = content
|
|
158
258
|
}
|
|
159
|
-
|
|
259
|
+
const returns = dataReturn(js, name)
|
|
260
|
+
if (returns !== "none") {
|
|
261
|
+
missing.push({ name, schema, line: lineAt(content, fn.index), proven: returns === "proven" })
|
|
262
|
+
}
|
|
160
263
|
}
|
|
161
264
|
return missing
|
|
162
265
|
}
|
|
@@ -170,15 +273,22 @@ export const missingOutputRule: CheckRule = {
|
|
|
170
273
|
if (classification.zone !== "route-backend") continue
|
|
171
274
|
const content = ctx.sources.read(file)
|
|
172
275
|
if (content === undefined) continue
|
|
173
|
-
for (const { name, schema, line } of missingOutputSchemas(content)) {
|
|
276
|
+
for (const { name, schema, line, proven } of missingOutputSchemas(content)) {
|
|
277
|
+
const returns = proven
|
|
278
|
+
? "It returns a data literal, so every request that reaches that return fails"
|
|
279
|
+
: "Data it returns fails the request"
|
|
174
280
|
findings.push(
|
|
175
281
|
diagnostic({
|
|
176
282
|
code: "NF-C030",
|
|
177
|
-
severity: "warn",
|
|
283
|
+
severity: proven ? "error" : "warn",
|
|
178
284
|
file,
|
|
179
285
|
line,
|
|
180
|
-
message: `${file} exports a ${name} but no ${schema}.
|
|
181
|
-
evidence: [
|
|
286
|
+
message: `${file} exports a ${name} but no ${schema}. ${returns}: everything sent to the browser needs an output schema. Export one: export const ${schema} = t.object({ ... })`,
|
|
287
|
+
evidence: [
|
|
288
|
+
`export: ${name}`,
|
|
289
|
+
`missing: ${schema}`,
|
|
290
|
+
...(proven ? ["returns: a data literal"] : []),
|
|
291
|
+
],
|
|
182
292
|
verify: "nifra check",
|
|
183
293
|
}),
|
|
184
294
|
)
|
|
@@ -191,35 +301,170 @@ export const missingOutputRule: CheckRule = {
|
|
|
191
301
|
const OUTPUT_DECLARATION =
|
|
192
302
|
/\bexport\s+(?:const|let|var)\s+(?:loaderOutput|actionOutput)\b[^=]*=|\boutput\s*:/g
|
|
193
303
|
|
|
304
|
+
/** The initializer of the top-level `const`/`let`/`var` named `name`, as an offset just past its `=`. */
|
|
305
|
+
function declarationOf(mask: string, name: string): number | undefined {
|
|
306
|
+
const declaration = new RegExp(
|
|
307
|
+
`(?:^|\\n)(?:export\\s+)?(?:const|let|var)\\s+${name.replaceAll("$", "\\$")}(?![\\w$])[^=]*=`,
|
|
308
|
+
).exec(mask)
|
|
309
|
+
return declaration === null ? undefined : declaration.index + declaration[0].length
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Where the schema an output declaration names is written: the expression itself, or, when it is a
|
|
314
|
+
* bare name, the initializer of the top-level declaration of that name in the same file, followed
|
|
315
|
+
* through aliases.
|
|
316
|
+
*/
|
|
317
|
+
function schemaExpression(mask: string, start: number): { start: number; end: number } {
|
|
318
|
+
let range = { start, end: expressionEnd(mask, start) }
|
|
319
|
+
const seen = new Set<string>()
|
|
320
|
+
for (;;) {
|
|
321
|
+
const name = mask.slice(range.start, range.end).trim()
|
|
322
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(name) || seen.has(name)) return range
|
|
323
|
+
seen.add(name)
|
|
324
|
+
const from = declarationOf(mask, name)
|
|
325
|
+
if (from === undefined) return range
|
|
326
|
+
range = { start: from, end: expressionEnd(mask, from) }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A name used as a value: not an object key, a member, a callee, or the object of a member. */
|
|
331
|
+
const VALUE_NAME = /(?<![\w$.])[A-Za-z_$][\w$]*(?![\w$])(?!\s*[:(.])/g
|
|
332
|
+
|
|
333
|
+
/** The module `name` is imported from as a named import, and its name there. */
|
|
334
|
+
function importOf(
|
|
335
|
+
source: SchemaSource,
|
|
336
|
+
name: string,
|
|
337
|
+
): { readonly specifier: string; readonly name: string } | undefined {
|
|
338
|
+
// The mask blanks the specifier string, so it is read from the source past `from`.
|
|
339
|
+
for (const clause of source.mask.matchAll(/\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\b/g)) {
|
|
340
|
+
const quoted = /^\s*(["'])([^"']+)\1/.exec(
|
|
341
|
+
source.content.slice(clause.index + clause[0].length),
|
|
342
|
+
)
|
|
343
|
+
if (quoted === null) continue
|
|
344
|
+
for (const element of (clause[1] ?? "").split(",")) {
|
|
345
|
+
const [imported, local = imported] = element
|
|
346
|
+
.trim()
|
|
347
|
+
.replace(/^type\s+/, "")
|
|
348
|
+
.split(/\s+as\s+/)
|
|
349
|
+
if (local === name && imported !== undefined)
|
|
350
|
+
return { specifier: quoted[2] ?? "", name: imported }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
interface SchemaSource {
|
|
357
|
+
readonly file: string
|
|
358
|
+
readonly content: string
|
|
359
|
+
readonly mask: string
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
interface SchemaRange {
|
|
363
|
+
readonly source: SchemaSource
|
|
364
|
+
readonly start: number
|
|
365
|
+
readonly end: number
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Every place an output schema is written: its own expression, then each schema it names inside it
|
|
370
|
+
* (`t.object({ user: userSchema })`), followed to this file's top-level declaration of that name or
|
|
371
|
+
* to the project file it is imported from. A schema under `declassified(...)` is not followed.
|
|
372
|
+
*/
|
|
373
|
+
function schemaRanges(
|
|
374
|
+
origin: SchemaSource,
|
|
375
|
+
start: number,
|
|
376
|
+
load: (from: string, specifier: string) => SchemaSource | undefined,
|
|
377
|
+
): SchemaRange[] {
|
|
378
|
+
const ranges: SchemaRange[] = [{ source: origin, ...schemaExpression(origin.mask, start) }]
|
|
379
|
+
const seen = new Set<string>()
|
|
380
|
+
// An array iterator reads the length each step, so ranges pushed below are walked too.
|
|
381
|
+
for (const { source, start: from, end } of ranges) {
|
|
382
|
+
const declassified: Array<readonly [number, number]> = []
|
|
383
|
+
for (const call of source.mask.slice(from, end).matchAll(/\bdeclassified\s*\(/g)) {
|
|
384
|
+
const open = from + call.index + call[0].length - 1
|
|
385
|
+
declassified.push([open, closing(source.mask, open)])
|
|
386
|
+
}
|
|
387
|
+
for (const reference of source.mask.slice(from, end).matchAll(VALUE_NAME)) {
|
|
388
|
+
const at = from + reference.index
|
|
389
|
+
if (declassified.some(([open, close]) => at > open && at < close)) continue
|
|
390
|
+
const name = reference[0]
|
|
391
|
+
if (seen.has(`${source.file}#${name}`)) continue
|
|
392
|
+
seen.add(`${source.file}#${name}`)
|
|
393
|
+
const local = declarationOf(source.mask, name)
|
|
394
|
+
if (local !== undefined) {
|
|
395
|
+
ranges.push({ source, start: local, end: expressionEnd(source.mask, local) })
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
const imported = importOf(source, name)
|
|
399
|
+
const target = imported === undefined ? undefined : load(source.file, imported.specifier)
|
|
400
|
+
const there =
|
|
401
|
+
target === undefined ? undefined : declarationOf(target.mask, imported?.name ?? "")
|
|
402
|
+
if (target !== undefined && there !== undefined) {
|
|
403
|
+
ranges.push({ source: target, start: there, end: expressionEnd(target.mask, there) })
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return ranges
|
|
408
|
+
}
|
|
409
|
+
|
|
194
410
|
export const sensitiveOutputRule: CheckRule = {
|
|
195
411
|
code: "NF-C031",
|
|
196
412
|
title: "Sensitive field in an output schema",
|
|
197
413
|
async scan(ctx) {
|
|
198
414
|
const findings: Diagnostic[] = []
|
|
415
|
+
const resolve = createSpecifierResolver()
|
|
416
|
+
const realRoot = (() => {
|
|
417
|
+
try {
|
|
418
|
+
return realpathSync(ctx.root)
|
|
419
|
+
} catch {
|
|
420
|
+
return ctx.root
|
|
421
|
+
}
|
|
422
|
+
})()
|
|
423
|
+
const sources = new Map<string, SchemaSource | undefined>()
|
|
424
|
+
const sourceOf = (file: string): SchemaSource | undefined => {
|
|
425
|
+
if (!sources.has(file)) {
|
|
426
|
+
const content = ctx.sources.read(file)
|
|
427
|
+
sources.set(
|
|
428
|
+
file,
|
|
429
|
+
content === undefined ? undefined : { file, content, mask: codePositionMask(content) },
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
return sources.get(file)
|
|
433
|
+
}
|
|
434
|
+
// Only project source: an installed package's schemas are not the app's to rename.
|
|
435
|
+
const load = (from: string, specifier: string): SchemaSource | undefined => {
|
|
436
|
+
const target = resolve(join(ctx.root, from), specifier)
|
|
437
|
+
if (target === undefined) return undefined
|
|
438
|
+
const rel = relative(realRoot, target).replaceAll("\\", "/")
|
|
439
|
+
if (rel.startsWith("../") || isAbsolute(rel) || rel.split("/").includes("node_modules")) {
|
|
440
|
+
return undefined
|
|
441
|
+
}
|
|
442
|
+
return sourceOf(rel)
|
|
443
|
+
}
|
|
444
|
+
// A schema several declarations name, or several routes import, is reported once.
|
|
445
|
+
const reported = new Set<string>()
|
|
199
446
|
for (const { file, classification } of zonedFiles(ctx).files) {
|
|
200
447
|
if (classification.zone !== "route-backend" && classification.zone !== "fn") continue
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
const start
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}),
|
|
222
|
-
)
|
|
448
|
+
const origin = sourceOf(file)
|
|
449
|
+
if (origin === undefined) continue
|
|
450
|
+
for (const declaration of origin.mask.matchAll(OUTPUT_DECLARATION)) {
|
|
451
|
+
const ranges = schemaRanges(origin, declaration.index + declaration[0].length, load)
|
|
452
|
+
for (const { source, start, end } of ranges) {
|
|
453
|
+
for (const { key, index } of sensitiveKeys(source.content, source.mask, start, end)) {
|
|
454
|
+
if (reported.has(`${source.file}:${index}`)) continue
|
|
455
|
+
reported.add(`${source.file}:${index}`)
|
|
456
|
+
findings.push(
|
|
457
|
+
diagnostic({
|
|
458
|
+
code: "NF-C031",
|
|
459
|
+
severity: "error",
|
|
460
|
+
file: source.file,
|
|
461
|
+
line: lineAt(source.content, index),
|
|
462
|
+
message: `${source.file} declares "${key}" in an output schema, a field name that usually holds a credential or personal identifier. Remove it, or wrap its schema in t.declassified("why it may reach the browser", ...)`,
|
|
463
|
+
evidence: [`field: ${key}`],
|
|
464
|
+
verify: "nifra check",
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
467
|
+
}
|
|
223
468
|
}
|
|
224
469
|
}
|
|
225
470
|
}
|
|
@@ -227,4 +472,68 @@ export const sensitiveOutputRule: CheckRule = {
|
|
|
227
472
|
},
|
|
228
473
|
}
|
|
229
474
|
|
|
230
|
-
export const
|
|
475
|
+
export const staleGeneratedOutputRule: CheckRule = {
|
|
476
|
+
code: "NF-C033",
|
|
477
|
+
title: "Generated output schema out of date",
|
|
478
|
+
async scan(ctx) {
|
|
479
|
+
const files = zonedFiles(ctx)
|
|
480
|
+
.files.filter((entry) => entry.classification.zone === "route-backend")
|
|
481
|
+
.map((entry) => entry.file)
|
|
482
|
+
.filter((file) => ctx.sources.read(file)?.includes("nifra:generated") === true)
|
|
483
|
+
if (files.length === 0) return []
|
|
484
|
+
const { staleGeneratedSchemas } = await import("../output-schemas.ts")
|
|
485
|
+
const { stale, unchecked, warnings } = await staleGeneratedSchemas(ctx.root, files, (file) =>
|
|
486
|
+
ctx.sources.read(file),
|
|
487
|
+
)
|
|
488
|
+
// A generated schema that could not be compared is reported, never passed silently.
|
|
489
|
+
const notChecked = [
|
|
490
|
+
...warnings.map((warning) =>
|
|
491
|
+
diagnostic({
|
|
492
|
+
code: "NF-C033",
|
|
493
|
+
severity: "warn",
|
|
494
|
+
message: `${files.length} generated output schema(s) were not checked: ${warning}`,
|
|
495
|
+
evidence: files.map((file) => `file: ${file}`),
|
|
496
|
+
verify: "nifra schemas",
|
|
497
|
+
}),
|
|
498
|
+
),
|
|
499
|
+
...unchecked.map((entry) =>
|
|
500
|
+
diagnostic({
|
|
501
|
+
code: "NF-C033",
|
|
502
|
+
severity: "warn",
|
|
503
|
+
file: entry.file,
|
|
504
|
+
line: entry.line,
|
|
505
|
+
message: `${entry.file}'s generated ${entry.schema} was not checked: ${entry.reason ?? "its type could not be read"}`,
|
|
506
|
+
evidence: [`schema: ${entry.schema}`],
|
|
507
|
+
verify: "nifra schemas",
|
|
508
|
+
}),
|
|
509
|
+
),
|
|
510
|
+
]
|
|
511
|
+
return [
|
|
512
|
+
...notChecked,
|
|
513
|
+
...stale.map((entry) => {
|
|
514
|
+
const fn = entry.schema === "loaderOutput" ? "loader" : "action"
|
|
515
|
+
const change =
|
|
516
|
+
entry.edited === true
|
|
517
|
+
? "it was edited by hand since"
|
|
518
|
+
: entry.dropped.length > 0
|
|
519
|
+
? `it now returns ${entry.dropped.join(", ")}, which the schema drops`
|
|
520
|
+
: "what it returns has changed"
|
|
521
|
+
return diagnostic({
|
|
522
|
+
code: "NF-C033",
|
|
523
|
+
severity: "error",
|
|
524
|
+
file: entry.file,
|
|
525
|
+
line: entry.line,
|
|
526
|
+
message: `${entry.file}'s ${entry.schema} was generated from the ${fn}'s return type, and ${change}. Run \`nifra schemas --write\` to regenerate it, or delete its "nifra:generated" line to own it by hand.`,
|
|
527
|
+
evidence: [`schema: ${entry.schema}`, ...entry.dropped.map((path) => `dropped: ${path}`)],
|
|
528
|
+
verify: "nifra schemas",
|
|
529
|
+
})
|
|
530
|
+
}),
|
|
531
|
+
]
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export const dataGuardRules = Object.freeze([
|
|
536
|
+
missingOutputRule,
|
|
537
|
+
sensitiveOutputRule,
|
|
538
|
+
staleGeneratedOutputRule,
|
|
539
|
+
])
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { retiredRootFiles } from "../app-files.ts"
|
|
2
|
+
import { diagnostic } from "../diagnostics.ts"
|
|
3
|
+
import type { CheckRule } from "./index.ts"
|
|
4
|
+
|
|
5
|
+
/** Every gate that reads backend/app.ts sees nothing in a retired root file, so the check fails here. */
|
|
6
|
+
export const retiredLayoutRule: CheckRule = {
|
|
7
|
+
code: "NF-C038",
|
|
8
|
+
title: "Retired root layout",
|
|
9
|
+
async scan(ctx) {
|
|
10
|
+
return retiredRootFiles(ctx.root).map(([from, to]) =>
|
|
11
|
+
diagnostic({
|
|
12
|
+
code: "NF-C038",
|
|
13
|
+
severity: "error",
|
|
14
|
+
file: from,
|
|
15
|
+
line: 1,
|
|
16
|
+
message: `${from} is the retired root layout; nifra reads it at ${to}. Until it moves, nifra check runs no contract gate on this app, and the commands that load it refuse it. \`nifra migrate layout\` shows the move; \`--write\` makes it`,
|
|
17
|
+
fix: { recipe: "layout.migrate", command: "nifra migrate layout --write" },
|
|
18
|
+
verify: "nifra check --lints-only",
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
},
|
|
22
|
+
}
|