@nifrajs/cli 4.0.2 → 5.1.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 +10 -2
- 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 +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -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 +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- 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 +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- 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/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.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 +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -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 +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- 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 +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -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 +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- 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 +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- 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 +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -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-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.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 +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -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/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.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/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.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 +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -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 +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -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 +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- 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 +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- 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 +218 -44
- 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 +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -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 +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- 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 +94 -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 +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- 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/dev-feed-tool.ts +112 -1
- 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 +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- 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 +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
|
@@ -20,6 +20,13 @@ export interface SourceFacts {
|
|
|
20
20
|
method: string,
|
|
21
21
|
path: string,
|
|
22
22
|
): boolean | undefined
|
|
23
|
+
/** A `.method(argument, …)` member call whose first argument is written exactly as `argument`. */
|
|
24
|
+
isRouteArgumentAt(
|
|
25
|
+
source: TSApi.SourceFile,
|
|
26
|
+
position: number,
|
|
27
|
+
method: string,
|
|
28
|
+
argument: string,
|
|
29
|
+
): boolean | undefined
|
|
23
30
|
/** A call to the free function `callee` whose argument at `pathIndex` is the literal `path`. */
|
|
24
31
|
isFunctionRouteCallAt(
|
|
25
32
|
source: TSApi.SourceFile,
|
|
@@ -75,8 +82,26 @@ function rawResponseExpression(ts: TypeScriptApi, node: TSApi.Expression): boole
|
|
|
75
82
|
)
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
export
|
|
85
|
+
export interface SourceFactsOptions {
|
|
86
|
+
/** Whether the compiler settings for a parsed file keep an import whose named specifiers are all
|
|
87
|
+
* type-only (`verbatimModuleSyntax`). Without it such an import is erased. */
|
|
88
|
+
readonly keepsTypeOnlyImports?: (file: string) => boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSourceFacts(
|
|
92
|
+
ts: TypeScriptApi,
|
|
93
|
+
options: SourceFactsOptions = {},
|
|
94
|
+
): SourceFacts {
|
|
79
95
|
const cache = new Map<string, { content: string; source: TSApi.SourceFile | undefined }>()
|
|
96
|
+
// `{}` loads its module in every bundler; a list of type-only specifiers is erased unless the
|
|
97
|
+
// compiler keeps it as `import {} from "x"`.
|
|
98
|
+
const loadsModule = (
|
|
99
|
+
source: TSApi.SourceFile,
|
|
100
|
+
elements: readonly { readonly isTypeOnly: boolean }[],
|
|
101
|
+
): boolean =>
|
|
102
|
+
elements.length === 0 ||
|
|
103
|
+
elements.some((element) => !element.isTypeOnly) ||
|
|
104
|
+
(options.keepsTypeOnlyImports?.(source.fileName) ?? false)
|
|
80
105
|
|
|
81
106
|
const parse = (file: string, content: string): TSApi.SourceFile | undefined => {
|
|
82
107
|
const cached = cache.get(file)
|
|
@@ -111,7 +136,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
111
136
|
if (reexport.isTypeOnly) return false
|
|
112
137
|
const named = reexport.exportClause
|
|
113
138
|
if (named !== undefined && ts.isNamedExports(named))
|
|
114
|
-
return named.elements
|
|
139
|
+
return loadsModule(source, named.elements)
|
|
115
140
|
return true // `export *` and `export * as ns`
|
|
116
141
|
}
|
|
117
142
|
const declaration = ancestorAt(ts, source, position, ts.isImportDeclaration)
|
|
@@ -121,9 +146,9 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
121
146
|
const clause = declaration.importClause
|
|
122
147
|
if (clause === undefined) return true // side-effect import
|
|
123
148
|
if (clause.isTypeOnly) return false
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
149
|
+
// A default binding is a runtime import whatever its named list holds.
|
|
150
|
+
const named = clause.name === undefined ? clause.namedBindings : undefined
|
|
151
|
+
if (named !== undefined && ts.isNamedImports(named)) return loadsModule(source, named.elements)
|
|
127
152
|
return true // default and namespace bindings are runtime imports
|
|
128
153
|
}
|
|
129
154
|
|
|
@@ -142,6 +167,23 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
142
167
|
)
|
|
143
168
|
}
|
|
144
169
|
|
|
170
|
+
const isRouteArgumentAt = (
|
|
171
|
+
source: TSApi.SourceFile,
|
|
172
|
+
position: number,
|
|
173
|
+
method: string,
|
|
174
|
+
argument: string,
|
|
175
|
+
): boolean | undefined => {
|
|
176
|
+
const call = ancestorAt(ts, source, position, ts.isCallExpression)
|
|
177
|
+
if (call === undefined || !ts.isPropertyAccessExpression(call.expression)) return false
|
|
178
|
+
const first = call.arguments[0]
|
|
179
|
+
return (
|
|
180
|
+
call.expression.name.text.toUpperCase() === method &&
|
|
181
|
+
call.arguments.length > 1 &&
|
|
182
|
+
first !== undefined &&
|
|
183
|
+
first.getText(source) === argument
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
145
187
|
const isFunctionRouteCallAt = (
|
|
146
188
|
source: TSApi.SourceFile,
|
|
147
189
|
position: number,
|
|
@@ -200,6 +242,7 @@ export function createSourceFacts(ts: TypeScriptApi): SourceFacts {
|
|
|
200
242
|
parse,
|
|
201
243
|
isValueImportAt,
|
|
202
244
|
isRouteRegistrationAt,
|
|
245
|
+
isRouteArgumentAt,
|
|
203
246
|
isFunctionRouteCallAt,
|
|
204
247
|
isResponseSyntaxAt,
|
|
205
248
|
isDynamicImportAt,
|
|
@@ -48,11 +48,14 @@ export function unsupportedTypeScriptMessage(version: string): string {
|
|
|
48
48
|
return `${NIFRA_TS_UNSUPPORTED}: TypeScript ${version} is outside the compiler API versions supported by Nifra scanners. Use TypeScript 5, 6, or 7, or pin the project to TypeScript 6 (for example, \`bun add -d typescript@^6\`).`
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export function unsupportedTypeScriptDiagnostic(
|
|
51
|
+
export function unsupportedTypeScriptDiagnostic(
|
|
52
|
+
version: string,
|
|
53
|
+
message = unsupportedTypeScriptMessage(version),
|
|
54
|
+
): Diagnostic {
|
|
52
55
|
return diagnostic({
|
|
53
56
|
code: NIFRA_TS_UNSUPPORTED,
|
|
54
57
|
severity: "error",
|
|
55
|
-
message
|
|
58
|
+
message,
|
|
56
59
|
fix: {
|
|
57
60
|
recipe: "toolchain.install-typescript",
|
|
58
61
|
command: "bun add -d typescript@^6",
|
|
@@ -320,9 +323,9 @@ function syntaxKinds(module: UnknownRecord, names: readonly string[]): ReadonlyS
|
|
|
320
323
|
|
|
321
324
|
function createVirtualFileSystem(
|
|
322
325
|
sourceFiles: ReadonlyMap<string, string>,
|
|
323
|
-
|
|
326
|
+
roots: readonly string[],
|
|
324
327
|
): UnknownRecord {
|
|
325
|
-
const rootPaths = pathVariants
|
|
328
|
+
const rootPaths = [...new Set(roots.flatMap(pathVariants))]
|
|
326
329
|
const virtualFiles: string[] = []
|
|
327
330
|
const seenFiles = new Set<string>()
|
|
328
331
|
for (const file of sourceFiles.keys()) {
|
|
@@ -412,7 +415,11 @@ function sourceEntries(
|
|
|
412
415
|
const content = sourceIndex.read(file)
|
|
413
416
|
if (content === undefined) continue
|
|
414
417
|
const absolute = isAbsolute(file) ? file : resolve(root, file)
|
|
415
|
-
|
|
418
|
+
// The session opens script extensions only; a component's script blocks are opened as TypeScript
|
|
419
|
+
// beside it, and `createSourceFile` finds them by their text.
|
|
420
|
+
const normalized = normalizedPath(
|
|
421
|
+
/\.(?:svelte|vue)$/.test(absolute) ? `${absolute}.ts` : absolute,
|
|
422
|
+
)
|
|
416
423
|
paths.push(normalized)
|
|
417
424
|
for (const variant of pathVariants(normalized)) files.set(variant, content)
|
|
418
425
|
}
|
|
@@ -447,7 +454,9 @@ async function createTypeScript7Session(
|
|
|
447
454
|
|
|
448
455
|
const api = new API({
|
|
449
456
|
cwd: sessionRoot,
|
|
450
|
-
|
|
457
|
+
// The root as the caller spells it too: a file not yet on disk keeps that spelling (`/var`, not
|
|
458
|
+
// `/private/var`), and the session reaches it through the caller's config.
|
|
459
|
+
fs: createVirtualFileSystem(files, [sessionRoot, root]),
|
|
451
460
|
})
|
|
452
461
|
let snapshot: TypeScript7Snapshot | undefined
|
|
453
462
|
let configuredProject: TypeScript7Project | undefined
|
package/src/introspect.ts
CHANGED
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
snapshotProjectEvidence,
|
|
14
14
|
} from "@nifrajs/core/evidence"
|
|
15
15
|
import { compileRoutePattern, type RoutePatternSegment } from "@nifrajs/core/pattern"
|
|
16
|
-
import type { ReflectedRoute } from "@nifrajs/core/reflection"
|
|
16
|
+
import type { ReflectedMount, ReflectedRoute } from "@nifrajs/core/reflection"
|
|
17
17
|
import type { Manifest } from "@nifrajs/web"
|
|
18
18
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
19
19
|
import { normalizeMountPath } from "@nifrajs/web/route-manifest"
|
|
20
20
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
21
|
-
import type {
|
|
21
|
+
import type { IntrospectedApp } from "./load.ts"
|
|
22
22
|
import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
|
|
23
23
|
|
|
24
24
|
/** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
|
|
@@ -225,6 +225,22 @@ export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string
|
|
|
225
225
|
return `## API routes (backend/app.ts)\n\n${routes.length} route${routes.length === 1 ? "" : "s"}. Call \`nifra_context\` again with \`path\` (a route prefix) and/or \`kind: "api"\` for the body/query/response contracts + the exact \`client<typeof app>\` call form - or \`nifra_routes\` for the same as structured JSON.\n\n${lines.join("\n")}`
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/** The routes opaque mounts declare, under `prefix`, or `undefined` when there are none. Listed apart
|
|
229
|
+
* from the API routes: nifra serves them but never analyzed them. */
|
|
230
|
+
function declaredMountRoutesSection(
|
|
231
|
+
mounts: readonly ReflectedMount[],
|
|
232
|
+
prefix: string | undefined,
|
|
233
|
+
): string | undefined {
|
|
234
|
+
const lines = mounts.flatMap((mount) =>
|
|
235
|
+
(mount.routes ?? [])
|
|
236
|
+
.filter((route) => prefix === undefined || route.path.startsWith(prefix))
|
|
237
|
+
.map((route) => `- \`${route.method} ${route.path}\` - ${mount.path} (${mount.opaque})`),
|
|
238
|
+
)
|
|
239
|
+
return lines.length === 0
|
|
240
|
+
? undefined
|
|
241
|
+
: `## Declared by opaque mounts (not analyzed)\n\n${lines.join("\n")}`
|
|
242
|
+
}
|
|
243
|
+
|
|
228
244
|
/** Markdown section listing the file-routed pages (URL pattern → source file). */
|
|
229
245
|
export function pageRoutesSection(manifest: Manifest | undefined): string {
|
|
230
246
|
if (!manifest || manifest.routes.length === 0) {
|
|
@@ -236,6 +252,10 @@ export function pageRoutesSection(manifest: Manifest | undefined): string {
|
|
|
236
252
|
return `## Page routes (routes/)\n\n${lines.join("\n")}`
|
|
237
253
|
}
|
|
238
254
|
|
|
255
|
+
const PAGES_CONVENTION = `- **Pages:** file-routed under \`routes/\`. A route is two files: \`x.tsx\` (browser) and \`x.backend.ts\`
|
|
256
|
+
(\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
|
|
257
|
+
use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.`
|
|
258
|
+
|
|
239
259
|
const CONVENTIONS = `## Conventions (summary)
|
|
240
260
|
|
|
241
261
|
- **Backend:** \`server()\` from \`@nifrajs/core\`; returning a value sends JSON. Validate untrusted input with
|
|
@@ -253,9 +273,7 @@ const CONVENTIONS = `## Conventions (summary)
|
|
|
253
273
|
\`res.data\` from the backend's route types, so the compiler catches any frontend/backend drift. Never
|
|
254
274
|
hand-roll \`fetch\` + ad-hoc response types for an internal API - that's exactly how screens drift. It
|
|
255
275
|
never throws: branch on \`res.ok ? res.data : res.error\`.
|
|
256
|
-
|
|
257
|
-
(\`loader\`, \`action\`, \`middleware\`; server only). Server code lives under \`backend/\`, code both sides
|
|
258
|
-
use under \`shared/\`, components under \`frontend/\`; the browser build refuses backend code outright.
|
|
276
|
+
${PAGES_CONVENTION}
|
|
259
277
|
- \`app.fetch(Request)\` is the universal entry. Full reference: this repo's \`AGENTS.md\`, or \`llms-full.txt\`.`
|
|
260
278
|
|
|
261
279
|
/** Optional narrowing for {@link describeProject} - a path prefix and/or one section. A filtered
|
|
@@ -274,15 +292,18 @@ export interface ContextFilter {
|
|
|
274
292
|
* body/query/response shapes + the typed-client `call` form), and omits the conventions block (the agent
|
|
275
293
|
* already has it from the index call).
|
|
276
294
|
*/
|
|
277
|
-
export function describeProject(app:
|
|
295
|
+
export function describeProject(app: IntrospectedApp, filter?: ContextFilter): string {
|
|
278
296
|
let manifest: Manifest | undefined
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
297
|
+
if (app.framework !== undefined) {
|
|
298
|
+
try {
|
|
299
|
+
manifest = discoverRoutes(app.routesDir)
|
|
300
|
+
} catch {
|
|
301
|
+
// No (or unreadable) routes/ - the page-routes section reports that.
|
|
302
|
+
}
|
|
283
303
|
}
|
|
284
304
|
const prefix = filter?.path
|
|
285
|
-
|
|
305
|
+
const evidence = snapshotProjectEvidence(app.backend)
|
|
306
|
+
let routes = reflectedRoutesFromEvidence(evidence)
|
|
286
307
|
let pages = manifest
|
|
287
308
|
if (prefix !== undefined && prefix !== "") {
|
|
288
309
|
routes = routes.filter((r) => r.path.startsWith(prefix))
|
|
@@ -296,20 +317,34 @@ export function describeProject(app: LoadedApp, filter?: ContextFilter): string
|
|
|
296
317
|
const sections: string[] = [`# nifra project: ${app.cwd.slice(app.cwd.lastIndexOf("/") + 1)}`]
|
|
297
318
|
if (isIndex) {
|
|
298
319
|
sections.push(
|
|
299
|
-
|
|
320
|
+
app.framework === undefined
|
|
321
|
+
? "An API-only nifra app (backend/app.ts, no web config) - Web-standard, one backend on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below)."
|
|
322
|
+
: "A full-stack nifra app - Web-standard, one app on Bun, Node, Deno, and the edge. This is the route INDEX; fetch a route's full body/query/response contract on demand (see below).",
|
|
300
323
|
// Point agents at the verified surface, not recalled APIs - nifra is young, so training data is the
|
|
301
324
|
// wrong source. The MCP tools (nifra_docs / nifra_example) are checked against the installed version.
|
|
302
325
|
"> **Build against THIS surface + the verified tools - do not write nifra APIs from memory** (the\n> framework is young; recalled APIs drift). For framework code, call `nifra_example` (snippets\n> typechecked against the installed version) or `nifra_docs`; verify edits with `nifra_run` + `nifra_check`.",
|
|
326
|
+
)
|
|
327
|
+
if (app.framework !== undefined) {
|
|
303
328
|
// The authoritative answer, not the static reading `nifra_check`/`nifra_doctor` give: this runs on
|
|
304
329
|
// the LOADED config, so it is the same decision `nifra dev` and `nifra build` make. Stated in the
|
|
305
330
|
// index because it governs where a plugin goes and which toolchain compiles a component - an edit
|
|
306
331
|
// put in the other pipeline's slot is accepted and then silently never runs.
|
|
307
|
-
|
|
308
|
-
|
|
332
|
+
sections.push(
|
|
333
|
+
`**Bundler**: ${describePipeline(chooseBuildPipeline(app.resolvedPlugins))}. \`vitePlugins\` feed Vite; \`clientPlugins\`/\`serverPlugins\` feed Bun.build. Both phases (dev and build) use the same one.`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
309
336
|
}
|
|
310
337
|
if (filter?.kind !== "pages") {
|
|
311
338
|
// Index → compact `METHOD path` list; a slice → the full schema + call-form detail.
|
|
312
339
|
sections.push(isIndex ? apiRoutesIndexSection(routes) : apiRoutesSection(routes))
|
|
340
|
+
const declared = declaredMountRoutesSection(evidence.mounts ?? [], prefix || undefined)
|
|
341
|
+
if (declared !== undefined) sections.push(declared)
|
|
342
|
+
}
|
|
343
|
+
if (app.framework === undefined) {
|
|
344
|
+
if (filter?.kind === "pages")
|
|
345
|
+
sections.push("## Page routes\n\nAn API-only app serves no pages.")
|
|
346
|
+
if (isIndex) sections.push(CONVENTIONS.replace(`${PAGES_CONVENTION}\n`, ""))
|
|
347
|
+
return sections.join("\n\n")
|
|
313
348
|
}
|
|
314
349
|
if (filter?.kind !== "api") sections.push(pageRoutesSection(pages))
|
|
315
350
|
if (isIndex) sections.push(CONVENTIONS)
|
|
@@ -368,7 +403,7 @@ export function routesToJsonFromEvidence(
|
|
|
368
403
|
}
|
|
369
404
|
|
|
370
405
|
export function routesToJson(
|
|
371
|
-
app:
|
|
406
|
+
app: IntrospectedApp,
|
|
372
407
|
pathPrefix?: string,
|
|
373
408
|
evidence?: ProjectEvidenceSnapshot,
|
|
374
409
|
): RouteJson[] {
|
|
@@ -382,7 +417,7 @@ export function routesToJson(
|
|
|
382
417
|
// `nifra routes` answers a different question: "what does this app actually serve, and with which
|
|
383
418
|
// methods?" - so an agent sees instantly that `POST /api/explain` is (or isn't) mounted, instead of
|
|
384
419
|
// discovering it via a 405. It marks which backend routes are AUTO-MOUNTED under the page router's
|
|
385
|
-
// `apiPrefix
|
|
420
|
+
// `apiPrefix`, and `--json` makes it machine-consumable. The table-building +
|
|
386
421
|
// rendering are pure (no fs / no module loads), so they're unit-tested; the cwd gathering is the thin
|
|
387
422
|
// async wrapper `describeRoutes`.
|
|
388
423
|
// ===================================================================================================
|
|
@@ -403,6 +438,9 @@ export interface RouteTableEntry {
|
|
|
403
438
|
* through the same web app, no hand-wired `if (pathname.startsWith("/api/"))`). Pages are never
|
|
404
439
|
* auto-mounted (they ARE the page router); only API routes carry this flag. */
|
|
405
440
|
readonly autoMounted?: boolean
|
|
441
|
+
/** True for an API route outside a set `apiPrefix` while `apiStrip` is off: the mount passes the
|
|
442
|
+
* request's full path, so no HTTP request reaches it; loaders still call it in-process. */
|
|
443
|
+
readonly inProcessOnly?: boolean
|
|
406
444
|
}
|
|
407
445
|
|
|
408
446
|
/** A page route + whether its module exports an `action` (→ it also serves POST). The caller resolves
|
|
@@ -416,7 +454,7 @@ export interface PageRouteInput {
|
|
|
416
454
|
export interface RouteTableInput {
|
|
417
455
|
readonly pages: readonly PageRouteInput[]
|
|
418
456
|
readonly api: ReadonlyArray<Pick<ReflectedRoute, "method" | "path">>
|
|
419
|
-
/** The page router's `apiPrefix
|
|
457
|
+
/** The page router's `apiPrefix`; an API route at/under it is auto-mounted. Unset or `""`
|
|
420
458
|
* disables the auto-mount, so no API route is marked auto-mounted. */
|
|
421
459
|
readonly apiPrefix?: string
|
|
422
460
|
/** The backend declares its routes without the prefix and is served at `apiPrefix + path`: every API
|
|
@@ -440,7 +478,7 @@ function isUnderPrefix(path: string, prefix: string): boolean {
|
|
|
440
478
|
* joined methods, for stable output.
|
|
441
479
|
*/
|
|
442
480
|
export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
443
|
-
const configured = input.apiPrefix ?? "
|
|
481
|
+
const configured = input.apiPrefix ?? ""
|
|
444
482
|
// Read the prefix as the server's mount table does (`/api/` and `/api/*` are `/api`).
|
|
445
483
|
const apiPrefix = configured === "" ? "" : (normalizeMountPath(configured) ?? configured)
|
|
446
484
|
const stripped = input.apiStrip === true && apiPrefix !== ""
|
|
@@ -471,6 +509,8 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
471
509
|
path,
|
|
472
510
|
methods: [...info.methods].sort(),
|
|
473
511
|
autoMounted: info.autoMounted,
|
|
512
|
+
// biome-ignore lint/plugin/noConditionalEmptyObjectSpread: only a route outside apiPrefix carries the flag, so every other row keeps its shape.
|
|
513
|
+
...(apiPrefix !== "" && !info.autoMounted ? { inProcessOnly: true } : {}),
|
|
474
514
|
})
|
|
475
515
|
}
|
|
476
516
|
return rows.sort(
|
|
@@ -509,7 +549,12 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
|
509
549
|
const display = rows.map((r) => ({
|
|
510
550
|
methods: r.methods.join(", "),
|
|
511
551
|
kind: r.kind,
|
|
512
|
-
path:
|
|
552
|
+
path:
|
|
553
|
+
r.kind === "api" && r.autoMounted
|
|
554
|
+
? `${r.path} (auto-mounted)`
|
|
555
|
+
: r.inProcessOnly === true
|
|
556
|
+
? `${r.path} (in-process only: outside apiPrefix)`
|
|
557
|
+
: r.path,
|
|
513
558
|
client: clientSpellingFor(r.path),
|
|
514
559
|
}))
|
|
515
560
|
const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
|
|
@@ -542,6 +587,7 @@ export interface RouteJsonRow {
|
|
|
542
587
|
readonly methods: readonly string[]
|
|
543
588
|
readonly file?: string
|
|
544
589
|
readonly autoMounted?: boolean
|
|
590
|
+
readonly inProcessOnly?: boolean
|
|
545
591
|
/** The reserved typed-client key a static segment of this path collides with, when one does. */
|
|
546
592
|
readonly reservedKey?: string
|
|
547
593
|
/** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
|
|
@@ -564,6 +610,8 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
|
564
610
|
methods: r.methods,
|
|
565
611
|
...(r.file !== undefined ? { file: r.file } : {}),
|
|
566
612
|
...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
|
|
613
|
+
// biome-ignore lint/plugin/noConditionalEmptyObjectSpread: `--json` rows gain the key only where it is true, so existing consumers see no change.
|
|
614
|
+
...(r.inProcessOnly === true ? { inProcessOnly: true } : {}),
|
|
567
615
|
...(client === undefined
|
|
568
616
|
? {}
|
|
569
617
|
: { reservedKey: client.reserved, clientSpelling: client.spelling }),
|
|
@@ -683,12 +731,15 @@ interface MaybeActionModule {
|
|
|
683
731
|
* text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
|
|
684
732
|
* degrades to GET-only for that route (never throws the whole command).
|
|
685
733
|
*/
|
|
686
|
-
async function collectRouteTable(app:
|
|
734
|
+
async function collectRouteTable(app: IntrospectedApp): Promise<RouteTableEntry[]> {
|
|
735
|
+
const { framework } = app
|
|
687
736
|
let manifest: Manifest | undefined
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
737
|
+
if (framework !== undefined) {
|
|
738
|
+
try {
|
|
739
|
+
manifest = discoverRoutes(app.routesDir)
|
|
740
|
+
} catch {
|
|
741
|
+
// No (or unreadable) routes/ - fall through with no pages; the table still shows API routes.
|
|
742
|
+
}
|
|
692
743
|
}
|
|
693
744
|
const pages: PageRouteInput[] = []
|
|
694
745
|
for (const route of manifest?.routes ?? []) {
|
|
@@ -704,14 +755,39 @@ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
|
|
|
704
755
|
const rows = buildRouteTable({
|
|
705
756
|
pages,
|
|
706
757
|
api: backendRoutes(app.backend),
|
|
707
|
-
...(
|
|
708
|
-
...(
|
|
758
|
+
...(framework?.apiPrefix !== undefined ? { apiPrefix: framework.apiPrefix } : {}),
|
|
759
|
+
...(framework?.apiStrip !== undefined ? { apiStrip: framework.apiStrip } : {}),
|
|
709
760
|
})
|
|
710
761
|
return rows
|
|
711
762
|
}
|
|
712
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The warning `nifra dev` and `nifra build` print for backend routes no HTTP request reaches, read from
|
|
766
|
+
* the routes the backend registers, prefixes and mounts resolved; `undefined` when there are none.
|
|
767
|
+
*/
|
|
768
|
+
export function unservedRoutesWarning(
|
|
769
|
+
app: Pick<IntrospectedApp, "framework" | "backend">,
|
|
770
|
+
): string | undefined {
|
|
771
|
+
const apiPrefix = app.framework?.apiPrefix ?? ""
|
|
772
|
+
if (apiPrefix === "" || app.backend === undefined) return undefined
|
|
773
|
+
const rows = buildRouteTable({
|
|
774
|
+
pages: [],
|
|
775
|
+
api: backendRoutes(app.backend),
|
|
776
|
+
apiPrefix,
|
|
777
|
+
apiStrip: app.framework?.apiStrip === true,
|
|
778
|
+
}).filter((row) => row.inProcessOnly === true)
|
|
779
|
+
if (rows.length === 0) return undefined
|
|
780
|
+
const listed = rows.map((row) => `${row.methods.join(",")} ${row.path}`)
|
|
781
|
+
const named =
|
|
782
|
+
listed.length > 3
|
|
783
|
+
? `${listed.slice(0, 3).join(", ")} and ${listed.length - 3} more`
|
|
784
|
+
: listed.join(", ")
|
|
785
|
+
const one = rows.length === 1
|
|
786
|
+
return `[nifra] ${one ? "a backend route is" : `${rows.length} backend routes are`} served over HTTP nowhere: ${named}. backend/app.ts is mounted at apiPrefix ${apiPrefix} with apiStrip off, so it sees each request's full path, and ${one ? "this route sits" : "these sit"} outside it; loaders still call ${one ? "it" : "them"} in-process. Register ${one ? "it" : "them"} under ${apiPrefix}, or export \`apiStrip = true\` to serve every backend route at ${apiPrefix} plus its path.`
|
|
787
|
+
}
|
|
788
|
+
|
|
713
789
|
export async function describeRoutes(
|
|
714
|
-
app:
|
|
790
|
+
app: IntrospectedApp,
|
|
715
791
|
opts: { readonly json?: boolean } = {},
|
|
716
792
|
): Promise<string> {
|
|
717
793
|
const rows = await collectRouteTable(app)
|
|
@@ -720,7 +796,7 @@ export async function describeRoutes(
|
|
|
720
796
|
|
|
721
797
|
/** Gather and render the public route graph for `nifra routes --graph`. */
|
|
722
798
|
export async function describeRouteGraph(
|
|
723
|
-
app:
|
|
799
|
+
app: IntrospectedApp,
|
|
724
800
|
opts: { readonly json?: boolean } = {},
|
|
725
801
|
): Promise<string> {
|
|
726
802
|
const graph = buildRouteGraph(await collectRouteTable(app))
|
package/src/levels-tool.ts
CHANGED
|
@@ -13,10 +13,21 @@
|
|
|
13
13
|
|
|
14
14
|
import { existsSync } from "node:fs"
|
|
15
15
|
import { resolve } from "node:path"
|
|
16
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
AssuranceReport,
|
|
18
|
+
AssuredRoute,
|
|
19
|
+
InvariantSetup,
|
|
20
|
+
InvariantSetupResult,
|
|
21
|
+
InvariantsConfig,
|
|
22
|
+
} from "@nifrajs/core/assurance"
|
|
17
23
|
import { snapshotCapabilities } from "@nifrajs/core/capabilities"
|
|
18
24
|
import { buildNifraManifest, parseNifraManifest } from "@nifrajs/core/manifest"
|
|
19
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type AdversarialContractOptions,
|
|
27
|
+
type ContractTestApp,
|
|
28
|
+
runAdversarialContract,
|
|
29
|
+
} from "@nifrajs/testing"
|
|
30
|
+
import { RESPONSE_CONTRACT_BASELINE_HINT, responseContractGaps } from "./assure.ts"
|
|
20
31
|
import {
|
|
21
32
|
type CapabilityProjectReport,
|
|
22
33
|
diffCapabilitySnapshots,
|
|
@@ -176,44 +187,132 @@ export async function collectVerificationLevels(
|
|
|
176
187
|
"no isolated invariant executor configured - add `invariants.executor` in nifra.assurance.ts",
|
|
177
188
|
)
|
|
178
189
|
} else {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
l4Reasons.push(...(await runInvariants(config.source, config.invariants, options.seed ?? 1)))
|
|
191
|
+
}
|
|
192
|
+
statuses.push({
|
|
193
|
+
level: 4,
|
|
194
|
+
name: "invariant-tested",
|
|
195
|
+
ok: l4Reasons.length === 0,
|
|
196
|
+
reasons: l4Reasons,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return finalize(statuses, assurance.routes)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Run L4 against the configured target. `setup` runs at most once, on the first request, and its
|
|
204
|
+
* `teardown` runs once the run ends - after a failure too - so an app can provision a throwaway target
|
|
205
|
+
* only when L4 actually exercises it.
|
|
206
|
+
*/
|
|
207
|
+
async function runInvariants(
|
|
208
|
+
source: unknown,
|
|
209
|
+
invariants: InvariantsConfig,
|
|
210
|
+
seed: number,
|
|
211
|
+
): Promise<string[]> {
|
|
212
|
+
let provisioned: Promise<InvariantSetupResult | undefined> | undefined
|
|
213
|
+
const provision = (setup: InvariantSetup): Promise<InvariantSetupResult | undefined> => {
|
|
214
|
+
provisioned ??= Promise.resolve()
|
|
215
|
+
.then(setup)
|
|
216
|
+
.then(
|
|
217
|
+
(result) => result ?? undefined,
|
|
218
|
+
(error: unknown) => {
|
|
219
|
+
throw new Error(`invariants.setup failed: ${messageOf(error)}`)
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
return provisioned
|
|
223
|
+
}
|
|
224
|
+
const fetch = async (request: Request): Promise<Response> => {
|
|
225
|
+
const ready = invariants.setup === undefined ? undefined : await provision(invariants.setup)
|
|
226
|
+
const executor = ready?.executor ?? invariants.executor
|
|
227
|
+
if (executor === undefined) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
"no invariant executor: invariants.setup returned none and invariants.executor is not set",
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
return executor(request)
|
|
233
|
+
}
|
|
234
|
+
const options: AdversarialContractOptions = {
|
|
235
|
+
seed,
|
|
236
|
+
runtimes: [{ name: "isolated", fetch }],
|
|
237
|
+
expectedValidationStatuses: [400, 422],
|
|
238
|
+
requireCoverage: true,
|
|
239
|
+
}
|
|
240
|
+
const runOptions: Record<string, unknown> = {}
|
|
241
|
+
for (const name of INVARIANT_RUN_OPTIONS) {
|
|
242
|
+
if (invariants[name] !== undefined) runOptions[name] = invariants[name]
|
|
243
|
+
}
|
|
244
|
+
const reasons: string[] = []
|
|
245
|
+
try {
|
|
246
|
+
const report = await runAdversarialContract(source as ContractTestApp, {
|
|
247
|
+
...options,
|
|
248
|
+
...runOptions,
|
|
184
249
|
})
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
250
|
+
const target = await targetFailure(provisioned, invariants)
|
|
251
|
+
if (target !== undefined) reasons.push(target)
|
|
252
|
+
else if (!report.ok) {
|
|
253
|
+
reasons.push(
|
|
254
|
+
...report.failures
|
|
188
255
|
.slice(0, 5)
|
|
189
256
|
.map(
|
|
190
257
|
(failure) =>
|
|
191
258
|
`${failure.runtime}: ${failure.id} - ${failure.message} (replay seed ${failure.replay.seed})`,
|
|
192
259
|
),
|
|
193
260
|
)
|
|
194
|
-
|
|
195
|
-
...
|
|
196
|
-
.slice(0, Math.max(0, 5 -
|
|
261
|
+
reasons.push(
|
|
262
|
+
...report.gaps
|
|
263
|
+
.slice(0, Math.max(0, 5 - reasons.length))
|
|
197
264
|
.map(
|
|
198
265
|
(gap) =>
|
|
199
266
|
`${gap.route ?? "contract"}${gap.target ? ` ${gap.target}` : ""}: ${gap.message}`,
|
|
200
267
|
),
|
|
201
268
|
)
|
|
202
269
|
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
reasons.push((await targetFailure(provisioned, invariants)) ?? messageOf(error))
|
|
272
|
+
} finally {
|
|
273
|
+
const ready = await provisioned?.catch(() => undefined)
|
|
274
|
+
if (ready?.teardown !== undefined) {
|
|
275
|
+
try {
|
|
276
|
+
await ready.teardown()
|
|
277
|
+
} catch (error) {
|
|
278
|
+
reasons.push(`invariants teardown failed: ${messageOf(error)}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
203
281
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
name: "invariant-tested",
|
|
207
|
-
ok: l4Reasons.length === 0,
|
|
208
|
-
reasons: l4Reasons,
|
|
209
|
-
})
|
|
282
|
+
return reasons
|
|
283
|
+
}
|
|
210
284
|
|
|
211
|
-
|
|
285
|
+
const INVARIANT_RUN_OPTIONS = [
|
|
286
|
+
"witnesses",
|
|
287
|
+
"prepareRequest",
|
|
288
|
+
"isRejected",
|
|
289
|
+
"include",
|
|
290
|
+
"maxMutationsPerInput",
|
|
291
|
+
] as const
|
|
292
|
+
|
|
293
|
+
/** The setup or missing-executor failure every case hit, if the target never came up. */
|
|
294
|
+
async function targetFailure(
|
|
295
|
+
provisioned: Promise<InvariantSetupResult | undefined> | undefined,
|
|
296
|
+
invariants: InvariantsConfig,
|
|
297
|
+
): Promise<string | undefined> {
|
|
298
|
+
if (provisioned === undefined) return undefined
|
|
299
|
+
try {
|
|
300
|
+
const ready = await provisioned
|
|
301
|
+
if (ready?.executor === undefined && invariants.executor === undefined) {
|
|
302
|
+
return "no invariant executor: invariants.setup returned none and invariants.executor is not set"
|
|
303
|
+
}
|
|
304
|
+
return undefined
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return messageOf(error)
|
|
307
|
+
}
|
|
212
308
|
}
|
|
213
309
|
|
|
310
|
+
const messageOf = (error: unknown): string =>
|
|
311
|
+
error instanceof Error ? error.message : String(error)
|
|
312
|
+
|
|
214
313
|
function finalize(
|
|
215
314
|
statuses: readonly { level: number; name: string; ok: boolean; reasons: string[] }[],
|
|
216
|
-
routes: readonly
|
|
315
|
+
routes: readonly AssuredRoute[],
|
|
217
316
|
): VerificationLevelsResult {
|
|
218
317
|
// Cumulative: the achieved level is the last rung with every rung below it also holding.
|
|
219
318
|
let achieved = -1
|
|
@@ -225,6 +324,7 @@ function finalize(
|
|
|
225
324
|
const enforced = declared.filter((route) => route.responseContract === "enforce").length
|
|
226
325
|
const warnOnly = declared.filter((route) => route.responseContract === "warn").length
|
|
227
326
|
const typedOnly = declared.filter((route) => route.responseContract === "unchecked").length
|
|
327
|
+
const gaps = responseContractGaps(routes)
|
|
228
328
|
const responseContracts: ResponseContractStatus = {
|
|
229
329
|
declared: declared.length,
|
|
230
330
|
enforced,
|
|
@@ -236,14 +336,9 @@ function finalize(
|
|
|
236
336
|
declared.length === 0
|
|
237
337
|
? ["no response/error schemas declared"]
|
|
238
338
|
: [
|
|
239
|
-
...(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
]
|
|
243
|
-
: []),
|
|
244
|
-
...(warnOnly > 0
|
|
245
|
-
? [`${warnOnly} declared route(s) are warn-only and still send the original payload`]
|
|
246
|
-
: []),
|
|
339
|
+
...(gaps.typedOnlyReason === undefined ? [] : [gaps.typedOnlyReason]),
|
|
340
|
+
...(gaps.warnOnlyReason === undefined ? [] : [gaps.warnOnlyReason]),
|
|
341
|
+
...(typedOnly + warnOnly > 0 ? [RESPONSE_CONTRACT_BASELINE_HINT] : []),
|
|
247
342
|
],
|
|
248
343
|
}
|
|
249
344
|
return Object.freeze({
|