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 +21 -0
- package/README.md +111 -0
- package/bin/carbonlint.js +3 -0
- package/package.json +49 -0
- package/src/cli.js +84 -0
- package/src/config.js +42 -0
- package/src/data.js +52 -0
- package/src/index.js +4 -0
- package/src/reporter.js +110 -0
- package/src/scanner.js +105 -0
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
|
+
[](https://www.npmjs.com/package/carbonlint-cli)
|
|
6
|
+
[](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)
|
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
package/src/reporter.js
ADDED
|
@@ -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
|
+
}
|