@nifrajs/cli 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +28 -3
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/command-catalog.ts
CHANGED
|
@@ -22,15 +22,22 @@ import { type Diagnostic, diagnostic, normalizeSeverity, toSarifLog } from "./di
|
|
|
22
22
|
import type { DoctorResult } from "./doctor.ts"
|
|
23
23
|
import { type I18nCheckOutput, i18nCheckPassed, renderI18nCheck } from "./i18n-check.ts"
|
|
24
24
|
import type { VerificationLevelsResult } from "./levels-tool.ts"
|
|
25
|
-
import type {
|
|
25
|
+
import type { AppLoader, IntrospectedApp } from "./load.ts"
|
|
26
26
|
import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
|
|
27
27
|
import type { LayoutMigrationResult } from "./migrate-layout.ts"
|
|
28
|
+
import type { OutputSchemaStatus, OutputSchemasReport } from "./output-schemas.ts"
|
|
28
29
|
import { collectPortResult, type PortResult, renderReport } from "./port.ts"
|
|
29
30
|
import type { ReplayResult } from "./replay.ts"
|
|
30
31
|
import { reviewSpec } from "./review.ts"
|
|
31
32
|
import type { RouteTypesReport } from "./route-types.ts"
|
|
32
33
|
import type { SmokeReport } from "./smoke.ts"
|
|
33
34
|
import type { StylexMigrationResult } from "./stylex-migrate.ts"
|
|
35
|
+
import {
|
|
36
|
+
renderSyncManifestResults,
|
|
37
|
+
type SyncManifestResult,
|
|
38
|
+
syncManifestsOk,
|
|
39
|
+
syncServerManifests,
|
|
40
|
+
} from "./sync-manifest.ts"
|
|
34
41
|
import {
|
|
35
42
|
collectProjectWorkGraph,
|
|
36
43
|
type ProjectWorkGraphResult,
|
|
@@ -74,8 +81,8 @@ export interface CommandCtx {
|
|
|
74
81
|
readonly signal?: AbortSignal
|
|
75
82
|
readonly progress?: (message: string) => void
|
|
76
83
|
readonly cliVersion?: string
|
|
77
|
-
/** Adapter-provided cache for commands that need the loaded
|
|
78
|
-
readonly loadApp?:
|
|
84
|
+
/** Adapter-provided cache for commands that need the loaded app. */
|
|
85
|
+
readonly loadApp?: AppLoader
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
export interface CommandSpec<Input, Output> {
|
|
@@ -322,6 +329,8 @@ interface RoutesInput {
|
|
|
322
329
|
readonly json?: boolean | undefined
|
|
323
330
|
readonly graph?: boolean | undefined
|
|
324
331
|
readonly modes?: boolean | undefined
|
|
332
|
+
readonly hooks?: boolean | undefined
|
|
333
|
+
readonly since?: string | undefined
|
|
325
334
|
readonly target?: string | undefined
|
|
326
335
|
readonly dir?: string | undefined
|
|
327
336
|
}
|
|
@@ -384,6 +393,12 @@ interface TypesInput {
|
|
|
384
393
|
readonly dir?: string | undefined
|
|
385
394
|
}
|
|
386
395
|
|
|
396
|
+
interface SchemasInput {
|
|
397
|
+
readonly write?: boolean | undefined
|
|
398
|
+
readonly json?: boolean | undefined
|
|
399
|
+
readonly dir?: string | undefined
|
|
400
|
+
}
|
|
401
|
+
|
|
387
402
|
interface SnapshotInput {
|
|
388
403
|
readonly out?: string | undefined
|
|
389
404
|
readonly json?: boolean | undefined
|
|
@@ -452,7 +467,7 @@ interface AssureCommandOutput {
|
|
|
452
467
|
interface LevelsCommandOutput extends VerificationLevelsResult {}
|
|
453
468
|
interface RoutesCommandOutput {
|
|
454
469
|
readonly ok: boolean
|
|
455
|
-
readonly mode: "default" | "graph" | "modes"
|
|
470
|
+
readonly mode: "default" | "graph" | "modes" | "hooks"
|
|
456
471
|
readonly data: unknown
|
|
457
472
|
readonly text: string
|
|
458
473
|
}
|
|
@@ -487,6 +502,9 @@ interface DiffCommandOutput {
|
|
|
487
502
|
readonly hasBreaking: boolean
|
|
488
503
|
readonly changes: readonly unknown[]
|
|
489
504
|
}
|
|
505
|
+
interface RouteDiffCommandOutput extends DiffCommandOutput {
|
|
506
|
+
readonly text: string
|
|
507
|
+
}
|
|
490
508
|
interface ContractsCommandOutput {
|
|
491
509
|
readonly ok: boolean
|
|
492
510
|
readonly lock?: ContractsLock
|
|
@@ -498,6 +516,10 @@ interface SyncCommandOutput {
|
|
|
498
516
|
readonly ok: true
|
|
499
517
|
readonly results: readonly unknown[]
|
|
500
518
|
}
|
|
519
|
+
interface SyncManifestCommandOutput {
|
|
520
|
+
readonly ok: boolean
|
|
521
|
+
readonly results: readonly SyncManifestResult[]
|
|
522
|
+
}
|
|
501
523
|
|
|
502
524
|
interface SmokeCommandOutput extends SmokeReport {}
|
|
503
525
|
|
|
@@ -637,15 +659,20 @@ const ROUTES_SCHEMA = input<RoutesInput>(
|
|
|
637
659
|
json: { type: "boolean" },
|
|
638
660
|
graph: { type: "boolean" },
|
|
639
661
|
modes: { type: "boolean" },
|
|
662
|
+
hooks: { type: "boolean" },
|
|
663
|
+
since: { type: "string" },
|
|
640
664
|
target: { type: "string" },
|
|
641
665
|
dir: { type: "string" },
|
|
642
666
|
}),
|
|
643
667
|
(value) => {
|
|
644
668
|
const raw = withDir(record(value))
|
|
645
|
-
if (raw.graph
|
|
646
|
-
throw new TypeError("graph and
|
|
669
|
+
if ([raw.graph, raw.modes, raw.hooks].filter((flag) => flag === true).length > 1)
|
|
670
|
+
throw new TypeError("graph, modes and hooks cannot be combined")
|
|
671
|
+
if (raw.since !== undefined && raw.hooks !== true)
|
|
672
|
+
throw new TypeError("since compares hook reports and needs hooks")
|
|
647
673
|
return {
|
|
648
|
-
...parseBooleanFlags(raw, ["json", "graph", "modes"]),
|
|
674
|
+
...parseBooleanFlags(raw, ["json", "graph", "modes", "hooks"]),
|
|
675
|
+
since: optionalString(raw.since, "since"),
|
|
649
676
|
target: optionalString(raw.target, "target"),
|
|
650
677
|
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
651
678
|
}
|
|
@@ -771,6 +798,17 @@ const TYPES_SCHEMA = input<TypesInput>(
|
|
|
771
798
|
},
|
|
772
799
|
)
|
|
773
800
|
|
|
801
|
+
const SCHEMAS_SCHEMA = input<SchemasInput>(
|
|
802
|
+
objectSchema({ write: { type: "boolean" }, json: { type: "boolean" }, dir: { type: "string" } }),
|
|
803
|
+
(value) => {
|
|
804
|
+
const raw = withDir(record(value))
|
|
805
|
+
return {
|
|
806
|
+
...parseBooleanFlags(raw, ["write", "json"]),
|
|
807
|
+
...(raw.dir === undefined ? {} : { dir: raw.dir }),
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
)
|
|
811
|
+
|
|
774
812
|
const SNAPSHOT_SCHEMA = input<SnapshotInput>(
|
|
775
813
|
objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
|
|
776
814
|
(value) => {
|
|
@@ -934,9 +972,10 @@ const I18N_SCHEMA = input<I18nInput>(
|
|
|
934
972
|
},
|
|
935
973
|
)
|
|
936
974
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
975
|
+
/** The app as the backend readers see it: an API-only app is its `backend/app.ts` alone. */
|
|
976
|
+
function backendAppFor(ctx: CommandCtx): Promise<IntrospectedApp> {
|
|
977
|
+
if (ctx.loadApp !== undefined) return ctx.loadApp.backend()
|
|
978
|
+
return import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
|
|
940
979
|
}
|
|
941
980
|
|
|
942
981
|
function structuredDiagnostics(out: CheckCommandOutput): readonly Diagnostic[] {
|
|
@@ -1185,7 +1224,8 @@ const manifestSpec: CommandSpec<ManifestInput, ManifestEmitCommandResult | DiffC
|
|
|
1185
1224
|
|
|
1186
1225
|
const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
|
|
1187
1226
|
name: "routes",
|
|
1188
|
-
summary:
|
|
1227
|
+
summary:
|
|
1228
|
+
"List, graph, or target-check every page and API route the app serves, or the hooks each runs.",
|
|
1189
1229
|
input: ROUTES_SCHEMA,
|
|
1190
1230
|
output: output({
|
|
1191
1231
|
type: "object",
|
|
@@ -1199,12 +1239,47 @@ const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
|
|
|
1199
1239
|
{ name: "json", field: "json", type: "boolean" },
|
|
1200
1240
|
{ name: "graph", field: "graph", type: "boolean" },
|
|
1201
1241
|
{ name: "modes", field: "modes", type: "boolean" },
|
|
1242
|
+
{ name: "hooks", field: "hooks", type: "boolean" },
|
|
1243
|
+
{ name: "since", field: "since", type: "string" },
|
|
1202
1244
|
{ name: "target", field: "target", type: "string" },
|
|
1203
1245
|
],
|
|
1204
1246
|
},
|
|
1205
1247
|
async run(value, ctx) {
|
|
1206
|
-
|
|
1248
|
+
if (value.hooks === true) {
|
|
1249
|
+
const app = await backendAppFor(ctx)
|
|
1250
|
+
const hooks = await import("./hooks-report.ts")
|
|
1251
|
+
const report = await hooks.collectHookReport(app.backend)
|
|
1252
|
+
if (value.since === undefined) {
|
|
1253
|
+
return { ok: true, mode: "hooks", data: report, text: hooks.renderHookReport(report) }
|
|
1254
|
+
}
|
|
1255
|
+
const saved = hooks.parseHookReport(
|
|
1256
|
+
await Bun.file(resolve(ctx.cwd, value.since))
|
|
1257
|
+
.text()
|
|
1258
|
+
.catch(() => ""),
|
|
1259
|
+
)
|
|
1260
|
+
if (saved === undefined) {
|
|
1261
|
+
return {
|
|
1262
|
+
ok: false,
|
|
1263
|
+
mode: "hooks",
|
|
1264
|
+
data: { report, error: `${value.since} is not a saved hook report` },
|
|
1265
|
+
text: `[nifra] ${value.since} is not a saved hook report (write one with \`nifra routes --hooks --json\`)`,
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const changes = hooks.diffHookReports(saved, report)
|
|
1269
|
+
return {
|
|
1270
|
+
ok: true,
|
|
1271
|
+
mode: "hooks",
|
|
1272
|
+
data: { report, changes },
|
|
1273
|
+
text: hooks.renderHookDiff(changes),
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const app = await backendAppFor(ctx)
|
|
1207
1277
|
if (value.modes === true) {
|
|
1278
|
+
if (app.framework === undefined) {
|
|
1279
|
+
throw new Error(
|
|
1280
|
+
`[nifra] routes --modes reads page routes, and ${ctx.cwd} is an API-only app (backend/app.ts, no web config)`,
|
|
1281
|
+
)
|
|
1282
|
+
}
|
|
1208
1283
|
const { buildRouteManifest, renderRouteManifest } = await import(
|
|
1209
1284
|
"@nifrajs/web/route-manifest"
|
|
1210
1285
|
)
|
|
@@ -1254,7 +1329,7 @@ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
|
|
|
1254
1329
|
async run(value, ctx) {
|
|
1255
1330
|
return {
|
|
1256
1331
|
ok: true,
|
|
1257
|
-
text: (await import("./introspect.ts")).describeProject(await
|
|
1332
|
+
text: (await import("./introspect.ts")).describeProject(await backendAppFor(ctx), {
|
|
1258
1333
|
...(value.path === undefined ? {} : { path: value.path }),
|
|
1259
1334
|
...(value.kind === undefined ? {} : { kind: value.kind }),
|
|
1260
1335
|
}),
|
|
@@ -1282,10 +1357,7 @@ const openApiSpec: CommandSpec<OpenApiInput, OpenApiCommandOutput> = {
|
|
|
1282
1357
|
],
|
|
1283
1358
|
},
|
|
1284
1359
|
async run(value, ctx) {
|
|
1285
|
-
const app =
|
|
1286
|
-
ctx.loadApp !== undefined
|
|
1287
|
-
? await ctx.loadApp()
|
|
1288
|
-
: await import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
|
|
1360
|
+
const app = await backendAppFor(ctx)
|
|
1289
1361
|
const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
|
|
1290
1362
|
const format = value.format ?? "json"
|
|
1291
1363
|
return {
|
|
@@ -1420,6 +1492,8 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
|
|
|
1420
1492
|
splits: { type: "array" },
|
|
1421
1493
|
moves: { type: "array" },
|
|
1422
1494
|
rewritten: { type: "array" },
|
|
1495
|
+
tooling: { type: "array" },
|
|
1496
|
+
mode: { type: "string" },
|
|
1423
1497
|
},
|
|
1424
1498
|
required: ["ok", "write", "issues"],
|
|
1425
1499
|
}),
|
|
@@ -1449,13 +1523,14 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
|
|
|
1449
1523
|
if (out.kind === "layout") {
|
|
1450
1524
|
return [
|
|
1451
1525
|
out.ok
|
|
1452
|
-
? `✓ ${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
|
|
1526
|
+
? `✓ ${out.mode === "api" ? "API-only app" : `${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split`}, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
|
|
1453
1527
|
: `⚠ ${out.issues.length} issue${out.issues.length === 1 ? "" : "s"} need a decision; everything else is planned`,
|
|
1454
1528
|
...out.splits.map(
|
|
1455
1529
|
(split) => ` split ${split.file} → ${split.backend} (${split.moved.join(", ")})`,
|
|
1456
1530
|
),
|
|
1457
1531
|
...out.moves.map((move) => ` move ${move.from} → ${move.to}`),
|
|
1458
1532
|
...out.rewritten.map((file) => ` edit ${file} (imports)`),
|
|
1533
|
+
...out.tooling.map((edit) => ` edit ${edit.file} (${edit.change})`),
|
|
1459
1534
|
...out.issues.map((issue) => ` ⚠ ${issue.file}: ${issue.reason}`),
|
|
1460
1535
|
...(out.write ? [] : [" dry run: pass --write to apply"]),
|
|
1461
1536
|
]
|
|
@@ -1576,6 +1651,75 @@ const typesSpec: CommandSpec<TypesInput, RouteTypesReport> = {
|
|
|
1576
1651
|
success: (out) => out.ok,
|
|
1577
1652
|
}
|
|
1578
1653
|
|
|
1654
|
+
const SCHEMA_STATUS_LINE: Readonly<Record<OutputSchemaStatus, string>> = {
|
|
1655
|
+
missing: "+ would write",
|
|
1656
|
+
generated: "+ wrote",
|
|
1657
|
+
stale: "↻ stale",
|
|
1658
|
+
regenerated: "↻ rewrote",
|
|
1659
|
+
current: "✓ current",
|
|
1660
|
+
declared: "· hand-written",
|
|
1661
|
+
"no-data": "· no data",
|
|
1662
|
+
unreadable: "⚠ unreadable",
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const schemasSpec: CommandSpec<SchemasInput, OutputSchemasReport> = {
|
|
1666
|
+
name: "schemas",
|
|
1667
|
+
summary:
|
|
1668
|
+
"Write a reviewable `loaderOutput`/`actionOutput` from the loader's or action's return type where a route has none (`--write`), keep generated ones current, and list returned fields a hand-written schema drops.",
|
|
1669
|
+
input: SCHEMAS_SCHEMA,
|
|
1670
|
+
output: output({
|
|
1671
|
+
type: "object",
|
|
1672
|
+
properties: {
|
|
1673
|
+
ok: { type: "boolean" },
|
|
1674
|
+
write: { type: "boolean" },
|
|
1675
|
+
entries: { type: "array" },
|
|
1676
|
+
written: { type: "array" },
|
|
1677
|
+
warnings: { type: "array" },
|
|
1678
|
+
},
|
|
1679
|
+
required: ["ok", "write", "entries", "written", "warnings"],
|
|
1680
|
+
}),
|
|
1681
|
+
transports: ["cli"],
|
|
1682
|
+
stability: "stable",
|
|
1683
|
+
argv: {
|
|
1684
|
+
flags: [
|
|
1685
|
+
{ name: "write", field: "write", type: "boolean" },
|
|
1686
|
+
{ name: "json", field: "json", type: "boolean" },
|
|
1687
|
+
{ name: "dir", field: "dir", type: "string" },
|
|
1688
|
+
],
|
|
1689
|
+
},
|
|
1690
|
+
async run(value, ctx) {
|
|
1691
|
+
const { outputSchemas } = await import("./output-schemas.ts")
|
|
1692
|
+
return outputSchemas(resolve(ctx.cwd, value.dir ?? "."), { write: value.write === true })
|
|
1693
|
+
},
|
|
1694
|
+
render: (out) => {
|
|
1695
|
+
const shown = out.entries.filter(
|
|
1696
|
+
(entry) =>
|
|
1697
|
+
entry.status !== "current" &&
|
|
1698
|
+
entry.status !== "no-data" &&
|
|
1699
|
+
(entry.status !== "declared" || entry.dropped.length > 0),
|
|
1700
|
+
)
|
|
1701
|
+
const current = out.entries.filter((entry) => entry.status === "current").length
|
|
1702
|
+
return [
|
|
1703
|
+
...out.warnings.map((warning) => `⚠ ${warning}`),
|
|
1704
|
+
`${out.ok ? "✓" : "✖"} ${out.entries.length} output schema${out.entries.length === 1 ? "" : "s"} read, ${current} generated and current`,
|
|
1705
|
+
...shown.map((entry) => {
|
|
1706
|
+
const detail =
|
|
1707
|
+
entry.reason ??
|
|
1708
|
+
(entry.edited === true
|
|
1709
|
+
? "edited by hand under its nifra:generated line"
|
|
1710
|
+
: entry.dropped.length > 0
|
|
1711
|
+
? `drops ${entry.dropped.join(", ")}`
|
|
1712
|
+
: undefined)
|
|
1713
|
+
return ` ${SCHEMA_STATUS_LINE[entry.status]} ${entry.file}:${entry.line} ${entry.schema}${detail === undefined ? "" : ` - ${detail}`}`
|
|
1714
|
+
}),
|
|
1715
|
+
...(out.write || !out.entries.some((e) => e.status === "missing" || e.status === "stale")
|
|
1716
|
+
? []
|
|
1717
|
+
: [" dry run: pass --write to apply"]),
|
|
1718
|
+
]
|
|
1719
|
+
},
|
|
1720
|
+
success: (out) => out.ok,
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1579
1723
|
const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
|
|
1580
1724
|
name: "snapshot",
|
|
1581
1725
|
summary: "Write the backend API contract as a versioned JSON baseline.",
|
|
@@ -1600,18 +1744,16 @@ const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
|
|
|
1600
1744
|
},
|
|
1601
1745
|
async run(value, ctx) {
|
|
1602
1746
|
const mod = await import("./diff-tool.ts")
|
|
1603
|
-
const routes = await mod.snapshotBackend(ctx.cwd)
|
|
1604
1747
|
const file = value.out ?? mod.DEFAULT_SNAPSHOT_FILE
|
|
1605
|
-
const snapshot =
|
|
1606
|
-
|
|
1607
|
-
return { ok: true, file, routes: routes.length, snapshot }
|
|
1748
|
+
const snapshot = await mod.writeSnapshot(ctx.cwd, file)
|
|
1749
|
+
return { ok: true, file, routes: snapshot.routes.length, snapshot }
|
|
1608
1750
|
},
|
|
1609
1751
|
render: (out) => [
|
|
1610
1752
|
`[nifra] wrote ${out.routes} route${out.routes === 1 ? "" : "s"} to ${out.file}`,
|
|
1611
1753
|
],
|
|
1612
1754
|
}
|
|
1613
1755
|
|
|
1614
|
-
const diffSpec: CommandSpec<DiffInput,
|
|
1756
|
+
const diffSpec: CommandSpec<DiffInput, RouteDiffCommandOutput> = {
|
|
1615
1757
|
name: "diff",
|
|
1616
1758
|
summary: "Compare the current backend contract with a baseline and fail on breaking changes.",
|
|
1617
1759
|
input: DIFF_SCHEMA,
|
|
@@ -1628,21 +1770,17 @@ const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
|
|
|
1628
1770
|
stability: "stable",
|
|
1629
1771
|
argv: { positionals: ["baseline"], flags: [{ name: "json", field: "json", type: "boolean" }] },
|
|
1630
1772
|
async run(value, ctx) {
|
|
1631
|
-
const
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1773
|
+
const { diffAgainstBaseline, formatDiff } = await import("./diff-tool.ts")
|
|
1774
|
+
const diff = await diffAgainstBaseline(ctx.cwd, value.baseline)
|
|
1775
|
+
return {
|
|
1776
|
+
ok: !diff.hasBreaking,
|
|
1777
|
+
hasBreaking: diff.hasBreaking,
|
|
1778
|
+
changes: diff.changes,
|
|
1779
|
+
text: formatDiff(diff),
|
|
1780
|
+
}
|
|
1639
1781
|
},
|
|
1640
|
-
render: (out) => [
|
|
1641
|
-
|
|
1642
|
-
...(out.changes as readonly { method: string; path: string; message: string }[]).map(
|
|
1643
|
-
(c) => `${c.method} ${c.path}: ${c.message}`,
|
|
1644
|
-
),
|
|
1645
|
-
],
|
|
1782
|
+
render: (out) => [out.text],
|
|
1783
|
+
json: ({ ok, hasBreaking, changes }) => ({ ok, hasBreaking, changes }),
|
|
1646
1784
|
success: (out) => out.ok,
|
|
1647
1785
|
}
|
|
1648
1786
|
|
|
@@ -1674,7 +1812,7 @@ const contractsSpec: CommandSpec<ContractsInput, ContractsCommandOutput> = {
|
|
|
1674
1812
|
success: (out) => out.ok,
|
|
1675
1813
|
}
|
|
1676
1814
|
|
|
1677
|
-
const syncManifestSpec: CommandSpec<SyncInput,
|
|
1815
|
+
const syncManifestSpec: CommandSpec<SyncInput, SyncManifestCommandOutput> = {
|
|
1678
1816
|
name: "sync-manifest",
|
|
1679
1817
|
summary: "Regenerate generated server-manifest route tables without a full build.",
|
|
1680
1818
|
input: SYNC_SCHEMA,
|
|
@@ -1687,16 +1825,11 @@ const syncManifestSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
|
|
|
1687
1825
|
stability: "stable",
|
|
1688
1826
|
argv: { flags: [{ name: "json", field: "json", type: "boolean" }] },
|
|
1689
1827
|
async run(_value, ctx) {
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
results: await (await import("./sync-manifest.ts")).syncServerManifests(ctx.cwd),
|
|
1693
|
-
}
|
|
1828
|
+
const results = await syncServerManifests(ctx.cwd)
|
|
1829
|
+
return { ok: syncManifestsOk(results), results }
|
|
1694
1830
|
},
|
|
1695
|
-
render: (out) =>
|
|
1696
|
-
|
|
1697
|
-
? "nifra sync-manifest: no generated server-manifest.ts found under this directory."
|
|
1698
|
-
: `✓ checked ${out.results.length} generated server manifest${out.results.length === 1 ? "" : "s"}`,
|
|
1699
|
-
],
|
|
1831
|
+
render: (out) => renderSyncManifestResults(out.results),
|
|
1832
|
+
success: (out) => out.ok,
|
|
1700
1833
|
}
|
|
1701
1834
|
|
|
1702
1835
|
const syncRoutesSpec: CommandSpec<SyncInput, SyncCommandOutput> = {
|
|
@@ -1875,6 +2008,7 @@ export const commandSpecs = Object.freeze([
|
|
|
1875
2008
|
migrateSpec,
|
|
1876
2009
|
targetSpec,
|
|
1877
2010
|
typesSpec,
|
|
2011
|
+
schemasSpec,
|
|
1878
2012
|
snapshotSpec,
|
|
1879
2013
|
diffSpec,
|
|
1880
2014
|
contractsSpec,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
isUnsupportedTypeScriptError,
|
|
5
|
+
type UnsupportedTypeScriptError,
|
|
6
|
+
} from "./internal/typescript-import.ts"
|
|
7
|
+
import type { SourceIndex } from "./rules/index.ts"
|
|
8
|
+
|
|
9
|
+
type ComponentSource = typeof import("@nifrajs/web/server-components/source")
|
|
10
|
+
|
|
11
|
+
export interface ComponentCheck {
|
|
12
|
+
readonly receipts: ReadonlySet<string>
|
|
13
|
+
/** Set when the project's TypeScript has no compiler API to read component modules with
|
|
14
|
+
* (TypeScript 7): no module gets a receipt, so the ordinary import rule judges each one. */
|
|
15
|
+
readonly unsupported: UnsupportedTypeScriptError | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COMPONENT_SERVER = /\.component\.server\.[cm]?[jt]sx?$/
|
|
19
|
+
|
|
20
|
+
/** A source-only proof. nifra check never executes nifra.config.ts. */
|
|
21
|
+
export async function componentCheckReceipts(
|
|
22
|
+
root: string,
|
|
23
|
+
source: SourceIndex,
|
|
24
|
+
load: () => Promise<ComponentSource> = () => import("@nifrajs/web/server-components/source"),
|
|
25
|
+
): Promise<ComponentCheck> {
|
|
26
|
+
const receipts = new Set<string>()
|
|
27
|
+
const result: ComponentCheck = { receipts, unsupported: undefined }
|
|
28
|
+
if (!source.files.some((file) => COMPONENT_SERVER.test(file))) return result
|
|
29
|
+
const configFile = ["nifra.config.ts", "backend/framework.ts"].find((file) =>
|
|
30
|
+
existsSync(resolve(root, file)),
|
|
31
|
+
)
|
|
32
|
+
if (configFile === undefined) return result
|
|
33
|
+
let components: ComponentSource
|
|
34
|
+
try {
|
|
35
|
+
components = await load()
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (isUnsupportedTypeScriptError(error)) return { receipts, unsupported: error }
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
const pipeline = components.staticComponentPipeline(
|
|
41
|
+
readFileSync(resolve(root, configFile), "utf8"),
|
|
42
|
+
configFile,
|
|
43
|
+
)
|
|
44
|
+
if (typeof pipeline !== "object" || pipeline === null || Array.isArray(pipeline)) return result
|
|
45
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
|
|
46
|
+
const options = pipeline as Record<string, unknown>
|
|
47
|
+
if (
|
|
48
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
|
|
49
|
+
!["react", "vue", "svelte", "solid", "preact"].includes(options.adapter as string) ||
|
|
50
|
+
typeof options.entries !== "object" ||
|
|
51
|
+
options.entries === null ||
|
|
52
|
+
Array.isArray(options.entries)
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Static receipt inspection checks object shape before reading fields; the source scanner never executes configuration.
|
|
56
|
+
const entries = options.entries as Record<string, unknown>
|
|
57
|
+
if (
|
|
58
|
+
typeof entries.client !== "string" ||
|
|
59
|
+
typeof entries.ssr !== "string" ||
|
|
60
|
+
(options.adapter === "react" && typeof entries.rsc !== "string") ||
|
|
61
|
+
Object.values(entries).some(
|
|
62
|
+
(file) => typeof file !== "string" || !existsSync(resolve(root, file)),
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return result
|
|
66
|
+
for (const file of source.files) {
|
|
67
|
+
if (!COMPONENT_SERVER.test(file)) continue
|
|
68
|
+
const content = source.read(file)
|
|
69
|
+
if (content === undefined) continue
|
|
70
|
+
try {
|
|
71
|
+
components.compileComponentModule(content, resolve(root, file), root, "client")
|
|
72
|
+
receipts.add(file)
|
|
73
|
+
receipts.add(resolve(root, file))
|
|
74
|
+
} catch {
|
|
75
|
+
/* A module without a compilation receipt remains forbidden by the ordinary import rule. */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
package/src/contract-proof.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { existsSync, realpathSync } from "node:fs"
|
|
2
|
-
import { resolve, sep } from "node:path"
|
|
3
1
|
import type { AssuranceFinding, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
|
|
4
2
|
import type {
|
|
5
3
|
AssuredCapabilityRoute,
|
|
6
4
|
CapabilityAssuranceReport,
|
|
7
5
|
CapabilityFinding,
|
|
8
6
|
} from "@nifrajs/core/capabilities"
|
|
9
|
-
import {
|
|
7
|
+
import type { RouteChange, RoutesDiff } from "@nifrajs/core/diff"
|
|
10
8
|
import type { CheckResult } from "./check.ts"
|
|
11
|
-
import { DEFAULT_SNAPSHOT_FILE,
|
|
9
|
+
import { DEFAULT_SNAPSHOT_FILE, diffAgainstBaseline } from "./diff-tool.ts"
|
|
12
10
|
import { collectProjectVerification } from "./verification.ts"
|
|
13
11
|
|
|
14
12
|
const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
|
|
@@ -59,15 +57,6 @@ export interface ContractProofOptions {
|
|
|
59
57
|
readonly signal?: AbortSignal
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
function safeBaselinePath(cwd: string, baselinePath: string): string {
|
|
63
|
-
const root = resolve(cwd)
|
|
64
|
-
const target = resolve(root, baselinePath)
|
|
65
|
-
if (target !== root && !target.startsWith(`${root}${sep}`)) {
|
|
66
|
-
throw new Error("contract proof baseline must stay inside the project directory")
|
|
67
|
-
}
|
|
68
|
-
return target
|
|
69
|
-
}
|
|
70
|
-
|
|
71
60
|
function errorMessage(error: unknown): string {
|
|
72
61
|
return error instanceof Error ? error.message : String(error)
|
|
73
62
|
}
|
|
@@ -144,18 +133,7 @@ export async function collectContractProof(
|
|
|
144
133
|
options: ContractProofOptions = {},
|
|
145
134
|
): Promise<ContractProofReport> {
|
|
146
135
|
const baselinePath = options.baselinePath ?? DEFAULT_SNAPSHOT_FILE
|
|
147
|
-
const
|
|
148
|
-
if (!existsSync(resolvedBaseline)) {
|
|
149
|
-
throw new Error(`contract proof baseline not found: ${resolvedBaseline}`)
|
|
150
|
-
}
|
|
151
|
-
const realRoot = realpathSync(resolve(cwd))
|
|
152
|
-
const realBaseline = realpathSync(resolvedBaseline)
|
|
153
|
-
if (realBaseline !== realRoot && !realBaseline.startsWith(`${realRoot}${sep}`)) {
|
|
154
|
-
throw new Error("contract proof baseline must stay inside the project directory")
|
|
155
|
-
}
|
|
156
|
-
const baseline = parseSnapshotFile(await Bun.file(resolvedBaseline).text(), baselinePath)
|
|
157
|
-
const current = await snapshotBackend(cwd)
|
|
158
|
-
const diff = diffRouteSnapshots(baseline.routes, current)
|
|
136
|
+
const diff = await diffAgainstBaseline(cwd, baselinePath, { contain: true })
|
|
159
137
|
const verification = await collectProjectVerification(cwd, {
|
|
160
138
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
161
139
|
})
|
package/src/contracts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs"
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
|
+
import { NIFRA_ASSURANCE } from "@nifrajs/core/assurance"
|
|
3
4
|
import {
|
|
4
5
|
type ProjectEvidenceSnapshot,
|
|
5
6
|
reflectedRoutesFromEvidence,
|
|
@@ -16,9 +17,18 @@ export interface ContractDigest {
|
|
|
16
17
|
|
|
17
18
|
export interface ContractsLock {
|
|
18
19
|
readonly version: 1
|
|
20
|
+
/** The {@link CONTRACT_DIGEST_FORMAT} that produced `routes`. Absent in a lock written before it was recorded. */
|
|
21
|
+
readonly digest?: number
|
|
19
22
|
readonly routes: Readonly<Record<string, ContractDigest>>
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Bumped whenever {@link digestRoute} hashes the same schema differently. The lock records it, so a
|
|
27
|
+
* format change reads as "re-snapshot" instead of as drift on every route. 2: properties named like
|
|
28
|
+
* annotation keywords are hashed (4.0.1).
|
|
29
|
+
*/
|
|
30
|
+
export const CONTRACT_DIGEST_FORMAT = 2
|
|
31
|
+
|
|
22
32
|
export const DEFAULT_CONTRACTS_LOCK = "contracts.lock.json"
|
|
23
33
|
const DIGEST = /^[a-f0-9]{64}$/
|
|
24
34
|
|
|
@@ -146,6 +156,7 @@ export async function buildContractsLock(
|
|
|
146
156
|
)
|
|
147
157
|
return {
|
|
148
158
|
version: 1,
|
|
159
|
+
digest: CONTRACT_DIGEST_FORMAT,
|
|
149
160
|
routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => codeUnitOrder(a, b))),
|
|
150
161
|
}
|
|
151
162
|
}
|
|
@@ -160,6 +171,27 @@ async function loadBackend(cwd: string): Promise<unknown> {
|
|
|
160
171
|
return loaded.backend
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
const INPUT_PARTS = ["body", "query", "headers", "cookies", "params"] as const
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A route that in-process auth guards, but through a hook that runs after its declared input is
|
|
178
|
+
* validated: an unauthenticated caller reaches the body parser and reads the validation issues.
|
|
179
|
+
* Gateway auth (declared provenance) never lets that caller in, and an explicit `validationOrder`
|
|
180
|
+
* is the author's choice.
|
|
181
|
+
*/
|
|
182
|
+
export function validatesBeforeAuthentication(route: ReflectedRoute): boolean {
|
|
183
|
+
const schema = route.schema
|
|
184
|
+
if (schema === undefined || schema.validationOrder !== undefined) return false
|
|
185
|
+
if (!INPUT_PARTS.some((part) => schema[part] !== undefined)) return false
|
|
186
|
+
const auth = (route.assurance ?? []).filter(
|
|
187
|
+
(evidence) =>
|
|
188
|
+
evidence.id === NIFRA_ASSURANCE.AUTHENTICATED &&
|
|
189
|
+
(evidence.provenance ?? (evidence.source === "declared" ? "declared" : "runtime")) ===
|
|
190
|
+
"runtime",
|
|
191
|
+
)
|
|
192
|
+
return auth.length > 0 && auth.every((evidence) => evidence.source !== "nifra.authenticate")
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
export async function snapshotContracts(
|
|
164
196
|
cwd: string,
|
|
165
197
|
out = DEFAULT_CONTRACTS_LOCK,
|
|
@@ -176,6 +208,9 @@ export function parseContractsLock(value: unknown): ContractsLock {
|
|
|
176
208
|
const routesRecord = recordOf(routesValue)
|
|
177
209
|
if (record.version !== 1 || routesRecord === undefined)
|
|
178
210
|
throw new TypeError("contracts lock must have version 1 and routes")
|
|
211
|
+
const digest = record.digest
|
|
212
|
+
if (digest !== undefined && !(Number.isSafeInteger(digest) && Number(digest) > 0))
|
|
213
|
+
throw new TypeError("contracts lock digest must be a positive integer")
|
|
179
214
|
const routes: Record<string, ContractDigest> = {}
|
|
180
215
|
for (const [key, raw] of Object.entries(routesRecord)) {
|
|
181
216
|
const item = recordOf(raw)
|
|
@@ -192,7 +227,7 @@ export function parseContractsLock(value: unknown): ContractsLock {
|
|
|
192
227
|
throw new TypeError(`invalid contract ${key}`)
|
|
193
228
|
routes[key] = { request, response }
|
|
194
229
|
}
|
|
195
|
-
return { version: 1, routes }
|
|
230
|
+
return typeof digest === "number" ? { version: 1, digest, routes } : { version: 1, routes }
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
export async function checkContractsLock(
|
|
@@ -202,13 +237,30 @@ export async function checkContractsLock(
|
|
|
202
237
|
present: boolean
|
|
203
238
|
vacuous: boolean
|
|
204
239
|
diagnostics: Array<{ route?: string; message: string }>
|
|
240
|
+
/** `METHOD /path` of every route the backend serves, read when the lock made the check load it. */
|
|
241
|
+
served?: readonly string[]
|
|
242
|
+
/** `METHOD /path` of each route {@link validatesBeforeAuthentication} names, read alongside `served`. */
|
|
243
|
+
inputBeforeAuth?: readonly string[]
|
|
205
244
|
}> {
|
|
206
245
|
const path = resolve(cwd, file)
|
|
207
246
|
if (!existsSync(path)) return { present: false, vacuous: false, diagnostics: [] }
|
|
208
247
|
const parsed = parseContractsLock(JSON.parse(await Bun.file(path).text()))
|
|
209
|
-
const
|
|
248
|
+
const evidence = snapshotProjectEvidence(await loadBackend(cwd))
|
|
249
|
+
const current = await buildContractsLock(undefined, evidence)
|
|
210
250
|
const vacuous = await isVacuousLock(current)
|
|
251
|
+
const served = Object.keys(current.routes)
|
|
252
|
+
const inputBeforeAuth = reflectedRoutesFromEvidence(evidence)
|
|
253
|
+
.filter(validatesBeforeAuthentication)
|
|
254
|
+
.map((route) => `${route.method} ${route.path}`)
|
|
211
255
|
const diagnostics: Array<{ route?: string; message: string }> = []
|
|
256
|
+
// Digests of two formats never compare, so this still fails; it just names the real cause.
|
|
257
|
+
if (parsed.digest !== undefined && parsed.digest !== CONTRACT_DIGEST_FORMAT) {
|
|
258
|
+
const newer = parsed.digest > CONTRACT_DIGEST_FORMAT
|
|
259
|
+
diagnostics.push({
|
|
260
|
+
message: `${file} was written with contract digest format ${parsed.digest}, but this nifra computes format ${CONTRACT_DIGEST_FORMAT}${newer ? " (a newer @nifrajs/cli wrote it; upgrade this one)" : ""}, so no route could be compared. Review the route schema changes since the lock was written, then run \`nifra contracts snapshot\` and commit the lock.`,
|
|
261
|
+
})
|
|
262
|
+
return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
|
|
263
|
+
}
|
|
212
264
|
const keys = new Set([...Object.keys(parsed.routes), ...Object.keys(current.routes)])
|
|
213
265
|
for (const key of [...keys].sort()) {
|
|
214
266
|
const before = parsed.routes[key]
|
|
@@ -225,5 +277,10 @@ export async function checkContractsLock(
|
|
|
225
277
|
})
|
|
226
278
|
}
|
|
227
279
|
}
|
|
228
|
-
|
|
280
|
+
if (diagnostics.length > 0 && parsed.digest === undefined) {
|
|
281
|
+
diagnostics.push({
|
|
282
|
+
message: `${file} records no contract digest format. A lock written before nifra 4.0.1 hashed schemas differently, so a route reported above may not have changed. Review them, then run \`nifra contracts snapshot\` to record the format.`,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
return { present: true, vacuous, diagnostics, served, inputBeforeAuth }
|
|
229
286
|
}
|