carbonlint-cli 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nishal K
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 ADDED
@@ -0,0 +1,111 @@
1
+ # carbonlint-cli
2
+
3
+ > Measure and track the carbon footprint of your software projects from the command line.
4
+
5
+ [![npm](https://img.shields.io/npm/v/carbonlint-cli)](https://www.npmjs.com/package/carbonlint-cli)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
+
8
+ **CarbonLint CLI** estimates the CO₂ emissions of your codebase by analyzing file types, sizes, and regional carbon intensity data. Use it locally or in CI/CD pipelines to set carbon budgets and catch regressions.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install -g carbonlint-cli
14
+ ```
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ # Initialize config in your project
20
+ carbonlint init
21
+
22
+ # Scan your project
23
+ carbonlint .
24
+
25
+ # Override region for a specific scan
26
+ carbonlint . --region EU-NORTH
27
+
28
+ # JSON output for CI pipelines
29
+ carbonlint . --json
30
+
31
+ # Fail the build if over budget (CI gate)
32
+ carbonlint . --ci --budget 50
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ | Command | Description |
38
+ |---------|-------------|
39
+ | `carbonlint init` | Create `.carbonlintrc.json` with default settings |
40
+ | `carbonlint [path]` | Scan project and generate carbon report |
41
+
42
+ ## Options
43
+
44
+ | Flag | Description | Default |
45
+ |------|-------------|---------|
46
+ | `-r, --region <region>` | Carbon intensity region | `GLOBAL-AVG` |
47
+ | `-b, --budget <grams>` | Carbon budget threshold in grams | `100` |
48
+ | `--profile <profile>` | Hardware profile (`laptop`/`desktop`/`server`) | `laptop` |
49
+ | `--json` | Output structured JSON | — |
50
+ | `--ci` | Exit code 1 if over budget | — |
51
+
52
+ ## Regions
53
+
54
+ | Key | Location | gCO₂/kWh |
55
+ |-----|----------|-----------|
56
+ | `EU-NORTH` | Sweden | 25 |
57
+ | `US-WEST` | California | 210 |
58
+ | `EU-WEST` | Ireland | 300 |
59
+ | `US-EAST` | Virginia | 380 |
60
+ | `ASIA-EAST` | Japan | 470 |
61
+ | `GLOBAL-AVG` | Global Average | 475 |
62
+ | `ASIA-SOUTH` | India | 700 |
63
+
64
+ ## CI/CD Integration
65
+
66
+ ### GitHub Actions
67
+
68
+ ```yaml
69
+ - name: Carbon Check
70
+ run: |
71
+ npm install -g carbonlint-cli
72
+ carbonlint . --ci --budget 50 --region US-WEST
73
+ ```
74
+
75
+ ### GitLab CI
76
+
77
+ ```yaml
78
+ carbon-check:
79
+ script:
80
+ - npm install -g carbonlint-cli
81
+ - carbonlint . --ci --budget 50
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ Create `.carbonlintrc.json` with `carbonlint init`:
87
+
88
+ ```json
89
+ {
90
+ "region": "GLOBAL-AVG",
91
+ "hardwareProfile": "laptop",
92
+ "pue": 1.0,
93
+ "maxCarbon": 100,
94
+ "failOnThreshold": false
95
+ }
96
+ ```
97
+
98
+ ## Programmatic API
99
+
100
+ ```js
101
+ import { scanProject, loadConfig } from 'carbonlint-cli';
102
+
103
+ const config = loadConfig('.');
104
+ const result = scanProject('.', config);
105
+ console.log(result.greenScore); // 0-100
106
+ console.log(result.carbon_grams);
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT © [Nishal K](https://github.com/nishal21)
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+ run();
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "carbonlint-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for measuring and tracking the carbon footprint of your software projects. Supports CI/CD integration with carbon budgets.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "carbonlint": "./bin/carbonlint.js"
8
+ },
9
+ "type": "module",
10
+ "scripts": {
11
+ "test": "node bin/carbonlint.js --help"
12
+ },
13
+ "keywords": [
14
+ "carbon",
15
+ "sustainability",
16
+ "green-coding",
17
+ "carbon-footprint",
18
+ "energy",
19
+ "co2",
20
+ "climate",
21
+ "linter",
22
+ "ci-cd",
23
+ "devtools"
24
+ ],
25
+ "author": "Nishal K <nishalamv@gmail.com>",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/nishal21/carbonlint"
30
+ },
31
+ "homepage": "https://nishal21.github.io/carbonlint/",
32
+ "bugs": {
33
+ "url": "https://github.com/nishal21/carbonlint/issues"
34
+ },
35
+ "engines": {
36
+ "node": ">=16.0.0"
37
+ },
38
+ "dependencies": {
39
+ "chalk": "^5.3.0",
40
+ "commander": "^12.1.0",
41
+ "ora": "^8.1.0"
42
+ },
43
+ "files": [
44
+ "bin/",
45
+ "src/",
46
+ "README.md",
47
+ "LICENSE"
48
+ ]
49
+ }
package/src/cli.js ADDED
@@ -0,0 +1,84 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { createConfig, loadConfig } from './config.js';
5
+ import { scanProject } from './scanner.js';
6
+ import { printReport, printJson } from './reporter.js';
7
+ import { REGIONS } from './data.js';
8
+
9
+ export function run() {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('carbonlint')
14
+ .description('Measure and track the carbon footprint of your software projects')
15
+ .version('1.0.0');
16
+
17
+ // ── Init command ──
18
+ program
19
+ .command('init')
20
+ .description('Create a .carbonlintrc.json config file in the current directory')
21
+ .action(() => {
22
+ const result = createConfig('.');
23
+ if (result.created) {
24
+ console.log(chalk.green('✓') + ` Created ${chalk.bold('.carbonlintrc.json')} in project root`);
25
+ console.log(chalk.dim(' Edit it to set region, budget, and hardware profile.'));
26
+ } else {
27
+ console.log(chalk.yellow('!') + ' .carbonlintrc.json already exists at ' + chalk.dim(result.path));
28
+ }
29
+ });
30
+
31
+ // ── Scan (default) command ──
32
+ program
33
+ .argument('[path]', 'Path to project directory to scan', '.')
34
+ .option('-r, --region <region>', 'Carbon intensity region', '')
35
+ .option('-b, --budget <grams>', 'Carbon budget in grams', '')
36
+ .option('--json', 'Output results as JSON')
37
+ .option('--ci', 'CI mode: exit with code 1 if over budget')
38
+ .option('--profile <profile>', 'Hardware profile (laptop/desktop/server)', '')
39
+ .action(async (targetPath, options) => {
40
+ // Load config, apply CLI overrides
41
+ const config = loadConfig(targetPath);
42
+ if (options.region) config.region = options.region;
43
+ if (options.budget) config.maxCarbon = Number(options.budget);
44
+ if (options.profile) config.hardwareProfile = options.profile;
45
+ if (options.ci) config.failOnThreshold = true;
46
+
47
+ // Validate region
48
+ const regionKey = config.region.toUpperCase();
49
+ if (!REGIONS[regionKey]) {
50
+ console.error(chalk.red('✗') + ` Unknown region: ${config.region}`);
51
+ console.error(chalk.dim(' Available: ' + Object.keys(REGIONS).join(', ')));
52
+ process.exit(1);
53
+ }
54
+
55
+ const spinner = options.json ? null : ora('Scanning project...').start();
56
+
57
+ try {
58
+ const result = scanProject(targetPath, config);
59
+
60
+ if (spinner) spinner.succeed(`Scanned ${result.totalFiles} files`);
61
+
62
+ if (options.json) {
63
+ printJson(result);
64
+ } else {
65
+ printReport(result);
66
+ }
67
+
68
+ // CI gate
69
+ if (config.failOnThreshold && result.overBudget) {
70
+ if (!options.json) {
71
+ console.log(chalk.red.bold(' ✗ CI CHECK FAILED') + chalk.dim(` — carbon ${result.carbon_grams.toFixed(4)}g exceeds budget ${result.budget}g`));
72
+ console.log();
73
+ }
74
+ process.exit(1);
75
+ }
76
+ } catch (err) {
77
+ if (spinner) spinner.fail('Scan failed');
78
+ console.error(chalk.red(err.message));
79
+ process.exit(1);
80
+ }
81
+ });
82
+
83
+ program.parse();
84
+ }
package/src/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DEFAULTS } from './data.js';
4
+
5
+ const CONFIG_FILE = '.carbonlintrc.json';
6
+
7
+ export function findConfig(dir) {
8
+ let current = path.resolve(dir);
9
+ while (true) {
10
+ const candidate = path.join(current, CONFIG_FILE);
11
+ if (fs.existsSync(candidate)) return candidate;
12
+ const parent = path.dirname(current);
13
+ if (parent === current) break;
14
+ current = parent;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ export function loadConfig(dir) {
20
+ const configPath = findConfig(dir);
21
+ if (!configPath) return { ...DEFAULTS };
22
+ try {
23
+ const raw = fs.readFileSync(configPath, 'utf-8');
24
+ return { ...DEFAULTS, ...JSON.parse(raw) };
25
+ } catch {
26
+ return { ...DEFAULTS };
27
+ }
28
+ }
29
+
30
+ export function createConfig(dir) {
31
+ const configPath = path.join(path.resolve(dir), CONFIG_FILE);
32
+ if (fs.existsSync(configPath)) return { created: false, path: configPath };
33
+ const config = {
34
+ region: DEFAULTS.region,
35
+ hardwareProfile: DEFAULTS.hardwareProfile,
36
+ pue: DEFAULTS.pue,
37
+ maxCarbon: DEFAULTS.maxCarbon,
38
+ failOnThreshold: DEFAULTS.failOnThreshold,
39
+ };
40
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
41
+ return { created: true, path: configPath };
42
+ }
package/src/data.js ADDED
@@ -0,0 +1,52 @@
1
+ // CarbonLint CLI — Carbon intensity data per region (gCO₂/kWh)
2
+ // Source: matches the Tauri desktop app's get_carbon_intensity_map()
3
+
4
+ export const REGIONS = {
5
+ 'US-WEST': { name: 'California, US', gco2_kwh: 210 },
6
+ 'US-EAST': { name: 'Virginia, US', gco2_kwh: 380 },
7
+ 'EU-WEST': { name: 'Ireland, EU', gco2_kwh: 300 },
8
+ 'EU-NORTH': { name: 'Sweden, EU', gco2_kwh: 25 },
9
+ 'ASIA-EAST': { name: 'Japan', gco2_kwh: 470 },
10
+ 'ASIA-SOUTH': { name: 'India', gco2_kwh: 700 },
11
+ 'GLOBAL-AVG': { name: 'Global Average', gco2_kwh: 475 },
12
+ };
13
+
14
+ // Hardware power profiles (watts)
15
+ export const HARDWARE = {
16
+ laptop: { cpu_tdp: 15, mem_per_gb: 0.3 },
17
+ desktop: { cpu_tdp: 65, mem_per_gb: 0.4 },
18
+ server: { cpu_tdp: 150, mem_per_gb: 0.5 },
19
+ };
20
+
21
+ // File type weights — how much relative energy different files consume during build
22
+ export const FILE_WEIGHTS = {
23
+ // Heavy compile / transpile
24
+ '.ts': 1.0, '.tsx': 1.0, '.jsx': 1.0,
25
+ '.rs': 1.2, '.cpp': 1.2, '.c': 1.0,
26
+ '.java': 1.1, '.go': 0.9, '.cs': 1.1,
27
+ // Normal
28
+ '.js': 0.6, '.mjs': 0.6, '.cjs': 0.6,
29
+ '.py': 0.5, '.rb': 0.5, '.php': 0.5,
30
+ // Styles / markup
31
+ '.css': 0.3, '.scss': 0.4, '.less': 0.4,
32
+ '.html': 0.2, '.vue': 0.8, '.svelte': 0.8,
33
+ // Config / docs (minimal)
34
+ '.json': 0.1, '.yaml': 0.1, '.yml': 0.1,
35
+ '.toml': 0.1, '.xml': 0.1, '.md': 0.05,
36
+ '.txt': 0.02, '.env': 0.01,
37
+ // Assets (energy comes from bundling/optimizing)
38
+ '.png': 0.15, '.jpg': 0.15, '.jpeg': 0.15,
39
+ '.gif': 0.15, '.svg': 0.1, '.webp': 0.1,
40
+ '.ico': 0.05, '.mp4': 0.2, '.webm': 0.2,
41
+ '.woff': 0.05, '.woff2': 0.05, '.ttf': 0.05,
42
+ };
43
+
44
+ // Default config values — matches Tauri's AppSettings defaults
45
+ export const DEFAULTS = {
46
+ region: 'GLOBAL-AVG',
47
+ hardwareProfile: 'laptop',
48
+ pue: 1.0,
49
+ maxCarbon: 100, // grams
50
+ maxEnergy: 0.5, // kWh
51
+ failOnThreshold: false,
52
+ };
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // CarbonLint CLI — Programmatic API
2
+ export { scanProject } from './scanner.js';
3
+ export { loadConfig, createConfig } from './config.js';
4
+ export { REGIONS, HARDWARE, FILE_WEIGHTS, DEFAULTS } from './data.js';
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+
3
+ const g = chalk.green;
4
+ const y = chalk.yellow;
5
+ const r = chalk.red;
6
+ const d = chalk.dim;
7
+ const b = chalk.bold;
8
+
9
+ export function printReport(result) {
10
+ const hr = d('─'.repeat(56));
11
+
12
+ console.log();
13
+ console.log(b(g(' 🌿 CarbonLint')) + d(' — Carbon Footprint Report'));
14
+ console.log(hr);
15
+ console.log();
16
+
17
+ // Green Score
18
+ const scoreColor = result.greenScore >= 80 ? g : result.greenScore >= 50 ? y : r;
19
+ const scoreBar = buildBar(result.greenScore);
20
+ console.log(` ${b('Green Score')} ${scoreColor(b(result.greenScore + '/100'))} ${scoreLabel(result.greenScore)}`);
21
+ console.log(` ${scoreBar}`);
22
+ console.log();
23
+
24
+ // Stats
25
+ console.log(` ${d('Files scanned')} ${b(result.totalFiles.toLocaleString())}`);
26
+ console.log(` ${d('Total size')} ${b(formatBytes(result.totalSize))}`);
27
+ console.log(` ${d('Region')} ${b(result.region.key)} ${d('(' + result.region.name + ', ' + result.region.gco2_kwh + ' gCO₂/kWh)')}`);
28
+ console.log(` ${d('Est. energy')} ${b(result.energy_kWh.toFixed(6))} ${d('kWh')}`);
29
+ console.log(` ${d('Est. CO₂')} ${carbonColor(result.carbon_grams, result.budget)(b(result.carbon_grams.toFixed(4) + 'g'))}`);
30
+ console.log(` ${d('Budget')} ${b(result.budget + 'g')} ${result.overBudget ? r('⚠ OVER BUDGET') : g('✓ within budget')}`);
31
+ console.log();
32
+
33
+ // Breakdown table
34
+ if (result.breakdown.length > 0) {
35
+ console.log(` ${b('File Breakdown')} ${d('(top 10 by weight)')}`);
36
+ console.log(` ${d('Ext'.padEnd(10))} ${'Count'.padStart(7)} ${'Size'.padStart(10)} ${'Weight'.padStart(10)}`);
37
+ console.log(` ${d('─'.repeat(42))}`);
38
+ for (const row of result.breakdown.slice(0, 10)) {
39
+ console.log(
40
+ ` ${(row.ext || '(none)').padEnd(10)} ${String(row.count).padStart(7)} ${formatBytes(row.size).padStart(10)} ${row.weight.toFixed(1).padStart(10)}`
41
+ );
42
+ }
43
+ console.log();
44
+ }
45
+
46
+ // Warnings
47
+ if (result.warnings.length > 0) {
48
+ console.log(` ${y(b('⚠ Warnings'))} ${d('(' + result.warnings.length + ')')}`);
49
+ for (const w of result.warnings.slice(0, 5)) {
50
+ console.log(` ${y('›')} ${w.message}`);
51
+ }
52
+ if (result.warnings.length > 5) {
53
+ console.log(` ${d(' ... and ' + (result.warnings.length - 5) + ' more')}`);
54
+ }
55
+ console.log();
56
+ }
57
+
58
+ console.log(hr);
59
+ console.log(d(' Powered by CarbonLint — https://nishal21.github.io/carbonlint/'));
60
+ console.log();
61
+ }
62
+
63
+ export function printJson(result) {
64
+ const output = {
65
+ version: '1.0.0',
66
+ greenScore: result.greenScore,
67
+ files: result.totalFiles,
68
+ totalSizeBytes: result.totalSize,
69
+ region: result.region.key,
70
+ regionName: result.region.name,
71
+ carbonIntensity_gCO2_kWh: result.region.gco2_kwh,
72
+ estimatedEnergy_kWh: Number(result.energy_kWh.toFixed(6)),
73
+ estimatedCarbon_grams: Number(result.carbon_grams.toFixed(4)),
74
+ budget_grams: result.budget,
75
+ overBudget: result.overBudget,
76
+ breakdown: result.breakdown,
77
+ warnings: result.warnings.map(w => w.message),
78
+ };
79
+ console.log(JSON.stringify(output, null, 2));
80
+ }
81
+
82
+ // ── Helpers ──
83
+
84
+ function buildBar(score) {
85
+ const width = 30;
86
+ const filled = Math.round((score / 100) * width);
87
+ const empty = width - filled;
88
+ const color = score >= 80 ? g : score >= 50 ? y : r;
89
+ return d('[') + color('█'.repeat(filled)) + d('░'.repeat(empty)) + d(']');
90
+ }
91
+
92
+ function scoreLabel(score) {
93
+ if (score >= 90) return g('(Excellent)');
94
+ if (score >= 75) return g('(Good)');
95
+ if (score >= 50) return y('(Fair)');
96
+ if (score >= 25) return y('(Needs Work)');
97
+ return r('(Poor)');
98
+ }
99
+
100
+ function carbonColor(grams, budget) {
101
+ if (grams > budget) return r;
102
+ if (grams > budget * 0.8) return y;
103
+ return g;
104
+ }
105
+
106
+ function formatBytes(bytes) {
107
+ if (bytes < 1024) return bytes + ' B';
108
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
109
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
110
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { FILE_WEIGHTS, REGIONS, HARDWARE } from './data.js';
4
+
5
+ // Directories to always skip
6
+ const SKIP_DIRS = new Set([
7
+ 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
8
+ '.next', '.nuxt', '.output', '__pycache__', '.venv', 'venv',
9
+ 'target', 'vendor', '.cache', '.parcel-cache', 'coverage',
10
+ '.turbo', '.vercel', '.netlify',
11
+ ]);
12
+
13
+ export function scanProject(targetPath, config = {}) {
14
+ const root = path.resolve(targetPath);
15
+ const files = [];
16
+ const warnings = [];
17
+
18
+ walk(root, files, warnings);
19
+
20
+ // Compute totals
21
+ const totalFiles = files.length;
22
+ const totalSize = files.reduce((s, f) => s + f.size, 0);
23
+ const totalWeight = files.reduce((s, f) => s + f.weight, 0);
24
+
25
+ // Estimate energy: weighted file score → simulated kWh
26
+ // Baseline: 1000 weight-units ≈ 0.01 kWh (a reasonable dev-build estimate)
27
+ const baseEnergy_kWh = (totalWeight / 1000) * 0.01;
28
+
29
+ // PUE factor (Power Usage Effectiveness — data centre overhead)
30
+ const pue = config.pue || 1.0;
31
+ const energy_kWh = baseEnergy_kWh * pue;
32
+
33
+ // Carbon
34
+ const regionKey = (config.region || 'GLOBAL-AVG').toUpperCase();
35
+ const region = REGIONS[regionKey] || REGIONS['GLOBAL-AVG'];
36
+ const carbon_grams = energy_kWh * region.gco2_kwh;
37
+
38
+ // Green Score (0-100) — lower carbon = higher score
39
+ const budget = config.maxCarbon || 100;
40
+ const ratio = carbon_grams / budget;
41
+ const greenScore = Math.max(0, Math.min(100, Math.round(100 - ratio * 50)));
42
+
43
+ // Breakdown by extension
44
+ const byExt = {};
45
+ for (const f of files) {
46
+ if (!byExt[f.ext]) byExt[f.ext] = { count: 0, size: 0, weight: 0 };
47
+ byExt[f.ext].count++;
48
+ byExt[f.ext].size += f.size;
49
+ byExt[f.ext].weight += f.weight;
50
+ }
51
+
52
+ // Sort by weight desc
53
+ const breakdown = Object.entries(byExt)
54
+ .sort((a, b) => b[1].weight - a[1].weight)
55
+ .map(([ext, data]) => ({ ext, ...data }));
56
+
57
+ // Heavy asset warnings
58
+ for (const f of files) {
59
+ if (f.size > 512 * 1024 && /\.(png|jpg|jpeg|gif|mp4|webm|bmp|tiff)$/i.test(f.ext)) {
60
+ warnings.push({
61
+ type: 'heavy-asset',
62
+ file: path.relative(root, f.path),
63
+ size: f.size,
64
+ message: `Heavy asset: ${path.relative(root, f.path)} (${(f.size / 1024).toFixed(0)} KB)`,
65
+ });
66
+ }
67
+ }
68
+
69
+ return {
70
+ root,
71
+ totalFiles,
72
+ totalSize,
73
+ totalWeight,
74
+ energy_kWh,
75
+ carbon_grams,
76
+ greenScore,
77
+ region: { key: regionKey, ...region },
78
+ budget,
79
+ overBudget: carbon_grams > budget,
80
+ breakdown,
81
+ warnings,
82
+ };
83
+ }
84
+
85
+ function walk(dir, files, warnings) {
86
+ let entries;
87
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
88
+ catch { return; }
89
+
90
+ for (const entry of entries) {
91
+ if (entry.name.startsWith('.') && entry.name !== '.env') continue;
92
+ const full = path.join(dir, entry.name);
93
+
94
+ if (entry.isDirectory()) {
95
+ if (SKIP_DIRS.has(entry.name)) continue;
96
+ walk(full, files, warnings);
97
+ } else if (entry.isFile()) {
98
+ const ext = path.extname(entry.name).toLowerCase();
99
+ let size;
100
+ try { size = fs.statSync(full).size; } catch { continue; }
101
+ const weight = (FILE_WEIGHTS[ext] || 0.05) * (size / 1024); // weight per KB
102
+ files.push({ path: full, ext, size, weight });
103
+ }
104
+ }
105
+ }