@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/src/rules/pages.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs"
|
|
15
15
|
import { join } from "node:path"
|
|
16
16
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
17
|
-
import { shadowedPages } from "@nifrajs/web/route-manifest"
|
|
17
|
+
import { BACKEND_ROUTE_EXPORTS, shadowedPages } from "@nifrajs/web/route-manifest"
|
|
18
18
|
import type * as TSApi from "typescript"
|
|
19
19
|
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
|
|
20
20
|
import { stripComments } from "../check-scan.ts"
|
|
@@ -159,10 +159,24 @@ export const statusLayoutRule: CheckRule = {
|
|
|
159
159
|
},
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** The two framework configs' sources, `undefined` for one the app lacks. */
|
|
163
|
+
export interface ApiPrefixSources {
|
|
164
|
+
readonly framework: string | undefined
|
|
165
|
+
readonly config: string | undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `apiPrefix` as the app runs it: nifra.config.ts's own, else backend/framework.ts's, which every
|
|
169
|
+
* command takes for an option nifra.config.ts leaves unset. */
|
|
170
|
+
function effectiveApiPrefix({ framework, config }: ApiPrefixSources): StaticApiPrefix {
|
|
171
|
+
const own: StaticApiPrefix =
|
|
172
|
+
config === undefined ? { kind: "default" } : readStaticApiPrefix(config)
|
|
173
|
+
return own.kind !== "default" || framework === undefined ? own : readStaticApiPrefix(framework)
|
|
174
|
+
}
|
|
175
|
+
|
|
162
176
|
/**
|
|
163
|
-
* NF-C035: `backend/app.ts` exports a backend
|
|
164
|
-
*
|
|
165
|
-
*
|
|
177
|
+
* NF-C035: `backend/app.ts` exports a backend that is not served over HTTP, so a browser call to `/api`
|
|
178
|
+
* reaches page routing. The framework config sets no `apiPrefix`; loader-only is a valid choice, and
|
|
179
|
+
* the finding asks for it to be stated. An unreadable `apiPrefix` is NF-C027's.
|
|
166
180
|
*/
|
|
167
181
|
export const implicitApiMountRule: CheckRule = {
|
|
168
182
|
code: "NF-C035",
|
|
@@ -172,18 +186,16 @@ export const implicitApiMountRule: CheckRule = {
|
|
|
172
186
|
// An API-only app serves its backend itself; there is no page routing to fall into.
|
|
173
187
|
if (!existsSync(backendFile) || !hasWebConfig(ctx.root, ".")) return []
|
|
174
188
|
if (!BACKEND_EXPORT.test(stripComments(readFileSync(backendFile, "utf8")))) return []
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
const file = configFile ?? FRAMEWORK_FILE
|
|
189
|
+
const read = (file: string): string | undefined =>
|
|
190
|
+
existsSync(join(ctx.root, file)) ? readFileSync(join(ctx.root, file), "utf8") : undefined
|
|
191
|
+
const sources = { framework: read(FRAMEWORK_FILE), config: read(CONFIG_FILE) }
|
|
192
|
+
if (sources.framework === undefined && sources.config === undefined) return []
|
|
193
|
+
if (effectiveApiPrefix(sources).kind !== "default") return []
|
|
194
|
+
const file = sources.framework === undefined ? CONFIG_FILE : FRAMEWORK_FILE
|
|
183
195
|
return [
|
|
184
196
|
diagnostic({
|
|
185
197
|
code: "NF-C035",
|
|
186
|
-
severity: "
|
|
198
|
+
severity: "warn",
|
|
187
199
|
file,
|
|
188
200
|
message: `${BACKEND_APP_FILE}'s backend is not served over HTTP, because ${file} sets no apiPrefix: a browser call to /api reaches page routing instead. Add \`export const apiPrefix = "/api"\` to ${file} to serve it there, as 4.x did by default, or \`export const apiPrefix = ""\` to keep it loader-only.`,
|
|
189
201
|
fix: { recipe: "framework.api-prefix", command: "nifra fix --code NF-C035" },
|
|
@@ -193,32 +205,54 @@ export const implicitApiMountRule: CheckRule = {
|
|
|
193
205
|
},
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
const appendLine = (source: string, line: string): string =>
|
|
209
|
+
source === "" ? line : `${source}${source.endsWith("\n") ? "" : "\n"}${line}`
|
|
210
|
+
|
|
196
211
|
/** Append `export const apiPrefix = "/api"` to a framework config that sets none, creating it if needed. */
|
|
197
212
|
export function withDefaultApiPrefix(source: string | undefined): string | undefined {
|
|
198
213
|
if (source !== undefined && readStaticApiPrefix(source).kind !== "default") return undefined
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
214
|
+
return appendLine(source ?? "", 'export const apiPrefix = "/api"\n')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The edit that serves the backend at `/api`: the mount in backend/framework.ts, where the server
|
|
219
|
+
* entry reads it, or in nifra.config.ts when it is the only config. A file that needs no edit comes
|
|
220
|
+
* back `undefined`; an `apiPrefix` either file sets is never replaced.
|
|
221
|
+
*/
|
|
222
|
+
export function apiPrefixFix(sources: ApiPrefixSources): ApiPrefixSources {
|
|
223
|
+
if (effectiveApiPrefix(sources).kind !== "default")
|
|
224
|
+
return { framework: undefined, config: undefined }
|
|
225
|
+
return sources.framework === undefined
|
|
226
|
+
? { framework: undefined, config: withDefaultApiPrefix(sources.config) }
|
|
227
|
+
: { framework: withDefaultApiPrefix(sources.framework), config: undefined }
|
|
202
228
|
}
|
|
203
229
|
|
|
204
230
|
const LOADER_EXPORT =
|
|
205
231
|
/\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+loader\b|\bexport\s*\{[^}]*\bloader\b[^}]*\}/
|
|
206
232
|
const SCRIPT_LAYOUT = /\.[cm]?[jt]sx?$/
|
|
207
233
|
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
// Read from the node, not `ts.getModifiers`: TypeScript 7's API has no getModifiers or canHaveModifiers.
|
|
235
|
+
const hasModifier = (
|
|
236
|
+
node: TSApi.VariableStatement | TSApi.FunctionDeclaration | TSApi.ClassDeclaration,
|
|
237
|
+
kind: TSApi.SyntaxKind,
|
|
238
|
+
): boolean => node.modifiers?.some((m) => m.kind === kind) === true
|
|
210
239
|
|
|
211
|
-
/** The function an exported `
|
|
212
|
-
function
|
|
240
|
+
/** The function an exported `name` names: a declaration, or a const initialized with one. */
|
|
241
|
+
function exportedFunction(
|
|
213
242
|
ts: typeof TSApi,
|
|
214
243
|
tree: TSApi.SourceFile,
|
|
244
|
+
name: string,
|
|
215
245
|
): TSApi.FunctionDeclaration | TSApi.ArrowFunction | TSApi.FunctionExpression | undefined {
|
|
216
246
|
for (const statement of tree.statements) {
|
|
217
|
-
if (
|
|
218
|
-
|
|
247
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
248
|
+
if (statement.name?.text === name && hasModifier(statement, ts.SyntaxKind.ExportKeyword))
|
|
249
|
+
return statement
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
219
252
|
if (!ts.isVariableStatement(statement)) continue
|
|
253
|
+
if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue
|
|
220
254
|
for (const declaration of statement.declarationList.declarations) {
|
|
221
|
-
if (!ts.isIdentifier(declaration.name) || declaration.name.text !==
|
|
255
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue
|
|
222
256
|
let init = declaration.initializer
|
|
223
257
|
while (
|
|
224
258
|
init !== undefined &&
|
|
@@ -237,7 +271,7 @@ function exportedMetaFunction(
|
|
|
237
271
|
|
|
238
272
|
/** Where an exported function `meta` reads its args' `data`: a destructured `data`, or `args.data`. */
|
|
239
273
|
export function metaDataReads(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
|
|
240
|
-
const meta =
|
|
274
|
+
const meta = exportedFunction(ts, tree, "meta")
|
|
241
275
|
const param = meta?.parameters[0]
|
|
242
276
|
if (param === undefined) return []
|
|
243
277
|
const lineOf = (node: TSApi.Node) =>
|
|
@@ -326,9 +360,217 @@ export const layoutMetaDataRule: CheckRule = {
|
|
|
326
360
|
},
|
|
327
361
|
}
|
|
328
362
|
|
|
363
|
+
const GATE_EXPORT = /\bexport\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*true\b/
|
|
364
|
+
const MIDDLEWARE_EXPORT =
|
|
365
|
+
/\bexport\s+(?:async\s+)?(?:const|let|var|function\*?)\s+middleware\b|\bexport\s*\{[^}]*\bmiddleware\b[^}]*\}/
|
|
366
|
+
|
|
367
|
+
/** Lines where an exported `loader` refuses access: `redirect()`, `statusPage(401|403)`, `new Response()`. */
|
|
368
|
+
export function loaderRefusals(ts: typeof TSApi, tree: TSApi.SourceFile): number[] {
|
|
369
|
+
const loader = exportedFunction(ts, tree, "loader")
|
|
370
|
+
if (loader?.body === undefined) return []
|
|
371
|
+
const lines: number[] = []
|
|
372
|
+
const lineOf = (node: TSApi.Node): number =>
|
|
373
|
+
tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
374
|
+
const visit = (node: TSApi.Node): void => {
|
|
375
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
376
|
+
const status = node.arguments[0]
|
|
377
|
+
if (
|
|
378
|
+
node.expression.text === "redirect" ||
|
|
379
|
+
(node.expression.text === "statusPage" &&
|
|
380
|
+
status !== undefined &&
|
|
381
|
+
ts.isNumericLiteral(status) &&
|
|
382
|
+
(status.text === "401" || status.text === "403"))
|
|
383
|
+
)
|
|
384
|
+
lines.push(lineOf(node))
|
|
385
|
+
} else if (
|
|
386
|
+
ts.isNewExpression(node) &&
|
|
387
|
+
ts.isIdentifier(node.expression) &&
|
|
388
|
+
node.expression.text === "Response"
|
|
389
|
+
) {
|
|
390
|
+
lines.push(lineOf(node))
|
|
391
|
+
}
|
|
392
|
+
ts.forEachChild(node, visit)
|
|
393
|
+
}
|
|
394
|
+
visit(loader.body)
|
|
395
|
+
return lines
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* NF-C042: a layout loader that refuses access - a redirect, a 401 or 403, a `Response` - in a layout
|
|
400
|
+
* that is not a gate and exports no middleware. A client navigation that keeps the layout skips that
|
|
401
|
+
* loader, and an action below it runs without it, so the check reads as a guard and is not one. A
|
|
402
|
+
* refusal inside a helper the loader calls is not seen; the development warning covers that.
|
|
403
|
+
*/
|
|
404
|
+
export const ungatedLayoutRefusalRule: CheckRule = {
|
|
405
|
+
code: "NF-C042",
|
|
406
|
+
title: "Layout loader refuses access without a gate",
|
|
407
|
+
async scan(ctx) {
|
|
408
|
+
const routesDir = join(ctx.root, "routes")
|
|
409
|
+
if (!existsSync(routesDir)) return []
|
|
410
|
+
let manifest: ReturnType<typeof discoverRoutes>
|
|
411
|
+
try {
|
|
412
|
+
manifest = discoverRoutes(routesDir)
|
|
413
|
+
} catch {
|
|
414
|
+
return []
|
|
415
|
+
}
|
|
416
|
+
const layouts = Object.values(manifest.layouts)
|
|
417
|
+
.filter((layout) => SCRIPT_LAYOUT.test(layout.file))
|
|
418
|
+
.sort((a, b) => codeUnitOrder(a.file, b.file))
|
|
419
|
+
if (layouts.length === 0) return []
|
|
420
|
+
const sources = layouts.map((layout) =>
|
|
421
|
+
[layout.file, ...(layout.backend === undefined ? [] : [layout.backend])].map((file) => ({
|
|
422
|
+
file,
|
|
423
|
+
text: readFileSync(join(routesDir, file), "utf8"),
|
|
424
|
+
})),
|
|
425
|
+
)
|
|
426
|
+
if (!sources.flat().some((half) => LOADER_EXPORT.test(stripComments(half.text)))) return []
|
|
427
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
428
|
+
const ts = loaded.compiler
|
|
429
|
+
if (ts === undefined) return []
|
|
430
|
+
const findings: Diagnostic[] = []
|
|
431
|
+
for (const halves of sources) {
|
|
432
|
+
const code = halves.map((half) => stripComments(half.text))
|
|
433
|
+
if (code.some((text) => GATE_EXPORT.test(text) || MIDDLEWARE_EXPORT.test(text))) continue
|
|
434
|
+
for (const half of halves) {
|
|
435
|
+
if (!LOADER_EXPORT.test(stripComments(half.text))) continue
|
|
436
|
+
const tree = ts.createSourceFile(
|
|
437
|
+
half.file,
|
|
438
|
+
half.text,
|
|
439
|
+
ts.ScriptTarget.Latest,
|
|
440
|
+
true,
|
|
441
|
+
scriptKindOf(ts, half.file),
|
|
442
|
+
)
|
|
443
|
+
const [line] = loaderRefusals(ts, tree)
|
|
444
|
+
if (line === undefined) continue
|
|
445
|
+
findings.push(
|
|
446
|
+
diagnostic({
|
|
447
|
+
code: "NF-C042",
|
|
448
|
+
severity: "warn",
|
|
449
|
+
file: `routes/${half.file}`,
|
|
450
|
+
line,
|
|
451
|
+
message: `routes/${half.file}'s loader refuses access, but the layout is not a gate. A client navigation that keeps the layout skips this loader, and an action below it runs without it, so it does not protect the pages under it. Export \`gate = true\` from this file, or move the check into its \`middleware\`.`,
|
|
452
|
+
evidence: [
|
|
453
|
+
"gate = true: the loader runs on every request and before every action, ahead of the page loader",
|
|
454
|
+
"middleware: runs before every page and action below the directory",
|
|
455
|
+
],
|
|
456
|
+
verify: "nifra check",
|
|
457
|
+
}),
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return findings
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The names a module exports at runtime, each with its line: declarations and `export { ... }` lists.
|
|
466
|
+
* `export *` names nothing it can read, and type-only exports carry no value. */
|
|
467
|
+
function runtimeExports(
|
|
468
|
+
ts: typeof TSApi,
|
|
469
|
+
tree: TSApi.SourceFile,
|
|
470
|
+
): Array<{ readonly name: string; readonly line: number }> {
|
|
471
|
+
const found: Array<{ name: string; line: number }> = []
|
|
472
|
+
const add = (name: string, node: TSApi.Node): void => {
|
|
473
|
+
found.push({ name, line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1 })
|
|
474
|
+
}
|
|
475
|
+
for (const statement of tree.statements) {
|
|
476
|
+
if (ts.isExportDeclaration(statement)) {
|
|
477
|
+
if (statement.isTypeOnly || statement.exportClause === undefined) continue
|
|
478
|
+
if (!ts.isNamedExports(statement.exportClause)) continue
|
|
479
|
+
for (const element of statement.exportClause.elements) {
|
|
480
|
+
if (!element.isTypeOnly) add(element.name.text, element)
|
|
481
|
+
}
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
if (
|
|
485
|
+
!(
|
|
486
|
+
ts.isVariableStatement(statement) ||
|
|
487
|
+
ts.isFunctionDeclaration(statement) ||
|
|
488
|
+
ts.isClassDeclaration(statement)
|
|
489
|
+
) ||
|
|
490
|
+
!hasModifier(statement, ts.SyntaxKind.ExportKeyword) ||
|
|
491
|
+
hasModifier(statement, ts.SyntaxKind.DeclareKeyword)
|
|
492
|
+
)
|
|
493
|
+
continue
|
|
494
|
+
if (ts.isVariableStatement(statement)) {
|
|
495
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
496
|
+
if (ts.isIdentifier(declaration.name)) add(declaration.name.text, declaration)
|
|
497
|
+
}
|
|
498
|
+
} else if (statement.name !== undefined) {
|
|
499
|
+
add(statement.name.text, statement)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return found
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* NF-C041: a route's frontend file (`x.tsx`, a layout, a status or error page) exports a name only
|
|
507
|
+
* the server reads, such as `loader` or `action`. The frontend file ships to the browser whole, so
|
|
508
|
+
* `nifra build` and the server refuse it; this reports it before either runs.
|
|
509
|
+
*/
|
|
510
|
+
export const frontendServerExportRule: CheckRule = {
|
|
511
|
+
code: "NF-C041",
|
|
512
|
+
title: "Server-only export in a route frontend file",
|
|
513
|
+
async scan(ctx) {
|
|
514
|
+
const routesDir = join(ctx.root, "routes")
|
|
515
|
+
if (!existsSync(routesDir)) return []
|
|
516
|
+
let manifest: ReturnType<typeof discoverRoutes>
|
|
517
|
+
try {
|
|
518
|
+
manifest = discoverRoutes(routesDir)
|
|
519
|
+
} catch {
|
|
520
|
+
return []
|
|
521
|
+
}
|
|
522
|
+
const files = new Set<string>()
|
|
523
|
+
for (const entry of [
|
|
524
|
+
...manifest.routes,
|
|
525
|
+
...Object.values(manifest.layouts),
|
|
526
|
+
...Object.values(manifest.errors ?? {}),
|
|
527
|
+
...(manifest.notFound === undefined ? [] : [manifest.notFound]),
|
|
528
|
+
...Object.values(manifest.notFounds ?? {}),
|
|
529
|
+
...Object.values(manifest.statusPages ?? {}),
|
|
530
|
+
...Object.values(manifest.loadings ?? {}),
|
|
531
|
+
]) {
|
|
532
|
+
if (SCRIPT_LAYOUT.test(entry.file)) files.add(entry.file)
|
|
533
|
+
}
|
|
534
|
+
if (files.size === 0) return []
|
|
535
|
+
const loaded = await loadRuleTypeScript(ctx)
|
|
536
|
+
const ts = loaded.compiler
|
|
537
|
+
if (ts === undefined) return []
|
|
538
|
+
const findings: Diagnostic[] = []
|
|
539
|
+
for (const file of [...files].sort(codeUnitOrder)) {
|
|
540
|
+
const text = readFileSync(join(routesDir, file), "utf8")
|
|
541
|
+
const tree = ts.createSourceFile(
|
|
542
|
+
file,
|
|
543
|
+
text,
|
|
544
|
+
ts.ScriptTarget.Latest,
|
|
545
|
+
true,
|
|
546
|
+
scriptKindOf(ts, file),
|
|
547
|
+
)
|
|
548
|
+
for (const { name, line } of runtimeExports(ts, tree)) {
|
|
549
|
+
if (!BACKEND_ROUTE_EXPORTS.has(name)) continue
|
|
550
|
+
const backend = `routes/${file.replace(SCRIPT_LAYOUT, ".backend.ts")}`
|
|
551
|
+
findings.push(
|
|
552
|
+
diagnostic({
|
|
553
|
+
code: "NF-C041",
|
|
554
|
+
severity: "error",
|
|
555
|
+
file: `routes/${file}`,
|
|
556
|
+
line,
|
|
557
|
+
message: `routes/${file} exports "${name}", which runs on the server only, and a route's frontend file ships to the browser whole, so nifra build and the server refuse it. Move it to ${backend}.`,
|
|
558
|
+
evidence: [`export: ${name}`, `backend half: ${backend}`],
|
|
559
|
+
fix: { recipe: "route.split", command: "nifra fix --code NF-C041" },
|
|
560
|
+
verify: "nifra check",
|
|
561
|
+
}),
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return findings
|
|
566
|
+
},
|
|
567
|
+
}
|
|
568
|
+
|
|
329
569
|
export const pageRules = Object.freeze([
|
|
330
570
|
shadowedPageRule,
|
|
331
571
|
statusLayoutRule,
|
|
332
572
|
implicitApiMountRule,
|
|
333
573
|
layoutMetaDataRule,
|
|
574
|
+
frontendServerExportRule,
|
|
575
|
+
ungatedLayoutRefusalRule,
|
|
334
576
|
])
|
package/src/rules/security.ts
CHANGED
|
@@ -74,8 +74,9 @@ function unexpectedMessage(
|
|
|
74
74
|
const parentOf = (dir: string): string => dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Whether NF-S002 may treat `file` as browser code,
|
|
78
|
-
*
|
|
77
|
+
* Whether NF-S001 and NF-S002 may treat `file` as browser code, where a finding is advisory: a
|
|
78
|
+
* browser compares values the client already holds, and a gate there that fails open grants nothing
|
|
79
|
+
* the server does not check again. It must be browser code by path (a route's frontend half,
|
|
79
80
|
* `frontend/`) and sit inside a nifra web app: a `routes/` folder in an API-only project or a package
|
|
80
81
|
* without a web dependency runs on the server. `shared/` also runs on the server, and a file that
|
|
81
82
|
* cannot be classified is treated as server - fail closed.
|
|
@@ -557,29 +558,46 @@ const GATE_NAMES = [
|
|
|
557
558
|
/^(?:require|assert|authori[sz]e|authenticate)/i,
|
|
558
559
|
/^can[A-Z]/,
|
|
559
560
|
/.Guard$/,
|
|
560
|
-
/^(?:ensure|verify|check|validate)(?:Auth
|
|
561
|
+
/^(?:ensure|verify|check|validate)(?:Auth\w*|LoggedIn|Login|Session|Token|Jwt|JWT|Signatures?|Webhook|Hmac|HMAC|Password|Credentials?|Permissions?|Access|Roles?|Admin(?:istrator)?|Owner(?:ship)?|Scopes?|Csrf|CSRF|ApiKey|Origin|Tenant|User|Member(?:ship)?)(?![a-z])/,
|
|
561
562
|
]
|
|
562
563
|
|
|
564
|
+
/** An authenticator returns who is calling, so its "nobody" result (`null`, `undefined`) is a denial. */
|
|
565
|
+
const AUTHENTICATOR = /^authenticate/i
|
|
566
|
+
|
|
567
|
+
/** Result-object fields whose `false` is the denial (`{ ok: false, error }`). */
|
|
568
|
+
const DECISION_FIELDS = new Set([
|
|
569
|
+
"ok",
|
|
570
|
+
"allowed",
|
|
571
|
+
"authorized",
|
|
572
|
+
"authenticated",
|
|
573
|
+
"valid",
|
|
574
|
+
"success",
|
|
575
|
+
"granted",
|
|
576
|
+
])
|
|
577
|
+
|
|
563
578
|
/** Helpers whose whole job is to fail (`fail(…)`, `deny(…)`, `reject(…)`, …). */
|
|
564
579
|
const FAILING_HELPERS = new Set(["fail", "deny", "reject", "forbid", "unauthorized", "abort"])
|
|
565
580
|
|
|
566
581
|
/**
|
|
567
|
-
* Whether a gate's catch block fails closed: it throws, returns `false`, a
|
|
568
|
-
* `
|
|
582
|
+
* Whether a gate's catch block fails closed: it throws, returns `false`, a result object whose decision
|
|
583
|
+
* field is `false`, a `new Response(…)` or a `deny…(…)` call (an authenticator also `null` or
|
|
584
|
+
* nothing), or hands the failure to a {@link FAILING_HELPERS} helper. The last form is why
|
|
569
585
|
* conformance/invariant asserts that funnel every error through a `never`-returning `fail()` are not
|
|
570
586
|
* fail-open - the scanner reads no types, so it trusts the helper by name, the same trade the SQL and
|
|
571
587
|
* gate-name heuristics already make. Read from the syntax, so a "throw" in a comment or a string counts
|
|
572
588
|
* for nothing, and a nested function's statements are not the catch's own.
|
|
573
589
|
*/
|
|
574
|
-
function failsClosed(ts: typeof TSApi, block: TSApi.Block): boolean {
|
|
590
|
+
function failsClosed(ts: typeof TSApi, block: TSApi.Block, gate: string): boolean {
|
|
591
|
+
const authenticator = AUTHENTICATOR.test(gate)
|
|
575
592
|
let closed = false
|
|
576
593
|
const visit = (node: TSApi.Node): void => {
|
|
577
594
|
if (closed || ts.isFunctionLike(node)) return
|
|
578
595
|
if (
|
|
579
596
|
ts.isThrowStatement(node) ||
|
|
580
597
|
(ts.isReturnStatement(node) &&
|
|
581
|
-
node.expression
|
|
582
|
-
|
|
598
|
+
(node.expression === undefined
|
|
599
|
+
? authenticator
|
|
600
|
+
: isDenial(ts, node.expression, authenticator))) ||
|
|
583
601
|
(ts.isCallExpression(node) &&
|
|
584
602
|
ts.isIdentifier(node.expression) &&
|
|
585
603
|
FAILING_HELPERS.has(node.expression.text))
|
|
@@ -593,7 +611,7 @@ function failsClosed(ts: typeof TSApi, block: TSApi.Block): boolean {
|
|
|
593
611
|
return closed
|
|
594
612
|
}
|
|
595
613
|
|
|
596
|
-
function isDenial(ts: typeof TSApi, expression: TSApi.Expression): boolean {
|
|
614
|
+
function isDenial(ts: typeof TSApi, expression: TSApi.Expression, authenticator: boolean): boolean {
|
|
597
615
|
let value = expression
|
|
598
616
|
while (
|
|
599
617
|
ts.isParenthesizedExpression(value) ||
|
|
@@ -603,6 +621,22 @@ function isDenial(ts: typeof TSApi, expression: TSApi.Expression): boolean {
|
|
|
603
621
|
)
|
|
604
622
|
value = value.expression
|
|
605
623
|
if (value.kind === ts.SyntaxKind.FalseKeyword) return true
|
|
624
|
+
if (
|
|
625
|
+
authenticator &&
|
|
626
|
+
(value.kind === ts.SyntaxKind.NullKeyword ||
|
|
627
|
+
ts.isVoidExpression(value) ||
|
|
628
|
+
(ts.isIdentifier(value) && value.text === "undefined"))
|
|
629
|
+
)
|
|
630
|
+
return true
|
|
631
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
632
|
+
return value.properties.some(
|
|
633
|
+
(property) =>
|
|
634
|
+
ts.isPropertyAssignment(property) &&
|
|
635
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
636
|
+
DECISION_FIELDS.has(property.name.text) &&
|
|
637
|
+
property.initializer.kind === ts.SyntaxKind.FalseKeyword,
|
|
638
|
+
)
|
|
639
|
+
}
|
|
606
640
|
if (ts.isNewExpression(value))
|
|
607
641
|
return ts.isIdentifier(value.expression) && value.expression.text === "Response"
|
|
608
642
|
return (
|
|
@@ -621,23 +655,26 @@ export const failOpenGateRule: CheckRule = {
|
|
|
621
655
|
const ts = loaded.compiler
|
|
622
656
|
if (ts === undefined) return didNotRun("NF-S001", "Fail-open gate scan")
|
|
623
657
|
const findings: Diagnostic[] = []
|
|
658
|
+
const webDirs = createWebAppProbe(ctx.root)
|
|
624
659
|
for (const file of ctx.project.source.files) {
|
|
625
660
|
const parsed = parsedFile(ts, ctx.project.source, file)
|
|
626
661
|
if (parsed === undefined) continue
|
|
627
662
|
const { tree, lines } = parsed
|
|
663
|
+
let browser: boolean | undefined
|
|
628
664
|
const visit = (node: TSApi.Node): void => {
|
|
629
665
|
if (ts.isCatchClause(node)) {
|
|
630
666
|
let parent: TSApi.Node | undefined = node.parent
|
|
631
667
|
while (parent !== undefined && !ts.isFunctionLike(parent)) parent = parent.parent
|
|
632
668
|
const name = parent === undefined ? undefined : functionName(ts, parent)
|
|
633
669
|
if (name !== undefined && GATE_NAMES.some((gate) => gate.test(name))) {
|
|
634
|
-
if (!failsClosed(ts, node.block)) {
|
|
670
|
+
if (!failsClosed(ts, node.block, name)) {
|
|
635
671
|
const line = tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1
|
|
636
672
|
const reviewed = expectedReason(lines, line, "NF-S001") !== undefined
|
|
673
|
+
browser ??= provenBrowserFile(ctx.root, file, webDirs)
|
|
637
674
|
findings.push(
|
|
638
675
|
diagnostic({
|
|
639
676
|
code: "NF-S001",
|
|
640
|
-
severity: reviewed ? "info" : "error",
|
|
677
|
+
severity: reviewed ? "info" : browser ? "warn" : "error",
|
|
641
678
|
file,
|
|
642
679
|
line,
|
|
643
680
|
message: reviewed
|
|
@@ -651,6 +688,7 @@ export const failOpenGateRule: CheckRule = {
|
|
|
651
688
|
evidence: [
|
|
652
689
|
name,
|
|
653
690
|
"catch block has no denial or rethrow",
|
|
691
|
+
...(browser ? ["browser code: the server's own gate decides"] : []),
|
|
654
692
|
...expectationEvidence(lines, line, "NF-S001"),
|
|
655
693
|
],
|
|
656
694
|
verify: "nifra check --lints-only",
|
package/src/rules/zones.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
importRuleMessage,
|
|
18
18
|
privateEnvReads,
|
|
19
19
|
privateEnvReason,
|
|
20
|
+
specifierPackage,
|
|
20
21
|
type ZoneClassifier,
|
|
21
22
|
ZoneConfigError,
|
|
22
23
|
} from "@nifrajs/web/zones"
|
|
@@ -26,6 +27,7 @@ import { appDirs, clientModuleRoot } from "../client-module.ts"
|
|
|
26
27
|
import { componentCheckReceipts } from "../component-check.ts"
|
|
27
28
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
28
29
|
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
30
|
+
import { installedManifest } from "../internal/installed-package.ts"
|
|
29
31
|
import {
|
|
30
32
|
createApiOnlyProbe,
|
|
31
33
|
createKindProbe,
|
|
@@ -35,7 +37,9 @@ import {
|
|
|
35
37
|
withApiKindHint,
|
|
36
38
|
} from "../internal/nifra-project.ts"
|
|
37
39
|
import { createSpecifierResolver } from "../internal/project-specifiers.ts"
|
|
40
|
+
import { createSourceFacts } from "../internal/source-facts.ts"
|
|
38
41
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
42
|
+
import { loadRuleTypeScript } from "./typescript.ts"
|
|
39
43
|
|
|
40
44
|
const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
|
|
41
45
|
const FRONTEND = new Set(["route-frontend", "frontend"])
|
|
@@ -186,6 +190,15 @@ export const zoneImportRule: CheckRule = {
|
|
|
186
190
|
// Outside any web app, a finding may only mean the project serves no pages: say how to declare it.
|
|
187
191
|
const inWebApp = createWebAppProbe(ctx.root)
|
|
188
192
|
const resolveSpecifier = createSpecifierResolver()
|
|
193
|
+
// Without a compiler nothing proves an import erased, so every import stays an edge.
|
|
194
|
+
const { compiler } = await loadRuleTypeScript(ctx)
|
|
195
|
+
const facts =
|
|
196
|
+
compiler === undefined
|
|
197
|
+
? undefined
|
|
198
|
+
: createSourceFacts(compiler, {
|
|
199
|
+
keepsTypeOnlyImports: (file) =>
|
|
200
|
+
resolveSpecifier.keepsTypeOnlyImports(isAbsolute(file) ? file : join(ctx.root, file)),
|
|
201
|
+
})
|
|
189
202
|
const queue: Importer[] = []
|
|
190
203
|
const queued = new Set<string>()
|
|
191
204
|
const follow = (importer: Importer): void => {
|
|
@@ -201,7 +214,7 @@ export const zoneImportRule: CheckRule = {
|
|
|
201
214
|
// The browser entry imports the app's `clientModule`, so the build classifies it like any file it
|
|
202
215
|
// loads, and loads what it imports.
|
|
203
216
|
for (const app of appDirs(ctx.sources.files)) {
|
|
204
|
-
const entry = clientModuleRoot(ctx.root, app, resolveSpecifier)
|
|
217
|
+
const entry = clientModuleRoot(ctx.root, app, resolveSpecifier, compiler)
|
|
205
218
|
if (entry === undefined) continue
|
|
206
219
|
let classification: Classification
|
|
207
220
|
try {
|
|
@@ -235,11 +248,21 @@ export const zoneImportRule: CheckRule = {
|
|
|
235
248
|
for (const { file, app, zone: from } of queue) {
|
|
236
249
|
const content = ctx.sources.read(file)
|
|
237
250
|
if (content === undefined) continue
|
|
238
|
-
for (const { specifier, line } of importSites(content, file)) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
251
|
+
for (const { specifier, line } of importSites(content, file, facts)) {
|
|
252
|
+
let target = resolveSpecifier(join(ctx.root, file), specifier)
|
|
253
|
+
let to: Classification
|
|
254
|
+
if (target === undefined) {
|
|
255
|
+
// An installed package is judged here only by the entry it names, when it gives each
|
|
256
|
+
// entry its own side; a package-wide side is NF-C004's in browser code.
|
|
257
|
+
const name = specifierPackage(specifier)
|
|
258
|
+
const manifest =
|
|
259
|
+
name === undefined ? undefined : installedManifest(join(ctx.root, file), name)
|
|
260
|
+
if (manifest === undefined) continue
|
|
261
|
+
to = classifierFor(app).classify(manifest, specifier)
|
|
262
|
+
if (to.entry === undefined) continue
|
|
263
|
+
target = manifest
|
|
264
|
+
} else to = classifierFor(app).classify(target, specifier)
|
|
265
|
+
const targetName = to.entry === undefined ? display(ctx.root, target) : specifier
|
|
243
266
|
if (
|
|
244
267
|
["frontend", "route-frontend", "shared"].includes(from) &&
|
|
245
268
|
(receipts.has(target) || receipts.has(targetName))
|
|
@@ -250,7 +273,7 @@ export const zoneImportRule: CheckRule = {
|
|
|
250
273
|
if (to.package !== undefined) {
|
|
251
274
|
if (reportedPackages.has(to.package)) continue
|
|
252
275
|
reportedPackages.add(to.package)
|
|
253
|
-
} else {
|
|
276
|
+
} else if (to.entry === undefined) {
|
|
254
277
|
// A file in no zone loads what it imports too: the build refuses each one it reaches.
|
|
255
278
|
follow({ file: targetName, app, zone: "error" })
|
|
256
279
|
}
|