@nifrajs/cli 4.0.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +16 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +133 -29
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +21 -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 +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +778 -64
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +192 -40
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +42 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +166 -37
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +25 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +8 -3
- package/src/migrate-layout.ts +885 -63
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/src/rules/zones.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* anything but shared code. NF-C029 owns private environment reads in browser code.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
|
11
|
-
import {
|
|
11
|
+
import { isAbsolute, join, relative } from "node:path"
|
|
12
12
|
import {
|
|
13
|
+
browserDenial,
|
|
13
14
|
type Classification,
|
|
14
15
|
createZoneClassifier,
|
|
15
16
|
importAllowed,
|
|
@@ -17,11 +18,23 @@ import {
|
|
|
17
18
|
privateEnvReads,
|
|
18
19
|
privateEnvReason,
|
|
19
20
|
type ZoneClassifier,
|
|
21
|
+
ZoneConfigError,
|
|
20
22
|
} from "@nifrajs/web/zones"
|
|
21
23
|
import { CONFIG_FILE, FRAMEWORK_FILE } from "../app-files.ts"
|
|
22
|
-
import { importSites, stripComments } from "../check-scan.ts"
|
|
24
|
+
import { COMPONENT_FILE, importSites, stripComments } from "../check-scan.ts"
|
|
25
|
+
import { appDirs, clientModuleRoot } from "../client-module.ts"
|
|
26
|
+
import { componentCheckReceipts } from "../component-check.ts"
|
|
23
27
|
import { type Diagnostic, diagnostic } from "../diagnostics.ts"
|
|
24
28
|
import { codeUnitOrder } from "../internal/code-unit-order.ts"
|
|
29
|
+
import {
|
|
30
|
+
createApiOnlyProbe,
|
|
31
|
+
createKindProbe,
|
|
32
|
+
createNifraProjectProbe,
|
|
33
|
+
createWebAppProbe,
|
|
34
|
+
hasWebConfig,
|
|
35
|
+
withApiKindHint,
|
|
36
|
+
} from "../internal/nifra-project.ts"
|
|
37
|
+
import { createSpecifierResolver } from "../internal/project-specifiers.ts"
|
|
25
38
|
import type { CheckRule, RuleContext } from "./index.ts"
|
|
26
39
|
|
|
27
40
|
const ZONE_FOLDER = new Set(["routes", "frontend", "backend", "shared"])
|
|
@@ -31,11 +44,35 @@ const BACKEND = new Set(["backend", "route-backend"])
|
|
|
31
44
|
const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"])
|
|
32
45
|
const CODE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
|
|
33
46
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The app a project file belongs to: the path before its first zone folder, else the deepest app in
|
|
49
|
+
* `apps` that encloses it, since a folder `nifra.zones` declares or a `.frontend`-style suffix zones a
|
|
50
|
+
* file anywhere in its app. `undefined` for a file in no app.
|
|
51
|
+
*/
|
|
52
|
+
function appOf(file: string, apps: readonly string[]): string | undefined {
|
|
36
53
|
const parts = file.split("/")
|
|
37
54
|
const at = parts.findIndex((part, i) => i < parts.length - 1 && ZONE_FOLDER.has(part))
|
|
38
|
-
|
|
55
|
+
if (at !== -1) return parts.slice(0, at).join("/")
|
|
56
|
+
let nearest: string | undefined
|
|
57
|
+
for (const app of apps) {
|
|
58
|
+
if (app !== "" && !file.startsWith(`${app}/`)) continue
|
|
59
|
+
if (nearest === undefined || app.length > nearest.length) nearest = app
|
|
60
|
+
}
|
|
61
|
+
return nearest
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A file's text as the build compiles it. The source index holds a `.svelte`/`.vue` file as its script
|
|
66
|
+
* blocks only, for the parsers; its markup carries expressions the build compiles too, so a rule that
|
|
67
|
+
* reads components itself gets the whole file.
|
|
68
|
+
*/
|
|
69
|
+
export function buildText(ctx: RuleContext, file: string): string | undefined {
|
|
70
|
+
if (!COMPONENT_FILE.test(file)) return ctx.sources.read(file)
|
|
71
|
+
try {
|
|
72
|
+
return readFileSync(join(ctx.root, file), "utf8")
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
39
76
|
}
|
|
40
77
|
|
|
41
78
|
interface ZonedFile {
|
|
@@ -48,28 +85,62 @@ interface ZonedFile {
|
|
|
48
85
|
export function zonedFiles(ctx: RuleContext): {
|
|
49
86
|
readonly files: readonly ZonedFile[]
|
|
50
87
|
readonly classifierFor: (app: string) => ZoneClassifier
|
|
88
|
+
/** Settings that cannot be applied (`nifra.zones`, `nifra.kind`); their apps' files are left out. */
|
|
89
|
+
readonly configErrors: ReadonlyArray<{ readonly file: string; readonly message: string }>
|
|
51
90
|
} {
|
|
52
|
-
const classifiers = new Map<string, ZoneClassifier>()
|
|
53
|
-
const
|
|
91
|
+
const classifiers = new Map<string, ZoneClassifier | ZoneConfigError>()
|
|
92
|
+
const load = (app: string): ZoneClassifier | ZoneConfigError => {
|
|
54
93
|
let classifier = classifiers.get(app)
|
|
55
94
|
if (classifier === undefined) {
|
|
56
95
|
const appRoot = join(ctx.root, app)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
try {
|
|
97
|
+
classifier = createZoneClassifier({
|
|
98
|
+
appRoot,
|
|
99
|
+
generatedFiles: [join(appRoot, "server-manifest.ts")],
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!(error instanceof ZoneConfigError)) throw error
|
|
103
|
+
classifier = error
|
|
104
|
+
}
|
|
61
105
|
classifiers.set(app, classifier)
|
|
62
106
|
}
|
|
63
107
|
return classifier
|
|
64
108
|
}
|
|
109
|
+
const classifierFor = (app: string): ZoneClassifier => {
|
|
110
|
+
const classifier = load(app)
|
|
111
|
+
if (classifier instanceof ZoneConfigError) throw classifier
|
|
112
|
+
return classifier
|
|
113
|
+
}
|
|
65
114
|
const files: ZonedFile[] = []
|
|
115
|
+
const configErrors: Array<{ file: string; message: string }> = []
|
|
116
|
+
const reportOnce = (file: string, message: string): void => {
|
|
117
|
+
if (!configErrors.some((entry) => entry.message === message))
|
|
118
|
+
configErrors.push({ file, message })
|
|
119
|
+
}
|
|
120
|
+
// Another framework's app keeps folders named `routes/` too; nifra builds none of it.
|
|
121
|
+
const inNifraProject = createNifraProjectProbe(ctx.root)
|
|
122
|
+
const kindOf = createKindProbe(ctx.root)
|
|
123
|
+
const apiOnly = createApiOnlyProbe(ctx.root)
|
|
124
|
+
const apps = appDirs(ctx.sources.files)
|
|
66
125
|
for (const file of ctx.sources.files) {
|
|
67
126
|
if (!CODE_FILE.test(file)) continue
|
|
68
|
-
const app = appOf(file)
|
|
69
|
-
if (app === undefined) continue
|
|
70
|
-
|
|
127
|
+
const app = appOf(file, apps)
|
|
128
|
+
if (app === undefined || !inNifraProject(app)) continue
|
|
129
|
+
const declared = kindOf(app)
|
|
130
|
+
if (declared.invalid !== undefined) {
|
|
131
|
+
reportOnce(declared.manifest ?? "package.json", declared.invalid)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
// An API-only project has no browser, so nothing in it is zoned.
|
|
135
|
+
if (apiOnly(app)) continue
|
|
136
|
+
const classifier = load(app)
|
|
137
|
+
if (classifier instanceof ZoneConfigError) {
|
|
138
|
+
reportOnce(app === "" ? "package.json" : `${app}/package.json`, classifier.message)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
files.push({ file, app, classification: classifier.classify(join(ctx.root, file)) })
|
|
71
142
|
}
|
|
72
|
-
return { files, classifierFor }
|
|
143
|
+
return { files, classifierFor, configErrors }
|
|
73
144
|
}
|
|
74
145
|
|
|
75
146
|
const real = (path: string): string => {
|
|
@@ -87,41 +158,112 @@ function display(root: string, file: string): string {
|
|
|
87
158
|
return rel.startsWith("../") || isAbsolute(rel) ? file : rel
|
|
88
159
|
}
|
|
89
160
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
161
|
+
// The unzoned files one check follows through, at most: each is a build refusal of its own.
|
|
162
|
+
const MAX_UNZONED = 5000
|
|
163
|
+
|
|
164
|
+
interface Importer {
|
|
165
|
+
readonly file: string
|
|
166
|
+
readonly app: string
|
|
167
|
+
/** `"error"` for a file in no zone, reached from code a build loads. */
|
|
168
|
+
readonly zone: Classification["zone"]
|
|
99
169
|
}
|
|
100
170
|
|
|
101
171
|
export const zoneImportRule: CheckRule = {
|
|
102
172
|
code: "NF-C028",
|
|
103
173
|
title: "Zone import check",
|
|
104
174
|
async scan(ctx) {
|
|
105
|
-
const { files, classifierFor } = zonedFiles(ctx)
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
175
|
+
const { files, classifierFor, configErrors } = zonedFiles(ctx)
|
|
176
|
+
const { receipts } = await componentCheckReceipts(ctx.root, ctx.sources)
|
|
177
|
+
const findings: Diagnostic[] = configErrors.map(({ file, message }) =>
|
|
178
|
+
diagnostic({
|
|
179
|
+
code: "NF-C028",
|
|
180
|
+
severity: "error",
|
|
181
|
+
file,
|
|
182
|
+
message,
|
|
183
|
+
verify: "nifra check",
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
// Outside any web app, a finding may only mean the project serves no pages: say how to declare it.
|
|
187
|
+
const inWebApp = createWebAppProbe(ctx.root)
|
|
188
|
+
const resolveSpecifier = createSpecifierResolver()
|
|
189
|
+
const queue: Importer[] = []
|
|
190
|
+
const queued = new Set<string>()
|
|
191
|
+
const follow = (importer: Importer): void => {
|
|
192
|
+
if (queued.has(importer.file) || queued.size >= MAX_UNZONED + files.length) return
|
|
193
|
+
queued.add(importer.file)
|
|
194
|
+
queue.push(importer)
|
|
195
|
+
}
|
|
196
|
+
for (const { file, app, classification } of files) {
|
|
197
|
+
const zone = classification.zone
|
|
198
|
+
if (zone !== "error" && zone !== "library" && zone !== "generated")
|
|
199
|
+
follow({ file, app, zone })
|
|
200
|
+
}
|
|
201
|
+
// The browser entry imports the app's `clientModule`, so the build classifies it like any file it
|
|
202
|
+
// loads, and loads what it imports.
|
|
203
|
+
for (const app of appDirs(ctx.sources.files)) {
|
|
204
|
+
const entry = clientModuleRoot(ctx.root, app, resolveSpecifier)
|
|
205
|
+
if (entry === undefined) continue
|
|
206
|
+
let classification: Classification
|
|
207
|
+
try {
|
|
208
|
+
classification = classifierFor(app).classify(entry.file)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof ZoneConfigError) continue
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
const name = display(ctx.root, entry.file)
|
|
214
|
+
const denial = browserDenial(classification)
|
|
215
|
+
if (denial !== undefined) {
|
|
216
|
+
findings.push(
|
|
217
|
+
diagnostic({
|
|
218
|
+
code: "NF-C028",
|
|
219
|
+
severity: "error",
|
|
220
|
+
file: entry.config,
|
|
221
|
+
line: entry.line,
|
|
222
|
+
message: `clientModule ${JSON.stringify(entry.specifier)} puts ${name} in the browser bundle: ${denial}`,
|
|
223
|
+
evidence: [`clientModule: ${entry.specifier}`, `zone: ${classification.zone}`],
|
|
224
|
+
verify: "nifra check",
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
if (classification.zone === "error" && classification.package === undefined)
|
|
229
|
+
follow({ file: name, app, zone: "error" })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// An undeclared package is one fact, however many files import it.
|
|
233
|
+
const reportedPackages = new Set<string>()
|
|
234
|
+
// An array iterator reads the length on every step, so files `follow` appends are visited too.
|
|
235
|
+
for (const { file, app, zone: from } of queue) {
|
|
109
236
|
const content = ctx.sources.read(file)
|
|
110
237
|
if (content === undefined) continue
|
|
111
238
|
for (const { specifier, line } of importSites(content, file)) {
|
|
112
|
-
const target =
|
|
239
|
+
const target = resolveSpecifier(join(ctx.root, file), specifier)
|
|
113
240
|
if (target === undefined) continue
|
|
114
241
|
const to = classifierFor(app).classify(target)
|
|
115
242
|
const targetName = display(ctx.root, target)
|
|
243
|
+
if (
|
|
244
|
+
["frontend", "route-frontend", "shared"].includes(from) &&
|
|
245
|
+
(receipts.has(target) || receipts.has(targetName))
|
|
246
|
+
)
|
|
247
|
+
continue
|
|
116
248
|
let message: string
|
|
117
|
-
if (to.zone === "error")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
249
|
+
if (to.zone === "error") {
|
|
250
|
+
if (to.package !== undefined) {
|
|
251
|
+
if (reportedPackages.has(to.package)) continue
|
|
252
|
+
reportedPackages.add(to.package)
|
|
253
|
+
} else {
|
|
254
|
+
// A file in no zone loads what it imports too: the build refuses each one it reaches.
|
|
255
|
+
follow({ file: targetName, app, zone: "error" })
|
|
256
|
+
}
|
|
257
|
+
message = `${file} imports ${targetName}: ${to.reason}`
|
|
258
|
+
} else if (
|
|
259
|
+
from !== "error" &&
|
|
260
|
+
((SERVER.has(from) && FRONTEND.has(to.zone)) ||
|
|
261
|
+
// Shared code reaching backend code is NF-C004's, reported there with the chain.
|
|
262
|
+
(from === "shared" && !importAllowed(from, to.zone) && !BACKEND.has(to.zone)))
|
|
122
263
|
) {
|
|
123
|
-
message = importRuleMessage(file, from
|
|
264
|
+
message = importRuleMessage(file, from, targetName, to.zone)
|
|
124
265
|
} else continue
|
|
266
|
+
if (!inWebApp(app)) message = withApiKindHint(message)
|
|
125
267
|
findings.push(
|
|
126
268
|
diagnostic({
|
|
127
269
|
code: "NF-C028",
|
|
@@ -129,7 +271,7 @@ export const zoneImportRule: CheckRule = {
|
|
|
129
271
|
file,
|
|
130
272
|
line,
|
|
131
273
|
message,
|
|
132
|
-
evidence: [`import: ${specifier}`, `from zone: ${from
|
|
274
|
+
evidence: [`import: ${specifier}`, `from zone: ${from}`, `to zone: ${to.zone}`],
|
|
133
275
|
verify: "nifra check",
|
|
134
276
|
}),
|
|
135
277
|
)
|
|
@@ -141,6 +283,31 @@ export const zoneImportRule: CheckRule = {
|
|
|
141
283
|
},
|
|
142
284
|
}
|
|
143
285
|
|
|
286
|
+
/** NF-C037: `"nifra": { "kind": "api" }` governing a directory that holds web config. */
|
|
287
|
+
export const apiKindRule: CheckRule = {
|
|
288
|
+
code: "NF-C037",
|
|
289
|
+
title: "API-only declaration over a web app",
|
|
290
|
+
async scan(ctx) {
|
|
291
|
+
const kindOf = createKindProbe(ctx.root)
|
|
292
|
+
const findings: Diagnostic[] = []
|
|
293
|
+
for (const app of appDirs(ctx.sources.files)) {
|
|
294
|
+
const declared = kindOf(app)
|
|
295
|
+
if (declared.kind !== "api" || !hasWebConfig(ctx.root, app)) continue
|
|
296
|
+
const where = app === "" ? "the project root" : `${app}/`
|
|
297
|
+
findings.push(
|
|
298
|
+
diagnostic({
|
|
299
|
+
code: "NF-C037",
|
|
300
|
+
severity: "error",
|
|
301
|
+
file: declared.manifest ?? "package.json",
|
|
302
|
+
message: `"nifra": { "kind": "api" } in ${declared.manifest ?? "package.json"} governs ${where}, which has web config (${app === "" ? "" : `${app}/`}${existsSync(join(ctx.root, app, FRAMEWORK_FILE)) ? FRAMEWORK_FILE : CONFIG_FILE}). An app that serves pages keeps its zone checks; remove "kind", or give the web app its own package.json`,
|
|
303
|
+
verify: "nifra check",
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
return findings
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
144
311
|
const PREFIX_EXPORT =
|
|
145
312
|
/\bexport\s+const\s+publicEnvPrefix\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
|
|
146
313
|
|
|
@@ -164,7 +331,7 @@ export const privateEnvRule: CheckRule = {
|
|
|
164
331
|
const findings: Diagnostic[] = []
|
|
165
332
|
for (const { file, app, classification } of files) {
|
|
166
333
|
if (!BROWSER_CODE.has(classification.zone)) continue
|
|
167
|
-
const content = ctx
|
|
334
|
+
const content = buildText(ctx, file)
|
|
168
335
|
if (content === undefined) continue
|
|
169
336
|
let prefix = prefixes.get(app)
|
|
170
337
|
if (prefix === undefined) {
|
|
@@ -192,4 +359,4 @@ export const privateEnvRule: CheckRule = {
|
|
|
192
359
|
},
|
|
193
360
|
}
|
|
194
361
|
|
|
195
|
-
export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule])
|
|
362
|
+
export const zoneRules = Object.freeze([zoneImportRule, privateEnvRule, apiKindRule])
|
package/src/scaffold.ts
CHANGED
|
@@ -29,7 +29,7 @@ const EXT: Record<Framework, string> = {
|
|
|
29
29
|
solid: "tsx",
|
|
30
30
|
vue: "vue",
|
|
31
31
|
svelte: "svelte",
|
|
32
|
-
vanilla: "
|
|
32
|
+
vanilla: "tsx",
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Derive the framework from a `clientModule` like `@nifrajs/web-react/client`. Defaults to react. */
|
|
@@ -93,7 +93,7 @@ export function routePathToFile(urlPath: string, ext: string): string {
|
|
|
93
93
|
|
|
94
94
|
const ROUTE_CONTRACT = `A route is two files:
|
|
95
95
|
- The page, \`routes/x.<ext>\` - ships to the browser: \`export default function Page({ data }: Route.ComponentProps)\` and \`export const meta = { title, meta: […] }\`. It never imports backend code.
|
|
96
|
-
- Its backend half, \`routes/x.backend.ts\` - never reaches the browser: \`export const loaderOutput = t.object({…})\` with \`export async
|
|
96
|
+
- Its backend half, \`routes/x.backend.ts\` - never reaches the browser: \`export const loaderOutput = t.object({…})\` with \`export const loader: Route.Loader = async ({ params, api }) => …\` (data for SSR - only what the schema declares is sent, and \`Route.Loader\` type-checks what it returns against that schema), \`actionOutput\` with \`export const action: Route.Action\` (the form POST), and \`hydrate\`, \`islandScripts\`, \`revalidate\`, \`middleware\`. Reach the backend through the typed \`api\`.
|
|
97
97
|
Both import \`type { Route } from "./+types/<name>"\`, which \`nifra dev\`, \`nifra build\`, \`nifra check\` and \`nifra types\` generate. Path params are typed on \`params\`.`
|
|
98
98
|
|
|
99
99
|
interface RouteParam {
|
|
@@ -120,11 +120,13 @@ function backendStub(file: string, params: readonly RouteParam[], vanilla: boole
|
|
|
120
120
|
const last = params.at(-1)
|
|
121
121
|
const loader =
|
|
122
122
|
last === undefined
|
|
123
|
-
?
|
|
123
|
+
? `// Route.Loader checks what it returns against loaderOutput.
|
|
124
|
+
export const loader: Route.Loader = async () => {
|
|
124
125
|
// Load through the typed backend client, \`api\` in these args; see nifra_example("loader").
|
|
125
126
|
return { title: "TODO" }
|
|
126
127
|
}`
|
|
127
|
-
:
|
|
128
|
+
: `// Route.Loader checks what it returns against loaderOutput.
|
|
129
|
+
export const loader: Route.Loader = async ({ params }) => {
|
|
128
130
|
// Load by params.${last.name} through the typed backend client, \`api\` in these args; see nifra_example("loader").
|
|
129
131
|
return { title: \`TODO: \${params.${last.name}${last.optional ? ' ?? ""' : ""}}\` }
|
|
130
132
|
}`
|
package/src/sdk.ts
CHANGED
|
@@ -381,7 +381,10 @@ function pythonModelFields(
|
|
|
381
381
|
if (properties === undefined) return " pass"
|
|
382
382
|
const required = schemaRequired(schema)
|
|
383
383
|
const used = new Set<string>()
|
|
384
|
+
// A dataclass rejects a field without a default after one with a default, so required fields
|
|
385
|
+
// lead and each group keeps the schema's order.
|
|
384
386
|
const fields: string[] = []
|
|
387
|
+
const optionalFields: string[] = []
|
|
385
388
|
for (const [jsonName, property] of Object.entries(properties)) {
|
|
386
389
|
let fieldName = pythonIdentifier(jsonName, "field")
|
|
387
390
|
while (used.has(fieldName)) fieldName += "_"
|
|
@@ -396,11 +399,12 @@ function pythonModelFields(
|
|
|
396
399
|
)
|
|
397
400
|
const optional = !required.has(jsonName)
|
|
398
401
|
if (optional && !type.includes("None")) type += " | None"
|
|
402
|
+
const group = optional ? optionalFields : fields
|
|
399
403
|
if (fieldName === jsonName) {
|
|
400
|
-
|
|
404
|
+
group.push(` ${fieldName}: ${type}${optional ? " = None" : ""}`)
|
|
401
405
|
} else {
|
|
402
406
|
const metadata = `{"json_name": ${JSON.stringify(jsonName)}}`
|
|
403
|
-
|
|
407
|
+
group.push(
|
|
404
408
|
" " +
|
|
405
409
|
fieldName +
|
|
406
410
|
": " +
|
|
@@ -413,6 +417,7 @@ function pythonModelFields(
|
|
|
413
417
|
)
|
|
414
418
|
}
|
|
415
419
|
}
|
|
420
|
+
fields.push(...optionalFields)
|
|
416
421
|
return fields.length === 0 ? " pass" : fields.join("\n")
|
|
417
422
|
}
|
|
418
423
|
|
|
@@ -430,9 +435,11 @@ function renderPythonModels(catalog: SchemaCatalog, tracker: OpaqueTracker): str
|
|
|
430
435
|
pythonModelFields(schema, name, catalog, tracker),
|
|
431
436
|
)
|
|
432
437
|
} else {
|
|
438
|
+
// A `type` statement evaluates lazily, so an alias may name a model declared below it.
|
|
433
439
|
rendered.push(
|
|
434
|
-
|
|
435
|
-
|
|
440
|
+
"type " +
|
|
441
|
+
name +
|
|
442
|
+
" = " +
|
|
436
443
|
pythonType(schema, name, catalog, tracker, "schema", `model ${name}`),
|
|
437
444
|
)
|
|
438
445
|
}
|
|
@@ -628,7 +635,7 @@ function pythonSdk(document: OpenAPIDocument, options: SdkRenderOptions): string
|
|
|
628
635
|
"import urllib.parse",
|
|
629
636
|
"import urllib.request",
|
|
630
637
|
"from dataclasses import dataclass, field, fields, is_dataclass",
|
|
631
|
-
"from typing import Any, Generic, Literal, Mapping, Never,
|
|
638
|
+
"from typing import Any, Generic, Literal, Mapping, Never, TypeAliasType, TypeVar, get_args, get_origin, get_type_hints",
|
|
632
639
|
"",
|
|
633
640
|
'T = TypeVar("T")',
|
|
634
641
|
"",
|
|
@@ -639,6 +646,8 @@ function pythonSdk(document: OpenAPIDocument, options: SdkRenderOptions): string
|
|
|
639
646
|
" self.body = body",
|
|
640
647
|
"",
|
|
641
648
|
"def _decode(value: Any, target: Any) -> Any:",
|
|
649
|
+
" if isinstance(target, TypeAliasType):",
|
|
650
|
+
" return _decode(value, target.__value__)",
|
|
642
651
|
" if target is None or target is Any or target is object:",
|
|
643
652
|
" return value",
|
|
644
653
|
" origin = get_origin(target)",
|
package/src/stylex-migrate.ts
CHANGED
|
@@ -927,17 +927,144 @@ const lineOf = (source: string, offset: number): number => {
|
|
|
927
927
|
return line
|
|
928
928
|
}
|
|
929
929
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
930
|
+
/** Words after which an expression - so a regex literal or a JSX element - may start. */
|
|
931
|
+
const EXPRESSION_KEYWORDS = new Set([
|
|
932
|
+
"return",
|
|
933
|
+
"typeof",
|
|
934
|
+
"instanceof",
|
|
935
|
+
"in",
|
|
936
|
+
"of",
|
|
937
|
+
"new",
|
|
938
|
+
"delete",
|
|
939
|
+
"void",
|
|
940
|
+
"throw",
|
|
941
|
+
"case",
|
|
942
|
+
"do",
|
|
943
|
+
"else",
|
|
944
|
+
"yield",
|
|
945
|
+
"await",
|
|
946
|
+
])
|
|
947
|
+
|
|
948
|
+
const WORD_CHAR = /[\w$]/
|
|
949
|
+
|
|
950
|
+
/** `<` at `offset` opens an element unless it starts a type parameter list (`<T,>`, `<T extends U>`). */
|
|
951
|
+
const opensElement = (source: string, offset: number): boolean => {
|
|
952
|
+
const name = /^<([A-Za-z_$][\w$.:-]*)?(\s*)/.exec(source.slice(offset, offset + 256))
|
|
953
|
+
if (name === null) return false
|
|
954
|
+
if (name[1] === undefined) return source[offset + 1] === ">"
|
|
955
|
+
const after = source.slice(offset + name[0].length, offset + name[0].length + 8)
|
|
956
|
+
return !after.startsWith(",") && !(name[2] !== "" && /^extends\b/.test(after))
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Which offsets of `source` sit inside a JSX tag, where an attribute can be. A small lexer, not a
|
|
961
|
+
* parser: it steps over strings, template literals, comments and regex literals, and follows JSX by
|
|
962
|
+
* the usual rule that `<` where an expression may start opens an element. A `className=` anywhere
|
|
963
|
+
* else - HTML in a template literal, a comment, a string, JSX text - is not an attribute.
|
|
964
|
+
*/
|
|
965
|
+
function jsxTagMask(source: string, jsx: boolean): Uint8Array {
|
|
966
|
+
const mask = new Uint8Array(source.length)
|
|
967
|
+
const stack: ("code" | "template" | "tag" | "children")[] = ["code"]
|
|
968
|
+
const length = source.length
|
|
969
|
+
let expression = true
|
|
970
|
+
let index = 0
|
|
971
|
+
const skipQuoted = (quote: string, multiline: boolean): void => {
|
|
972
|
+
index += 1
|
|
973
|
+
while (index < length && source[index] !== quote && (multiline || source[index] !== "\n"))
|
|
974
|
+
index += source[index] === "\\" && !multiline ? 2 : 1
|
|
975
|
+
index += 1
|
|
976
|
+
}
|
|
977
|
+
while (index < length) {
|
|
978
|
+
const frame = stack[stack.length - 1]
|
|
979
|
+
const char = source[index] ?? ""
|
|
980
|
+
const next = source[index + 1]
|
|
981
|
+
if (frame === "template") {
|
|
982
|
+
if (char === "\\") index += 2
|
|
983
|
+
else if (char === "`") {
|
|
984
|
+
stack.pop()
|
|
985
|
+
index += 1
|
|
986
|
+
expression = false
|
|
987
|
+
} else if (char === "$" && next === "{") {
|
|
988
|
+
stack.push("code")
|
|
989
|
+
index += 2
|
|
990
|
+
expression = true
|
|
991
|
+
} else index += 1
|
|
992
|
+
} else if (frame === "children") {
|
|
993
|
+
if (char === "{") {
|
|
994
|
+
stack.push("code")
|
|
995
|
+
expression = true
|
|
996
|
+
} else if (char === "<" && next === "/") {
|
|
997
|
+
const close = source.indexOf(">", index)
|
|
998
|
+
index = close === -1 ? length : close
|
|
999
|
+
stack.pop()
|
|
1000
|
+
expression = false
|
|
1001
|
+
} else if (char === "<") stack.push("tag")
|
|
1002
|
+
index += 1
|
|
1003
|
+
} else if (frame === "tag") {
|
|
1004
|
+
mask[index] = 1
|
|
1005
|
+
if (char === '"' || char === "'") skipQuoted(char, true)
|
|
1006
|
+
else if (char === "{") {
|
|
1007
|
+
stack.push("code")
|
|
1008
|
+
expression = true
|
|
1009
|
+
index += 1
|
|
1010
|
+
} else if (char === "/" && next === ">") {
|
|
1011
|
+
stack.pop()
|
|
1012
|
+
expression = false
|
|
1013
|
+
index += 2
|
|
1014
|
+
} else {
|
|
1015
|
+
if (char === ">") stack[stack.length - 1] = "children"
|
|
1016
|
+
index += 1
|
|
1017
|
+
}
|
|
1018
|
+
} else if (char === "/" && next === "/") {
|
|
1019
|
+
const end = source.indexOf("\n", index)
|
|
1020
|
+
index = end === -1 ? length : end
|
|
1021
|
+
} else if (char === "/" && next === "*") {
|
|
1022
|
+
const end = source.indexOf("*/", index + 2)
|
|
1023
|
+
index = end === -1 ? length : end + 2
|
|
1024
|
+
} else if (char === '"' || char === "'") {
|
|
1025
|
+
skipQuoted(char, false)
|
|
1026
|
+
expression = false
|
|
1027
|
+
} else if (char === "`") {
|
|
1028
|
+
stack.push("template")
|
|
1029
|
+
index += 1
|
|
1030
|
+
} else if (char === "/" && expression) {
|
|
1031
|
+
let inClass = false
|
|
1032
|
+
index += 1
|
|
1033
|
+
while (index < length && source[index] !== "\n") {
|
|
1034
|
+
const current = source[index]
|
|
1035
|
+
if (current === "\\") index += 1
|
|
1036
|
+
else if (current === "[") inClass = true
|
|
1037
|
+
else if (current === "]") inClass = false
|
|
1038
|
+
else if (current === "/" && !inClass) break
|
|
1039
|
+
index += 1
|
|
1040
|
+
}
|
|
1041
|
+
index += 1
|
|
1042
|
+
while (index < length && WORD_CHAR.test(source[index] ?? "")) index += 1
|
|
1043
|
+
expression = false
|
|
1044
|
+
} else if (char === "<" && expression && jsx && opensElement(source, index)) {
|
|
1045
|
+
stack.push("tag")
|
|
1046
|
+
index += 1
|
|
1047
|
+
} else if (WORD_CHAR.test(char)) {
|
|
1048
|
+
const start = index
|
|
1049
|
+
while (index < length && WORD_CHAR.test(source[index] ?? "")) index += 1
|
|
1050
|
+
expression = EXPRESSION_KEYWORDS.has(source.slice(start, index))
|
|
1051
|
+
} else {
|
|
1052
|
+
if (char === "{") stack.push("code")
|
|
1053
|
+
else if (char === "}" && stack.length > 1) stack.pop()
|
|
1054
|
+
if (char.trim() !== "") expression = char !== ")" && char !== "]"
|
|
1055
|
+
index += 1
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return mask
|
|
934
1059
|
}
|
|
935
1060
|
|
|
936
|
-
const classAttributes = (source: string): readonly ClassAttribute[] => {
|
|
1061
|
+
const classAttributes = (source: string, file: string): readonly ClassAttribute[] => {
|
|
937
1062
|
const attributes: ClassAttribute[] = []
|
|
1063
|
+
// A TypeScript module that is not `.tsx` cannot hold JSX, and its `<T>` would read as an element.
|
|
1064
|
+
const inTag = jsxTagMask(source, !/\.[cm]?ts$/.test(file))
|
|
938
1065
|
for (const match of source.matchAll(CLASS_ATTRIBUTE)) {
|
|
939
1066
|
const start = match.index ?? 0
|
|
940
|
-
if (
|
|
1067
|
+
if (inTag[start] !== 1) continue
|
|
941
1068
|
const end = start + match[0].length
|
|
942
1069
|
const value = match[1] ?? match[2] ?? match[3] ?? match[4]
|
|
943
1070
|
attributes.push(
|
|
@@ -997,7 +1124,7 @@ const insertionOffset = (source: string): number => {
|
|
|
997
1124
|
|
|
998
1125
|
/** Transform one source file. The returned source is unchanged when every match is unsupported. */
|
|
999
1126
|
export function migrateTailwindSource(source: string, file: string): SourceMigration {
|
|
1000
|
-
const attributes = classAttributes(source)
|
|
1127
|
+
const attributes = classAttributes(source, file)
|
|
1001
1128
|
if (attributes.length === 0) return { source, changed: false, replacements: 0, issues: [] }
|
|
1002
1129
|
|
|
1003
1130
|
const replacements: { start: number; end: number; text: string }[] = []
|
package/src/test-tool.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type HeadTailOutput,
|
|
3
|
+
OWN_PROCESS_GROUP,
|
|
4
|
+
readHeadTail,
|
|
5
|
+
signalProcessGroup,
|
|
6
|
+
} from "./mcp-io.ts"
|
|
2
7
|
import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.ts"
|
|
3
8
|
|
|
4
9
|
export interface TestToolArgs {
|
|
@@ -58,6 +63,8 @@ function normalizeTimeout(value: unknown): number {
|
|
|
58
63
|
return Math.min(Math.max(Math.trunc(value), 1_000), MAX_TIMEOUT_MS)
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
const KILL_GRACE_MS = 2_000
|
|
67
|
+
|
|
61
68
|
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g")
|
|
62
69
|
|
|
63
70
|
// A UTF-8 character is at most four bytes, so these hold every character the result can show.
|
|
@@ -146,17 +153,24 @@ export async function collectTestResult(
|
|
|
146
153
|
env: process.env,
|
|
147
154
|
stdout: "pipe",
|
|
148
155
|
stderr: "pipe",
|
|
156
|
+
detached: OWN_PROCESS_GROUP,
|
|
149
157
|
})
|
|
150
158
|
let timedOut = false
|
|
151
159
|
let cancelled = false
|
|
160
|
+
let escalation: ReturnType<typeof setTimeout> | undefined
|
|
161
|
+
// A test can ignore SIGTERM, so the group gets SIGKILL once the grace period runs out.
|
|
162
|
+
const stop = (): void => {
|
|
163
|
+
signalProcessGroup(proc, "SIGTERM")
|
|
164
|
+
escalation ??= setTimeout(() => signalProcessGroup(proc, "SIGKILL"), KILL_GRACE_MS)
|
|
165
|
+
}
|
|
152
166
|
const abort = (): void => {
|
|
153
167
|
cancelled = true
|
|
154
|
-
|
|
168
|
+
stop()
|
|
155
169
|
}
|
|
156
170
|
opts.signal?.addEventListener("abort", abort, { once: true })
|
|
157
171
|
const timer = setTimeout(() => {
|
|
158
172
|
timedOut = true
|
|
159
|
-
|
|
173
|
+
stop()
|
|
160
174
|
}, timeoutMs)
|
|
161
175
|
const empty: HeadTailOutput = { head: "", tail: "", droppedBytes: 0 }
|
|
162
176
|
let stdoutRaw = empty
|
|
@@ -175,6 +189,7 @@ export async function collectTestResult(
|
|
|
175
189
|
exitCode = result[2]
|
|
176
190
|
} finally {
|
|
177
191
|
clearTimeout(timer)
|
|
192
|
+
clearTimeout(escalation)
|
|
178
193
|
opts.signal?.removeEventListener("abort", abort)
|
|
179
194
|
}
|
|
180
195
|
const stdout = clean(stdoutRaw)
|