predeploy-check 1.0.0 → 1.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alok Kushwaha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,74 +1,105 @@
1
- # predeploy-check
2
-
3
- > Catch deployment failures **before** you push. Scans your project for known Render & Vercel pitfalls.
4
-
5
- ```bash
6
- npx predeploy-check
7
- ```
8
-
9
- No install required — just run it in your project directory.
10
-
11
- ## What it checks
12
-
13
- | # | Check | Targets | Status |
14
- |---|-------|---------|--------|
15
- | 1 | **Python + Render** | Rust-compiled deps (pydantic, fastapi, orjson…) on Python ≥ 3.13 | ⚠️ Warn |
16
- | 2 | **ESLint + Vercel** | Mismatched eslint / eslint-config-next versions; deprecated `ignoreDuringBuilds` on Next 16+ | ❌ Fail / ⚠️ Warn |
17
- | 3 | **Case Sensitivity** | Import paths that differ in casing from actual filenames (breaks on Linux) | ⚠️ Warn |
18
- | 4 | **Missing Engines** | No `"engines"` field in package.json | ⚠️ Warn |
19
- | 5 | **Env Var Check** | `process.env.X` references not declared in `.env` / `.env.example` | ⚠️ Warn |
20
- | 6 | **Render Start Cmd** | No Procfile, no `"start"` script, no render.yaml start command | ❌ Fail |
21
-
22
- ## Output
23
-
24
- Clean, colored terminal output with:
25
- - ✅ / ⚠️ / ❌ per check
26
- - File and line context
27
- - One-line suggested fix
28
-
29
- Exit code `1` if any ❌ failures, `0` otherwise.
30
-
31
- ## Usage
32
-
33
- ```bash
34
- # Scan current directory
35
- npx predeploy-check
36
-
37
- # Scan a specific project
38
- npx predeploy-check ./my-project
39
-
40
- # Show help
41
- npx predeploy-check --help
42
- ```
43
-
44
- ## Adding custom checks
45
-
46
- Create a new file in the `checks/` folder:
47
-
48
- ```js
49
- // checks/07-my-check.js
50
- 'use strict';
51
-
52
- const name = 'My Custom Check';
53
-
54
- async function run(projectRoot) {
55
- // Your check logic here
56
- return {
57
- status: 'pass', // 'pass' | 'warn' | 'fail' | 'skip'
58
- message: 'Everything looks good',
59
- fix: 'Suggested fix if status is warn or fail',
60
- details: [
61
- { file: 'some-file.js', line: 42, message: 'Detail about the issue' }
62
- ],
63
- };
64
- }
65
-
66
- module.exports = { name, run };
67
- ```
68
-
69
- Checks are loaded alphabetically, so prefix with a number to control order.
70
-
71
- ## License
72
-
73
- MIT
74
- "# predeploy_check"
1
+ # predeploy-check
2
+
3
+ > Catch deployment failures **before** you push. Scans your project for known Render & Vercel pitfalls.
4
+
5
+ ```bash
6
+ npx predeploy-check
7
+ ```
8
+
9
+ No install required — just run it in your project directory.
10
+
11
+ ## What it checks
12
+
13
+ | # | Check | Targets | Status |
14
+ |---|-------|---------|--------|
15
+ | 1 | **Python + Render** | Rust-compiled deps (pydantic, fastapi, orjson…) on Python ≥ 3.13 — add `--live` to verify against PyPI instead of guessing | ⚠️ Warn / ❌ Fail (live) |
16
+ | 2 | **ESLint + Vercel** | Mismatched eslint / eslint-config-next versions; deprecated `ignoreDuringBuilds` on Next 16+ | ❌ Fail / ⚠️ Warn |
17
+ | 3 | **Case Sensitivity** | Import paths that differ in casing from actual filenames (breaks on Linux) | ⚠️ Warn |
18
+ | 4 | **Missing Engines** | No `"engines"` field in package.json | ⚠️ Warn |
19
+ | 5 | **Env Var Check** | `process.env.X` references not declared in `.env` / `.env.example` | ⚠️ Warn |
20
+ | 6 | **Render Start Cmd** | No Procfile, no `"start"` script, no render.yaml start command | ❌ Fail |
21
+
22
+ ## Output
23
+
24
+ Clean, colored terminal output with:
25
+ - ✅ / ⚠️ / ❌ per check
26
+ - File and line context
27
+ - One-line suggested fix
28
+
29
+ Exit code `1` if any ❌ failures, `0` otherwise.
30
+
31
+ ## Usage
32
+
33
+ ```bash
34
+ # Scan current directory
35
+ npx predeploy-check
36
+
37
+ # Scan a specific project
38
+ npx predeploy-check ./my-project
39
+
40
+ # Verify Python wheel availability live against PyPI, instead of
41
+ # relying on the built-in known-package list (slower, needs internet)
42
+ npx predeploy-check --live
43
+
44
+ # Show help
45
+ npx predeploy-check --help
46
+ ```
47
+
48
+ By default, the Python + Render check warns based on a built-in list of
49
+ packages known to have Rust-compiled components — it's instant and works
50
+ offline, but the list can go stale as packages ship new wheels over time.
51
+ Pass `--live` to query PyPI directly for the exact pinned version in your
52
+ `requirements.txt`, which turns a "might be missing" warning into a
53
+ confirmed pass or fail.
54
+
55
+ ## Adding custom checks
56
+
57
+ Create a new file in the `checks/` folder:
58
+
59
+ ```js
60
+ // checks/07-my-check.js
61
+ 'use strict';
62
+
63
+ const name = 'My Custom Check';
64
+
65
+ async function run(projectRoot) {
66
+ // Your check logic here
67
+ return {
68
+ status: 'pass', // 'pass' | 'warn' | 'fail' | 'skip'
69
+ message: 'Everything looks good',
70
+ fix: 'Suggested fix if status is warn or fail',
71
+ details: [
72
+ { file: 'some-file.js', line: 42, message: 'Detail about the issue' }
73
+ ],
74
+ };
75
+ }
76
+
77
+ module.exports = { name, run };
78
+ ```
79
+
80
+ Checks are loaded alphabetically, so prefix with a number to control order.
81
+
82
+ ## Testing
83
+
84
+ The project has a full test suite (49 tests covering all 6 checks) built
85
+ on Node's built-in test runner — no extra dependencies needed.
86
+
87
+ ```bash
88
+ npm test
89
+ ```
90
+
91
+ ## Contributing
92
+
93
+ Found a deploy-only failure that isn't on this list? Open an issue or a
94
+ PR — the project is intentionally narrow right now (six checks), and it
95
+ gets more useful with every real-world gotcha someone adds.
96
+
97
+ ## Author
98
+
99
+ Built by [Alok Kushwaha](https://github.com/Alok-Fusion) — NLP/ML
100
+ engineer, born out of a real afternoon lost to a deploy failure that
101
+ had nothing to do with the actual code.
102
+
103
+ ## License
104
+
105
+ MIT — see [LICENSE](./LICENSE) for details.
package/bin/cli.js CHANGED
@@ -1,48 +1,50 @@
1
- #!/usr/bin/env node
2
-
3
- 'use strict';
4
-
5
- const path = require('path');
6
- const { runAllChecks } = require('../index');
7
-
8
- const args = process.argv.slice(2);
9
-
10
- if (args.includes('--help') || args.includes('-h')) {
11
- console.log(`
12
- predeploy-check — catch deployment failures before they happen
13
-
14
- Usage:
15
- npx predeploy-check [directory]
16
-
17
- Arguments:
18
- directory Path to the project to scan (defaults to current directory)
19
-
20
- Options:
21
- -h, --help Show this help message
22
- -v, --version Show version number
23
-
24
- Checks:
25
- 1. Python + Render: Rust-compiled deps on unsupported Python versions
26
- 2. ESLint + Vercel: Peer-dependency mismatches in Next.js projects
27
- 3. Case Sensitivity: Import paths that differ from actual filenames
28
- 4. Missing Engines: No "engines" field in package.json
29
- 5. Env Var Check: process.env references missing from .env files
30
- 6. Render Start Cmd: Missing start command for Render deployments
31
- `);
32
- process.exit(0);
33
- }
34
-
35
- if (args.includes('--version') || args.includes('-v')) {
36
- const pkg = require('../package.json');
37
- console.log(pkg.version);
38
- process.exit(0);
39
- }
40
-
41
- const projectRoot = path.resolve(args[0] || process.cwd());
42
-
43
- runAllChecks(projectRoot).then((exitCode) => {
44
- process.exit(exitCode);
45
- }).catch((err) => {
46
- console.error('Fatal error:', err.message);
47
- process.exit(2);
48
- });
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const path = require('path');
6
+ const { runAllChecks } = require('../index');
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ if (args.includes('--help') || args.includes('-h')) {
11
+ console.log(`
12
+ predeploy-check — catch deployment failures before they happen
13
+
14
+ Usage:
15
+ npx predeploy-check [directory]
16
+
17
+ Arguments:
18
+ directory Path to the project to scan (defaults to current directory)
19
+
20
+ Options:
21
+ -h, --help Show this help message
22
+ -v, --version Show version number
23
+ --live Verify Python wheel availability against PyPI (slower, needs internet)
24
+
25
+ Checks:
26
+ 1. Python + Render: Rust-compiled deps on unsupported Python versions
27
+ 2. ESLint + Vercel: Peer-dependency mismatches in Next.js projects
28
+ 3. Case Sensitivity: Import paths that differ from actual filenames
29
+ 4. Missing Engines: No "engines" field in package.json
30
+ 5. Env Var Check: process.env references missing from .env files
31
+ 6. Render Start Cmd: Missing start command for Render deployments
32
+ `);
33
+ process.exit(0);
34
+ }
35
+
36
+ if (args.includes('--version') || args.includes('-v')) {
37
+ const pkg = require('../package.json');
38
+ console.log(pkg.version);
39
+ process.exit(0);
40
+ }
41
+
42
+ const projectRoot = path.resolve(args.find((a) => !a.startsWith('-')) || process.cwd());
43
+ const options = { live: args.includes('--live') };
44
+
45
+ runAllChecks(projectRoot, options).then((exitCode) => {
46
+ process.exit(exitCode);
47
+ }).catch((err) => {
48
+ console.error('Fatal error:', err.message);
49
+ process.exit(2);
50
+ });
@@ -1,123 +1,207 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- const name = 'Python + Render: Rust-compiled dependencies';
7
-
8
- // Packages known to have Rust-compiled components that may lack
9
- // prebuilt wheels for bleeding-edge Python versions.
10
- const RUST_DEP_PACKAGES = [
11
- 'pydantic',
12
- 'pydantic-core',
13
- 'fastapi',
14
- 'orjson',
15
- 'cryptography',
16
- 'tiktoken',
17
- 'tokenizers',
18
- 'safetensors',
19
- 'polars',
20
- 'ruff',
21
- 'rpds-py',
22
- 'jiter',
23
- ];
24
-
25
- /**
26
- * Parse a Python version string like "python-3.13.2" or "3.13" into
27
- * { major, minor } or null if unparseable.
28
- */
29
- function parsePythonVersion(raw) {
30
- const match = raw.match(/(\d+)\.(\d+)/);
31
- if (!match) return null;
32
- return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
33
- }
34
-
35
- /**
36
- * Parse requirements.txt and return an array of lowercase package names.
37
- */
38
- function parseRequirements(content) {
39
- return content
40
- .split('\n')
41
- .map((line) => line.trim())
42
- .filter((line) => line && !line.startsWith('#') && !line.startsWith('-'))
43
- .map((line) => {
44
- // Strip version specifiers, extras, etc.
45
- const name = line.split(/[>=<!~\[;@\s]/)[0];
46
- return name.toLowerCase().replace(/_/g, '-');
47
- })
48
- .filter(Boolean);
49
- }
50
-
51
- async function run(projectRoot) {
52
- const runtimePath = path.join(projectRoot, 'runtime.txt');
53
- const requirementsPath = path.join(projectRoot, 'requirements.txt');
54
-
55
- // Check if this is a Python project at all
56
- if (!fs.existsSync(runtimePath) && !fs.existsSync(requirementsPath)) {
57
- return {
58
- status: 'skip',
59
- message: `${name} no Python project detected`,
60
- };
61
- }
62
-
63
- // If there's no runtime.txt, we can't check the version
64
- if (!fs.existsSync(runtimePath)) {
65
- return {
66
- status: 'skip',
67
- message: `${name} — no runtime.txt found, skipping version check`,
68
- };
69
- }
70
-
71
- const runtimeContent = fs.readFileSync(runtimePath, 'utf-8').trim();
72
- const version = parsePythonVersion(runtimeContent);
73
-
74
- if (!version) {
75
- return {
76
- status: 'warn',
77
- message: `${name} could not parse Python version from runtime.txt`,
78
- fix: 'Ensure runtime.txt contains a valid version like "python-3.12.3"',
79
- details: [{ file: 'runtime.txt', message: `Content: "${runtimeContent}"` }],
80
- };
81
- }
82
-
83
- // Only flag Python >= 3.13
84
- if (version.major < 3 || (version.major === 3 && version.minor < 13)) {
85
- return {
86
- status: 'pass',
87
- message: `${name} — Python ${version.major}.${version.minor} is well-supported`,
88
- };
89
- }
90
-
91
- // Now check requirements.txt for Rust-compiled deps
92
- if (!fs.existsSync(requirementsPath)) {
93
- return {
94
- status: 'warn',
95
- message: `${name} — Python ${version.major}.${version.minor} detected but no requirements.txt found`,
96
- fix: 'Add a requirements.txt or pin Python to 3.12 in runtime.txt',
97
- details: [{ file: 'runtime.txt' }],
98
- };
99
- }
100
-
101
- const reqContent = fs.readFileSync(requirementsPath, 'utf-8');
102
- const packages = parseRequirements(reqContent);
103
- const flagged = packages.filter((pkg) => RUST_DEP_PACKAGES.includes(pkg));
104
-
105
- if (flagged.length === 0) {
106
- return {
107
- status: 'pass',
108
- message: `${name} Python ${version.major}.${version.minor} with no known Rust-compiled deps`,
109
- };
110
- }
111
-
112
- return {
113
- status: 'warn',
114
- message: `${name} — Python ${version.major}.${version.minor} with Rust-compiled dependencies`,
115
- fix: `Pin to Python 3.12 in runtime.txt ("python-3.12.7") or verify wheel availability for ${version.major}.${version.minor}`,
116
- details: flagged.map((pkg) => ({
117
- file: 'requirements.txt',
118
- message: `"${pkg}" has Rust-compiled components — prebuilt wheels may not exist for Python ${version.major}.${version.minor}`,
119
- })),
120
- };
121
- }
122
-
123
- module.exports = { name, run };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const name = 'Python + Render: Rust-compiled dependencies';
7
+
8
+ // Packages known to have Rust-compiled components that may lack
9
+ // prebuilt wheels for bleeding-edge Python versions.
10
+ const RUST_DEP_PACKAGES = [
11
+ 'pydantic',
12
+ 'pydantic-core',
13
+ 'fastapi',
14
+ 'orjson',
15
+ 'cryptography',
16
+ 'tiktoken',
17
+ 'tokenizers',
18
+ 'safetensors',
19
+ 'polars',
20
+ 'ruff',
21
+ 'rpds-py',
22
+ 'jiter',
23
+ ];
24
+
25
+ /**
26
+ * Parse a Python version string like "python-3.13.2" or "3.13" into
27
+ * { major, minor } or null if unparseable.
28
+ */
29
+ function parsePythonVersion(raw) {
30
+ const match = raw.match(/(\d+)\.(\d+)/);
31
+ if (!match) return null;
32
+ return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
33
+ }
34
+
35
+ /**
36
+ * Parse requirements.txt and return an array of { name, version } where
37
+ * version is the pinned version if one was specified with "==", else null.
38
+ */
39
+ function parseRequirements(content) {
40
+ return content
41
+ .split('\n')
42
+ .map((line) => line.trim())
43
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('-'))
44
+ .map((line) => {
45
+ const beforeSemicolon = line.split(';')[0].trim(); // strip env markers
46
+ const name = beforeSemicolon.split(/[>=<!~\[\s]/)[0].toLowerCase().replace(/_/g, '-');
47
+ const pinMatch = beforeSemicolon.match(/==\s*([\d][\w.]*)/);
48
+ return { name, version: pinMatch ? pinMatch[1] : null };
49
+ })
50
+ .filter((pkg) => pkg.name);
51
+ }
52
+
53
+ /**
54
+ * Query PyPI's JSON API to check whether a prebuilt wheel exists for the
55
+ * given package, version, and CPython minor version (e.g. "313" for 3.13).
56
+ * Returns true/false/null (null = couldn't determine, e.g. network error
57
+ * or unpinned version — caller should fall back to the static warning).
58
+ */
59
+ async function checkWheelAvailability(pkgName, pkgVersion, major, minor) {
60
+ if (!pkgVersion) return null; // no pinned version, can't query a specific release
61
+
62
+ if (typeof fetch !== 'function') {
63
+ // Node < 18 has no global fetch. Treat as unknown rather than crashing,
64
+ // so --live degrades to the same "unverified" warn path as a network failure.
65
+ return null;
66
+ }
67
+
68
+ const cpTag = `cp${major}${minor}`;
69
+
70
+ try {
71
+ const res = await fetch(`https://pypi.org/pypi/${pkgName}/${pkgVersion}/json`, {
72
+ signal: AbortSignal.timeout(5000),
73
+ });
74
+ if (!res.ok) return null;
75
+
76
+ const data = await res.json();
77
+ const urls = data.urls || [];
78
+
79
+ const hasMatchingWheel = urls.some(
80
+ (u) => u.packagetype === 'bdist_wheel' && u.filename.includes(`-${cpTag}-`)
81
+ );
82
+ return hasMatchingWheel;
83
+ } catch {
84
+ return null; // network error, timeout, bad JSON, etc. treat as unknown
85
+ }
86
+ }
87
+
88
+ async function run(projectRoot, options = {}) {
89
+ const runtimePath = path.join(projectRoot, 'runtime.txt');
90
+ const requirementsPath = path.join(projectRoot, 'requirements.txt');
91
+
92
+ // Check if this is a Python project at all
93
+ if (!fs.existsSync(runtimePath) && !fs.existsSync(requirementsPath)) {
94
+ return {
95
+ status: 'skip',
96
+ message: `${name} no Python project detected`,
97
+ };
98
+ }
99
+
100
+ // If there's no runtime.txt, we can't check the version
101
+ if (!fs.existsSync(runtimePath)) {
102
+ return {
103
+ status: 'skip',
104
+ message: `${name} — no runtime.txt found, skipping version check`,
105
+ };
106
+ }
107
+
108
+ const runtimeContent = fs.readFileSync(runtimePath, 'utf-8').trim();
109
+ const version = parsePythonVersion(runtimeContent);
110
+
111
+ if (!version) {
112
+ return {
113
+ status: 'warn',
114
+ message: `${name} — could not parse Python version from runtime.txt`,
115
+ fix: 'Ensure runtime.txt contains a valid version like "python-3.12.3"',
116
+ details: [{ file: 'runtime.txt', message: `Content: "${runtimeContent}"` }],
117
+ };
118
+ }
119
+
120
+ // Only flag Python >= 3.13
121
+ if (version.major < 3 || (version.major === 3 && version.minor < 13)) {
122
+ return {
123
+ status: 'pass',
124
+ message: `${name} — Python ${version.major}.${version.minor} is well-supported`,
125
+ };
126
+ }
127
+
128
+ // Now check requirements.txt for Rust-compiled deps
129
+ if (!fs.existsSync(requirementsPath)) {
130
+ return {
131
+ status: 'warn',
132
+ message: `${name} — Python ${version.major}.${version.minor} detected but no requirements.txt found`,
133
+ fix: 'Add a requirements.txt or pin Python to 3.12 in runtime.txt',
134
+ details: [{ file: 'runtime.txt' }],
135
+ };
136
+ }
137
+
138
+ const reqContent = fs.readFileSync(requirementsPath, 'utf-8');
139
+ const packages = parseRequirements(reqContent);
140
+ const flagged = packages.filter((pkg) => RUST_DEP_PACKAGES.includes(pkg.name));
141
+
142
+ if (flagged.length === 0) {
143
+ return {
144
+ status: 'pass',
145
+ message: `${name} — Python ${version.major}.${version.minor} with no known Rust-compiled deps`,
146
+ };
147
+ }
148
+
149
+ // ── Static mode (default): warn based on the known-package list alone ──
150
+ if (!options.live) {
151
+ return {
152
+ status: 'warn',
153
+ message: `${name} — Python ${version.major}.${version.minor} with Rust-compiled dependencies`,
154
+ fix: `Pin to Python 3.12 in runtime.txt ("python-3.12.7"), or re-run with --live to verify wheel availability for ${version.major}.${version.minor}`,
155
+ details: flagged.map((pkg) => ({
156
+ file: 'requirements.txt',
157
+ message: `"${pkg.name}" has Rust-compiled components — prebuilt wheels may not exist for Python ${version.major}.${version.minor}`,
158
+ })),
159
+ };
160
+ }
161
+
162
+ // ── Live mode: actually query PyPI to confirm wheel availability ──
163
+ const liveResults = await Promise.all(
164
+ flagged.map(async (pkg) => {
165
+ const available = await checkWheelAvailability(pkg.name, pkg.version, version.major, version.minor);
166
+ return { ...pkg, available };
167
+ })
168
+ );
169
+
170
+ const confirmedMissing = liveResults.filter((r) => r.available === false);
171
+ const unknown = liveResults.filter((r) => r.available === null);
172
+ const confirmedOk = liveResults.filter((r) => r.available === true);
173
+
174
+ if (confirmedMissing.length === 0 && unknown.length === 0) {
175
+ return {
176
+ status: 'pass',
177
+ message: `${name} — verified wheels exist on PyPI for Python ${version.major}.${version.minor}`,
178
+ details: confirmedOk.map((pkg) => ({
179
+ file: 'requirements.txt',
180
+ message: `"${pkg.name}${pkg.version ? `==${pkg.version}` : ''}" has a confirmed cp${version.major}${version.minor} wheel`,
181
+ })),
182
+ };
183
+ }
184
+
185
+ const details = [];
186
+ for (const pkg of confirmedMissing) {
187
+ details.push({
188
+ file: 'requirements.txt',
189
+ message: `"${pkg.name}${pkg.version ? `==${pkg.version}` : ''}" — confirmed: no wheel for Python ${version.major}.${version.minor}`,
190
+ });
191
+ }
192
+ for (const pkg of unknown) {
193
+ details.push({
194
+ file: 'requirements.txt',
195
+ message: `"${pkg.name}" — could not verify (unpinned version or PyPI lookup failed); treat as at-risk`,
196
+ });
197
+ }
198
+
199
+ return {
200
+ status: confirmedMissing.length > 0 ? 'fail' : 'warn',
201
+ message: `${name} — wheel availability ${confirmedMissing.length > 0 ? 'confirmed missing' : 'unverified'} for Python ${version.major}.${version.minor}`,
202
+ fix: `Pin to Python 3.12 in runtime.txt ("python-3.12.7"), or upgrade the affected package(s) to a version with a cp${version.major}${version.minor} wheel`,
203
+ details,
204
+ };
205
+ }
206
+
207
+ module.exports = { name, run };