@nifrajs/cli 5.0.0 → 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 +7 -2
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +4 -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 +24 -7
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +2 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +8 -2
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +43 -10
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +39 -33
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -48
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +5 -3
- package/dist/client-module.d.ts.map +1 -1
- package/dist/client-module.js +34 -10
- package/dist/client-module.js.map +1 -1
- package/dist/command-catalog.d.ts +9 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +67 -22
- package/dist/command-catalog.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/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +93 -12
- package/dist/fix-recipes.js.map +1 -1
- package/dist/init-agents.d.ts +16 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +3 -2
- 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/project-specifiers.d.ts +14 -1
- package/dist/internal/project-specifiers.d.ts.map +1 -1
- package/dist/internal/project-specifiers.js +54 -15
- package/dist/internal/project-specifiers.js.map +1 -1
- package/dist/internal/source-facts.d.ts +6 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -5
- package/dist/internal/source-facts.js.map +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 +9 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +32 -1
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +5 -8
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +2 -2
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +23 -3
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +6 -21
- 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 +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +8 -3
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +13 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +23 -0
- package/dist/mcp-io.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.d.ts.map +1 -1
- package/dist/mcp.js +40 -17
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +40 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1316 -228
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +34 -0
- package/dist/output-schemas.d.ts.map +1 -1
- package/dist/output-schemas.js +132 -21
- package/dist/output-schemas.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/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/review.d.ts.map +1 -1
- package/dist/review.js +5 -0
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +3 -1
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +3 -1
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +6 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +37 -0
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +20 -1
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +29 -3
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +236 -22
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/security.d.ts +3 -2
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +42 -11
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +31 -9
- package/dist/rules/zones.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +127 -17
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +3 -9
- package/dist/test-tool.js.map +1 -1
- package/dist/upgrade.d.ts +20 -5
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +86 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +5 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts.map +1 -1
- package/dist/version-drift.js +55 -3
- package/dist/version-drift.js.map +1 -1
- package/docs/examples.json +2 -2
- package/docs/llms-full.txt +481 -61
- package/docs/types.json +147 -70
- package/package.json +11 -11
- package/src/assure-hydration.ts +4 -1
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +33 -7
- package/src/check-diagnostics.ts +2 -0
- package/src/check-scan.ts +56 -9
- package/src/check.ts +55 -39
- package/src/cli.ts +104 -35
- package/src/client-module.ts +45 -9
- package/src/command-catalog.ts +77 -21
- package/src/dev-feed-tool.ts +112 -1
- package/src/fix-recipes.ts +101 -10
- package/src/init-agents.ts +4 -3
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/project-specifiers.ts +69 -16
- package/src/internal/source-facts.ts +23 -5
- package/src/internal/typescript-import.ts +5 -3
- package/src/introspect.ts +39 -1
- package/src/levels-tool.ts +7 -9
- package/src/load.ts +29 -3
- package/src/mcp-context.ts +5 -18
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +9 -2
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +33 -0
- package/src/mcp-path.ts +8 -22
- package/src/mcp.ts +48 -19
- package/src/migrate-layout.ts +1496 -237
- package/src/output-schemas.ts +179 -23
- package/src/project-path.ts +44 -13
- package/src/release-verification.ts +20 -0
- package/src/review.ts +5 -0
- package/src/rules/codes.ts +3 -1
- package/src/rules/data-guard.ts +40 -0
- package/src/rules/legacy.ts +22 -1
- package/src/rules/pages.ts +266 -24
- package/src/rules/security.ts +49 -11
- package/src/rules/zones.ts +30 -7
- package/src/stylex-migrate.ts +117 -17
- package/src/test-tool.ts +3 -9
- package/src/upgrade.ts +96 -8
- package/src/verification-plan.ts +9 -2
- package/src/version-drift.ts +53 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "The nifra CLI - zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -69,16 +69,16 @@
|
|
|
69
69
|
"access": "public"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@nifrajs/client": "5.
|
|
73
|
-
"@nifrajs/core": "5.
|
|
74
|
-
"@nifrajs/agent-review": "5.
|
|
75
|
-
"@nifrajs/i18n": "5.
|
|
76
|
-
"@nifrajs/mcp": "5.
|
|
77
|
-
"@nifrajs/mcp-db": "5.
|
|
78
|
-
"@nifrajs/runner": "5.
|
|
79
|
-
"@nifrajs/schema": "5.
|
|
80
|
-
"@nifrajs/testing": "5.
|
|
81
|
-
"@nifrajs/web": "5.
|
|
72
|
+
"@nifrajs/client": "5.1.0",
|
|
73
|
+
"@nifrajs/core": "5.1.0",
|
|
74
|
+
"@nifrajs/agent-review": "5.1.0",
|
|
75
|
+
"@nifrajs/i18n": "5.1.0",
|
|
76
|
+
"@nifrajs/mcp": "5.1.0",
|
|
77
|
+
"@nifrajs/mcp-db": "5.1.0",
|
|
78
|
+
"@nifrajs/runner": "5.1.0",
|
|
79
|
+
"@nifrajs/schema": "5.1.0",
|
|
80
|
+
"@nifrajs/testing": "5.1.0",
|
|
81
|
+
"@nifrajs/web": "5.1.0"
|
|
82
82
|
},
|
|
83
83
|
"repository": {
|
|
84
84
|
"type": "git",
|
package/src/assure-hydration.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { collectDoctorResult } from "./doctor.ts"
|
|
|
13
13
|
import { loadApp, resolvePlugins } from "./load.ts"
|
|
14
14
|
import {
|
|
15
15
|
CHILD_OUTPUT_MAX_BYTES,
|
|
16
|
+
escalatingStop,
|
|
16
17
|
forwardTerminalSignals,
|
|
17
18
|
joinHeadTail,
|
|
18
19
|
OWN_PROCESS_GROUP,
|
|
@@ -647,10 +648,11 @@ export async function runHydrationAssurance(
|
|
|
647
648
|
detached: OWN_PROCESS_GROUP,
|
|
648
649
|
},
|
|
649
650
|
)
|
|
651
|
+
const stopper = escalatingStop((name) => signalProcessGroup(proc, name))
|
|
650
652
|
let stopped: string | undefined
|
|
651
653
|
const stop = (reason: string): void => {
|
|
652
654
|
stopped ??= reason
|
|
653
|
-
|
|
655
|
+
stopper.stop()
|
|
654
656
|
}
|
|
655
657
|
const onAbort = (): void => stop("cancelled")
|
|
656
658
|
control.signal?.addEventListener("abort", onAbort, { once: true })
|
|
@@ -684,6 +686,7 @@ export async function runHydrationAssurance(
|
|
|
684
686
|
return failed(joinHeadTail(stderr).trim() || "no result")
|
|
685
687
|
} finally {
|
|
686
688
|
clearTimeout(timer)
|
|
689
|
+
stopper.dispose()
|
|
687
690
|
control.signal?.removeEventListener("abort", onAbort)
|
|
688
691
|
releaseSignals()
|
|
689
692
|
}
|
package/src/assure.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { existsSync } from "node:fs"
|
|
4
4
|
import { join, resolve } from "node:path"
|
|
5
5
|
import { pathToFileURL } from "node:url"
|
|
6
|
-
import type { AssuranceConfig, AssuranceReport } from "@nifrajs/core/assurance"
|
|
6
|
+
import type { AssuranceConfig, AssuranceReport, AssuredRoute } from "@nifrajs/core/assurance"
|
|
7
7
|
import { CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
|
|
8
8
|
import type { Diagnostic } from "./diagnostics.ts"
|
|
9
|
+
import type { ProjectVerification } from "./verification.ts"
|
|
9
10
|
|
|
10
11
|
export interface AssureSink {
|
|
11
12
|
record(bundle: AssureBundle): Promise<void>
|
|
@@ -14,6 +15,7 @@ export interface AssureSink {
|
|
|
14
15
|
export interface GateResult {
|
|
15
16
|
readonly gate:
|
|
16
17
|
| "check"
|
|
18
|
+
| "assurance"
|
|
17
19
|
| "doctor"
|
|
18
20
|
| "render"
|
|
19
21
|
| "hydration"
|
|
@@ -91,6 +93,45 @@ export async function loadAssuranceConfig(
|
|
|
91
93
|
return loaded.default
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/** Why declared response/error schemas go unenforced, phrased for the server that has to change. */
|
|
97
|
+
export interface ResponseContractGaps {
|
|
98
|
+
readonly typedOnly: readonly string[]
|
|
99
|
+
readonly warnOnly: readonly string[]
|
|
100
|
+
readonly typedOnlyReason: string | undefined
|
|
101
|
+
readonly warnOnlyReason: string | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sample = (routes: readonly string[]): string =>
|
|
105
|
+
routes.length <= 3
|
|
106
|
+
? routes.join(", ")
|
|
107
|
+
: `${routes.slice(0, 3).join(", ")} and ${routes.length - 3} more`
|
|
108
|
+
|
|
109
|
+
export function responseContractGaps(routes: readonly AssuredRoute[]): ResponseContractGaps {
|
|
110
|
+
const key = (route: AssuredRoute): string => `${route.method} ${route.path}`
|
|
111
|
+
const typedOnly = routes.filter((route) => route.responseContract === "unchecked").map(key)
|
|
112
|
+
const warnOnly = routes.filter((route) => route.responseContract === "warn").map(key)
|
|
113
|
+
const covered = routes.filter(
|
|
114
|
+
(route) => route.responseContract === "enforce" || route.responseContract === "warn",
|
|
115
|
+
).length
|
|
116
|
+
// A plugin that covers some routes is installed - just not where these were registered, which is
|
|
117
|
+
// the case "install responseContract" advice used to send people in circles over.
|
|
118
|
+
const typedOnlyReason =
|
|
119
|
+
typedOnly.length === 0
|
|
120
|
+
? undefined
|
|
121
|
+
: covered > 0
|
|
122
|
+
? `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}) although responseContract() covers ${covered} other(s): it covers routes registered after it on the same server, and routes merge()d into that server after it. Install responseContract("enforce") on the server that registers these, before them.`
|
|
123
|
+
: `${typedOnly.length} declared route(s) are typed-only (${sample(typedOnly)}); install responseContract("enforce") on the server that registers them, before them - a parent's also covers servers merge()d into it afterwards.`
|
|
124
|
+
const warnOnlyReason =
|
|
125
|
+
warnOnly.length === 0
|
|
126
|
+
? undefined
|
|
127
|
+
: `${warnOnly.length} declared route(s) are warn-only and still send the original payload (${sample(warnOnly)}); switch responseContract("warn") to "enforce".`
|
|
128
|
+
return { typedOnly, warnOnly, typedOnlyReason, warnOnlyReason }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The policy line that turns an unenforced contract into a route-assurance failure. */
|
|
132
|
+
export const RESPONSE_CONTRACT_BASELINE_HINT =
|
|
133
|
+
'to fail route assurance on these, add baseline: [{ name: "response-contract", match: { hasResponse: true }, require: ["nifra.response-contract"] }] to the policy'
|
|
134
|
+
|
|
94
135
|
export function formatAssuranceReport(report: AssuranceReport): string {
|
|
95
136
|
if (report.ok) {
|
|
96
137
|
const contracted = report.routes.filter((route) => route.responseContract !== undefined)
|
|
@@ -247,20 +288,57 @@ export async function collectAssureBundle(
|
|
|
247
288
|
}
|
|
248
289
|
}
|
|
249
290
|
|
|
291
|
+
// One verification serves both gates that read the app, so the config is evaluated once.
|
|
292
|
+
let verification: Promise<ProjectVerification> | undefined
|
|
293
|
+
const verify = (): Promise<ProjectVerification> =>
|
|
294
|
+
(verification ??= import("./verification.ts").then(({ collectProjectVerification }) =>
|
|
295
|
+
collectProjectVerification(cwd, {
|
|
296
|
+
...(options.config !== undefined ? { config: options.config } : {}),
|
|
297
|
+
preloaded:
|
|
298
|
+
assuranceConfig !== undefined
|
|
299
|
+
? { config: assuranceConfig }
|
|
300
|
+
: { error: assuranceConfigError },
|
|
301
|
+
}),
|
|
302
|
+
))
|
|
303
|
+
|
|
250
304
|
await addGate("check", async () => {
|
|
251
|
-
const
|
|
252
|
-
const verification = await collectProjectVerification(cwd, {
|
|
253
|
-
...(options.config !== undefined ? { config: options.config } : {}),
|
|
254
|
-
preloaded:
|
|
255
|
-
assuranceConfig !== undefined
|
|
256
|
-
? { config: assuranceConfig }
|
|
257
|
-
: { error: assuranceConfigError },
|
|
258
|
-
})
|
|
259
|
-
const result = await verification.check()
|
|
305
|
+
const result = await (await verify()).check()
|
|
260
306
|
const diagnostics = (result.structuredDiagnostics ?? []).map((item) => item)
|
|
261
307
|
return { diagnostics }
|
|
262
308
|
})
|
|
263
309
|
|
|
310
|
+
// Route assurance is the gate the bundle exists to carry: a failing policy fails it, and a declared
|
|
311
|
+
// response the runtime does not enforce is a warning, so `--strict` refuses to call it green.
|
|
312
|
+
await addGate("assurance", async () => {
|
|
313
|
+
const report = assuranceConfig === undefined ? undefined : (await verify()).routeAssurance
|
|
314
|
+
if (report === undefined)
|
|
315
|
+
return {
|
|
316
|
+
diagnostics: [],
|
|
317
|
+
skipReason: existsSync(resolve(cwd, options.config ?? DEFAULT_ASSURANCE_CONFIG))
|
|
318
|
+
? "route assurance could not be evaluated (reported by the check gate)"
|
|
319
|
+
: "no route assurance config",
|
|
320
|
+
}
|
|
321
|
+
const diagnostics = report.findings.map((finding) =>
|
|
322
|
+
diagnosticFromMessage("NF-A001", finding.message, "error"),
|
|
323
|
+
)
|
|
324
|
+
const gaps = responseContractGaps(report.routes)
|
|
325
|
+
for (const [reason, routes] of [
|
|
326
|
+
[gaps.typedOnlyReason, gaps.typedOnly],
|
|
327
|
+
[gaps.warnOnlyReason, gaps.warnOnly],
|
|
328
|
+
] as const) {
|
|
329
|
+
if (reason === undefined) continue
|
|
330
|
+
diagnostics.push(
|
|
331
|
+
Object.freeze({
|
|
332
|
+
code: "NF-A001",
|
|
333
|
+
severity: "warn" as const,
|
|
334
|
+
message: `${reason} (${RESPONSE_CONTRACT_BASELINE_HINT})`,
|
|
335
|
+
evidence: Object.freeze(routes.slice(0, 20)),
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
return { diagnostics }
|
|
340
|
+
})
|
|
341
|
+
|
|
264
342
|
await addGate("doctor", async () => {
|
|
265
343
|
const { collectDoctorResult } = await import("./doctor.ts")
|
|
266
344
|
const result = await collectDoctorResult(cwd)
|
package/src/capabilities-tool.ts
CHANGED
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
} from "@nifrajs/core/capabilities"
|
|
19
19
|
import { expandOptionalParams } from "@nifrajs/core/pattern"
|
|
20
20
|
import { type ReflectedMount, type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
scanSkippedRouteText,
|
|
23
|
+
scanStaticRouteText,
|
|
24
|
+
scanStaticSocketText,
|
|
25
|
+
stripComments,
|
|
26
|
+
walkSource,
|
|
27
|
+
} from "./check.ts"
|
|
22
28
|
|
|
23
29
|
// Import and re-export clauses are spelled out (a default binding, then `* as ns` or a `{ … }` list)
|
|
24
30
|
// rather than scanned lazily, which is quadratic on a long semicolon-free module.
|
|
@@ -340,6 +346,7 @@ export async function collectCapabilityProjectReport(
|
|
|
340
346
|
const policy = defineCapabilityPolicy(policyInput)
|
|
341
347
|
const sources = await readSources(cwd)
|
|
342
348
|
const automatic = new Map<string, Set<string>>()
|
|
349
|
+
const skipped = new Map<string, string>()
|
|
343
350
|
for (const [file, content] of sources) {
|
|
344
351
|
for (const route of [
|
|
345
352
|
...scanStaticRouteText(file, content, undefined, sources),
|
|
@@ -352,6 +359,12 @@ export async function collectCapabilityProjectReport(
|
|
|
352
359
|
automatic.set(routeKey(route.method, path), modules)
|
|
353
360
|
}
|
|
354
361
|
}
|
|
362
|
+
for (const route of scanSkippedRouteText(file, content, sources)) {
|
|
363
|
+
for (const path of staticRouteForms(route.path)) {
|
|
364
|
+
if (!skipped.has(routeKey(route.method, path)))
|
|
365
|
+
skipped.set(routeKey(route.method, path), `${file}:${route.line}`)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
355
368
|
}
|
|
356
369
|
|
|
357
370
|
const evidenceRoutes = []
|
|
@@ -380,12 +393,25 @@ export async function collectCapabilityProjectReport(
|
|
|
380
393
|
policy,
|
|
381
394
|
sources,
|
|
382
395
|
)
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
396
|
+
const site = modules.size === 0 ? skipped.get(routeKey(route.method, route.path)) : undefined
|
|
397
|
+
const uncoveredReason =
|
|
398
|
+
modules.size > 0
|
|
399
|
+
? undefined
|
|
400
|
+
: site === undefined
|
|
401
|
+
? "no source file registers it with a path the static scan can read; map it with capabilities.provenance.routeModules"
|
|
402
|
+
: `${site} appears to register it on a server the static scan cannot identify (built by a helper that is neither \`server()\` nor imported from a module that calls it); call server() there, or map the route with capabilities.provenance.routeModules`
|
|
403
|
+
const covered = modules.size > 0 && walked.covered
|
|
404
|
+
evidenceRoutes.push(
|
|
405
|
+
uncoveredReason === undefined
|
|
406
|
+
? { method: route.method, path: route.path, covered, evidence: walked.evidence }
|
|
407
|
+
: {
|
|
408
|
+
method: route.method,
|
|
409
|
+
path: route.path,
|
|
410
|
+
covered,
|
|
411
|
+
evidence: walked.evidence,
|
|
412
|
+
uncoveredReason,
|
|
413
|
+
},
|
|
414
|
+
)
|
|
389
415
|
violations.push(...walked.violations)
|
|
390
416
|
truncations.push(...walked.truncations)
|
|
391
417
|
}
|
package/src/check-diagnostics.ts
CHANGED
|
@@ -368,6 +368,8 @@ export interface CheckTypecheckResult {
|
|
|
368
368
|
readonly note?: string
|
|
369
369
|
readonly output?: string
|
|
370
370
|
readonly missingTypeScript?: boolean
|
|
371
|
+
/** TypeScript sources found with no tsconfig.json to typecheck them. */
|
|
372
|
+
readonly sourcesWithoutTsconfig?: number
|
|
371
373
|
readonly outsideProgram?: readonly string[]
|
|
372
374
|
}
|
|
373
375
|
|
package/src/check-scan.ts
CHANGED
|
@@ -352,15 +352,59 @@ export function parseSimpleFetchCall(snippet: string): SimpleFetchCall | undefin
|
|
|
352
352
|
return { path, method, start: match.index, end: close + 1 }
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
|
|
355
|
+
const SERVER_CONSTRUCTION = /(?<![.\w])server\s*\(|new\s+Hono(?:<[^>]+>)?\s*\(/
|
|
356
|
+
|
|
357
|
+
function isPotentialBackendSource(
|
|
358
|
+
code: string,
|
|
359
|
+
file?: string,
|
|
360
|
+
project?: ReadonlyMap<string, string>,
|
|
361
|
+
): boolean {
|
|
356
362
|
return (
|
|
357
363
|
code.includes("@nifrajs/core") ||
|
|
358
|
-
|
|
364
|
+
SERVER_CONSTRUCTION.test(code) ||
|
|
359
365
|
/(?:from\s*|import\s*)["']hono["']/.test(code) ||
|
|
360
|
-
|
|
366
|
+
(file !== undefined && project !== undefined && callsLocalServerFactory(code, file, project))
|
|
361
367
|
)
|
|
362
368
|
}
|
|
363
369
|
|
|
370
|
+
/**
|
|
371
|
+
* A module that builds its server through a local helper - `newServer().get(...)` with `newServer`
|
|
372
|
+
* imported from a project file that calls `server()` - names neither `@nifrajs/core` nor `server(`
|
|
373
|
+
* itself. One import hop is followed: the imported module must construct a server, so a helper module
|
|
374
|
+
* that merely mentions `@nifrajs/core` (a schema re-export) does not turn every importer into a
|
|
375
|
+
* backend and its `client.get("/x")` calls into routes.
|
|
376
|
+
*/
|
|
377
|
+
function callsLocalServerFactory(
|
|
378
|
+
code: string,
|
|
379
|
+
file: string,
|
|
380
|
+
project: ReadonlyMap<string, string>,
|
|
381
|
+
): boolean {
|
|
382
|
+
for (const [local, binding] of namedImports(code)) {
|
|
383
|
+
if (!new RegExp(`(?<![.\\w$])${local.replaceAll("$", "\\$")}\\s*\\(`).test(code)) continue
|
|
384
|
+
const target = projectModule(project, file, binding.specifier)
|
|
385
|
+
if (target !== undefined && SERVER_CONSTRUCTION.test(stripComments(project.get(target) ?? "")))
|
|
386
|
+
return true
|
|
387
|
+
}
|
|
388
|
+
return false
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Literal route registrations in a file the backend-source gate turned away, so a route the scan
|
|
393
|
+
* could not cover can say where it probably lives. Nothing here is treated as a route.
|
|
394
|
+
*/
|
|
395
|
+
export function scanSkippedRouteText(
|
|
396
|
+
file: string,
|
|
397
|
+
content: string,
|
|
398
|
+
project?: ReadonlyMap<string, string>,
|
|
399
|
+
): StaticRouteFinding[] {
|
|
400
|
+
const code = stripComments(content)
|
|
401
|
+
if (isPotentialBackendSource(code, file, project)) return []
|
|
402
|
+
return [
|
|
403
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ),
|
|
404
|
+
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ),
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
|
|
364
408
|
function scanRoutePattern(
|
|
365
409
|
file: string,
|
|
366
410
|
content: string,
|
|
@@ -727,7 +771,7 @@ export function scanStaticRouteText(
|
|
|
727
771
|
project?: ReadonlyMap<string, string>,
|
|
728
772
|
): StaticRouteFinding[] {
|
|
729
773
|
const code = stripComments(content)
|
|
730
|
-
if (!isPotentialBackendSource(code)) return []
|
|
774
|
+
if (!isPotentialBackendSource(code, file, project)) return []
|
|
731
775
|
return [
|
|
732
776
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts),
|
|
733
777
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts),
|
|
@@ -849,7 +893,7 @@ function scanRouteArguments(
|
|
|
849
893
|
const routes: StaticRouteFinding[] = []
|
|
850
894
|
const unresolved: UnresolvedRoutePathFinding[] = []
|
|
851
895
|
const code = stripComments(content)
|
|
852
|
-
if (!isPotentialBackendSource(code)) return { routes, unresolved }
|
|
896
|
+
if (!isPotentialBackendSource(code, file, project)) return { routes, unresolved }
|
|
853
897
|
const lines = content.split("\n")
|
|
854
898
|
const constants = moduleConstants(code, content)
|
|
855
899
|
const imports = namedImports(code)
|
|
@@ -922,7 +966,7 @@ export function scanStaticSocketText(
|
|
|
922
966
|
project?: ReadonlyMap<string, string>,
|
|
923
967
|
): StaticRouteFinding[] {
|
|
924
968
|
const code = stripComments(content)
|
|
925
|
-
if (!isPotentialBackendSource(code)) return []
|
|
969
|
+
if (!isPotentialBackendSource(code, file, project)) return []
|
|
926
970
|
return [
|
|
927
971
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_DQ, facts, SOCKET_VERB),
|
|
928
972
|
...scanRoutePattern(file, content, code, ROUTE_REGISTRATION_SQ, facts, SOCKET_VERB),
|
|
@@ -2939,7 +2983,8 @@ function staticImportEdges(
|
|
|
2939
2983
|
for (let m = re.exec(code); m !== null; m = re.exec(code)) {
|
|
2940
2984
|
if (positions[m.index] === " ") continue
|
|
2941
2985
|
if (m[1] === undefined) continue
|
|
2942
|
-
|
|
2986
|
+
// Only a `type` modifier can erase a clause the pattern matched, so other clauses skip the parse.
|
|
2987
|
+
if (facts !== undefined && file !== undefined && /\btype\b/.test(m[0])) {
|
|
2943
2988
|
const source = facts.parse(file, content)
|
|
2944
2989
|
if (source !== undefined && facts.isValueImportAt(source, m.index, m[1]) === false) continue
|
|
2945
2990
|
}
|
|
@@ -3020,12 +3065,14 @@ function importEdges(
|
|
|
3020
3065
|
return edges.sort((a, b) => a.index - b.index)
|
|
3021
3066
|
}
|
|
3022
3067
|
|
|
3023
|
-
/** The runtime imports of a module with the line each sits on, in source order.
|
|
3068
|
+
/** The runtime imports of a module with the line each sits on, in source order. With `facts`, an
|
|
3069
|
+
* import the compiler erases (`import { type X }`) is none. Pure. */
|
|
3024
3070
|
export function importSites(
|
|
3025
3071
|
content: string,
|
|
3026
3072
|
file?: string,
|
|
3073
|
+
facts?: SourceFacts,
|
|
3027
3074
|
): Array<{ readonly specifier: string; readonly line: number }> {
|
|
3028
|
-
return importEdges(content,
|
|
3075
|
+
return importEdges(content, facts, file).map(({ specifier, index }) => ({
|
|
3029
3076
|
specifier,
|
|
3030
3077
|
line: lineAt(content, index),
|
|
3031
3078
|
}))
|
package/src/check.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmdirSync, rmSync } from "node:fs"
|
|
20
20
|
import { tmpdir } from "node:os"
|
|
21
21
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
|
22
|
-
import { specifierPackage } from "@nifrajs/web/zones"
|
|
22
|
+
import { declaredSide, specifierPackage } from "@nifrajs/web/zones"
|
|
23
23
|
import { BACKEND_APP_FILE } from "./app-files.ts"
|
|
24
24
|
import {
|
|
25
25
|
type CheckAssuranceContext,
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import { appDirs, clientModuleRoot } from "./client-module.ts"
|
|
34
34
|
import { checkContractsLock, DEFAULT_CONTRACTS_LOCK } from "./contracts.ts"
|
|
35
35
|
import { type Diagnostic, normalizeSeverity, toSarifLog } from "./diagnostics.ts"
|
|
36
|
+
import { installedManifest } from "./internal/installed-package.ts"
|
|
36
37
|
import { createApiOnlyProbe, createNifraProjectProbe } from "./internal/nifra-project.ts"
|
|
37
38
|
import { createSpecifierResolver } from "./internal/project-specifiers.ts"
|
|
38
39
|
import { createSourceFacts } from "./internal/source-facts.ts"
|
|
@@ -95,6 +96,10 @@ interface TypecheckResult {
|
|
|
95
96
|
/** tsconfig.json exists but no `typescript` install was found walking up from cwd. The contract
|
|
96
97
|
* gate could not run - the caller surfaces this as a FAILING diagnostic, never a silent skip. */
|
|
97
98
|
readonly missingTypeScript?: boolean
|
|
99
|
+
/** No tsconfig.json at the project root. Fails the check only when TypeScript sources exist. */
|
|
100
|
+
readonly missingTsconfig?: boolean
|
|
101
|
+
/** TypeScript sources the scan found with no tsconfig.json to typecheck them. */
|
|
102
|
+
readonly sourcesWithoutTsconfig?: number
|
|
98
103
|
/** Every file in the compiler's program, absolute. Absent when the compiler listed none. */
|
|
99
104
|
readonly programFiles?: readonly string[]
|
|
100
105
|
/** Zoned TypeScript files outside the program, project-relative: the typecheck never read them. */
|
|
@@ -123,7 +128,7 @@ function resolveTscBin(cwd: string): string | undefined {
|
|
|
123
128
|
async function typecheck(cwd: string, signal?: AbortSignal): Promise<TypecheckResult> {
|
|
124
129
|
const tsconfig = join(cwd, "tsconfig.json")
|
|
125
130
|
if (!(await Bun.file(tsconfig).exists()))
|
|
126
|
-
return { ran: false, ok: true, note: "no tsconfig.json" }
|
|
131
|
+
return { ran: false, ok: true, missingTsconfig: true, note: "no tsconfig.json" }
|
|
127
132
|
// Routes import their generated `./+types` modules; bring them up to date before tsc reads them.
|
|
128
133
|
const { refreshRouteTypes } = await import("./route-types.ts")
|
|
129
134
|
refreshRouteTypes(cwd, () => {})
|
|
@@ -309,6 +314,16 @@ async function collectContractFacts(cwd: string): Promise<ContractCheckFacts> {
|
|
|
309
314
|
}
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
/** A project whose TypeScript no tsconfig.json covers has no typecheck to pass: the gate fails. */
|
|
318
|
+
function withoutTsconfig(
|
|
319
|
+
result: TypecheckResult,
|
|
320
|
+
sourceFiles: ReadonlyArray<{ readonly file: string }>,
|
|
321
|
+
): TypecheckResult {
|
|
322
|
+
if (result.missingTsconfig !== true) return result
|
|
323
|
+
const sources = sourceFiles.filter(({ file }) => !/\.d\.[cm]?ts$/.test(file)).length
|
|
324
|
+
return sources === 0 ? result : { ...result, ok: false, sourcesWithoutTsconfig: sources }
|
|
325
|
+
}
|
|
326
|
+
|
|
312
327
|
/** Record the zoned files the compiler's program left out, minus the typecheck rule's `ignore` globs. */
|
|
313
328
|
function withProgramCoverage(
|
|
314
329
|
cwd: string,
|
|
@@ -376,11 +391,10 @@ async function buildProjectScan(
|
|
|
376
391
|
|
|
377
392
|
// tsc compiles TypeScript sources only; the JavaScript and component files the scan reads are no
|
|
378
393
|
// program's to cover.
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
typecheckRun,
|
|
382
|
-
|
|
383
|
-
checkConfig,
|
|
394
|
+
const typeScriptSources = sourceFiles.filter(({ file }) => /\.[cm]?tsx?$/.test(file))
|
|
395
|
+
const typecheckResult = withoutTsconfig(
|
|
396
|
+
withProgramCoverage(cwd, typecheckRun, typeScriptSources, checkConfig),
|
|
397
|
+
typeScriptSources,
|
|
384
398
|
)
|
|
385
399
|
|
|
386
400
|
// TypeScript 7's unstable API materializes source files through one project session. Build the
|
|
@@ -390,7 +404,24 @@ async function buildProjectScan(
|
|
|
390
404
|
const loadedTypeScript = await loadProjectTypeScript(cwd, opts.loadTypeScript, source)
|
|
391
405
|
try {
|
|
392
406
|
const sqlCompiler = loadedTypeScript.compiler
|
|
393
|
-
|
|
407
|
+
// Resolved modules stay project-relative, as the scanned files are: the zone of a path is read
|
|
408
|
+
// from its folders, and a checkout under a folder named `backend/` must not make it all backend.
|
|
409
|
+
// The resolver answers with real paths, so the project root is read as one too.
|
|
410
|
+
const realCwd = (() => {
|
|
411
|
+
try {
|
|
412
|
+
return realpathSync(cwd)
|
|
413
|
+
} catch {
|
|
414
|
+
return cwd
|
|
415
|
+
}
|
|
416
|
+
})()
|
|
417
|
+
const resolveSpecifier = createSpecifierResolver()
|
|
418
|
+
const sourceFacts =
|
|
419
|
+
sqlCompiler === undefined
|
|
420
|
+
? undefined
|
|
421
|
+
: createSourceFacts(sqlCompiler, {
|
|
422
|
+
keepsTypeOnlyImports: (file) =>
|
|
423
|
+
resolveSpecifier.keepsTypeOnlyImports(isAbsolute(file) ? file : join(realCwd, file)),
|
|
424
|
+
})
|
|
394
425
|
const sqlImports = sqlCompiler === undefined ? undefined : createProjectSqlImports(cwd)
|
|
395
426
|
const project = new Map(
|
|
396
427
|
sourceFiles.map(({ file, content }) => [file.replaceAll("\\", "/"), content]),
|
|
@@ -403,17 +434,6 @@ async function buildProjectScan(
|
|
|
403
434
|
interpolatedSql.push(...scanSqlSites(file, content, sqlCompiler, sqlImports))
|
|
404
435
|
}
|
|
405
436
|
|
|
406
|
-
// Resolved modules stay project-relative, as the scanned files are: the zone of a path is read
|
|
407
|
-
// from its folders, and a checkout under a folder named `backend/` must not make it all backend.
|
|
408
|
-
// The resolver answers with real paths, so the project root is read as one too.
|
|
409
|
-
const realCwd = (() => {
|
|
410
|
-
try {
|
|
411
|
-
return realpathSync(cwd)
|
|
412
|
-
} catch {
|
|
413
|
-
return cwd
|
|
414
|
-
}
|
|
415
|
-
})()
|
|
416
|
-
const resolveSpecifier = createSpecifierResolver()
|
|
417
437
|
const projectPath = (path: string): string => {
|
|
418
438
|
const rel = relative(realCwd, path).replaceAll("\\", "/")
|
|
419
439
|
return rel.startsWith("../") || isAbsolute(rel) ? path : rel
|
|
@@ -439,7 +459,7 @@ async function buildProjectScan(
|
|
|
439
459
|
// An app's `clientModule` is browser code wherever it lives: the generated client entry imports it.
|
|
440
460
|
const browserRoots = new Set<string>()
|
|
441
461
|
for (const app of appDirs(source.files)) {
|
|
442
|
-
const entry = clientModuleRoot(cwd, app, resolveSpecifier)
|
|
462
|
+
const entry = clientModuleRoot(cwd, app, resolveSpecifier, sqlCompiler)
|
|
443
463
|
if (entry === undefined) continue
|
|
444
464
|
const rel = projectPath(entry.file)
|
|
445
465
|
if (isBrowserSource(rel) || browserRoots.has(rel)) continue
|
|
@@ -448,28 +468,24 @@ async function buildProjectScan(
|
|
|
448
468
|
browserRoots.add(rel)
|
|
449
469
|
browserModules.push({ rel, content })
|
|
450
470
|
}
|
|
451
|
-
//
|
|
471
|
+
// The side the package gives the entry the import names: package-wide, or per entry.
|
|
452
472
|
const backendPackage = (fromFile: string, specifier: string): boolean => {
|
|
453
473
|
const name = specifierPackage(specifier)
|
|
454
474
|
if (name === undefined) return false
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
const parent = dirname(dir)
|
|
471
|
-
if (parent === dir) return false
|
|
472
|
-
dir = parent
|
|
475
|
+
const manifest = installedManifest(
|
|
476
|
+
isAbsolute(fromFile) ? fromFile : join(realCwd, fromFile),
|
|
477
|
+
name,
|
|
478
|
+
)
|
|
479
|
+
if (manifest === undefined) return false
|
|
480
|
+
try {
|
|
481
|
+
const nifra: unknown = JSON.parse(readFileSync(manifest, "utf8"))?.nifra
|
|
482
|
+
return (
|
|
483
|
+
typeof nifra === "object" &&
|
|
484
|
+
nifra !== null &&
|
|
485
|
+
declaredSide(Reflect.get(nifra, "environment"), specifier) === "backend"
|
|
486
|
+
)
|
|
487
|
+
} catch {
|
|
488
|
+
return false
|
|
473
489
|
}
|
|
474
490
|
}
|
|
475
491
|
const serverOnlyChains = createServerOnlyChains({
|