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 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 Cargo checks, listen on
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,6 +1,6 @@
1
1
  {
2
2
  "name": "tovuk",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "Deploy Rust backends, static frontends, and fullstack apps to Tovuk.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- if (!/^\d+\s*(mb|mib|gb|gib)$/iu.test(config.resources.memory)) {
108
- throw new Error('[resources].memory must look like 512mb or 1gb')
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
- if (!/^\d+(?:\.\d{1,3})?$/u.test(config.resources.cpu)) {
111
- throw new Error('[resources].cpu must look like 0.25, 0.5, 1, or 2')
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 = ['cargo fmt --all --check', 'cargo check --locked', 'cargo clippy --locked', '--all-targets', '--all-features', '-D warnings']
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 include cargo fmt --all --check, cargo check --locked, and cargo clippy --locked --all-targets --all-features -- -D warnings')
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 DEFAULT_RUST_CHECK_COMMAND: string = 'cargo fmt --all --check && cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
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 {
@@ -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
- name: 'javascript source',
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 `cargo clippy --locked --all-targets --all-features -- -D warnings` before deploying.',
69
- failed: 'Run `cargo clippy --locked --all-targets --all-features -- -D warnings`, fix every warning, then redeploy.'
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 ok = cargoLintLevel(source, 'unsafe_code') === 'forbid' &&
114
- cargoLintLevel(source, 'warnings') === 'deny'
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 unsafe_code=forbid or warnings=deny',
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 (!isRustLintSection(section)) {
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 isRustLintSection(section: string): boolean {
149
- return section === 'lints.rust' || section === 'workspace.lints.rust'
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
@@ -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 }