tovuk 0.1.48 → 0.1.50
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 +2 -2
- package/package.json +1 -1
- package/src/internal/config-validation.ts +72 -9
- package/src/internal/constants.ts +28 -2
- package/src/internal/doctor.ts +26 -1
- package/src/internal/frontend-policy.ts +39 -7
- package/src/internal/rust-doctor.ts +27 -15
- package/src/internal/templates.ts +18 -0
- package/src/internal/types.ts +1 -1
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ npx tovuk deploy --wait --json
|
|
|
12
12
|
`npx tovuk` is the public npm command.
|
|
13
13
|
|
|
14
14
|
Rust backends expect `Cargo.toml`, `Cargo.lock`, and `tovuk.toml`. They must
|
|
15
|
-
pass `cargo fmt --all --check`, locked
|
|
16
|
-
`0.0.0.0:$PORT`, and expose the configured health endpoint.
|
|
15
|
+
pass `cargo fmt --all --check`, locked release-mode check/test/Clippy gates,
|
|
16
|
+
listen on `0.0.0.0:$PORT`, and expose the configured health endpoint.
|
|
17
17
|
|
|
18
18
|
Static frontends must use TypeScript browser source, stable native type-aware
|
|
19
19
|
TypeScript checks, native linting such as `oxlint`, `biome check`, or
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PROJECT_KINDS } from './constants.ts'
|
|
1
|
+
import { JAVASCRIPT_BACKEND_RUNTIMES, PROJECT_KINDS, RUST_STRICT_CLIPPY_DENY_LINTS } from './constants.ts'
|
|
2
2
|
import { commandTokens, hasFrontendInstallCommand, hasFrontendScriptRun, usesJavascriptLinter } from './frontend-policy.ts'
|
|
3
3
|
import { isSafeRelativePath } from './project.ts'
|
|
4
4
|
import type { ProjectKind, TovukConfig } from './types.ts'
|
|
@@ -34,6 +34,9 @@ function validateBuildConfig(config: TovukConfig): void {
|
|
|
34
34
|
throw new Error('[build].check is required')
|
|
35
35
|
}
|
|
36
36
|
validateCheckCommand(config.kind, config.build.check)
|
|
37
|
+
if (config.kind === 'rust_backend') {
|
|
38
|
+
validateRustBuildCommand(config.build.command)
|
|
39
|
+
}
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
function validateStaticFrontendConfig(config: TovukConfig): void {
|
|
@@ -45,6 +48,7 @@ function validateRustBackendConfig(config: TovukConfig): void {
|
|
|
45
48
|
throw new Error('[build].output is only valid for static_frontend')
|
|
46
49
|
}
|
|
47
50
|
requireCommand(config.run.command, '[run].command')
|
|
51
|
+
validateRustRunCommand(config.run.command)
|
|
48
52
|
validatePort(config.run.port, '[run].port')
|
|
49
53
|
validateHealth(config.run.health, '[run].health')
|
|
50
54
|
validateResourceConfig(config)
|
|
@@ -62,8 +66,8 @@ function validateFullstackConfig(config: TovukConfig): void {
|
|
|
62
66
|
|
|
63
67
|
function validateFullstackSections(config: TovukConfig): void {
|
|
64
68
|
validateRustCheckCommand(requireCommand(config.backend.check, '[backend].check'))
|
|
65
|
-
requireCommand(config.backend.build, '[backend].build')
|
|
66
|
-
requireCommand(config.backend.command, '[backend].command')
|
|
69
|
+
validateRustBuildCommand(requireCommand(config.backend.build, '[backend].build'))
|
|
70
|
+
validateRustRunCommand(requireCommand(config.backend.command, '[backend].command'))
|
|
67
71
|
validatePort(config.backend.port, '[backend].port')
|
|
68
72
|
validateHealth(config.backend.health, '[backend].health')
|
|
69
73
|
validateFrontendCheckCommand(requireCommand(config.frontend.check, '[frontend].check'))
|
|
@@ -104,11 +108,16 @@ function validateOutput(value: string | undefined, field: string): void {
|
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
function validateResourceConfig(config: TovukConfig): void {
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
const memoryMib = memoryToMib(config.resources.memory)
|
|
112
|
+
if (memoryMib < 128 || memoryMib > 2048) {
|
|
113
|
+
throw new Error('[resources].memory must be between 128mb and 2gb; use the smallest working value')
|
|
109
114
|
}
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
const cpuMillis = cpuToMillis(config.resources.cpu)
|
|
116
|
+
if (cpuMillis < 50 || cpuMillis > 2000) {
|
|
117
|
+
throw new Error('[resources].cpu must be between 0.05 and 2; use the smallest working value')
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isInteger(config.resources.idle_timeout_minutes) || config.resources.idle_timeout_minutes < 1 || config.resources.idle_timeout_minutes > 60) {
|
|
120
|
+
throw new Error('[resources].idle_timeout_minutes must be between 1 and 60')
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
123
|
|
|
@@ -122,11 +131,40 @@ function validateCheckCommand(kind: ProjectKind, command: string): void {
|
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
function validateRustCheckCommand(command: string): void {
|
|
125
|
-
const required = [
|
|
134
|
+
const required = [
|
|
135
|
+
'cargo fmt --all --check',
|
|
136
|
+
'cargo check --locked --release --all-targets --all-features',
|
|
137
|
+
'cargo test --locked --release --all-targets --all-features',
|
|
138
|
+
'cargo clippy --locked --release --all-targets --all-features',
|
|
139
|
+
'-D warnings',
|
|
140
|
+
...RUST_STRICT_CLIPPY_DENY_LINTS.map((lint) => `-D ${lint}`)
|
|
141
|
+
]
|
|
126
142
|
if (required.every((fragment) => command.includes(fragment))) {
|
|
127
143
|
return
|
|
128
144
|
}
|
|
129
|
-
throw new Error('[build].check must
|
|
145
|
+
throw new Error('[build].check must run rustfmt, locked release-mode cargo check, locked release-mode tests, and strict Clippy resource lints')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validateRustBuildCommand(command: string): void {
|
|
149
|
+
if (usesJavascriptBackendRuntime(command)) {
|
|
150
|
+
throw new Error('Rust backend build commands cannot invoke JavaScript or TypeScript runtimes; use cargo build --release')
|
|
151
|
+
}
|
|
152
|
+
const tokens = commandTokens(command)
|
|
153
|
+
if (tokens.some((token) => commandNameFromToken(token) === 'cargo') && tokens.includes('build') && tokens.includes('--release')) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
throw new Error('Rust backend build commands must run cargo build --release')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validateRustRunCommand(command: string | undefined): void {
|
|
160
|
+
const value = command ?? ''
|
|
161
|
+
if (usesJavascriptBackendRuntime(value)) {
|
|
162
|
+
throw new Error('Rust backend runtime commands cannot invoke JavaScript or TypeScript runtimes; run ./target/release/<binary> instead')
|
|
163
|
+
}
|
|
164
|
+
if (commandTokens(value).some((token) => token.includes('target/release/'))) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
throw new Error('Rust backend runtime commands must start a binary under ./target/release/')
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
function validateFrontendCheckCommand(command: string): void {
|
|
@@ -147,6 +185,11 @@ function validateFrontendCheckCommand(command: string): void {
|
|
|
147
185
|
throw new Error('[build].check must install dependencies and run package scripts, for example `bun ci && bun run typecheck && bun run lint` or `npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint`')
|
|
148
186
|
}
|
|
149
187
|
|
|
188
|
+
function usesJavascriptBackendRuntime(command: string): boolean {
|
|
189
|
+
return commandTokens(command)
|
|
190
|
+
.some((token) => JAVASCRIPT_BACKEND_RUNTIMES.has(commandNameFromToken(token)))
|
|
191
|
+
}
|
|
192
|
+
|
|
150
193
|
function isSafeRelativeDirectory(value: string): boolean {
|
|
151
194
|
return value === '.' || isSafeRelativePath(value)
|
|
152
195
|
}
|
|
@@ -155,4 +198,24 @@ function isNoopCommand(command: string): boolean {
|
|
|
155
198
|
return command.trim() === ':' || command.trim() === 'true'
|
|
156
199
|
}
|
|
157
200
|
|
|
201
|
+
function commandNameFromToken(token: string): string {
|
|
202
|
+
return token.split('/').pop() ?? ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function memoryToMib(value: string): number {
|
|
206
|
+
const match = value.trim().toLowerCase().match(/^(\d+)\s*(mb|mib|gb|gib)$/u)
|
|
207
|
+
if (!match) {
|
|
208
|
+
throw new Error('[resources].memory must look like 256mb, 512mb, or 1gb')
|
|
209
|
+
}
|
|
210
|
+
const amount = Number.parseInt(match[1] ?? '', 10)
|
|
211
|
+
return amount * ((match[2] ?? '').startsWith('g') ? 1024 : 1)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function cpuToMillis(value: string): number {
|
|
215
|
+
if (!/^\d+(?:\.\d{1,3})?$/u.test(value.trim())) {
|
|
216
|
+
throw new Error('[resources].cpu must look like 0.25, 0.5, 1, or 2')
|
|
217
|
+
}
|
|
218
|
+
return Math.round(Number.parseFloat(value) * 1000)
|
|
219
|
+
}
|
|
220
|
+
|
|
158
221
|
export { validateConfig }
|
|
@@ -15,12 +15,36 @@ export const SESSION_ACCOUNT: string = 'session-token'
|
|
|
15
15
|
export const SESSION_LABEL: string = 'Tovuk session'
|
|
16
16
|
export const DEFAULT_LOGIN_EXPIRES_SECONDS: number = 600
|
|
17
17
|
export const DEFAULT_LOGIN_INTERVAL_SECONDS: number = 5
|
|
18
|
-
export const
|
|
18
|
+
export const RUST_STRICT_CLIPPY_DENY_LINTS: readonly string[] = [
|
|
19
|
+
'clippy::all',
|
|
20
|
+
'clippy::pedantic',
|
|
21
|
+
'clippy::dbg_macro',
|
|
22
|
+
'clippy::todo',
|
|
23
|
+
'clippy::unimplemented',
|
|
24
|
+
'clippy::panic',
|
|
25
|
+
'clippy::unwrap_used',
|
|
26
|
+
'clippy::expect_used',
|
|
27
|
+
'clippy::large_futures',
|
|
28
|
+
'clippy::large_include_file',
|
|
29
|
+
'clippy::large_stack_frames',
|
|
30
|
+
'clippy::mem_forget',
|
|
31
|
+
'clippy::rc_buffer',
|
|
32
|
+
'clippy::rc_mutex',
|
|
33
|
+
'clippy::redundant_clone',
|
|
34
|
+
'clippy::clone_on_ref_ptr'
|
|
35
|
+
]
|
|
36
|
+
export const DEFAULT_RUST_CHECK_COMMAND: string = [
|
|
37
|
+
'cargo fmt --all --check',
|
|
38
|
+
'cargo check --locked --release --all-targets --all-features',
|
|
39
|
+
'cargo test --locked --release --all-targets --all-features',
|
|
40
|
+
`cargo clippy --locked --release --all-targets --all-features -- -D warnings ${RUST_STRICT_CLIPPY_DENY_LINTS.map((lint) => `-D ${lint}`).join(' ')}`
|
|
41
|
+
].join(' && ')
|
|
19
42
|
export const DEFAULT_NPM_FRONTEND_CHECK_COMMAND: string = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
|
|
20
43
|
export const DEFAULT_BUN_FRONTEND_CHECK_COMMAND: string = 'bun ci && bun run typecheck && bun run lint'
|
|
21
44
|
export const PROJECT_KINDS: ReadonlySet<string> = new Set(['fullstack', 'rust_backend', 'static_frontend'])
|
|
22
45
|
export const PROJECT_TEMPLATES: ReadonlySet<string> = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
|
|
23
46
|
export const JAVASCRIPT_LINTERS: ReadonlySet<string> = new Set(['eslint', 'eslint_d', 'jscs', 'jshint', 'prettier', 'prettierd', 'standard', 'xo'])
|
|
47
|
+
export const JAVASCRIPT_BACKEND_RUNTIMES: ReadonlySet<string> = new Set(['astro', 'bun', 'deno', 'next', 'node', 'npm', 'npx', 'pnpm', 'svelte-kit', 'tsx', 'ts-node', 'vite', 'yarn'])
|
|
24
48
|
export const FRONTEND_SOURCE_ROOTS: ReadonlySet<string> = new Set(['src', 'app', 'pages', 'routes', 'components'])
|
|
25
49
|
export const FRONTEND_JAVASCRIPT_EXTENSIONS: readonly string[] = ['.js', '.jsx', '.mjs', '.cjs']
|
|
26
50
|
export const FRONTEND_PACKAGE_MANAGERS: ReadonlySet<string> = new Set(['npm', 'bun', 'pnpm', 'yarn'])
|
|
@@ -126,9 +150,10 @@ Usage:
|
|
|
126
150
|
|
|
127
151
|
Agent contract:
|
|
128
152
|
- Fullstack apps set kind = "fullstack", keep backend and frontend roots in one tovuk.toml, serve the frontend at /, and serve the Rust API under /api.
|
|
129
|
-
- Rust backends keep Cargo.lock committed, pass rustfmt, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
|
|
153
|
+
- Rust backends keep Cargo.lock committed, pass rustfmt plus locked release-mode check/test/Clippy gates, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
|
|
130
154
|
- Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, stable native typecheck, native lint, and Fallow quality gates.
|
|
131
155
|
- Plain static HTML/CSS/JS frontends may use kind = "static_frontend" with check = ":", command = ":", and output = ".".
|
|
156
|
+
- JavaScript and TypeScript are frontend-only on Tovuk; backend build and runtime commands must be Cargo release builds and Rust release binaries.
|
|
132
157
|
- Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
|
|
133
158
|
- Run deploy from a fullstack repo root with one tovuk.toml to build backend and frontend together.
|
|
134
159
|
- When split frontend and backend apps use different hostnames, configure backend CORS or use a same-origin custom domain.
|
|
@@ -136,6 +161,7 @@ Agent contract:
|
|
|
136
161
|
- Create support tickets only with command output, app id, build id, deploy id, and the first actionable log line.
|
|
137
162
|
- Resolve support tickets after the issue is fixed so later agents do not duplicate work.
|
|
138
163
|
- Keep direct unsafe out of Rust source.
|
|
164
|
+
- Keep Rust backend resources small: 128mb-2gb memory, 0.05-2 CPU, and 1-60 minute idle timeout.
|
|
139
165
|
`
|
|
140
166
|
|
|
141
167
|
function packageVersion(): string {
|
package/src/internal/doctor.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { doctorCheck } from './checks.ts'
|
|
|
4
4
|
import { agentError } from './errors.ts'
|
|
5
5
|
import { parseTovukToml, validateConfig } from './config.ts'
|
|
6
6
|
import { discoverDeployProjects } from './workspace.ts'
|
|
7
|
-
import { printJson } from './project.ts'
|
|
7
|
+
import { printJson, walkProjectFiles } from './project.ts'
|
|
8
8
|
import { rustDoctorChecks, unsafeCheck } from './rust-doctor.ts'
|
|
9
9
|
import { frontendLockfileExists, frontendScriptChecks, frontendSourceChecks, isPlainStaticFrontend } from './frontend-policy.ts'
|
|
10
10
|
import type { DoctorCheck, DoctorReport, WorkspaceDoctorReport, TovukConfig } from './types.ts'
|
|
@@ -67,6 +67,7 @@ function runDoctor(projectDir: string): DoctorReport {
|
|
|
67
67
|
if (kind === 'static_frontend') {
|
|
68
68
|
checks.push(...staticFrontendChecks(projectDir, configResult.valid))
|
|
69
69
|
} else {
|
|
70
|
+
checks.push(backendJavascriptSourceCheck(projectDir))
|
|
70
71
|
checks.push(...rustDoctorChecks(projectDir, configResult.valid))
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -151,6 +152,7 @@ function fullstackChecks(projectDir: string, config: TovukConfig, configValid: b
|
|
|
151
152
|
const frontendDir = path.join(projectDir, frontendRoot)
|
|
152
153
|
return [
|
|
153
154
|
...requiredFilesAt(backendDir, backendRoot, ['Cargo.toml', 'Cargo.lock']),
|
|
155
|
+
backendJavascriptSourceCheck(backendDir, backendRoot),
|
|
154
156
|
...rustDoctorChecks(backendDir, configValid),
|
|
155
157
|
...requiredFilesAt(frontendDir, frontendRoot, isPlainStaticFrontend(frontendDir) ? ['index.html'] : ['package.json']),
|
|
156
158
|
...staticFrontendChecks(frontendDir, configValid)
|
|
@@ -181,6 +183,29 @@ function frontendLockfileCheck(projectDir: string): DoctorCheck {
|
|
|
181
183
|
return doctorCheck('frontend lockfile', ok, 'found', 'missing', 'Commit package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, or bun.lockb, then retry.')
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
function backendJavascriptSourceCheck(projectDir: string, label = ''): DoctorCheck {
|
|
187
|
+
const matches: string[] = []
|
|
188
|
+
walkProjectFiles(projectDir, (_file, relative) => {
|
|
189
|
+
if (isBackendJavascriptOrTypescriptSource(relative)) {
|
|
190
|
+
matches.push(label ? `${label}/${relative}` : relative)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
return doctorCheck(
|
|
194
|
+
'rust backend js/ts server source',
|
|
195
|
+
matches.length === 0,
|
|
196
|
+
'none found',
|
|
197
|
+
matches.slice(0, 5).join(', '),
|
|
198
|
+
'Move API routes, SSR handlers, middleware, and server logic to Rust. Keep JS/TS only in static frontend roots.'
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isBackendJavascriptOrTypescriptSource(relative: string): boolean {
|
|
203
|
+
if (relative.endsWith('.d.ts') || !/\.(?:cjs|js|jsx|mjs|ts|tsx)$/iu.test(relative)) {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
return relative.split('/').some((component) => ['api', 'app', 'pages', 'routes', 'server', 'src'].includes(component))
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
function isWorkspaceDoctorReport(report: DoctorReport | WorkspaceDoctorReport): report is WorkspaceDoctorReport {
|
|
185
210
|
return 'projects' in report
|
|
186
211
|
}
|
|
@@ -7,6 +7,8 @@ import { readPackageJson, walkProjectFiles } from './project.ts'
|
|
|
7
7
|
import type { DoctorCheck, FrontendSourceReport, PackageManifest } from './types.ts'
|
|
8
8
|
|
|
9
9
|
const REQUIRED_FRONTEND_SCRIPTS = ['typecheck', 'lint'] as const
|
|
10
|
+
const FRONTEND_PAGES_API_PREFIXES: readonly (readonly string[])[] = [['pages', 'api'], ['src', 'pages', 'api']]
|
|
11
|
+
const FRONTEND_APP_API_PREFIXES: readonly (readonly string[])[] = [['app', 'api'], ['src', 'app', 'api']]
|
|
10
12
|
type FrontendScriptName = typeof REQUIRED_FRONTEND_SCRIPTS[number]
|
|
11
13
|
type CommandPredicate = (command: string) => boolean
|
|
12
14
|
|
|
@@ -95,18 +97,26 @@ function frontendSourceChecks(projectDir: string): DoctorCheck[] {
|
|
|
95
97
|
message: report.typescript.length > 0 ? report.typescript.slice(0, 3).join(', ') : 'missing',
|
|
96
98
|
agent_instruction: report.typescript.length > 0 ? null : 'Add browser source as .ts or .tsx under src, app, pages, routes, or components, then retry.'
|
|
97
99
|
},
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
ok: report.javascript.length === 0,
|
|
101
|
-
message: report.javascript.length === 0 ? 'none found' : report.javascript.slice(0, 5).join(', '),
|
|
102
|
-
agent_instruction: report.javascript.length === 0 ? null : 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'
|
|
103
|
-
}
|
|
100
|
+
forbiddenSourceCheck('javascript source', report.javascript, 'Rename browser .js, .jsx, .mjs, or .cjs source files to .ts or .tsx and fix type errors before deploying.'),
|
|
101
|
+
forbiddenSourceCheck('frontend server routes', report.serverRoutes, 'Move API routes, SSR handlers, middleware, and server logic to the Rust backend; static frontend source may only contain browser code.')
|
|
104
102
|
]
|
|
105
103
|
}
|
|
106
104
|
|
|
105
|
+
function forbiddenSourceCheck(name: string, files: readonly string[], instruction: string): DoctorCheck {
|
|
106
|
+
return {
|
|
107
|
+
name,
|
|
108
|
+
ok: files.length === 0,
|
|
109
|
+
message: files.length === 0 ? 'none found' : files.slice(0, 5).join(', '),
|
|
110
|
+
agent_instruction: files.length === 0 ? null : instruction
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
function frontendSourceReport(projectDir: string): FrontendSourceReport {
|
|
108
|
-
const report: FrontendSourceReport = { typescript: [], javascript: [] }
|
|
115
|
+
const report: FrontendSourceReport = { typescript: [], javascript: [], serverRoutes: [] }
|
|
109
116
|
walkProjectFiles(projectDir, (_file, relative) => {
|
|
117
|
+
if (isFrontendServerRoute(relative)) {
|
|
118
|
+
report.serverRoutes.push(relative)
|
|
119
|
+
}
|
|
110
120
|
const sourceKind = frontendSourceKind(relative)
|
|
111
121
|
if (sourceKind) {
|
|
112
122
|
report[sourceKind].push(relative)
|
|
@@ -138,6 +148,28 @@ function isFrontendJavascriptSource(relative: string): boolean {
|
|
|
138
148
|
return FRONTEND_JAVASCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
function isFrontendServerRoute(relative: string): boolean {
|
|
152
|
+
if (!isFrontendTypescriptSource(relative) && !isFrontendJavascriptSource(relative)) {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
const parts = relative.toLowerCase().split('/')
|
|
156
|
+
const file = parts.at(-1) ?? ''
|
|
157
|
+
return isFrontendServerHandlerFile(file) || isFrontendApiRoute(parts, file)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isFrontendServerHandlerFile(file: string): boolean {
|
|
161
|
+
return file.startsWith('+server.') || file.startsWith('middleware.')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isFrontendApiRoute(pathParts: readonly string[], file: string): boolean {
|
|
165
|
+
return FRONTEND_PAGES_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix))
|
|
166
|
+
|| (file.startsWith('route.') && FRONTEND_APP_API_PREFIXES.some((prefix) => pathStartsWith(pathParts, prefix)))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pathStartsWith(pathParts: readonly string[], prefix: readonly string[]): boolean {
|
|
170
|
+
return pathParts.length >= prefix.length && prefix.every((part, index) => pathParts[index] === part)
|
|
171
|
+
}
|
|
172
|
+
|
|
141
173
|
function packageScriptValue(manifest: PackageManifest | null, script: string): string {
|
|
142
174
|
const value = manifest?.scripts?.[script]
|
|
143
175
|
return typeof value === 'string' ? value.trim() : ''
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import { RUST_STRICT_CLIPPY_DENY_LINTS } from './constants.ts'
|
|
4
5
|
import { walkProjectFiles } from './project.ts'
|
|
5
6
|
import type { DoctorCheck } from './types.ts'
|
|
6
7
|
|
|
@@ -14,7 +15,7 @@ interface CargoCheckSpec {
|
|
|
14
15
|
function rustDoctorChecks(projectDir: string, configValid: boolean): DoctorCheck[] {
|
|
15
16
|
const checks = [cargoLints(projectDir), unsafeCheck(projectDir)]
|
|
16
17
|
if (configValid) {
|
|
17
|
-
checks.push(cargoFmt(projectDir), cargoCheck(projectDir), cargoClippy(projectDir))
|
|
18
|
+
checks.push(cargoFmt(projectDir), cargoCheck(projectDir), cargoTest(projectDir), cargoClippy(projectDir))
|
|
18
19
|
}
|
|
19
20
|
return checks
|
|
20
21
|
}
|
|
@@ -46,9 +47,18 @@ function scanUnsafe(projectDir: string): string[] {
|
|
|
46
47
|
function cargoCheck(projectDir: string): DoctorCheck {
|
|
47
48
|
return cargoCommandCheck(projectDir, {
|
|
48
49
|
name: 'cargo check',
|
|
49
|
-
args: ['check', '--locked', '--quiet'],
|
|
50
|
-
missing: 'Install Rust and Cargo, then run `cargo check --locked` locally before deploying.',
|
|
51
|
-
failed: 'Run `cargo check --locked`, fix every compiler error and warning, then redeploy.'
|
|
50
|
+
args: ['check', '--locked', '--release', '--all-targets', '--all-features', '--quiet'],
|
|
51
|
+
missing: 'Install Rust and Cargo, then run `cargo check --locked --release --all-targets --all-features` locally before deploying.',
|
|
52
|
+
failed: 'Run `cargo check --locked --release --all-targets --all-features`, fix every compiler error and warning, then redeploy.'
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cargoTest(projectDir: string): DoctorCheck {
|
|
57
|
+
return cargoCommandCheck(projectDir, {
|
|
58
|
+
name: 'cargo test',
|
|
59
|
+
args: ['test', '--locked', '--release', '--all-targets', '--all-features', '--quiet'],
|
|
60
|
+
missing: 'Install Rust and Cargo, then run `cargo test --locked --release --all-targets --all-features` locally before deploying.',
|
|
61
|
+
failed: 'Run `cargo test --locked --release --all-targets --all-features`, fix every failed test, then redeploy.'
|
|
52
62
|
})
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -64,9 +74,9 @@ function cargoFmt(projectDir: string): DoctorCheck {
|
|
|
64
74
|
function cargoClippy(projectDir: string): DoctorCheck {
|
|
65
75
|
return cargoCommandCheck(projectDir, {
|
|
66
76
|
name: 'cargo clippy',
|
|
67
|
-
args: ['clippy', '--locked', '--all-targets', '--all-features', '--quiet', '--', '-D', 'warnings'],
|
|
68
|
-
missing: 'Install Rust clippy, then run
|
|
69
|
-
failed: 'Run
|
|
77
|
+
args: ['clippy', '--locked', '--release', '--all-targets', '--all-features', '--quiet', '--', '-D', 'warnings', ...RUST_STRICT_CLIPPY_DENY_LINTS.flatMap((lint) => ['-D', lint])],
|
|
78
|
+
missing: 'Install Rust clippy, then run Tovuk strict Clippy checks before deploying.',
|
|
79
|
+
failed: 'Run the strict Tovuk Clippy command from tovuk.toml, fix every warning, panic/unwrap issue, and resource lint, then redeploy.'
|
|
70
80
|
})
|
|
71
81
|
}
|
|
72
82
|
|
|
@@ -110,17 +120,19 @@ function cargoLints(projectDir: string): DoctorCheck {
|
|
|
110
120
|
}
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
const
|
|
114
|
-
|
|
123
|
+
const requiredClippyLints = RUST_STRICT_CLIPPY_DENY_LINTS.map((lint) => lint.replace(/^clippy::/u, ''))
|
|
124
|
+
const ok = cargoLintLevel(source, 'rust', 'unsafe_code') === 'forbid' &&
|
|
125
|
+
cargoLintLevel(source, 'rust', 'warnings') === 'deny' &&
|
|
126
|
+
requiredClippyLints.every((lint) => cargoLintLevel(source, 'clippy', lint) === 'deny')
|
|
115
127
|
return {
|
|
116
128
|
name: 'cargo lints',
|
|
117
129
|
ok,
|
|
118
|
-
message: ok ? 'strict' : 'missing
|
|
119
|
-
agent_instruction: ok ? null : 'Add `[lints.rust]` with `unsafe_code = "forbid"` and `warnings = "deny"`, then retry.'
|
|
130
|
+
message: ok ? 'strict' : 'missing strict Rust or Clippy resource lints',
|
|
131
|
+
agent_instruction: ok ? null : 'Add `[lints.rust]` with `unsafe_code = "forbid"` and `warnings = "deny"`, plus `[lints.clippy]` deny entries for all, pedantic, panic/unwrap bans, and resource lints, then retry.'
|
|
120
132
|
}
|
|
121
133
|
}
|
|
122
134
|
|
|
123
|
-
function cargoLintLevel(source: string, lintName: string): string {
|
|
135
|
+
function cargoLintLevel(source: string, lintGroup: 'clippy' | 'rust', lintName: string): string {
|
|
124
136
|
let section = ''
|
|
125
137
|
for (const rawLine of source.split(/\r?\n/u)) {
|
|
126
138
|
const line = rawLine.replace(/#.*/u, '').trim()
|
|
@@ -129,7 +141,7 @@ function cargoLintLevel(source: string, lintName: string): string {
|
|
|
129
141
|
section = nextSection
|
|
130
142
|
continue
|
|
131
143
|
}
|
|
132
|
-
if (!
|
|
144
|
+
if (!isLintSection(section, lintGroup)) {
|
|
133
145
|
continue
|
|
134
146
|
}
|
|
135
147
|
const level = lintAssignmentLevel(line, lintName)
|
|
@@ -145,8 +157,8 @@ function tomlSection(line: string): string | null {
|
|
|
145
157
|
return line.match(/^\[([^\]]+)\]$/u)?.[1] ?? null
|
|
146
158
|
}
|
|
147
159
|
|
|
148
|
-
function
|
|
149
|
-
return section ===
|
|
160
|
+
function isLintSection(section: string, lintGroup: 'clippy' | 'rust'): boolean {
|
|
161
|
+
return section === `lints.${lintGroup}` || section === `workspace.lints.${lintGroup}`
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
function lintAssignmentLevel(line: string, lintName: string): string {
|
|
@@ -67,6 +67,24 @@ publish = false
|
|
|
67
67
|
[lints.rust]
|
|
68
68
|
unsafe_code = "forbid"
|
|
69
69
|
warnings = "deny"
|
|
70
|
+
|
|
71
|
+
[lints.clippy]
|
|
72
|
+
all = { level = "deny", priority = -1 }
|
|
73
|
+
pedantic = { level = "deny", priority = -1 }
|
|
74
|
+
dbg_macro = "deny"
|
|
75
|
+
todo = "deny"
|
|
76
|
+
unimplemented = "deny"
|
|
77
|
+
panic = "deny"
|
|
78
|
+
unwrap_used = "deny"
|
|
79
|
+
expect_used = "deny"
|
|
80
|
+
large_futures = "deny"
|
|
81
|
+
large_include_file = "deny"
|
|
82
|
+
large_stack_frames = "deny"
|
|
83
|
+
mem_forget = "deny"
|
|
84
|
+
rc_buffer = "deny"
|
|
85
|
+
rc_mutex = "deny"
|
|
86
|
+
redundant_clone = "deny"
|
|
87
|
+
clone_on_ref_ptr = "deny"
|
|
70
88
|
`],
|
|
71
89
|
['Cargo.lock', `# This file is automatically @generated by Cargo.
|
|
72
90
|
version = 4
|
package/src/internal/types.ts
CHANGED
|
@@ -65,7 +65,7 @@ export type ProjectDoctorReport = DoctorReport & { relative: string }
|
|
|
65
65
|
export type WorkspaceDoctorReport = JsonObject & { ok: boolean; workspace: string; projects: ProjectDoctorReport[] }
|
|
66
66
|
export type DeployProjectInfo = { dir: string; relative: string; name: string; kind: DiscoveredProjectKind }
|
|
67
67
|
export type DeployPlanProject = { project: DeployProjectInfo; wantsDatabase: boolean }
|
|
68
|
-
export type FrontendSourceReport = { typescript: string[]; javascript: string[] }
|
|
68
|
+
export type FrontendSourceReport = { typescript: string[]; javascript: string[]; serverRoutes: string[] }
|
|
69
69
|
export type LoginStartResponse = JsonObject & { loginUrl?: string; userCode?: string; deviceCode?: string; expiresInSeconds?: number; intervalSeconds?: number }
|
|
70
70
|
export type LoginPollResponse = JsonObject & { status?: string; token?: string; email?: string; intervalSeconds?: number }
|
|
71
71
|
export type AppSummary = JsonObject & { id?: string; name?: string; url?: string; databaseStorageMib?: number }
|