@nifrajs/cli 4.0.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +218 -44
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +94 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/dev-feed-tool.ts +112 -1
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/port.ts
CHANGED
|
@@ -310,9 +310,10 @@ export function scanFileForFeatures(file: string, content: string, state: ScanSt
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
// Mirror nifra check/doctor's ignore set: deps, build output, generated/per-runtime dist dirs, VCS.
|
|
313
|
-
// PLUS *.config.* and
|
|
313
|
+
// PLUS *.config.* and build scripts (build.ts, build-<target>.ts, build.<x>.ts) - `port` scans
|
|
314
|
+
// request/app source, not tooling. A source file that merely starts with "build" is still source.
|
|
314
315
|
const IGNORED =
|
|
315
|
-
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)[^/]*\.config\.[cm]?[jt]sx?$|(^|\/)build[^/]
|
|
316
|
+
/(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)[^/]*\.config\.[cm]?[jt]sx?$|(^|\/)build(?:[-.][^/]*)?\.[cm]?[jt]sx?$/
|
|
316
317
|
|
|
317
318
|
/** Walk the project's source (same surface as `nifra check`, minus config + build scripts). */
|
|
318
319
|
async function walkPortSource(
|
package/src/project-facts.ts
CHANGED
|
@@ -7,8 +7,11 @@ import type {
|
|
|
7
7
|
import type {
|
|
8
8
|
ManifestDriftFinding,
|
|
9
9
|
SourceFinding,
|
|
10
|
+
SqlFinding,
|
|
10
11
|
StaticRouteFinding,
|
|
11
12
|
TransitiveServerImportFinding,
|
|
13
|
+
TypedClientFinding,
|
|
14
|
+
UnresolvedRoutePathFinding,
|
|
12
15
|
} from "./check-scan.ts"
|
|
13
16
|
import type { DoctorResult } from "./doctor.ts"
|
|
14
17
|
import type { TypeScriptSession } from "./internal/typescript-import.ts"
|
|
@@ -16,13 +19,15 @@ import type { PipelineReport } from "./pipeline-report.ts"
|
|
|
16
19
|
import type { RulePack, SourceIndex } from "./rules/index.ts"
|
|
17
20
|
|
|
18
21
|
export interface ProjectSourceFindings {
|
|
19
|
-
readonly fetches: readonly
|
|
22
|
+
readonly fetches: readonly TypedClientFinding[]
|
|
20
23
|
/** Hand-rolled `new EventSource("/…")` / `new WebSocket("/…")` to the app's own streaming surface. */
|
|
21
|
-
readonly streams: readonly
|
|
24
|
+
readonly streams: readonly TypedClientFinding[]
|
|
22
25
|
readonly untypedClients: readonly SourceFinding[]
|
|
23
26
|
readonly removedImports: readonly SourceFinding[]
|
|
24
27
|
readonly responseRoutes: readonly SourceFinding[]
|
|
25
|
-
readonly interpolatedSql: readonly
|
|
28
|
+
readonly interpolatedSql: readonly SqlFinding[]
|
|
29
|
+
/** Route registrations whose path is neither a literal nor a constant the scan resolves. */
|
|
30
|
+
readonly routePaths: readonly UnresolvedRoutePathFinding[]
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface ProjectPackageFacts {
|
|
@@ -41,6 +46,10 @@ export interface ContractCheckFacts {
|
|
|
41
46
|
readonly present: boolean
|
|
42
47
|
readonly vacuous: boolean
|
|
43
48
|
readonly diagnostics: readonly { readonly route?: string; readonly message: string }[]
|
|
49
|
+
/** `METHOD /path` of every route `backend/app.ts` serves; set only when the lock check loaded it. */
|
|
50
|
+
readonly served?: readonly string[]
|
|
51
|
+
/** `METHOD /path` of each authenticated route that validates its input before its auth hook runs. */
|
|
52
|
+
readonly inputBeforeAuth?: readonly string[]
|
|
44
53
|
readonly error?: string
|
|
45
54
|
}
|
|
46
55
|
|
|
@@ -50,6 +59,10 @@ export interface ProjectCheckFacts {
|
|
|
50
59
|
readonly sqlCompilerAvailable: boolean
|
|
51
60
|
/** Set when a project TypeScript install is present but outside the scanner adapter range. */
|
|
52
61
|
readonly unsupportedTypeScriptVersion?: string
|
|
62
|
+
/** Why component modules could not be checked: the project's TypeScript has no compiler API. */
|
|
63
|
+
readonly componentCheckUnsupported?:
|
|
64
|
+
| { readonly version: string; readonly message: string }
|
|
65
|
+
| undefined
|
|
53
66
|
/** Shared syntax/checker session for built-in rules; omitted from serialized check output. */
|
|
54
67
|
readonly typescriptSession?: TypeScriptSession
|
|
55
68
|
readonly checkConfigError?: string
|
|
@@ -87,6 +100,7 @@ export function freezeProjectFacts(seed: ProjectFactsSeed): ProjectFacts {
|
|
|
87
100
|
removedImports: freezeFindings(seed.sourceFindings.removedImports),
|
|
88
101
|
responseRoutes: freezeFindings(seed.sourceFindings.responseRoutes),
|
|
89
102
|
interpolatedSql: freezeFindings(seed.sourceFindings.interpolatedSql),
|
|
103
|
+
routePaths: freezeFindings(seed.sourceFindings.routePaths),
|
|
90
104
|
})
|
|
91
105
|
const policies = Object.freeze({
|
|
92
106
|
...seed.policies,
|
package/src/project-path.ts
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
|
|
2
2
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
3
3
|
|
|
4
|
+
const MAX_SYMLINKS = 40
|
|
5
|
+
|
|
6
|
+
function errorCode(error: unknown): unknown {
|
|
7
|
+
return typeof error === "object" && error !== null ? Reflect.get(error, "code") : undefined
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where a write to `path` lands, canonicalized: the real path of its nearest existing ancestor plus
|
|
12
|
+
* the components still missing below it. A dangling symlink is followed to the path it names, since
|
|
13
|
+
* writing through it creates that path. `undefined` when the filesystem cannot say.
|
|
14
|
+
*/
|
|
15
|
+
export function writeTargetOf(path: string): string | undefined {
|
|
16
|
+
let probe = resolve(path)
|
|
17
|
+
const missing: string[] = []
|
|
18
|
+
let links = 0
|
|
19
|
+
for (;;) {
|
|
20
|
+
try {
|
|
21
|
+
return resolve(realpathSync(probe), ...[...missing].reverse())
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (errorCode(error) !== "ENOENT") return undefined
|
|
24
|
+
}
|
|
25
|
+
let link: string | undefined
|
|
26
|
+
try {
|
|
27
|
+
if (lstatSync(probe).isSymbolicLink()) link = readlinkSync(probe)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (errorCode(error) !== "ENOENT") return undefined
|
|
30
|
+
}
|
|
31
|
+
if (link !== undefined) {
|
|
32
|
+
if (++links > MAX_SYMLINKS) return undefined
|
|
33
|
+
probe = resolve(dirname(probe), link)
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(probe)
|
|
37
|
+
if (parent === probe) return undefined
|
|
38
|
+
missing.push(basename(probe))
|
|
39
|
+
probe = parent
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
4
43
|
function escapesRoot(root: string, candidate: string): boolean {
|
|
5
44
|
const rel = relative(root, candidate)
|
|
6
45
|
return rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)
|
|
@@ -17,18 +56,10 @@ export async function resolveInsideProject(
|
|
|
17
56
|
const resolved = resolve(resolvedRoot, candidate)
|
|
18
57
|
if (escapesRoot(resolvedRoot, resolved)) return undefined
|
|
19
58
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} catch (error) {
|
|
25
|
-
if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT")) {
|
|
26
|
-
return undefined
|
|
27
|
-
}
|
|
28
|
-
const realParent = await realpath(dirname(resolved))
|
|
29
|
-
realCandidate = resolve(realParent, basename(resolved))
|
|
30
|
-
}
|
|
31
|
-
if (escapesRoot(realRoot, realCandidate)) return undefined
|
|
59
|
+
// The same canonicalizer on both sides: fs/promises expands Windows 8.3 names, realpathSync does not.
|
|
60
|
+
const realRoot = realpathSync(resolvedRoot)
|
|
61
|
+
const realCandidate = writeTargetOf(resolved)
|
|
62
|
+
if (realCandidate === undefined || escapesRoot(realRoot, realCandidate)) return undefined
|
|
32
63
|
return resolved
|
|
33
64
|
} catch {
|
|
34
65
|
return undefined
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { UpgradeRecipe } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Upgrade recipe for Nifra 5.0.
|
|
5
|
+
*
|
|
6
|
+
* The runner pins the fixed group and lists every retired `@nifra-gate-reviewed` comment. It never
|
|
7
|
+
* writes the `nifra-expect` comment that replaces one: a reason it made up would silence a finding
|
|
8
|
+
* nobody reviewed. Every changed default below has a `nifra check` finding or a one-line way to keep
|
|
9
|
+
* the 4.x behavior, so the notes name both.
|
|
10
|
+
*/
|
|
11
|
+
export const recipe: UpgradeRecipe = {
|
|
12
|
+
version: "5.0.0",
|
|
13
|
+
pins: [
|
|
14
|
+
{ match: "@nifrajs/", to: "5.0.0" },
|
|
15
|
+
{ match: "create-nifra", to: "5.0.0" },
|
|
16
|
+
{ match: "nifra", to: "5.0.0" },
|
|
17
|
+
],
|
|
18
|
+
importMoves: [],
|
|
19
|
+
retiredMarkers: [
|
|
20
|
+
{
|
|
21
|
+
marker: "@nifra-gate-reviewed",
|
|
22
|
+
instead: "// nifra-expect <code>: <reason>, with the code nifra check reports there",
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
notes: [
|
|
26
|
+
"`@nifra-gate-reviewed` silences nothing. After review, silence one finding with `// nifra-expect <code>: <reason>` on its line or anywhere in the comment block above it. The upgrade lists every old comment and the line it covered; it never writes a reason. Every security finding and every error needs the reason, so `// nifra-expect route-overlap` (NF-C024, NF-C025) without one no longer silences the overlap.",
|
|
27
|
+
"`nifra check` reports a secret comparison (NF-S002) as advisory only in browser code inside a nifra web app: a directory with `nifra.config.ts` or `backend/framework.ts`, or whose nearest package.json depends on `@nifrajs/web` or a `@nifrajs/web-*` adapter. The same comparison in `routes/` or `frontend/` of an API-only project, or of a package without a web dependency, fails the check.",
|
|
28
|
+
"A WebSocket handshake runs the route chain an HTTP `GET` registered at the same point runs: `authenticate()`, auth middleware, `derive` and `beforeHandle` added before `app.ws()` guard the socket and can refuse the handshake with the response they send an HTTP request. Register a socket before the guard to keep it public. `nifra routes --hooks` lists what each socket runs.",
|
|
29
|
+
'`createWebApp` serves the backend over HTTP only at an `apiPrefix` the app sets; without one it is loader-only (`ctx.api`) and a browser call to `/api` reaches page routing. `nifra check` reports a `backend/framework.ts` that sets none (NF-C035); run `nifra fix --code NF-C035` to add `export const apiPrefix = "/api"` and keep the 4.x mount, or export `""` to keep the backend loader-only. A hand-written `createWebApp` call passes `apiPrefix: "/api"` itself.',
|
|
30
|
+
"Root status pages (`_404.tsx`, `_<status>.tsx`) and `_error.tsx` render inside the layouts above them with their loader data, and a page rendered with layout data is `private, no-store`. A root status page that imports the root layout to wrap itself now renders it twice: `nifra check` reports it (NF-C034); remove the wrap. `export const inLayouts = false` renders a page the 4.x way and runs no layout loader for it.",
|
|
31
|
+
"A Response returned to more than one request is no longer shared: a body-less one is copied for each request before cookies and headers are written, and one whose body was already sent answers `500`. Build a Response per request, or return data and let the handler serialize it.",
|
|
32
|
+
"`invariants.executor` in `nifra.assurance.ts` is optional now that `invariants.setup` can provision the target, so code that reads the config's type handles a missing `executor`.",
|
|
33
|
+
"A layout's `meta` receives its own loader data as `data`, `null` when the layout has no loader; the page's data moved to `pageData`. A layout `meta` written against the page's data, such as a title template, reads `pageData` now: `({ pageData }) => ({ title: pageData.title })`. `nifra check` reports a layout with no loader whose `meta` reads `data` (NF-C040). A page's own `meta` is unchanged.",
|
|
34
|
+
],
|
|
35
|
+
}
|
package/src/recipes/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { recipe as v1_8_0 } from "./1.8.0.ts"
|
|
|
8
8
|
import { recipe as v2_0_0 } from "./2.0.0.ts"
|
|
9
9
|
import { recipe as v3_0_0 } from "./3.0.0.ts"
|
|
10
10
|
import { recipe as v4_0_0 } from "./4.0.0.ts"
|
|
11
|
+
import { recipe as v5_0_0 } from "./5.0.0.ts"
|
|
11
12
|
|
|
12
13
|
/** A pin rule: every dependency whose name starts with `match` is set to version `to`. */
|
|
13
14
|
export interface PinRule {
|
|
@@ -30,11 +31,19 @@ export interface ImportMove {
|
|
|
30
31
|
readonly to: string
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** A source comment a release stopped honoring. The runner lists each one; it never rewrites it. */
|
|
35
|
+
export interface RetiredMarker {
|
|
36
|
+
readonly marker: string
|
|
37
|
+
/** What to write instead, printed beside each comment. */
|
|
38
|
+
readonly instead: string
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface UpgradeRecipe {
|
|
34
42
|
readonly version: string
|
|
35
43
|
readonly pins: readonly PinRule[]
|
|
36
44
|
readonly dependencyMoves?: readonly DependencyMove[]
|
|
37
45
|
readonly importMoves: readonly ImportMove[]
|
|
46
|
+
readonly retiredMarkers?: readonly RetiredMarker[]
|
|
38
47
|
/** Human notes printed after the plan - e.g. structural changes the runner deliberately can't do. */
|
|
39
48
|
readonly notes?: readonly string[]
|
|
40
49
|
}
|
|
@@ -44,6 +53,7 @@ const RECIPES: Record<string, UpgradeRecipe> = {
|
|
|
44
53
|
[v2_0_0.version]: v2_0_0,
|
|
45
54
|
[v3_0_0.version]: v3_0_0,
|
|
46
55
|
[v4_0_0.version]: v4_0_0,
|
|
56
|
+
[v5_0_0.version]: v5_0_0,
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
export function getRecipe(version: string): UpgradeRecipe | undefined {
|
|
@@ -59,6 +59,11 @@ export interface ReleaseVerificationOptions {
|
|
|
59
59
|
* runner swings too far run to run to gate a release on.
|
|
60
60
|
*/
|
|
61
61
|
readonly sharedRunner?: boolean
|
|
62
|
+
/**
|
|
63
|
+
* Fail a gate whose script package.json does not declare, instead of reporting it `undeclared`.
|
|
64
|
+
* The repository's own gate runs every gate in its plan, so a renamed script cannot drop one.
|
|
65
|
+
*/
|
|
66
|
+
readonly requireScripts?: boolean
|
|
62
67
|
readonly runCommand?: (spec: ReleaseCommandSpec) => Promise<ReleaseCommandResult>
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -213,6 +218,14 @@ const undeclaredGate = (gate: VerificationGateSpec, script: string): ReleaseGate
|
|
|
213
218
|
remediation: `Declare a \`${script}\` script to run this gate.`,
|
|
214
219
|
})
|
|
215
220
|
|
|
221
|
+
const missingScriptGate = (gate: VerificationGateSpec, script: string): ReleaseGateResult => ({
|
|
222
|
+
id: gate.id,
|
|
223
|
+
status: "fail",
|
|
224
|
+
commands: gate.commands.map(commandLabel),
|
|
225
|
+
message: `\`${script}\` is not a script in package.json`,
|
|
226
|
+
remediation: `Declare the \`${script}\` script this gate runs, or rename the gate's command to the script that replaced it.`,
|
|
227
|
+
})
|
|
228
|
+
|
|
216
229
|
/** The first `bun run <script>` of a gate that the root package.json does not declare. */
|
|
217
230
|
const undeclaredScript = (
|
|
218
231
|
gate: VerificationGateSpec,
|
|
@@ -248,6 +261,12 @@ export async function collectReleaseVerification(
|
|
|
248
261
|
}
|
|
249
262
|
// The plan names the nifra repository's own scripts; a project without one has nothing to run.
|
|
250
263
|
const missing = undeclaredScript(gate, scripts)
|
|
264
|
+
if (missing !== undefined && options.requireScripts === true) {
|
|
265
|
+
const result = missingScriptGate(gate, missing)
|
|
266
|
+
gates.push(result)
|
|
267
|
+
failedId = result.id
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
251
270
|
if (missing !== undefined) {
|
|
252
271
|
gates.push(undeclaredGate(gate, missing))
|
|
253
272
|
continue
|
|
@@ -315,6 +334,7 @@ if (import.meta.main) {
|
|
|
315
334
|
const ok = await runReleaseVerification(process.cwd(), {
|
|
316
335
|
mode,
|
|
317
336
|
sharedRunner: process.argv.includes("--shared-runner"),
|
|
337
|
+
requireScripts: true,
|
|
318
338
|
json: process.argv.includes("--json"),
|
|
319
339
|
})
|
|
320
340
|
if (!ok) process.exitCode = 1
|
package/src/replay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from "node:fs/promises"
|
|
2
|
+
import { isAbsolute, relative, resolve } from "node:path"
|
|
2
3
|
import { type CompatibleReplayFile, parseCompatibleReplayFile } from "@nifrajs/core/replay"
|
|
3
4
|
import { runHydrationAssurance } from "./assure-hydration.ts"
|
|
4
5
|
import { checkContractsLock } from "./contracts.ts"
|
|
@@ -12,10 +13,20 @@ export interface ReplayResult {
|
|
|
12
13
|
readonly replays?: readonly string[]
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const within = (root: string, path: string): boolean => {
|
|
17
|
+
const rel = relative(root, path)
|
|
18
|
+
return !rel.startsWith("..") && !isAbsolute(rel)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const canonical = (path: string): Promise<string> => realpath(path).catch(() => path)
|
|
22
|
+
|
|
15
23
|
/** Validate and dispatch one token-only replay while keeping the file inside the project root. */
|
|
16
24
|
export async function runReplay(cwd: string, file: string): Promise<ReplayResult> {
|
|
17
|
-
const
|
|
18
|
-
|
|
25
|
+
const root = resolve(cwd)
|
|
26
|
+
// Compared as real paths: the MCP host hands over an already-resolved file, which never starts
|
|
27
|
+
// with a root reached through a symlink, and a replay file that is itself a link must not lead out.
|
|
28
|
+
const path = await canonical(resolve(root, file))
|
|
29
|
+
if (!within(await canonical(root), path) && !within(root, path)) {
|
|
19
30
|
throw new Error("replay file must stay inside the project root")
|
|
20
31
|
}
|
|
21
32
|
const replay = parseCompatibleReplayFile(JSON.parse(await Bun.file(path).text()))
|
package/src/review.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
import type { CheckResult } from "./check.ts"
|
|
17
17
|
import type { CommandCtx, CommandSpec } from "./command-catalog.ts"
|
|
18
18
|
import type { Diagnostic } from "./diagnostics.ts"
|
|
19
|
-
import { toSarifLog } from "./diagnostics.ts"
|
|
19
|
+
import { diagnosticCompatibilityOf, toSarifLog } from "./diagnostics.ts"
|
|
20
20
|
import { RULE_CODES } from "./rules/codes.ts"
|
|
21
21
|
import { LEGACY_RULE_CODES } from "./rules/legacy.ts"
|
|
22
22
|
|
|
@@ -110,7 +110,8 @@ const CHECK_SOURCES: Readonly<Record<ReviewCheckId, ReviewEvidenceSource>> = {
|
|
|
110
110
|
|
|
111
111
|
const ALLOWED_FIXES = new Set<ReviewFixRecipeId>(["manifest.sync", "workspace-dist.rebuild"])
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
/** The review check each built-in code reports under; a code missing here would read as configuration. */
|
|
114
|
+
export const CHECK_CODES: Readonly<Record<string, ReviewCheckId>> = {
|
|
114
115
|
"NF-C001": "typecheck",
|
|
115
116
|
"NF-C002": "typed-client",
|
|
116
117
|
"NF-C003": "typed-client",
|
|
@@ -134,18 +135,43 @@ const CHECK_CODES: Readonly<Record<string, ReviewCheckId>> = {
|
|
|
134
135
|
"NF-C021": "route-boundary",
|
|
135
136
|
"NF-C022": "route-boundary",
|
|
136
137
|
"NF-C023": "route-boundary",
|
|
138
|
+
"NF-C024": "route-boundary",
|
|
139
|
+
"NF-C025": "route-boundary",
|
|
140
|
+
"NF-C026": "route-boundary",
|
|
141
|
+
"NF-C027": "route-boundary",
|
|
142
|
+
"NF-C028": "server-boundary",
|
|
143
|
+
"NF-C029": "security",
|
|
144
|
+
"NF-C030": "security",
|
|
145
|
+
"NF-C031": "security",
|
|
146
|
+
"NF-C032": "security",
|
|
147
|
+
"NF-C033": "security",
|
|
148
|
+
"NF-C034": "route-boundary",
|
|
149
|
+
"NF-C035": "route-boundary",
|
|
150
|
+
"NF-C036": "route-boundary",
|
|
151
|
+
"NF-C037": "configuration",
|
|
152
|
+
"NF-C038": "configuration",
|
|
153
|
+
"NF-C039": "contract-witness",
|
|
154
|
+
"NF-C040": "route-boundary",
|
|
155
|
+
"NF-C041": "route-boundary",
|
|
156
|
+
"NF-C042": "route-boundary",
|
|
137
157
|
"NF-D001": "dependency",
|
|
158
|
+
"NF-D002": "dependency",
|
|
138
159
|
"NF-A001": "route-assurance",
|
|
139
160
|
"NF-H001": "hydration",
|
|
140
161
|
"NF-H002": "hydration",
|
|
141
162
|
"NF-H003": "hydration",
|
|
142
163
|
"NF-H004": "hydration",
|
|
143
164
|
"NF-K001": "contract-witness",
|
|
165
|
+
"NF-S001": "security",
|
|
166
|
+
"NF-S002": "security",
|
|
167
|
+
"NF-S003": "security",
|
|
168
|
+
"NF-S004": "security",
|
|
169
|
+
"NF-S005": "security",
|
|
170
|
+
"NF-S006": "security",
|
|
171
|
+
"NF-S007": "security",
|
|
172
|
+
"NF-S008": "security",
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
for (const code of ["NF-S001", "NF-S002", "NF-S003", "NF-S004", "NF-S005", "NF-S006", "NF-S007"])
|
|
147
|
-
(CHECK_CODES as Record<string, ReviewCheckId>)[code] = "security"
|
|
148
|
-
|
|
149
175
|
const REVIEW_INPUT_SCHEMA = {
|
|
150
176
|
type: "object",
|
|
151
177
|
properties: {
|
|
@@ -432,8 +458,11 @@ async function diffScope(cwd: string, ref: string): Promise<ReviewScope> {
|
|
|
432
458
|
if (!verified.ok) throw new ReviewInputError("invalid git ref")
|
|
433
459
|
const commit = verified.stdout.trim()
|
|
434
460
|
if (!/^[0-9a-f]{40,64}$/.test(commit)) throw new ReviewInputError("invalid git ref")
|
|
461
|
+
// `--relative` keeps tracked paths app-relative, like `ls-files` and every finding, when the app is a
|
|
462
|
+
// subdirectory of the repository; without it no finding in a changed tracked file is in scope.
|
|
435
463
|
const changed = await runGit(cwd, [
|
|
436
464
|
"diff",
|
|
465
|
+
"--relative",
|
|
437
466
|
"--name-only",
|
|
438
467
|
"-z",
|
|
439
468
|
"--diff-filter=ACDMRTUXB",
|
|
@@ -613,17 +642,22 @@ async function composeCollectedReport(
|
|
|
613
642
|
}
|
|
614
643
|
|
|
615
644
|
const changedPaths = new Set(scope.changedPaths)
|
|
645
|
+
// A server-only chain is in scope when any file on it changed: a new import in a shared module
|
|
646
|
+
// reaches the browser through a route the diff never touched.
|
|
647
|
+
const inScope = (candidate: CandidateFinding): boolean =>
|
|
648
|
+
candidate.location === undefined ||
|
|
649
|
+
changedPaths.has(candidate.location.path) ||
|
|
650
|
+
(diagnosticCompatibilityOf(candidate.diagnostic)?.modules ?? []).some((module) => {
|
|
651
|
+
const path = normalizedLocation(cwd, module, undefined)?.path
|
|
652
|
+
return path !== undefined && changedPaths.has(path)
|
|
653
|
+
})
|
|
616
654
|
const retainedByCheck = new Map<ReviewCheckId, CandidateFinding[]>()
|
|
617
655
|
let totalOutOfScope = 0
|
|
618
656
|
let typeErrorOutOfScope = false
|
|
619
657
|
for (const id of CHECK_IDS) {
|
|
620
658
|
const retained: CandidateFinding[] = []
|
|
621
659
|
for (const candidate of candidatesByCheck.get(id) ?? []) {
|
|
622
|
-
if (
|
|
623
|
-
scope.kind === "diff" &&
|
|
624
|
-
candidate.location !== undefined &&
|
|
625
|
-
!changedPaths.has(candidate.location.path)
|
|
626
|
-
) {
|
|
660
|
+
if (scope.kind === "diff" && !inScope(candidate)) {
|
|
627
661
|
totalOutOfScope += 1
|
|
628
662
|
if (id === "typecheck" && candidate.severity === "error") typeErrorOutOfScope = true
|
|
629
663
|
} else retained.push(candidate)
|
package/src/rules/codes.ts
CHANGED
|
@@ -29,11 +29,22 @@ export const RULE_CODES = Object.freeze({
|
|
|
29
29
|
"NF-C030": "Route data without an output schema",
|
|
30
30
|
"NF-C031": "Sensitive field in an output schema",
|
|
31
31
|
"NF-C032": "Credential in browser code",
|
|
32
|
+
"NF-C033": "Generated output schema out of date",
|
|
33
|
+
"NF-C034": "Root status page wraps the root layout itself",
|
|
34
|
+
"NF-C035": "Backend not served over HTTP",
|
|
35
|
+
"NF-C036": "Route path the static scan cannot read",
|
|
36
|
+
"NF-C037": "API-only declaration over a web app",
|
|
37
|
+
"NF-C038": "Retired root layout file",
|
|
38
|
+
"NF-C039": "Implemented contract operation the backend does not serve",
|
|
39
|
+
"NF-C040": "Layout meta reads data its layout never loads",
|
|
40
|
+
"NF-C041": "Server-only export in a route frontend file",
|
|
41
|
+
"NF-C042": "Layout loader refuses access without a gate",
|
|
32
42
|
"NF-C020": "Island enhancer cleanup check",
|
|
33
43
|
"NF-C021": "nano binding cleanup check",
|
|
34
44
|
"NF-C022": "nano bindList key check",
|
|
35
45
|
"NF-C023": "nano computed deps check",
|
|
36
46
|
"NF-D001": "Doctor check",
|
|
47
|
+
"NF-D002": "Mixed nifra versions",
|
|
37
48
|
"NF-A001": "Assurance bundle check",
|
|
38
49
|
"NF-H001": "Hydration mismatch",
|
|
39
50
|
"NF-H002": "Duplicate framework runtime",
|
|
@@ -47,6 +58,7 @@ export const RULE_CODES = Object.freeze({
|
|
|
47
58
|
"NF-S005": "External redirect opt-out",
|
|
48
59
|
"NF-S006": "Security escape hatch enabled",
|
|
49
60
|
"NF-S007": "Secure cookie without a __Host-/__Secure- prefix",
|
|
61
|
+
"NF-S008": "Authenticated route validates input before authentication",
|
|
50
62
|
} as const)
|
|
51
63
|
|
|
52
64
|
/** Codes that no longer fire but remain reserved forever. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Marker recognition for in-source escape hatches (
|
|
2
|
+
* Marker recognition for in-source escape hatches (`nifra-expect <code>: <reason>`).
|
|
3
3
|
*
|
|
4
4
|
* A marker counts when it appears on the flagged line itself (trailing comment) or ANYWHERE in the
|
|
5
5
|
* contiguous comment block directly above it - which is where a human naturally writes the
|
|
@@ -11,12 +11,27 @@
|
|
|
11
11
|
// open/body/close lines, and the JSX comment form.
|
|
12
12
|
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*\/|\*|\{\/\*)/
|
|
13
13
|
|
|
14
|
+
/** The trailing comment on a code line: from its first `//` or `/*` outside a string literal to the
|
|
15
|
+
* end, else `""`. A marker in a string the code passes is data, not a comment. */
|
|
16
|
+
function trailingComment(text: string): string {
|
|
17
|
+
let quote: string | undefined
|
|
18
|
+
for (let i = 0; i < text.length; i++) {
|
|
19
|
+
const char = text[i]
|
|
20
|
+
if (quote !== undefined) {
|
|
21
|
+
if (char === "\\") i++
|
|
22
|
+
else if (char === quote) quote = undefined
|
|
23
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char
|
|
24
|
+
else if (char === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) return text.slice(i)
|
|
25
|
+
}
|
|
26
|
+
return ""
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export function commentBlockHasMarker(
|
|
15
30
|
lines: readonly string[],
|
|
16
31
|
line: number,
|
|
17
32
|
marker: string,
|
|
18
33
|
): boolean {
|
|
19
|
-
if ((lines[line - 1] ?? "").includes(marker)) return true
|
|
34
|
+
if (trailingComment(lines[line - 1] ?? "").includes(marker)) return true
|
|
20
35
|
for (let i = line - 2; i >= 0; i--) {
|
|
21
36
|
const text = lines[i] ?? ""
|
|
22
37
|
if (!COMMENT_LINE.test(text)) return false
|
|
@@ -26,37 +41,58 @@ export function commentBlockHasMarker(
|
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/**
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
31
|
-
* comment-block-above region as {@link commentBlockHasMarker}, so a hatch that demands a written reason
|
|
32
|
-
* can require this to be non-empty before it silences anything - an empty `<marker>:` never suppresses,
|
|
33
|
-
* which is how "reason mandatory" is enforced without a second diagnostic.
|
|
44
|
+
* The 1-based line a marker comment on `line` applies to: `line` itself when code precedes the comment
|
|
45
|
+
* there, else the first line after its comment block, or `undefined` when a blank line ends the block.
|
|
34
46
|
*/
|
|
35
|
-
export function
|
|
47
|
+
export function markerTarget(lines: readonly string[], line: number): number | undefined {
|
|
48
|
+
if (!COMMENT_LINE.test(lines[line - 1] ?? "")) return line
|
|
49
|
+
for (let i = line; i < lines.length; i++) {
|
|
50
|
+
const text = lines[i] ?? ""
|
|
51
|
+
if (COMMENT_LINE.test(text)) continue
|
|
52
|
+
return text.trim() === "" ? undefined : i + 1
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a finding adds when a `nifra-expect` comment names it but leaves out the reason it needs. */
|
|
58
|
+
export const MISSING_EXPECTATION_REASON =
|
|
59
|
+
"the nifra-expect comment here gives no reason after its colon, so it does not silence this"
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A `nifra-expect <name>: <reason>` comment for the finding on `line`, where `<name>` is one of
|
|
63
|
+
* `names`: the finding's NF code, or a slug it also answers to. It counts in the same trailing-comment /
|
|
64
|
+
* comment-block-above region as {@link commentBlockHasMarker}. `undefined` when no such comment is
|
|
65
|
+
* there; `reason` is `undefined` when none follows the name's colon. A hatch that silences an error
|
|
66
|
+
* requires the reason, so an empty `nifra-expect NF-C006:` never suppresses one.
|
|
67
|
+
*/
|
|
68
|
+
export function commentBlockExpectation(
|
|
36
69
|
lines: readonly string[],
|
|
37
70
|
line: number,
|
|
38
|
-
|
|
39
|
-
): string | undefined {
|
|
71
|
+
names: readonly string[],
|
|
72
|
+
): { readonly reason: string | undefined } | undefined {
|
|
73
|
+
const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
74
|
+
const pattern = new RegExp(`nifra-expect\\s+(?:${escaped.join("|")})(?![\\w-])(.*)`)
|
|
75
|
+
let found = false
|
|
40
76
|
const reasonOn = (text: string): string | undefined => {
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
77
|
+
const match = pattern.exec(text)
|
|
78
|
+
if (match === null) return undefined
|
|
79
|
+
found = true
|
|
80
|
+
const rest = (match[1] ?? "").trimStart()
|
|
81
|
+
if (!rest.startsWith(":")) return undefined
|
|
46
82
|
// A block comment's `*/` closer is not part of the reason.
|
|
47
83
|
const reason = rest
|
|
48
|
-
.slice(
|
|
84
|
+
.slice(1)
|
|
49
85
|
.replace(/\*\/\s*$/, "")
|
|
50
86
|
.trim()
|
|
51
87
|
return reason.length > 0 ? reason : undefined
|
|
52
88
|
}
|
|
53
|
-
const onLine = reasonOn(lines[line - 1] ?? "")
|
|
54
|
-
if (onLine !== undefined) return onLine
|
|
89
|
+
const onLine = reasonOn(trailingComment(lines[line - 1] ?? ""))
|
|
90
|
+
if (onLine !== undefined) return { reason: onLine }
|
|
55
91
|
for (let i = line - 2; i >= 0; i--) {
|
|
56
92
|
const text = lines[i] ?? ""
|
|
57
|
-
if (!COMMENT_LINE.test(text))
|
|
93
|
+
if (!COMMENT_LINE.test(text)) break
|
|
58
94
|
const reason = reasonOn(text)
|
|
59
|
-
if (reason !== undefined) return reason
|
|
95
|
+
if (reason !== undefined) return { reason }
|
|
60
96
|
}
|
|
61
|
-
return undefined
|
|
97
|
+
return found ? { reason: undefined } : undefined
|
|
62
98
|
}
|