@nifrajs/cli 4.0.2 → 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 +16 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +133 -29
- 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 +21 -2
- 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 +778 -64
- 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/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/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 +166 -37
- 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 +25 -2
- 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 +885 -63
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- 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/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
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output schemas from a route's own return types. `loaderOutput` and `actionOutput` stay the reviewed
|
|
3
|
+
* allow-list of what reaches the browser: nothing here runs at request time, and no schema is derived
|
|
4
|
+
* implicitly. `nifra schemas` reads each loader's and action's TypeScript return type, writes a
|
|
5
|
+
* schema for review where a route has none, keeps the schemas it wrote current, and names the
|
|
6
|
+
* returned fields a hand-written schema drops. `nifra check` (NF-C033) fails on a generated schema
|
|
7
|
+
* whose route has changed what it returns since.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto"
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
12
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
13
|
+
import { basename, join } from "node:path"
|
|
14
|
+
import { isSensitiveFieldName } from "@nifrajs/web/zones"
|
|
15
|
+
import type * as TSApi from "typescript"
|
|
16
|
+
import { codePositionMask } from "./check-scan.ts"
|
|
17
|
+
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
18
|
+
import {
|
|
19
|
+
loadProjectTypeScript,
|
|
20
|
+
type TypeScriptApi,
|
|
21
|
+
type TypeScriptSemanticSession,
|
|
22
|
+
} from "./internal/typescript-import.ts"
|
|
23
|
+
import { exported, expressionEnd } from "./rules/data-guard.ts"
|
|
24
|
+
|
|
25
|
+
export type RouteFn = "loader" | "action"
|
|
26
|
+
export type OutputExport = "loaderOutput" | "actionOutput"
|
|
27
|
+
|
|
28
|
+
const PAIRS: ReadonlyArray<readonly [RouteFn, OutputExport]> = [
|
|
29
|
+
["loader", "loaderOutput"],
|
|
30
|
+
["action", "actionOutput"],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/** The comment that marks a schema `nifra schemas` wrote, with the hash of what it wrote. */
|
|
34
|
+
export const GENERATED_MARK = "nifra:generated"
|
|
35
|
+
const MARKER = /^[ \t]*\/\/ nifra:generated ([0-9a-f]{8})\b/
|
|
36
|
+
|
|
37
|
+
/** A JSON-side reading of a TypeScript type: what a schema for it would have to describe. */
|
|
38
|
+
export type Shape =
|
|
39
|
+
| { readonly kind: "string" | "number" | "boolean" | "null" | "date" | "undefined" | "never" }
|
|
40
|
+
| { readonly kind: "json"; readonly reason?: string }
|
|
41
|
+
| { readonly kind: "literal"; readonly value: string | number | boolean }
|
|
42
|
+
| { readonly kind: "array"; readonly items: Shape }
|
|
43
|
+
| { readonly kind: "tuple"; readonly items: readonly Shape[] }
|
|
44
|
+
| { readonly kind: "object"; readonly fields: readonly Field[] }
|
|
45
|
+
| { readonly kind: "record"; readonly values: Shape }
|
|
46
|
+
| { readonly kind: "union"; readonly members: readonly Shape[] }
|
|
47
|
+
| { readonly kind: "deferred"; readonly value: Shape }
|
|
48
|
+
| { readonly kind: "opaque"; readonly what: string }
|
|
49
|
+
|
|
50
|
+
export interface Field {
|
|
51
|
+
readonly name: string
|
|
52
|
+
readonly optional: boolean
|
|
53
|
+
readonly shape: Shape
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const FLAG_NAMES = [
|
|
57
|
+
"Any",
|
|
58
|
+
"Unknown",
|
|
59
|
+
"Never",
|
|
60
|
+
"Undefined",
|
|
61
|
+
"Void",
|
|
62
|
+
"Null",
|
|
63
|
+
"StringLiteral",
|
|
64
|
+
"NumberLiteral",
|
|
65
|
+
"BooleanLiteral",
|
|
66
|
+
"StringLike",
|
|
67
|
+
"NumberLike",
|
|
68
|
+
"BooleanLike",
|
|
69
|
+
"BigIntLike",
|
|
70
|
+
"ESSymbolLike",
|
|
71
|
+
"Union",
|
|
72
|
+
"Intersection",
|
|
73
|
+
"Object",
|
|
74
|
+
"NonPrimitive",
|
|
75
|
+
] as const
|
|
76
|
+
type Flags = Readonly<Record<(typeof FLAG_NAMES)[number], number>>
|
|
77
|
+
|
|
78
|
+
const flagsFrom = (read: (name: string) => unknown): Flags => {
|
|
79
|
+
const at = (name: (typeof FLAG_NAMES)[number]): number => {
|
|
80
|
+
const value = read(name)
|
|
81
|
+
return typeof value === "number" ? value : 0
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
Any: at("Any"),
|
|
85
|
+
Unknown: at("Unknown"),
|
|
86
|
+
Never: at("Never"),
|
|
87
|
+
Undefined: at("Undefined"),
|
|
88
|
+
Void: at("Void"),
|
|
89
|
+
Null: at("Null"),
|
|
90
|
+
StringLiteral: at("StringLiteral"),
|
|
91
|
+
NumberLiteral: at("NumberLiteral"),
|
|
92
|
+
BooleanLiteral: at("BooleanLiteral"),
|
|
93
|
+
StringLike: at("StringLike"),
|
|
94
|
+
NumberLike: at("NumberLike"),
|
|
95
|
+
BooleanLike: at("BooleanLike"),
|
|
96
|
+
BigIntLike: at("BigIntLike"),
|
|
97
|
+
ESSymbolLike: at("ESSymbolLike"),
|
|
98
|
+
Union: at("Union"),
|
|
99
|
+
Intersection: at("Intersection"),
|
|
100
|
+
Object: at("Object"),
|
|
101
|
+
NonPrimitive: at("NonPrimitive"),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Property<T> {
|
|
106
|
+
readonly name: string
|
|
107
|
+
readonly optional: boolean
|
|
108
|
+
readonly type: T
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The checker calls the walker needs, over TypeScript 5/6's synchronous API or 7's asynchronous one. */
|
|
112
|
+
interface TypeReader<T> {
|
|
113
|
+
readonly flag: Flags
|
|
114
|
+
flags(type: T): number
|
|
115
|
+
members(type: T): Promise<readonly T[]>
|
|
116
|
+
symbolName(type: T): Promise<string | undefined>
|
|
117
|
+
aliasName(type: T): Promise<string | undefined>
|
|
118
|
+
literal(type: T): Promise<string | number | boolean | undefined>
|
|
119
|
+
arrayElement(type: T): Promise<T | undefined>
|
|
120
|
+
tupleElements(type: T): Promise<readonly T[] | undefined>
|
|
121
|
+
typeArguments(type: T): Promise<readonly T[]>
|
|
122
|
+
properties(type: T): Promise<readonly Property<T>[]>
|
|
123
|
+
stringIndex(type: T): Promise<T | undefined>
|
|
124
|
+
callable(type: T): Promise<boolean>
|
|
125
|
+
text(type: T): Promise<string>
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const MAX_DEPTH = 12
|
|
129
|
+
const JSON_ALIASES = new Set(["JsonValue", "JSONValue", "Json"])
|
|
130
|
+
const NOT_JSON = new Set([
|
|
131
|
+
"Map",
|
|
132
|
+
"Set",
|
|
133
|
+
"WeakMap",
|
|
134
|
+
"WeakSet",
|
|
135
|
+
"RegExp",
|
|
136
|
+
"Error",
|
|
137
|
+
"Promise",
|
|
138
|
+
"URL",
|
|
139
|
+
"URLSearchParams",
|
|
140
|
+
"Headers",
|
|
141
|
+
"Request",
|
|
142
|
+
"Response",
|
|
143
|
+
"Blob",
|
|
144
|
+
"File",
|
|
145
|
+
"FormData",
|
|
146
|
+
"ArrayBuffer",
|
|
147
|
+
"SharedArrayBuffer",
|
|
148
|
+
"DataView",
|
|
149
|
+
"ReadableStream",
|
|
150
|
+
"WritableStream",
|
|
151
|
+
"Function",
|
|
152
|
+
])
|
|
153
|
+
const TYPED_ARRAY = /^(?:Big)?(?:Int|Uint|Float)(?:8|16|32|64)(?:Clamped)?Array$/
|
|
154
|
+
|
|
155
|
+
const sameShape = (a: Shape, b: Shape): boolean => JSON.stringify(a) === JSON.stringify(b)
|
|
156
|
+
|
|
157
|
+
function unionOf(input: readonly Shape[]): Shape {
|
|
158
|
+
const flat = input
|
|
159
|
+
.flatMap((member) => (member.kind === "union" ? member.members : [member]))
|
|
160
|
+
.filter((member) => member.kind !== "never")
|
|
161
|
+
const json = flat.find((member) => member.kind === "json")
|
|
162
|
+
if (json !== undefined) return json
|
|
163
|
+
const isBool = (member: Shape): boolean =>
|
|
164
|
+
member.kind === "literal" && typeof member.value === "boolean"
|
|
165
|
+
const both =
|
|
166
|
+
flat.some((m) => m.kind === "literal" && m.value === true) &&
|
|
167
|
+
flat.some((m) => m.kind === "literal" && m.value === false)
|
|
168
|
+
const members: Shape[] = []
|
|
169
|
+
for (const member of flat) {
|
|
170
|
+
const next: Shape = both && isBool(member) ? { kind: "boolean" } : member
|
|
171
|
+
if (!members.some((seen) => sameShape(seen, next))) members.push(next)
|
|
172
|
+
}
|
|
173
|
+
if (members.length === 0) return { kind: "never" }
|
|
174
|
+
if (members.length === 1 && members[0] !== undefined) return members[0]
|
|
175
|
+
// TypeScript versions order union members differently; a canonical order keeps the generated text,
|
|
176
|
+
// and the hash in its marker, the same under each. `null` reads best last.
|
|
177
|
+
const rank = (shape: Shape): string =>
|
|
178
|
+
`${shape.kind === "null" || shape.kind === "undefined" ? 1 : 0}${JSON.stringify(shape)}`
|
|
179
|
+
return { kind: "union", members: members.sort((a, b) => codeUnitOrder(rank(a), rank(b))) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function withoutUndefined(shape: Shape): Shape {
|
|
183
|
+
if (shape.kind !== "union" || !shape.members.some((member) => member.kind === "undefined"))
|
|
184
|
+
return shape
|
|
185
|
+
return unionOf(shape.members.filter((member) => member.kind !== "undefined"))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function shapeOf<T>(
|
|
189
|
+
reader: TypeReader<T>,
|
|
190
|
+
type: T,
|
|
191
|
+
seen: Set<T>,
|
|
192
|
+
depth: number,
|
|
193
|
+
): Promise<Shape> {
|
|
194
|
+
if (depth > MAX_DEPTH) return { kind: "json", reason: "nested too deep to read" }
|
|
195
|
+
const f = reader.flag
|
|
196
|
+
const flags = reader.flags(type)
|
|
197
|
+
const is = (mask: number): boolean => (flags & mask) !== 0
|
|
198
|
+
if (is(f.Any)) return { kind: "json", reason: "any" }
|
|
199
|
+
if (is(f.Unknown)) return { kind: "json", reason: "unknown" }
|
|
200
|
+
if (is(f.Never)) return { kind: "never" }
|
|
201
|
+
if (is(f.Undefined | f.Void)) return { kind: "undefined" }
|
|
202
|
+
if (is(f.Null)) return { kind: "null" }
|
|
203
|
+
if (is(f.StringLiteral | f.NumberLiteral | f.BooleanLiteral)) {
|
|
204
|
+
const value = await reader.literal(type)
|
|
205
|
+
if (value !== undefined) return { kind: "literal", value }
|
|
206
|
+
}
|
|
207
|
+
if (is(f.StringLike)) return { kind: "string" }
|
|
208
|
+
if (is(f.NumberLike)) return { kind: "number" }
|
|
209
|
+
// `boolean` is the union `true | false` with the Boolean flag, so this precedes the union branch.
|
|
210
|
+
if (is(f.BooleanLike)) return { kind: "boolean" }
|
|
211
|
+
if (is(f.BigIntLike)) return { kind: "opaque", what: "a bigint" }
|
|
212
|
+
if (is(f.ESSymbolLike)) return { kind: "opaque", what: "a symbol" }
|
|
213
|
+
const alias = await reader.aliasName(type)
|
|
214
|
+
if (alias !== undefined && JSON_ALIASES.has(alias)) return { kind: "json" }
|
|
215
|
+
if (is(f.Union)) {
|
|
216
|
+
const members = await reader.members(type)
|
|
217
|
+
return unionOf(
|
|
218
|
+
await Promise.all(members.map((member) => shapeOf(reader, member, seen, depth + 1))),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
if (seen.has(type)) return { kind: "json", reason: "a recursive type" }
|
|
222
|
+
if (is(f.Intersection)) {
|
|
223
|
+
// A branded primitive (`string & { __brand: "Id" }`) crosses the wire as the primitive.
|
|
224
|
+
for (const part of await reader.members(type)) {
|
|
225
|
+
if ((reader.flags(part) & (f.StringLike | f.NumberLike | f.BooleanLike)) !== 0)
|
|
226
|
+
return shapeOf(reader, part, seen, depth + 1)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!is(f.Object | f.Intersection | f.NonPrimitive))
|
|
230
|
+
return { kind: "opaque", what: await reader.text(type) }
|
|
231
|
+
const tuple = await reader.tupleElements(type)
|
|
232
|
+
if (tuple !== undefined) {
|
|
233
|
+
return {
|
|
234
|
+
kind: "tuple",
|
|
235
|
+
items: await Promise.all(tuple.map((item) => shapeOf(reader, item, seen, depth + 1))),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const element = await reader.arrayElement(type)
|
|
239
|
+
if (element !== undefined)
|
|
240
|
+
return { kind: "array", items: await shapeOf(reader, element, seen, depth + 1) }
|
|
241
|
+
const name = await reader.symbolName(type)
|
|
242
|
+
if (name === "Date") return { kind: "date" }
|
|
243
|
+
if (name !== undefined && (NOT_JSON.has(name) || TYPED_ARRAY.test(name)))
|
|
244
|
+
return { kind: "opaque", what: `a ${name}` }
|
|
245
|
+
if (await reader.callable(type)) return { kind: "opaque", what: "a function" }
|
|
246
|
+
seen.add(type)
|
|
247
|
+
try {
|
|
248
|
+
const properties = await reader.properties(type)
|
|
249
|
+
// A `Response`, or nifra's own response carrier (`redirect()`, `status()`): control flow, not data.
|
|
250
|
+
if (properties.some((property) => property.name === "toResponse"))
|
|
251
|
+
return { kind: "opaque", what: "a Response" }
|
|
252
|
+
const promise = properties.find((property) => property.name === "promise")
|
|
253
|
+
if (promise !== undefined && properties.some((p) => p.name === "__nifra_deferred")) {
|
|
254
|
+
const [inner] = await reader.typeArguments(promise.type)
|
|
255
|
+
return {
|
|
256
|
+
kind: "deferred",
|
|
257
|
+
value:
|
|
258
|
+
inner === undefined
|
|
259
|
+
? { kind: "json", reason: "unknown" }
|
|
260
|
+
: await shapeOf(reader, inner, seen, depth + 1),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const index = await reader.stringIndex(type)
|
|
264
|
+
if (index !== undefined && properties.length === 0)
|
|
265
|
+
return { kind: "record", values: await shapeOf(reader, index, seen, depth + 1) }
|
|
266
|
+
const fields: Field[] = []
|
|
267
|
+
for (const property of properties) {
|
|
268
|
+
if (property.name.startsWith("__@") || (await reader.callable(property.type))) continue
|
|
269
|
+
const shape = await shapeOf(reader, property.type, seen, depth + 1)
|
|
270
|
+
const defined = withoutUndefined(shape)
|
|
271
|
+
fields.push({
|
|
272
|
+
name: property.name,
|
|
273
|
+
optional: property.optional || defined !== shape,
|
|
274
|
+
shape: defined,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
return { kind: "object", fields }
|
|
278
|
+
} finally {
|
|
279
|
+
seen.delete(type)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** What a loader's return type sends: control-flow returns (nothing, a `Response`) carry no data. */
|
|
284
|
+
function dataShape(shape: Shape): Shape {
|
|
285
|
+
const members = shape.kind === "union" ? shape.members : [shape]
|
|
286
|
+
return unionOf(
|
|
287
|
+
members.filter(
|
|
288
|
+
(member) =>
|
|
289
|
+
member.kind !== "undefined" && !(member.kind === "opaque" && member.what === "a Response"),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
295
|
+
const note = (text: string): string => text.replaceAll("*/", "* /")
|
|
296
|
+
|
|
297
|
+
/** `shape` as `t.*` source, objects one field per line below `indent`. */
|
|
298
|
+
export function schemaSource(shape: Shape, indent = ""): string {
|
|
299
|
+
switch (shape.kind) {
|
|
300
|
+
case "string":
|
|
301
|
+
case "number":
|
|
302
|
+
case "boolean":
|
|
303
|
+
case "null":
|
|
304
|
+
case "date":
|
|
305
|
+
return `t.${shape.kind}()`
|
|
306
|
+
case "undefined":
|
|
307
|
+
case "never":
|
|
308
|
+
return "t.null()"
|
|
309
|
+
case "json":
|
|
310
|
+
return shape.reason === undefined
|
|
311
|
+
? "t.json()"
|
|
312
|
+
: `t.json() /* ${note(shape.reason)} in the source type */`
|
|
313
|
+
case "opaque":
|
|
314
|
+
return `t.json() /* ${note(shape.what)}, which is not JSON */`
|
|
315
|
+
case "literal":
|
|
316
|
+
return `t.literal(${JSON.stringify(shape.value)})`
|
|
317
|
+
case "array":
|
|
318
|
+
return `t.array(${schemaSource(shape.items, indent)})`
|
|
319
|
+
case "tuple":
|
|
320
|
+
return `t.array(${schemaSource(unionOf(shape.items), indent)})`
|
|
321
|
+
case "record":
|
|
322
|
+
return `t.record(${schemaSource(shape.values, indent)})`
|
|
323
|
+
case "deferred":
|
|
324
|
+
return `t.deferred(${schemaSource(shape.value, indent)})`
|
|
325
|
+
case "union":
|
|
326
|
+
return `t.union([${shape.members.map((member) => schemaSource(member, indent)).join(", ")}])`
|
|
327
|
+
case "object": {
|
|
328
|
+
if (shape.fields.length === 0) return "t.object({})"
|
|
329
|
+
const inner = `${indent} `
|
|
330
|
+
const lines = shape.fields.map((field) => {
|
|
331
|
+
const key = IDENTIFIER.test(field.name) ? field.name : JSON.stringify(field.name)
|
|
332
|
+
if (isSensitiveFieldName(field.name))
|
|
333
|
+
return `${inner}// left out: ${key} - a credential-like name; declare it with t.declassified("why", ...) if it must reach the browser`
|
|
334
|
+
if (field.shape.kind === "opaque")
|
|
335
|
+
return `${inner}// left out: ${key} - ${note(field.shape.what)}, which is not JSON`
|
|
336
|
+
const value = schemaSource(field.shape, inner)
|
|
337
|
+
return `${inner}${key}: ${field.optional ? `t.optional(${value})` : value},`
|
|
338
|
+
})
|
|
339
|
+
return `t.object({\n${lines.join("\n")}\n${indent}})`
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The paths `returned` carries that `declared` does not, as `a.b`, `items[].c`: what the output guard
|
|
346
|
+
* drops before the page renders. A declared `t.json()` or record admits everything below it.
|
|
347
|
+
*/
|
|
348
|
+
export function undeclaredPaths(returned: Shape, declared: Shape, path = ""): string[] {
|
|
349
|
+
if (declared.kind === "json" || declared.kind === "record") return []
|
|
350
|
+
if (returned.kind === "union") {
|
|
351
|
+
const found = returned.members.flatMap((member) => undeclaredPaths(member, declared, path))
|
|
352
|
+
return [...new Set(found)]
|
|
353
|
+
}
|
|
354
|
+
if (declared.kind === "union") {
|
|
355
|
+
const candidates = declared.members.filter((m) => m.kind !== "null" && m.kind !== "undefined")
|
|
356
|
+
if (candidates.some((m) => m.kind === "json" || m.kind === "record")) return []
|
|
357
|
+
const results = candidates.map((candidate) => undeclaredPaths(returned, candidate, path))
|
|
358
|
+
const [first, ...rest] = results
|
|
359
|
+
return first === undefined ? [] : first.filter((p) => rest.every((r) => r.includes(p)))
|
|
360
|
+
}
|
|
361
|
+
if (returned.kind === "deferred")
|
|
362
|
+
return undeclaredPaths(
|
|
363
|
+
returned.value,
|
|
364
|
+
declared.kind === "deferred" ? declared.value : declared,
|
|
365
|
+
path,
|
|
366
|
+
)
|
|
367
|
+
if (declared.kind === "deferred") return undeclaredPaths(returned, declared.value, path)
|
|
368
|
+
const items = (shape: Shape): Shape | undefined =>
|
|
369
|
+
shape.kind === "array" ? shape.items : shape.kind === "tuple" ? unionOf(shape.items) : undefined
|
|
370
|
+
const returnedItems = items(returned)
|
|
371
|
+
const declaredItems = items(declared)
|
|
372
|
+
if (returnedItems !== undefined && declaredItems !== undefined)
|
|
373
|
+
return undeclaredPaths(returnedItems, declaredItems, `${path}[]`)
|
|
374
|
+
if (returned.kind !== "object" || declared.kind !== "object") return []
|
|
375
|
+
const found: string[] = []
|
|
376
|
+
for (const field of returned.fields) {
|
|
377
|
+
const at = path === "" ? field.name : `${path}.${field.name}`
|
|
378
|
+
const match = declared.fields.find((candidate) => candidate.name === field.name)
|
|
379
|
+
if (match === undefined) found.push(at)
|
|
380
|
+
else found.push(...undeclaredPaths(field.shape, match.shape, at))
|
|
381
|
+
}
|
|
382
|
+
return found
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const hashOf = (body: string): string => createHash("sha256").update(body).digest("hex").slice(0, 8)
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A schema expression as a formatter cannot change it: comments, whitespace, a comma before a closing
|
|
389
|
+
* bracket and a trailing semicolon dropped, every string written with double quotes, and a quoted key
|
|
390
|
+
* that needs no quotes written bare. Two texts
|
|
391
|
+
* that read the same here are the same schema; an edit to what it declares is not.
|
|
392
|
+
*/
|
|
393
|
+
function schemaTokens(text: string): string {
|
|
394
|
+
let out = ""
|
|
395
|
+
let i = 0
|
|
396
|
+
while (i < text.length) {
|
|
397
|
+
const c = text.charAt(i)
|
|
398
|
+
if (c === "/" && text[i + 1] === "/") {
|
|
399
|
+
const end = text.indexOf("\n", i)
|
|
400
|
+
i = end === -1 ? text.length : end
|
|
401
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
402
|
+
const end = text.indexOf("*/", i + 2)
|
|
403
|
+
i = end === -1 ? text.length : end + 2
|
|
404
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
405
|
+
let value = ""
|
|
406
|
+
let j = i + 1
|
|
407
|
+
while (j < text.length && text[j] !== c) {
|
|
408
|
+
if (text[j] === "\\") {
|
|
409
|
+
const next = text[j + 1] ?? ""
|
|
410
|
+
value += next === c ? next : `\\${next}`
|
|
411
|
+
j += 2
|
|
412
|
+
} else {
|
|
413
|
+
value += text[j]
|
|
414
|
+
j++
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
i = j + 1
|
|
418
|
+
let after = i
|
|
419
|
+
while (/\s/.test(text[after] ?? "")) after++
|
|
420
|
+
// `"name": …` and `name: …` declare the same key.
|
|
421
|
+
out +=
|
|
422
|
+
text[after] === ":" && IDENTIFIER.test(value) ? value : `"${value.replaceAll('"', '\\"')}"`
|
|
423
|
+
} else {
|
|
424
|
+
if (!/\s/.test(c)) out += c
|
|
425
|
+
i++
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return out.replace(/,(?=[}\])])/g, "").replace(/;+$/, "")
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function declarationText(schema: OutputExport, fn: RouteFn, body: string): string {
|
|
432
|
+
return [
|
|
433
|
+
`// ${GENERATED_MARK} ${hashOf(body)} - from the ${fn}'s return type; \`nifra schemas --write\` keeps it current. Delete this line to edit it by hand.`,
|
|
434
|
+
`export const ${schema} = ${body}`,
|
|
435
|
+
].join("\n")
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- reading the types -------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
interface ProbeRoute {
|
|
441
|
+
readonly file: string
|
|
442
|
+
readonly fns: readonly RouteFn[]
|
|
443
|
+
readonly schemas: readonly OutputExport[]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Shapes keyed `<file>#<export>`: a route function's return type, or a schema's output type. */
|
|
447
|
+
interface ProbeResult {
|
|
448
|
+
readonly shapes: ReadonlyMap<string, Shape>
|
|
449
|
+
readonly warnings: readonly string[]
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const posix = (path: string): string => path.replaceAll("\\", "/")
|
|
453
|
+
|
|
454
|
+
const PROBE_HELPERS = [
|
|
455
|
+
"type __NifraReturned<M, F extends string> = M extends { readonly [K in F]: (...args: any) => infer R } ? Awaited<R> : never",
|
|
456
|
+
'type __NifraDeclared<M, N extends string> = M extends { readonly [K in N]: infer S } ? S extends { readonly "~standard": { readonly types?: infer T } } ? NonNullable<T> extends { readonly output: infer O } ? O : never : never : never',
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
function probeSource(routes: readonly ProbeRoute[]): {
|
|
460
|
+
readonly text: string
|
|
461
|
+
readonly names: ReadonlyMap<string, string>
|
|
462
|
+
} {
|
|
463
|
+
const lines = [...PROBE_HELPERS]
|
|
464
|
+
const names = new Map<string, string>()
|
|
465
|
+
routes.forEach((route, i) => {
|
|
466
|
+
const rel = posix(route.file)
|
|
467
|
+
lines.push(
|
|
468
|
+
`import type * as __nifra_m${i} from ${JSON.stringify(rel.startsWith(".") ? rel : `./${rel}`)}`,
|
|
469
|
+
)
|
|
470
|
+
for (const fn of route.fns) {
|
|
471
|
+
const name = `__nifra_r${i}_${fn}`
|
|
472
|
+
names.set(name, `${route.file}#${fn}`)
|
|
473
|
+
lines.push(`declare const ${name}: __NifraReturned<typeof __nifra_m${i}, "${fn}">`)
|
|
474
|
+
}
|
|
475
|
+
for (const schema of route.schemas) {
|
|
476
|
+
const name = `__nifra_d${i}_${schema}`
|
|
477
|
+
names.set(name, `${route.file}#${schema}`)
|
|
478
|
+
lines.push(`declare const ${name}: __NifraDeclared<typeof __nifra_m${i}, "${schema}">`)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
return { text: `${lines.join("\n")}\n`, names }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function legacyReader(
|
|
485
|
+
ts: TypeScriptApi,
|
|
486
|
+
checker: TSApi.TypeChecker,
|
|
487
|
+
location: TSApi.Node,
|
|
488
|
+
): TypeReader<TSApi.Type> {
|
|
489
|
+
const isReference = (type: TSApi.Type): type is TSApi.TypeReference => "target" in type
|
|
490
|
+
return {
|
|
491
|
+
flag: flagsFrom((name) => Reflect.get(ts.TypeFlags, name)),
|
|
492
|
+
flags: (type) => type.flags,
|
|
493
|
+
members: async (type) => (type.isUnionOrIntersection() ? type.types : []),
|
|
494
|
+
symbolName: async (type) => type.getSymbol()?.name,
|
|
495
|
+
aliasName: async (type) => type.aliasSymbol?.name,
|
|
496
|
+
literal: async (type) => {
|
|
497
|
+
if (type.isStringLiteral() || type.isNumberLiteral()) return type.value
|
|
498
|
+
if ((type.flags & ts.TypeFlags.BooleanLiteral) !== 0)
|
|
499
|
+
return checker.typeToString(type) === "true"
|
|
500
|
+
return undefined
|
|
501
|
+
},
|
|
502
|
+
arrayElement: async (type) =>
|
|
503
|
+
checker.isArrayType(type) && isReference(type)
|
|
504
|
+
? checker.getTypeArguments(type)[0]
|
|
505
|
+
: undefined,
|
|
506
|
+
tupleElements: async (type) =>
|
|
507
|
+
checker.isTupleType(type) && isReference(type) ? checker.getTypeArguments(type) : undefined,
|
|
508
|
+
typeArguments: async (type) => (isReference(type) ? checker.getTypeArguments(type) : []),
|
|
509
|
+
properties: async (type) =>
|
|
510
|
+
checker.getPropertiesOfType(type).map((property) => ({
|
|
511
|
+
name: property.name,
|
|
512
|
+
optional: (property.flags & ts.SymbolFlags.Optional) !== 0,
|
|
513
|
+
type: checker.getTypeOfSymbolAtLocation(property, location),
|
|
514
|
+
})),
|
|
515
|
+
stringIndex: async (type) =>
|
|
516
|
+
checker
|
|
517
|
+
.getIndexInfosOfType(type)
|
|
518
|
+
.find((info) => (info.keyType.flags & ts.TypeFlags.StringLike) !== 0)?.type,
|
|
519
|
+
callable: async (type) => checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0,
|
|
520
|
+
text: async (type) => checker.typeToString(type),
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
interface Symbol7 {
|
|
525
|
+
readonly name: string
|
|
526
|
+
readonly flags: number
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
interface Type7 {
|
|
530
|
+
readonly flags: number
|
|
531
|
+
readonly value?: unknown
|
|
532
|
+
getSymbol(): Promise<Symbol7 | undefined>
|
|
533
|
+
getAliasSymbol?(): Promise<Symbol7 | undefined>
|
|
534
|
+
getTypes?(): Promise<readonly Type7[] | undefined>
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
interface Checker7 {
|
|
538
|
+
getTypeAtPosition(file: string, position: number): Promise<Type7 | undefined>
|
|
539
|
+
getTypeOfSymbol(symbol: Symbol7): Promise<Type7 | undefined>
|
|
540
|
+
getPropertiesOfType(type: Type7): Promise<readonly Symbol7[]>
|
|
541
|
+
getIndexInfosOfType(type: Type7): Promise<readonly { keyType: Type7; valueType: Type7 }[]>
|
|
542
|
+
getSignaturesOfType(type: Type7, kind: number): Promise<readonly unknown[]>
|
|
543
|
+
getTypeArguments(type: Type7): Promise<readonly Type7[]>
|
|
544
|
+
typeToString(type: Type7): Promise<string>
|
|
545
|
+
isTupleType(type: Type7): Promise<boolean>
|
|
546
|
+
isArrayType(type: Type7): Promise<boolean>
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const CHECKER7_METHODS = [
|
|
550
|
+
"getTypeAtPosition",
|
|
551
|
+
"getTypeOfSymbol",
|
|
552
|
+
"getPropertiesOfType",
|
|
553
|
+
"getIndexInfosOfType",
|
|
554
|
+
"getSignaturesOfType",
|
|
555
|
+
"getTypeArguments",
|
|
556
|
+
"typeToString",
|
|
557
|
+
"isTupleType",
|
|
558
|
+
"isArrayType",
|
|
559
|
+
] as const
|
|
560
|
+
|
|
561
|
+
function isChecker7(value: unknown): value is Checker7 {
|
|
562
|
+
return (
|
|
563
|
+
typeof value === "object" &&
|
|
564
|
+
value !== null &&
|
|
565
|
+
CHECKER7_METHODS.every((method) => typeof Reflect.get(value, method) === "function")
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function reader7(semantic: TypeScriptSemanticSession, checker: Checker7): TypeReader<Type7> {
|
|
570
|
+
const flag = flagsFrom((name) => semantic.typeFlags[name])
|
|
571
|
+
const optional = semantic.symbolFlags.Optional
|
|
572
|
+
const call = semantic.signatureKind.Call
|
|
573
|
+
return {
|
|
574
|
+
flag,
|
|
575
|
+
flags: (type) => type.flags,
|
|
576
|
+
members: async (type) => (await type.getTypes?.()) ?? [],
|
|
577
|
+
symbolName: async (type) => (await type.getSymbol())?.name,
|
|
578
|
+
aliasName: async (type) => (await type.getAliasSymbol?.())?.name,
|
|
579
|
+
literal: async (type) => {
|
|
580
|
+
if ((type.flags & flag.BooleanLiteral) !== 0)
|
|
581
|
+
return (await checker.typeToString(type)) === "true"
|
|
582
|
+
return typeof type.value === "string" || typeof type.value === "number"
|
|
583
|
+
? type.value
|
|
584
|
+
: undefined
|
|
585
|
+
},
|
|
586
|
+
arrayElement: async (type) =>
|
|
587
|
+
(await checker.isArrayType(type)) ? (await checker.getTypeArguments(type))[0] : undefined,
|
|
588
|
+
tupleElements: async (type) =>
|
|
589
|
+
(await checker.isTupleType(type)) ? checker.getTypeArguments(type) : undefined,
|
|
590
|
+
typeArguments: async (type) => {
|
|
591
|
+
try {
|
|
592
|
+
return await checker.getTypeArguments(type)
|
|
593
|
+
} catch {
|
|
594
|
+
return []
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
properties: async (type) => {
|
|
598
|
+
const out: Property<Type7>[] = []
|
|
599
|
+
for (const property of await checker.getPropertiesOfType(type)) {
|
|
600
|
+
const propertyType = await checker.getTypeOfSymbol(property)
|
|
601
|
+
if (propertyType === undefined) continue
|
|
602
|
+
out.push({
|
|
603
|
+
name: property.name,
|
|
604
|
+
optional: typeof optional === "number" && (property.flags & optional) !== 0,
|
|
605
|
+
type: propertyType,
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
return out
|
|
609
|
+
},
|
|
610
|
+
stringIndex: async (type) =>
|
|
611
|
+
(await checker.getIndexInfosOfType(type)).find(
|
|
612
|
+
(info) => (info.keyType.flags & flag.StringLike) !== 0,
|
|
613
|
+
)?.valueType,
|
|
614
|
+
callable: async (type) =>
|
|
615
|
+
typeof call === "number" && (await checker.getSignaturesOfType(type, call)).length > 0,
|
|
616
|
+
text: (type) => checker.typeToString(type),
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Read every listed route function's return type and schema's output type through one program. */
|
|
621
|
+
async function readShapes(root: string, routes: readonly ProbeRoute[]): Promise<ProbeResult> {
|
|
622
|
+
const warnings: string[] = []
|
|
623
|
+
const shapes = new Map<string, Shape>()
|
|
624
|
+
if (routes.length === 0) return { shapes, warnings }
|
|
625
|
+
// TypeScript 7 resolves node handles only inside a configured project's root, so the probe and a
|
|
626
|
+
// short-lived config extending the app's sit at the root, and are removed afterwards.
|
|
627
|
+
const temp = await mkdtemp(join(root, ".nifra-schemas-"))
|
|
628
|
+
const tempName = basename(temp)
|
|
629
|
+
const probePath = join(root, `.${tempName}.ts`)
|
|
630
|
+
const probeConfigPath = join(root, `.${tempName}.tsconfig.json`)
|
|
631
|
+
const { text: probe, names } = probeSource(routes)
|
|
632
|
+
const routeFiles = routes.map((route) => join(root, route.file))
|
|
633
|
+
let close: (() => Promise<void>) | undefined
|
|
634
|
+
try {
|
|
635
|
+
await writeFile(probePath, probe, "utf8")
|
|
636
|
+
const include = [...routes.map((route) => posix(route.file)), basename(probePath)]
|
|
637
|
+
await writeFile(
|
|
638
|
+
probeConfigPath,
|
|
639
|
+
JSON.stringify(
|
|
640
|
+
existsSync(join(root, "tsconfig.json"))
|
|
641
|
+
? {
|
|
642
|
+
extends: "./tsconfig.json",
|
|
643
|
+
compilerOptions: { allowImportingTsExtensions: true, noEmit: true },
|
|
644
|
+
include,
|
|
645
|
+
}
|
|
646
|
+
: {
|
|
647
|
+
compilerOptions: {
|
|
648
|
+
module: "ESNext",
|
|
649
|
+
moduleResolution: "Bundler",
|
|
650
|
+
target: "ESNext",
|
|
651
|
+
strict: true,
|
|
652
|
+
allowImportingTsExtensions: true,
|
|
653
|
+
noEmit: true,
|
|
654
|
+
skipLibCheck: true,
|
|
655
|
+
},
|
|
656
|
+
include,
|
|
657
|
+
},
|
|
658
|
+
),
|
|
659
|
+
"utf8",
|
|
660
|
+
)
|
|
661
|
+
const contents = new Map<string, string>([[probePath, probe]])
|
|
662
|
+
for (const file of routeFiles) contents.set(file, readFileSync(file, "utf8"))
|
|
663
|
+
const loaded = await loadProjectTypeScript(root, undefined, {
|
|
664
|
+
files: [...routeFiles, probePath],
|
|
665
|
+
read: (file) => contents.get(file),
|
|
666
|
+
projectConfig: probeConfigPath,
|
|
667
|
+
})
|
|
668
|
+
close = loaded.session?.close
|
|
669
|
+
if (loaded.unsupported !== undefined) {
|
|
670
|
+
warnings.push(`TypeScript ${loaded.unsupported.version} is outside the supported API`)
|
|
671
|
+
return { shapes, warnings }
|
|
672
|
+
}
|
|
673
|
+
const ts = loaded.compiler
|
|
674
|
+
if (ts === undefined) {
|
|
675
|
+
warnings.push("TypeScript is not installed, so return types could not be read")
|
|
676
|
+
return { shapes, warnings }
|
|
677
|
+
}
|
|
678
|
+
const semantic = loaded.session?.semantic
|
|
679
|
+
if (semantic !== undefined) {
|
|
680
|
+
const checker = (await semantic.getCheckerForFile(probePath)) ?? semantic.checker
|
|
681
|
+
if (!isChecker7(checker)) {
|
|
682
|
+
warnings.push("the TypeScript 7 checker lacks the calls this needs")
|
|
683
|
+
return { shapes, warnings }
|
|
684
|
+
}
|
|
685
|
+
const reader = reader7(semantic, checker)
|
|
686
|
+
for (const [name, key] of names) {
|
|
687
|
+
const at = probe.indexOf(`declare const ${name}:`) + "declare const ".length
|
|
688
|
+
const type = await checker.getTypeAtPosition(probePath, at)
|
|
689
|
+
if (type !== undefined) shapes.set(key, await shapeOf(reader, type, new Set(), 0))
|
|
690
|
+
}
|
|
691
|
+
return { shapes, warnings }
|
|
692
|
+
}
|
|
693
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(
|
|
694
|
+
probeConfigPath,
|
|
695
|
+
{},
|
|
696
|
+
{
|
|
697
|
+
...ts.sys,
|
|
698
|
+
onUnRecoverableConfigFileDiagnostic: () => {},
|
|
699
|
+
},
|
|
700
|
+
)
|
|
701
|
+
const options: TSApi.CompilerOptions = {
|
|
702
|
+
...(parsed?.options ?? {}),
|
|
703
|
+
noEmit: true,
|
|
704
|
+
skipLibCheck: true,
|
|
705
|
+
allowImportingTsExtensions: true,
|
|
706
|
+
}
|
|
707
|
+
const program = ts.createProgram([probePath], options, ts.createCompilerHost(options, true))
|
|
708
|
+
const source = program.getSourceFile(probePath)
|
|
709
|
+
if (source === undefined) {
|
|
710
|
+
warnings.push("could not create the type probe")
|
|
711
|
+
return { shapes, warnings }
|
|
712
|
+
}
|
|
713
|
+
const checker = program.getTypeChecker()
|
|
714
|
+
const reader = legacyReader(ts, checker, source)
|
|
715
|
+
for (const statement of source.statements) {
|
|
716
|
+
if (!ts.isVariableStatement(statement)) continue
|
|
717
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
718
|
+
if (!ts.isIdentifier(declaration.name)) continue
|
|
719
|
+
const key = names.get(declaration.name.text)
|
|
720
|
+
if (key === undefined) continue
|
|
721
|
+
const type = checker.getTypeAtLocation(declaration.name)
|
|
722
|
+
shapes.set(key, await shapeOf(reader, type, new Set(), 0))
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return { shapes, warnings }
|
|
726
|
+
} finally {
|
|
727
|
+
await close?.()
|
|
728
|
+
await rm(probePath, { force: true })
|
|
729
|
+
await rm(probeConfigPath, { force: true })
|
|
730
|
+
await rm(temp, { recursive: true, force: true })
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---- planning edits ----------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
export type OutputSchemaStatus =
|
|
737
|
+
| "missing"
|
|
738
|
+
| "generated"
|
|
739
|
+
| "stale"
|
|
740
|
+
| "regenerated"
|
|
741
|
+
| "current"
|
|
742
|
+
| "declared"
|
|
743
|
+
| "no-data"
|
|
744
|
+
| "unreadable"
|
|
745
|
+
|
|
746
|
+
export interface OutputSchemaEntry {
|
|
747
|
+
readonly file: string
|
|
748
|
+
readonly line: number
|
|
749
|
+
readonly schema: OutputExport
|
|
750
|
+
readonly status: OutputSchemaStatus
|
|
751
|
+
/** The schema carries the `nifra:generated` marker. */
|
|
752
|
+
readonly generated: boolean
|
|
753
|
+
/** Returned paths the schema does not declare, so the output guard drops them. */
|
|
754
|
+
readonly dropped: readonly string[]
|
|
755
|
+
/** A generated schema whose text was changed under its marker: stale though its route is not. */
|
|
756
|
+
readonly edited?: boolean
|
|
757
|
+
readonly reason?: string
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
interface SchemaDeclaration {
|
|
761
|
+
/** Start of the marker line when generated, else of the `export`. */
|
|
762
|
+
readonly start: number
|
|
763
|
+
readonly end: number
|
|
764
|
+
/** The text the schema is declared with, after the `=`. */
|
|
765
|
+
readonly value: string
|
|
766
|
+
readonly line: number
|
|
767
|
+
readonly hash: string | undefined
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
interface Target {
|
|
771
|
+
readonly fn: RouteFn
|
|
772
|
+
readonly schema: OutputExport
|
|
773
|
+
/** Where a new declaration goes: above the function's export and its doc comment. */
|
|
774
|
+
readonly insertAt: number
|
|
775
|
+
readonly fnLine: number
|
|
776
|
+
/** `export const loader: <type> = ...`: the checker reads that type, not what the body returns. */
|
|
777
|
+
readonly annotated: boolean
|
|
778
|
+
/** `undefined` when the schema is not exported; `null` when exported in a form not rewritten. */
|
|
779
|
+
readonly declaration: SchemaDeclaration | null | undefined
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const lineAt = (text: string, index: number): number => text.slice(0, index).split("\n").length
|
|
783
|
+
const lineStart = (text: string, index: number): number => text.lastIndexOf("\n", index - 1) + 1
|
|
784
|
+
|
|
785
|
+
/** The start of the comment block directly above the line at `start`, or `start`. */
|
|
786
|
+
function aboveComments(text: string, start: number): number {
|
|
787
|
+
let at = start
|
|
788
|
+
while (at > 0) {
|
|
789
|
+
const previous = lineStart(text, at - 1)
|
|
790
|
+
const line = text.slice(previous, at - 1).trim()
|
|
791
|
+
if (!(line.startsWith("//") || line.startsWith("/*") || line.startsWith("*"))) break
|
|
792
|
+
at = previous
|
|
793
|
+
}
|
|
794
|
+
return at
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function declarationOf(
|
|
798
|
+
content: string,
|
|
799
|
+
mask: string,
|
|
800
|
+
schema: OutputExport,
|
|
801
|
+
): SchemaDeclaration | null | undefined {
|
|
802
|
+
const found = new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${schema}\\b[^=]*=`).exec(mask)
|
|
803
|
+
if (found === null) return exported(mask, schema) === null ? undefined : null
|
|
804
|
+
const valueStart = found.index + found[0].length
|
|
805
|
+
let end = expressionEnd(mask, valueStart)
|
|
806
|
+
if (content[end] === ";") end++
|
|
807
|
+
const exportStart = lineStart(content, found.index)
|
|
808
|
+
const markerStart = exportStart === 0 ? -1 : lineStart(content, exportStart - 1)
|
|
809
|
+
const marker =
|
|
810
|
+
markerStart === -1 ? null : MARKER.exec(content.slice(markerStart, exportStart - 1))
|
|
811
|
+
return {
|
|
812
|
+
start: marker === null ? found.index : markerStart,
|
|
813
|
+
end,
|
|
814
|
+
value: content.slice(valueStart, end),
|
|
815
|
+
line: lineAt(content, found.index),
|
|
816
|
+
hash: marker?.[1],
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function targetsOf(content: string): Target[] {
|
|
821
|
+
const mask = codePositionMask(content)
|
|
822
|
+
const targets: Target[] = []
|
|
823
|
+
for (const [fn, schema] of PAIRS) {
|
|
824
|
+
const found = exported(mask, fn)
|
|
825
|
+
if (found === null) continue
|
|
826
|
+
const reexport = found[0].startsWith("export {") || found[0].startsWith("export{")
|
|
827
|
+
let after = found.index + found[0].length
|
|
828
|
+
while (/\s/.test(mask[after] ?? "")) after++
|
|
829
|
+
targets.push({
|
|
830
|
+
fn,
|
|
831
|
+
schema,
|
|
832
|
+
insertAt: reexport ? content.length : aboveComments(content, lineStart(content, found.index)),
|
|
833
|
+
fnLine: lineAt(content, found.index),
|
|
834
|
+
annotated:
|
|
835
|
+
!reexport &&
|
|
836
|
+
/^(?:const|let|var)\b/.test(found[0].replace(/^export\s+/, "")) &&
|
|
837
|
+
mask[after] === ":",
|
|
838
|
+
declaration: declarationOf(content, mask, schema),
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
return targets
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const SCHEMA_IMPORT = /\bimport\s*(type\s*)?\{([^}]*)\}\s*from\s*["']@nifrajs\/schema["']/g
|
|
845
|
+
const IMPORT = /^import\b[\s\S]*?from\s*["'][^"'\n]+["'];?|^import\s*["'][^"'\n]+["'];?/gm
|
|
846
|
+
const T_BINDING =
|
|
847
|
+
/\b(?:const|let|var|function|class)\s+t\b|\bimport\s+t\b|[{,]\s*t\s*[,}]|\bas\s+t\b/
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* `content` with `t` imported from `@nifrajs/schema`, or `undefined` when `t` already names something
|
|
851
|
+
* else in the file. Strings are blank in the code mask, so imports are read from the text and kept
|
|
852
|
+
* only where the mask shows code.
|
|
853
|
+
*/
|
|
854
|
+
function withSchemaImport(content: string): string | undefined {
|
|
855
|
+
const mask = codePositionMask(content)
|
|
856
|
+
const inCode = (match: RegExpExecArray): boolean => mask[match.index] === "i"
|
|
857
|
+
const imports = [...content.matchAll(SCHEMA_IMPORT)].filter(inCode)
|
|
858
|
+
if (imports.some((m) => m[1] === undefined && /(?:^|,)\s*t\s*(?:,|$)/.test(m[2] ?? "")))
|
|
859
|
+
return content
|
|
860
|
+
if (T_BINDING.test(mask)) return undefined
|
|
861
|
+
const named = imports.find((m) => m[1] === undefined)
|
|
862
|
+
if (named !== undefined) {
|
|
863
|
+
const brace = content.indexOf("{", named.index)
|
|
864
|
+
return `${content.slice(0, brace + 1)} t,${content.slice(brace + 1)}`
|
|
865
|
+
}
|
|
866
|
+
let end = 0
|
|
867
|
+
for (const match of content.matchAll(IMPORT))
|
|
868
|
+
if (inCode(match)) end = match.index + match[0].length
|
|
869
|
+
const line = 'import { t } from "@nifrajs/schema"'
|
|
870
|
+
return end === 0
|
|
871
|
+
? `${line}\n\n${content}`
|
|
872
|
+
: `${content.slice(0, end)}\n${line}${content.slice(end)}`
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
interface Plan {
|
|
876
|
+
readonly entries: OutputSchemaEntry[]
|
|
877
|
+
readonly edits: Map<string, string>
|
|
878
|
+
readonly warnings: readonly string[]
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** What each listed route backend half needs, and the file contents that would apply it. */
|
|
882
|
+
async function plan(
|
|
883
|
+
root: string,
|
|
884
|
+
files: readonly string[],
|
|
885
|
+
read: (file: string) => string | undefined,
|
|
886
|
+
): Promise<Plan> {
|
|
887
|
+
const perFile = new Map<string, { content: string; targets: Target[] }>()
|
|
888
|
+
for (const file of files) {
|
|
889
|
+
const content = read(file)
|
|
890
|
+
if (content === undefined) continue
|
|
891
|
+
const targets = targetsOf(content)
|
|
892
|
+
if (targets.length > 0) perFile.set(file, { content, targets })
|
|
893
|
+
}
|
|
894
|
+
const probe: ProbeRoute[] = [...perFile].map(([file, { targets }]) => ({
|
|
895
|
+
file,
|
|
896
|
+
fns: targets.map((target) => target.fn),
|
|
897
|
+
schemas: targets.filter((t) => t.declaration !== undefined).map((t) => t.schema),
|
|
898
|
+
}))
|
|
899
|
+
const { shapes, warnings } = await readShapes(root, probe)
|
|
900
|
+
const entries: OutputSchemaEntry[] = []
|
|
901
|
+
const edits = new Map<string, string>()
|
|
902
|
+
if (warnings.length > 0) return { entries, edits, warnings }
|
|
903
|
+
for (const [file, { content, targets }] of perFile) {
|
|
904
|
+
const replacements: Array<{ start: number; end: number; text: string; entry: number }> = []
|
|
905
|
+
for (const target of targets) {
|
|
906
|
+
const raw = shapes.get(`${file}#${target.fn}`)
|
|
907
|
+
const declared = shapes.get(`${file}#${target.schema}`)
|
|
908
|
+
const { declaration } = target
|
|
909
|
+
const line = declaration?.line ?? target.fnLine
|
|
910
|
+
const entry = (
|
|
911
|
+
status: OutputSchemaStatus,
|
|
912
|
+
dropped: readonly string[] = [],
|
|
913
|
+
reason?: string,
|
|
914
|
+
edited = false,
|
|
915
|
+
): number => {
|
|
916
|
+
const schema = target.schema
|
|
917
|
+
const generated = declaration?.hash !== undefined
|
|
918
|
+
const base: OutputSchemaEntry = { file, line, schema, status, generated, dropped }
|
|
919
|
+
const item: OutputSchemaEntry =
|
|
920
|
+
reason !== undefined ? { ...base, reason } : edited ? { ...base, edited } : base
|
|
921
|
+
return entries.push(item) - 1
|
|
922
|
+
}
|
|
923
|
+
if (target.annotated) {
|
|
924
|
+
// A `Route.Loader` annotation returns the schema's own input type: comparing the two would
|
|
925
|
+
// always agree, so the schema is reported unchecked rather than passed.
|
|
926
|
+
entry(
|
|
927
|
+
"unreadable",
|
|
928
|
+
[],
|
|
929
|
+
`${target.fn} has a type annotation, so its return type is the annotation's, not what it returns. Remove the annotation to have the schema generated and checked, or delete the "nifra:generated" line to own the schema by hand`,
|
|
930
|
+
)
|
|
931
|
+
continue
|
|
932
|
+
}
|
|
933
|
+
if (raw === undefined) {
|
|
934
|
+
entry("unreadable", [], "TypeScript could not read its type")
|
|
935
|
+
continue
|
|
936
|
+
}
|
|
937
|
+
const returned = dataShape(raw)
|
|
938
|
+
if (returned.kind === "never") {
|
|
939
|
+
entry("no-data")
|
|
940
|
+
continue
|
|
941
|
+
}
|
|
942
|
+
const dropped =
|
|
943
|
+
declared === undefined || declared.kind === "never"
|
|
944
|
+
? []
|
|
945
|
+
: undeclaredPaths(returned, declared)
|
|
946
|
+
if (declaration === null || (declaration !== undefined && declaration.hash === undefined)) {
|
|
947
|
+
entry("declared", dropped)
|
|
948
|
+
continue
|
|
949
|
+
}
|
|
950
|
+
if (returned.kind === "json" && returned.reason !== undefined) {
|
|
951
|
+
entry(
|
|
952
|
+
"unreadable",
|
|
953
|
+
dropped,
|
|
954
|
+
`the ${target.fn}'s return type is ${returned.reason}: annotate it, or write the schema by hand`,
|
|
955
|
+
)
|
|
956
|
+
continue
|
|
957
|
+
}
|
|
958
|
+
const body = schemaSource(returned)
|
|
959
|
+
if (declaration === undefined) {
|
|
960
|
+
const at = entry("missing")
|
|
961
|
+
const written = declarationText(target.schema, target.fn, body)
|
|
962
|
+
const text = target.insertAt === content.length ? `\n${written}\n` : `${written}\n\n`
|
|
963
|
+
replacements.push({ start: target.insertAt, end: target.insertAt, text, entry: at })
|
|
964
|
+
} else if (
|
|
965
|
+
declaration.hash === hashOf(body) &&
|
|
966
|
+
schemaTokens(declaration.value) === schemaTokens(body)
|
|
967
|
+
) {
|
|
968
|
+
entry("current")
|
|
969
|
+
} else {
|
|
970
|
+
// The marker still matches what would be written, so the text under it was edited.
|
|
971
|
+
const edited = declaration.hash === hashOf(body)
|
|
972
|
+
const at = entry("stale", dropped, undefined, edited)
|
|
973
|
+
const text = declarationText(target.schema, target.fn, body)
|
|
974
|
+
replacements.push({ start: declaration.start, end: declaration.end, text, entry: at })
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (replacements.length === 0) continue
|
|
978
|
+
let next = content
|
|
979
|
+
for (const r of replacements.sort((a, b) => b.start - a.start))
|
|
980
|
+
next = `${next.slice(0, r.start)}${r.text}${next.slice(r.end)}`
|
|
981
|
+
const imported = withSchemaImport(next)
|
|
982
|
+
if (imported === undefined) {
|
|
983
|
+
for (const r of replacements) {
|
|
984
|
+
const blocked = entries[r.entry]
|
|
985
|
+
if (blocked !== undefined)
|
|
986
|
+
entries[r.entry] = {
|
|
987
|
+
...blocked,
|
|
988
|
+
reason: "`t` already names something else in this file, so it was left as is",
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
continue
|
|
992
|
+
}
|
|
993
|
+
edits.set(file, imported)
|
|
994
|
+
}
|
|
995
|
+
entries.sort((a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line)
|
|
996
|
+
return { entries, edits, warnings }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---- entry points ------------------------------------------------------------------------------
|
|
1000
|
+
|
|
1001
|
+
const ROUTE_BACKEND_TS = /\.backend\.[cm]?ts$/
|
|
1002
|
+
|
|
1003
|
+
function routeBackends(dir: string): string[] {
|
|
1004
|
+
const routes = join(dir, "routes")
|
|
1005
|
+
if (!existsSync(routes)) return []
|
|
1006
|
+
return readdirSync(routes, { recursive: true, encoding: "utf8" })
|
|
1007
|
+
.map((file) => posix(join("routes", file)))
|
|
1008
|
+
.filter((file) => ROUTE_BACKEND_TS.test(file) && !file.split("/").includes("node_modules"))
|
|
1009
|
+
.sort(codeUnitOrder)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export interface OutputSchemasReport {
|
|
1013
|
+
readonly ok: boolean
|
|
1014
|
+
readonly write: boolean
|
|
1015
|
+
readonly entries: readonly OutputSchemaEntry[]
|
|
1016
|
+
readonly written: readonly string[]
|
|
1017
|
+
readonly warnings: readonly string[]
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Every route backend half under `<dir>/routes`: report what each loader's and action's schema needs,
|
|
1022
|
+
* and with `write`, write the missing schemas and refresh the stale generated ones. A hand-written
|
|
1023
|
+
* schema is never touched; its entry lists the returned fields it drops.
|
|
1024
|
+
*/
|
|
1025
|
+
export async function outputSchemas(
|
|
1026
|
+
dir: string,
|
|
1027
|
+
options: { readonly write?: boolean } = {},
|
|
1028
|
+
): Promise<OutputSchemasReport> {
|
|
1029
|
+
const write = options.write === true
|
|
1030
|
+
const { entries, edits, warnings } = await plan(dir, routeBackends(dir), (file) => {
|
|
1031
|
+
try {
|
|
1032
|
+
return readFileSync(join(dir, file), "utf8")
|
|
1033
|
+
} catch {
|
|
1034
|
+
return undefined
|
|
1035
|
+
}
|
|
1036
|
+
})
|
|
1037
|
+
const written: string[] = []
|
|
1038
|
+
let final = entries
|
|
1039
|
+
if (write) {
|
|
1040
|
+
for (const [file, content] of edits) {
|
|
1041
|
+
writeFileSync(join(dir, file), content)
|
|
1042
|
+
written.push(file)
|
|
1043
|
+
}
|
|
1044
|
+
final = entries.map((entry) =>
|
|
1045
|
+
!written.includes(entry.file) || entry.reason !== undefined
|
|
1046
|
+
? entry
|
|
1047
|
+
: entry.status === "missing"
|
|
1048
|
+
? { ...entry, status: "generated" }
|
|
1049
|
+
: entry.status === "stale"
|
|
1050
|
+
? { ...entry, status: "regenerated" }
|
|
1051
|
+
: entry,
|
|
1052
|
+
)
|
|
1053
|
+
}
|
|
1054
|
+
return {
|
|
1055
|
+
ok: warnings.length === 0 && !final.some((entry) => entry.status === "stale"),
|
|
1056
|
+
write,
|
|
1057
|
+
entries: final,
|
|
1058
|
+
written,
|
|
1059
|
+
warnings,
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* The generated schemas among `files` whose route now returns a different shape (NF-C033), and those
|
|
1065
|
+
* it could not compare. `warnings` says why nothing could be read at all.
|
|
1066
|
+
*/
|
|
1067
|
+
export async function staleGeneratedSchemas(
|
|
1068
|
+
root: string,
|
|
1069
|
+
files: readonly string[],
|
|
1070
|
+
read: (file: string) => string | undefined,
|
|
1071
|
+
): Promise<{
|
|
1072
|
+
readonly stale: readonly OutputSchemaEntry[]
|
|
1073
|
+
readonly unchecked: readonly OutputSchemaEntry[]
|
|
1074
|
+
readonly warnings: readonly string[]
|
|
1075
|
+
}> {
|
|
1076
|
+
const marked = files.filter((file) => read(file)?.includes(GENERATED_MARK) === true)
|
|
1077
|
+
if (marked.length === 0) return { stale: [], unchecked: [], warnings: [] }
|
|
1078
|
+
const { entries, warnings } = await plan(root, marked, read)
|
|
1079
|
+
return {
|
|
1080
|
+
stale: entries.filter((entry) => entry.status === "stale"),
|
|
1081
|
+
unchecked: entries.filter((entry) => entry.generated && entry.status === "unreadable"),
|
|
1082
|
+
warnings,
|
|
1083
|
+
}
|
|
1084
|
+
}
|