contextdiet 0.2.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/action.yml +29 -0
- package/bin/contextdiet.js +5 -0
- package/package.json +54 -0
- package/schema/contextdiet.schema.json +41 -0
- package/src/action.js +48 -0
- package/src/analyzers.js +222 -0
- package/src/cli.js +127 -0
- package/src/config.js +63 -0
- package/src/fixes.js +30 -0
- package/src/format.js +97 -0
- package/src/scanner.js +71 -0
- package/src/scoring.js +17 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to contextdiet are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 - 2026-05-19
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `init` command for creating a starter `contextdiet.config.json`.
|
|
10
|
+
- `--help` and `--version` CLI support.
|
|
11
|
+
- `contextdiet.config.json` support for `threshold`, `ignoredRules`, and rule `weight` overrides.
|
|
12
|
+
- `scan --sarif` for SARIF 2.1.0 output.
|
|
13
|
+
- Reusable GitHub Action with `root`, `threshold`, and `format` inputs.
|
|
14
|
+
- JSON schema for contextdiet configuration.
|
|
15
|
+
|
|
16
|
+
## 0.1.0 - 2026-05-19
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Initial `scan`, `score`, `badge`, and `fix --safe` CLI commands.
|
|
21
|
+
- Discovery for `AGENTS.md`, `CLAUDE.md`, Cursor rules, Copilot instructions, `SKILL.md`, `.mcp.json`, and `.cursor/mcp.json`.
|
|
22
|
+
- Deterministic findings for stale commands, stale paths, repeated instructions, conflicting instructions, skill metadata problems, risky MCP commands, and invalid MCP JSON.
|
|
23
|
+
- Strict CI threshold support with `--strict --threshold`.
|
|
24
|
+
- JSON output for `scan` and `score`.
|
|
25
|
+
- Launch README, CI workflow, docs, and noisy example fixture.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Contextdiet contributors
|
|
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,246 @@
|
|
|
1
|
+
# contextdiet
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Tehlikeli107/contextdiet/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Your coding agent is only as good as the context you feed it.
|
|
8
|
+
|
|
9
|
+
`contextdiet` scans `AGENTS.md`, `CLAUDE.md`, Cursor rules, Copilot instructions, Agent Skills, and MCP configs for stale, noisy, contradictory, or risky context.
|
|
10
|
+
|
|
11
|
+
Think Lighthouse for AI coding-agent context.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx github:Tehlikeli107/contextdiet scan --root .
|
|
15
|
+
npx github:Tehlikeli107/contextdiet score --root .
|
|
16
|
+
npx github:Tehlikeli107/contextdiet badge --root .
|
|
17
|
+
npx github:Tehlikeli107/contextdiet init --root .
|
|
18
|
+
npx github:Tehlikeli107/contextdiet fix --safe --root .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
After the npm package is published, the commands become:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx contextdiet scan
|
|
25
|
+
npx contextdiet score
|
|
26
|
+
npx contextdiet badge
|
|
27
|
+
npx contextdiet init
|
|
28
|
+
npx contextdiet fix --safe
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
AI coding agents are moving from demos into real repositories, but repo-level context can quietly make them worse:
|
|
34
|
+
|
|
35
|
+
- stale commands send agents down dead paths
|
|
36
|
+
- missing files waste tool calls
|
|
37
|
+
- repeated rules burn context
|
|
38
|
+
- conflicting instructions lower trust
|
|
39
|
+
- risky MCP commands expand blast radius
|
|
40
|
+
- bloated context increases token spend
|
|
41
|
+
|
|
42
|
+
`contextdiet` starts with deterministic local checks. No API keys, no network calls, no model judgment.
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
Scan the current repository:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
node ./bin/contextdiet.js scan --root .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Fail CI when the score drops below 90:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node ./bin/contextdiet.js scan --root . --strict --threshold 90
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Generate a README badge:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
node ./bin/contextdiet.js badge --root .
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Create a starter config:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node ./bin/contextdiet.js init --root .
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Try the intentionally noisy demo fixture:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
node ./bin/contextdiet.js scan --root examples/noisy-agent-context
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
| Command | Purpose |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `scan` | Print a full human-readable report |
|
|
81
|
+
| `score` | Print compact score output |
|
|
82
|
+
| `badge` | Print a Shields.io markdown badge |
|
|
83
|
+
| `init` | Create `contextdiet.config.json` if it does not exist |
|
|
84
|
+
| `fix --safe` | Apply conservative whitespace-only cleanup |
|
|
85
|
+
| `--help` | Print usage |
|
|
86
|
+
| `--version` | Print package version |
|
|
87
|
+
|
|
88
|
+
Common options:
|
|
89
|
+
|
|
90
|
+
| Option | Purpose |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `--root <path>` | Scan a specific repository root |
|
|
93
|
+
| `--json` | Print machine-readable JSON for `scan` and `score` |
|
|
94
|
+
| `--sarif` | Print SARIF 2.1.0 output for `scan` |
|
|
95
|
+
| `--strict` | Exit 1 when score is below threshold |
|
|
96
|
+
| `--threshold <0-100>` | Score threshold for strict mode, default `90` |
|
|
97
|
+
|
|
98
|
+
## What It Checks
|
|
99
|
+
|
|
100
|
+
| Surface | Files |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| Agent instructions | `AGENTS.md`, `CLAUDE.md` |
|
|
103
|
+
| IDE rules | `.cursor/rules/*.md`, `.cursor/rules/*.mdc` |
|
|
104
|
+
| Copilot | `.github/copilot-instructions.md` |
|
|
105
|
+
| Agent Skills | `**/SKILL.md` |
|
|
106
|
+
| MCP | `.mcp.json`, `.cursor/mcp.json` |
|
|
107
|
+
|
|
108
|
+
## Findings
|
|
109
|
+
|
|
110
|
+
The MVP detects:
|
|
111
|
+
|
|
112
|
+
- `missing-entrypoint`: no primary agent instruction surface
|
|
113
|
+
- `stale-command`: referenced `npm run` script does not exist
|
|
114
|
+
- `stale-path`: referenced local path does not exist
|
|
115
|
+
- `repeated-instruction`: exact repeated bullet instruction
|
|
116
|
+
- `conflicting-instruction`: simple positive/negative instruction conflict
|
|
117
|
+
- `skill-metadata`: missing `name` or `description` in `SKILL.md`
|
|
118
|
+
- `risky-mcp-command`: suspicious MCP command patterns
|
|
119
|
+
- `invalid-mcp-json`: invalid MCP JSON
|
|
120
|
+
|
|
121
|
+
## Example
|
|
122
|
+
|
|
123
|
+
```text
|
|
124
|
+
Contextdiet report
|
|
125
|
+
Score: 84/100
|
|
126
|
+
Files scanned: 1
|
|
127
|
+
Findings: 2
|
|
128
|
+
|
|
129
|
+
Findings:
|
|
130
|
+
- [warning] stale-command AGENTS.md: Referenced npm script "missing" is not defined in package.json.
|
|
131
|
+
- [warning] stale-path AGENTS.md: Referenced path "docs/missing.md" does not exist.
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## GitHub Actions
|
|
135
|
+
|
|
136
|
+
Use contextdiet as a reusable action:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
name: Agent Context
|
|
140
|
+
|
|
141
|
+
on:
|
|
142
|
+
pull_request:
|
|
143
|
+
push:
|
|
144
|
+
branches: [main]
|
|
145
|
+
|
|
146
|
+
jobs:
|
|
147
|
+
contextdiet:
|
|
148
|
+
runs-on: ubuntu-latest
|
|
149
|
+
steps:
|
|
150
|
+
- uses: actions/checkout@v5
|
|
151
|
+
- uses: Tehlikeli107/contextdiet@v0.2.0
|
|
152
|
+
with:
|
|
153
|
+
root: .
|
|
154
|
+
threshold: 90
|
|
155
|
+
format: text
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or run the CLI directly:
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
name: Agent Context
|
|
162
|
+
|
|
163
|
+
on:
|
|
164
|
+
pull_request:
|
|
165
|
+
push:
|
|
166
|
+
branches: [main]
|
|
167
|
+
|
|
168
|
+
jobs:
|
|
169
|
+
contextdiet:
|
|
170
|
+
runs-on: ubuntu-latest
|
|
171
|
+
steps:
|
|
172
|
+
- uses: actions/checkout@v5
|
|
173
|
+
- uses: actions/setup-node@v5
|
|
174
|
+
with:
|
|
175
|
+
node-version: 24
|
|
176
|
+
- run: npx github:Tehlikeli107/contextdiet scan --root . --strict --threshold 90
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Machine Output
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npx github:Tehlikeli107/contextdiet scan --root . --json
|
|
183
|
+
npx github:Tehlikeli107/contextdiet score --root . --json
|
|
184
|
+
npx github:Tehlikeli107/contextdiet scan --root . --sarif > contextdiet.sarif
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Configuration
|
|
188
|
+
|
|
189
|
+
Create `contextdiet.config.json` in the repository root with `contextdiet init`, or write it manually:
|
|
190
|
+
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
"$schema": "./schema/contextdiet.schema.json",
|
|
194
|
+
"threshold": 90,
|
|
195
|
+
"ignoredRules": [],
|
|
196
|
+
"rules": {
|
|
197
|
+
"stale-command": {
|
|
198
|
+
"weight": 8
|
|
199
|
+
},
|
|
200
|
+
"risky-mcp-command": {
|
|
201
|
+
"weight": 10
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
CLI `--threshold` overrides the config threshold. `ignoredRules` removes matching findings before scoring.
|
|
208
|
+
|
|
209
|
+
## Safe Fixes
|
|
210
|
+
|
|
211
|
+
`fix --safe` only applies low-risk formatting cleanup:
|
|
212
|
+
|
|
213
|
+
- trims trailing whitespace
|
|
214
|
+
- collapses more than two consecutive blank lines
|
|
215
|
+
|
|
216
|
+
It does not delete instructions, rewrite MCP config, change permissions, or invent new agent docs.
|
|
217
|
+
|
|
218
|
+
## Local Development
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
npm test
|
|
222
|
+
npm run scan
|
|
223
|
+
npm run score
|
|
224
|
+
npm run badge
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Roadmap
|
|
228
|
+
|
|
229
|
+
- GitHub Action annotations
|
|
230
|
+
- SARIF upload workflow examples
|
|
231
|
+
- session-log analysis for Claude Code, Codex, Cursor, and Gemini CLI
|
|
232
|
+
- before/after context benchmarks
|
|
233
|
+
- safer skill and MCP lifecycle checks
|
|
234
|
+
- team policies for agent context drift
|
|
235
|
+
|
|
236
|
+
## Contributing
|
|
237
|
+
|
|
238
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Keep rules deterministic, local-first, and covered by tests.
|
|
239
|
+
|
|
240
|
+
## Security
|
|
241
|
+
|
|
242
|
+
See [SECURITY.md](SECURITY.md). Please report suspected vulnerabilities privately.
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
MIT
|
package/action.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: contextdiet
|
|
2
|
+
description: Scan AI coding-agent context quality in CI
|
|
3
|
+
author: Tehlikeli107
|
|
4
|
+
|
|
5
|
+
inputs:
|
|
6
|
+
root:
|
|
7
|
+
description: Repository root to scan
|
|
8
|
+
required: false
|
|
9
|
+
default: "."
|
|
10
|
+
threshold:
|
|
11
|
+
description: Minimum score required for success
|
|
12
|
+
required: false
|
|
13
|
+
default: "90"
|
|
14
|
+
format:
|
|
15
|
+
description: Output format: text, json, or sarif
|
|
16
|
+
required: false
|
|
17
|
+
default: "text"
|
|
18
|
+
|
|
19
|
+
outputs:
|
|
20
|
+
exit-code:
|
|
21
|
+
description: contextdiet CLI exit code
|
|
22
|
+
|
|
23
|
+
runs:
|
|
24
|
+
using: node20
|
|
25
|
+
main: src/action.js
|
|
26
|
+
|
|
27
|
+
branding:
|
|
28
|
+
icon: activity
|
|
29
|
+
color: green
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "contextdiet",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Measure, trim, and prove AI coding-agent context quality.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Tehlikeli107/contextdiet.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Tehlikeli107/contextdiet#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Tehlikeli107/contextdiet/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"contextdiet": "bin/contextdiet.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"action.yml",
|
|
19
|
+
"bin",
|
|
20
|
+
"schema",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test",
|
|
28
|
+
"scan": "node ./bin/contextdiet.js scan --root .",
|
|
29
|
+
"score": "node ./bin/contextdiet.js score --root .",
|
|
30
|
+
"badge": "node ./bin/contextdiet.js badge --root ."
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"agent-context",
|
|
34
|
+
"agent-lint",
|
|
35
|
+
"agents",
|
|
36
|
+
"AGENTS.md",
|
|
37
|
+
"ai-coding",
|
|
38
|
+
"Claude",
|
|
39
|
+
"claude-code",
|
|
40
|
+
"Codex",
|
|
41
|
+
"Cursor",
|
|
42
|
+
"MCP",
|
|
43
|
+
"model-context-protocol",
|
|
44
|
+
"context-engineering",
|
|
45
|
+
"linter"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/Tehlikeli107/contextdiet/schema/contextdiet.schema.json",
|
|
4
|
+
"title": "contextdiet configuration",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"threshold": {
|
|
12
|
+
"type": "integer",
|
|
13
|
+
"minimum": 0,
|
|
14
|
+
"maximum": 100,
|
|
15
|
+
"default": 90
|
|
16
|
+
},
|
|
17
|
+
"ignoredRules": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "string"
|
|
21
|
+
},
|
|
22
|
+
"uniqueItems": true,
|
|
23
|
+
"default": []
|
|
24
|
+
},
|
|
25
|
+
"rules": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"weight": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 0,
|
|
34
|
+
"maximum": 100
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"default": {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/action.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { runCli } from './cli.js';
|
|
5
|
+
|
|
6
|
+
export function buildActionArgs(env = process.env) {
|
|
7
|
+
const root = env.INPUT_ROOT || '.';
|
|
8
|
+
const threshold = env.INPUT_THRESHOLD || '90';
|
|
9
|
+
const format = env.INPUT_FORMAT || 'text';
|
|
10
|
+
const args = ['scan', '--root', root, '--strict', '--threshold', threshold];
|
|
11
|
+
|
|
12
|
+
if (format === 'sarif') {
|
|
13
|
+
args.push('--sarif');
|
|
14
|
+
} else if (format === 'json') {
|
|
15
|
+
args.push('--json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function writeOutput(name, value, env = process.env) {
|
|
22
|
+
if (!env.GITHUB_OUTPUT) return;
|
|
23
|
+
await appendFile(env.GITHUB_OUTPUT, `${name}=${value}\n`, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runAction(env = process.env) {
|
|
27
|
+
const output = [];
|
|
28
|
+
const errors = [];
|
|
29
|
+
const result = await runCli(buildActionArgs(env), {
|
|
30
|
+
stdout: (line) => output.push(line),
|
|
31
|
+
stderr: (line) => errors.push(line)
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
for (const line of output) console.log(line);
|
|
35
|
+
for (const line of errors) console.error(line);
|
|
36
|
+
|
|
37
|
+
await writeOutput('exit-code', String(result.exitCode), env);
|
|
38
|
+
process.exitCode = result.exitCode;
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isDirectRun(metaUrl, argvPath) {
|
|
43
|
+
return fileURLToPath(metaUrl) === fileURLToPath(pathToFileURL(argvPath));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isDirectRun(import.meta.url, process.argv[1])) {
|
|
47
|
+
await runAction();
|
|
48
|
+
}
|
package/src/analyzers.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const ENTRYPOINT_KINDS = new Set(['agents', 'claude', 'copilot', 'cursor-rule']);
|
|
5
|
+
const PATH_REFERENCE_PATTERN = /`([^`\n]+)`|\[[^\]]+\]\(([^)\n]+)\)/g;
|
|
6
|
+
const NPM_RUN_PATTERN = /\bnpm\s+run\s+([A-Za-z0-9:_-]+)/g;
|
|
7
|
+
const RISKY_COMMAND_PATTERN = /\b(curl|wget)\b.+\|\s*(sh|bash)|rm\s+-rf|Invoke-WebRequest.+iex|iwr.+iex/i;
|
|
8
|
+
|
|
9
|
+
function finding(ruleId, severity, file, message, weight) {
|
|
10
|
+
return {
|
|
11
|
+
ruleId,
|
|
12
|
+
severity,
|
|
13
|
+
file: file?.relativePath ?? file?.path ?? '<repository>',
|
|
14
|
+
message,
|
|
15
|
+
weight
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function pathExists(path) {
|
|
20
|
+
try {
|
|
21
|
+
await access(path);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readPackageScripts(root) {
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(join(root, 'package.json'), 'utf8');
|
|
31
|
+
const parsed = JSON.parse(content);
|
|
32
|
+
return parsed.scripts && typeof parsed.scripts === 'object' ? parsed.scripts : {};
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineInstructions(content) {
|
|
39
|
+
return content
|
|
40
|
+
.split(/\r?\n/)
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => /^[-*]\s+/.test(line))
|
|
43
|
+
.map((line) => line.replace(/^[-*]\s+/, '').trim())
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizedInstruction(instruction) {
|
|
48
|
+
return instruction
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/\b(always|must|should|please|do not|don't|never|avoid)\b/g, '')
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasNegativeCue(instruction) {
|
|
56
|
+
return /\b(do not|don't|never|avoid)\b/i.test(instruction);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasPositiveCue(instruction) {
|
|
60
|
+
return /\b(always|must|should)\b/i.test(instruction);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function analyzeRepeatedAndConflictingInstructions(file) {
|
|
64
|
+
const findings = [];
|
|
65
|
+
const instructions = lineInstructions(file.content);
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
const polarityByInstruction = new Map();
|
|
68
|
+
|
|
69
|
+
for (const instruction of instructions) {
|
|
70
|
+
const exact = instruction.toLowerCase();
|
|
71
|
+
if (seen.has(exact)) {
|
|
72
|
+
findings.push(finding(
|
|
73
|
+
'repeated-instruction',
|
|
74
|
+
'warning',
|
|
75
|
+
file,
|
|
76
|
+
`Repeated instruction: "${instruction}"`,
|
|
77
|
+
4
|
|
78
|
+
));
|
|
79
|
+
}
|
|
80
|
+
seen.add(exact);
|
|
81
|
+
|
|
82
|
+
const normalized = normalizedInstruction(instruction);
|
|
83
|
+
if (!normalized) continue;
|
|
84
|
+
|
|
85
|
+
const polarity = hasNegativeCue(instruction) ? 'negative' : hasPositiveCue(instruction) ? 'positive' : 'neutral';
|
|
86
|
+
const previous = polarityByInstruction.get(normalized);
|
|
87
|
+
if (previous && previous !== polarity && polarity !== 'neutral' && previous !== 'neutral') {
|
|
88
|
+
findings.push(finding(
|
|
89
|
+
'conflicting-instruction',
|
|
90
|
+
'warning',
|
|
91
|
+
file,
|
|
92
|
+
`Conflicting instruction about "${normalized}"`,
|
|
93
|
+
7
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
polarityByInstruction.set(normalized, polarity);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function analyzeCommandReferences(root, file) {
|
|
103
|
+
const findings = [];
|
|
104
|
+
const scripts = await readPackageScripts(root);
|
|
105
|
+
const matches = file.content.matchAll(NPM_RUN_PATTERN);
|
|
106
|
+
|
|
107
|
+
for (const match of matches) {
|
|
108
|
+
const scriptName = match[1];
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
|
|
110
|
+
findings.push(finding(
|
|
111
|
+
'stale-command',
|
|
112
|
+
'warning',
|
|
113
|
+
file,
|
|
114
|
+
`Referenced npm script "${scriptName}" is not defined in package.json.`,
|
|
115
|
+
8
|
|
116
|
+
));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function analyzePathReferences(root, file) {
|
|
124
|
+
const findings = [];
|
|
125
|
+
const matches = file.content.matchAll(PATH_REFERENCE_PATTERN);
|
|
126
|
+
|
|
127
|
+
for (const match of matches) {
|
|
128
|
+
const reference = match[1] ?? match[2];
|
|
129
|
+
if (!reference) continue;
|
|
130
|
+
if (/^(https?:|mailto:|#)/i.test(reference)) continue;
|
|
131
|
+
if (reference.startsWith('npm run ')) continue;
|
|
132
|
+
if (/\s/.test(reference)) continue;
|
|
133
|
+
if (!/[/.]/.test(reference)) continue;
|
|
134
|
+
|
|
135
|
+
const cleanReference = reference.replace(/^\.?\//, '');
|
|
136
|
+
const candidate = join(root, cleanReference);
|
|
137
|
+
if (!(await pathExists(candidate))) {
|
|
138
|
+
findings.push(finding(
|
|
139
|
+
'stale-path',
|
|
140
|
+
'warning',
|
|
141
|
+
file,
|
|
142
|
+
`Referenced path "${reference}" does not exist.`,
|
|
143
|
+
8
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return findings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function analyzeSkillMetadata(file) {
|
|
152
|
+
if (file.kind !== 'skill') return [];
|
|
153
|
+
const match = file.content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
154
|
+
const metadata = match?.[1] ?? '';
|
|
155
|
+
const hasName = /^name:\s*\S+/m.test(metadata);
|
|
156
|
+
const hasDescription = /^description:\s*\S+/m.test(metadata);
|
|
157
|
+
|
|
158
|
+
if (hasName && hasDescription) return [];
|
|
159
|
+
|
|
160
|
+
return [finding(
|
|
161
|
+
'skill-metadata',
|
|
162
|
+
'warning',
|
|
163
|
+
file,
|
|
164
|
+
'SKILL.md frontmatter should include name and description.',
|
|
165
|
+
6
|
|
166
|
+
)];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function analyzeMcpConfig(file) {
|
|
170
|
+
if (file.kind !== 'mcp') return [];
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(file.content);
|
|
174
|
+
const servers = parsed.mcpServers && typeof parsed.mcpServers === 'object' ? parsed.mcpServers : {};
|
|
175
|
+
return Object.entries(servers).flatMap(([name, server]) => {
|
|
176
|
+
const command = String(server.command ?? '');
|
|
177
|
+
const args = Array.isArray(server.args) ? server.args.map(String).join(' ') : '';
|
|
178
|
+
const commandLine = `${command} ${args}`.trim();
|
|
179
|
+
if (!RISKY_COMMAND_PATTERN.test(commandLine)) return [];
|
|
180
|
+
|
|
181
|
+
return [finding(
|
|
182
|
+
'risky-mcp-command',
|
|
183
|
+
'error',
|
|
184
|
+
file,
|
|
185
|
+
`MCP server "${name}" uses a risky command pattern.`,
|
|
186
|
+
10
|
|
187
|
+
)];
|
|
188
|
+
});
|
|
189
|
+
} catch {
|
|
190
|
+
return [finding(
|
|
191
|
+
'invalid-mcp-json',
|
|
192
|
+
'error',
|
|
193
|
+
file,
|
|
194
|
+
'MCP config is not valid JSON.',
|
|
195
|
+
10
|
|
196
|
+
)];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function analyzeFiles(root, files) {
|
|
201
|
+
const findings = [];
|
|
202
|
+
|
|
203
|
+
if (!files.some((file) => ENTRYPOINT_KINDS.has(file.kind))) {
|
|
204
|
+
findings.push(finding(
|
|
205
|
+
'missing-entrypoint',
|
|
206
|
+
'warning',
|
|
207
|
+
null,
|
|
208
|
+
'No AGENTS.md, CLAUDE.md, Cursor rule, or Copilot instructions file found.',
|
|
209
|
+
10
|
|
210
|
+
));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const file of files) {
|
|
214
|
+
findings.push(...analyzeRepeatedAndConflictingInstructions(file));
|
|
215
|
+
findings.push(...await analyzeCommandReferences(root, file));
|
|
216
|
+
findings.push(...await analyzePathReferences(root, file));
|
|
217
|
+
findings.push(...analyzeSkillMetadata(file));
|
|
218
|
+
findings.push(...analyzeMcpConfig(file));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return findings;
|
|
222
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { applySafeFixes } from './fixes.js';
|
|
6
|
+
import { formatBadge, formatSarif, formatScore, formatTextReport, publicScanResult } from './format.js';
|
|
7
|
+
import { scanRepository } from './scanner.js';
|
|
8
|
+
import { writeDefaultConfig } from './config.js';
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage:
|
|
11
|
+
contextdiet scan [--root <path>] [--json]
|
|
12
|
+
contextdiet score [--root <path>] [--json]
|
|
13
|
+
contextdiet badge [--root <path>]
|
|
14
|
+
contextdiet init [--root <path>]
|
|
15
|
+
contextdiet fix --safe [--root <path>]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--strict Exit 1 when score is below threshold
|
|
19
|
+
--threshold <0-100> Score threshold for --strict (default: 90)
|
|
20
|
+
--sarif Print SARIF 2.1.0 output for scan
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function packageVersion() {
|
|
24
|
+
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
25
|
+
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
|
26
|
+
return packageJson.version;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const command = argv[0] ?? 'scan';
|
|
31
|
+
const options = {
|
|
32
|
+
root: process.cwd(),
|
|
33
|
+
json: false,
|
|
34
|
+
safe: false,
|
|
35
|
+
strict: false,
|
|
36
|
+
thresholdPassed: false,
|
|
37
|
+
sarif: false,
|
|
38
|
+
threshold: 90
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
42
|
+
const arg = argv[index];
|
|
43
|
+
if (arg === '--root') {
|
|
44
|
+
options.root = argv[index + 1];
|
|
45
|
+
index += 1;
|
|
46
|
+
} else if (arg === '--json') {
|
|
47
|
+
options.json = true;
|
|
48
|
+
} else if (arg === '--safe') {
|
|
49
|
+
options.safe = true;
|
|
50
|
+
} else if (arg === '--strict') {
|
|
51
|
+
options.strict = true;
|
|
52
|
+
} else if (arg === '--threshold') {
|
|
53
|
+
options.threshold = Number.parseInt(argv[index + 1], 10);
|
|
54
|
+
options.thresholdPassed = true;
|
|
55
|
+
index += 1;
|
|
56
|
+
} else if (arg === '--sarif') {
|
|
57
|
+
options.sarif = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { command, options };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runCli(argv, io = {}) {
|
|
65
|
+
const stdout = io.stdout ?? console.log;
|
|
66
|
+
const stderr = io.stderr ?? console.error;
|
|
67
|
+
const { command, options } = parseArgs(argv);
|
|
68
|
+
|
|
69
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
70
|
+
stdout(USAGE.trimEnd());
|
|
71
|
+
return { exitCode: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (command === 'version' || command === '--version' || command === '-v') {
|
|
75
|
+
stdout(await packageVersion());
|
|
76
|
+
return { exitCode: 0 };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!Number.isInteger(options.threshold) || options.threshold < 0 || options.threshold > 100) {
|
|
80
|
+
stderr('Error: threshold must be an integer from 0 to 100.');
|
|
81
|
+
stderr(USAGE.trimEnd());
|
|
82
|
+
return { exitCode: 2 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (command === 'scan') {
|
|
86
|
+
const scan = await scanRepository(options.root);
|
|
87
|
+
const threshold = options.thresholdPassed ? options.threshold : scan.config.threshold;
|
|
88
|
+
const output = options.sarif
|
|
89
|
+
? JSON.stringify(formatSarif(scan), null, 2)
|
|
90
|
+
: options.json
|
|
91
|
+
? JSON.stringify(publicScanResult(scan), null, 2)
|
|
92
|
+
: formatTextReport(scan);
|
|
93
|
+
stdout(output);
|
|
94
|
+
return { exitCode: options.strict && scan.score.score < threshold ? 1 : 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (command === 'score') {
|
|
98
|
+
const scan = await scanRepository(options.root);
|
|
99
|
+
const threshold = options.thresholdPassed ? options.threshold : scan.config.threshold;
|
|
100
|
+
stdout(options.json ? JSON.stringify(scan.score, null, 2) : formatScore(scan));
|
|
101
|
+
return { exitCode: options.strict && scan.score.score < threshold ? 1 : 0 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (command === 'badge') {
|
|
105
|
+
const scan = await scanRepository(options.root);
|
|
106
|
+
stdout(formatBadge(scan));
|
|
107
|
+
return { exitCode: 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (command === 'init') {
|
|
111
|
+
const result = await writeDefaultConfig(options.root);
|
|
112
|
+
stdout(result.created ? 'Created contextdiet.config.json' : 'contextdiet.config.json already exists');
|
|
113
|
+
return { exitCode: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (command === 'fix' && options.safe) {
|
|
117
|
+
const result = await applySafeFixes(options.root);
|
|
118
|
+
stdout(`Changed files: ${result.changedFiles}`);
|
|
119
|
+
for (const file of result.files) {
|
|
120
|
+
stdout(`- ${file}`);
|
|
121
|
+
}
|
|
122
|
+
return { exitCode: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stderr(USAGE.trimEnd());
|
|
126
|
+
return { exitCode: 2 };
|
|
127
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
threshold: 90,
|
|
6
|
+
ignoredRules: [],
|
|
7
|
+
rules: {}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function defaultConfigText() {
|
|
11
|
+
return `${JSON.stringify({
|
|
12
|
+
$schema: './schema/contextdiet.schema.json',
|
|
13
|
+
threshold: 90,
|
|
14
|
+
ignoredRules: [],
|
|
15
|
+
rules: {}
|
|
16
|
+
}, null, 2)}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeConfig(config) {
|
|
20
|
+
return {
|
|
21
|
+
threshold: Number.isInteger(config.threshold) ? config.threshold : DEFAULT_CONFIG.threshold,
|
|
22
|
+
ignoredRules: Array.isArray(config.ignoredRules) ? config.ignoredRules.map(String) : [],
|
|
23
|
+
rules: config.rules && typeof config.rules === 'object' ? config.rules : {}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadConfig(root) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await readFile(join(root, 'contextdiet.config.json'), 'utf8');
|
|
30
|
+
return normalizeConfig(JSON.parse(content));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT') return { ...DEFAULT_CONFIG };
|
|
33
|
+
throw new Error(`Failed to read contextdiet.config.json: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function writeDefaultConfig(root) {
|
|
38
|
+
const path = join(root, 'contextdiet.config.json');
|
|
39
|
+
try {
|
|
40
|
+
await readFile(path, 'utf8');
|
|
41
|
+
return { path, created: false };
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT') throw error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await writeFile(path, defaultConfigText(), 'utf8');
|
|
47
|
+
return { path, created: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyConfigToFindings(findings, config) {
|
|
51
|
+
const ignoredRules = new Set(config.ignoredRules);
|
|
52
|
+
|
|
53
|
+
return findings
|
|
54
|
+
.filter((finding) => !ignoredRules.has(finding.ruleId))
|
|
55
|
+
.map((finding) => {
|
|
56
|
+
const ruleConfig = config.rules[finding.ruleId];
|
|
57
|
+
if (!ruleConfig || !Number.isInteger(ruleConfig.weight)) return finding;
|
|
58
|
+
return {
|
|
59
|
+
...finding,
|
|
60
|
+
weight: ruleConfig.weight
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/fixes.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { discoverContextFiles } from './scanner.js';
|
|
4
|
+
|
|
5
|
+
export function safeFixContent(content) {
|
|
6
|
+
return content
|
|
7
|
+
.replace(/\r\n/g, '\n')
|
|
8
|
+
.split('\n')
|
|
9
|
+
.map((line) => line.replace(/[ \t]+$/g, ''))
|
|
10
|
+
.join('\n')
|
|
11
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function applySafeFixes(root) {
|
|
15
|
+
const files = await discoverContextFiles(root);
|
|
16
|
+
const changed = [];
|
|
17
|
+
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
const fixed = safeFixContent(file.content);
|
|
20
|
+
if (fixed === file.content) continue;
|
|
21
|
+
|
|
22
|
+
await writeFile(file.path, fixed, 'utf8');
|
|
23
|
+
changed.push(file.relativePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
changedFiles: changed.length,
|
|
28
|
+
files: changed
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export function publicScanResult(scan) {
|
|
2
|
+
return {
|
|
3
|
+
root: scan.root,
|
|
4
|
+
score: scan.score,
|
|
5
|
+
files: scan.files.map((file) => ({
|
|
6
|
+
path: file.relativePath,
|
|
7
|
+
kind: file.kind
|
|
8
|
+
})),
|
|
9
|
+
findings: scan.findings
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatTextReport(scan) {
|
|
14
|
+
const lines = [
|
|
15
|
+
'Contextdiet report',
|
|
16
|
+
`Score: ${scan.score.score}/${scan.score.maxScore}`,
|
|
17
|
+
`Files scanned: ${scan.files.length}`,
|
|
18
|
+
`Findings: ${scan.findings.length}`
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
if (scan.findings.length > 0) {
|
|
22
|
+
lines.push('', 'Findings:');
|
|
23
|
+
for (const finding of scan.findings) {
|
|
24
|
+
lines.push(`- [${finding.severity}] ${finding.ruleId} ${finding.file}: ${finding.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatScore(scan) {
|
|
32
|
+
return [
|
|
33
|
+
`Score: ${scan.score.score}/${scan.score.maxScore}`,
|
|
34
|
+
`Findings: ${scan.score.findings}`
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function badgeColor(score) {
|
|
39
|
+
if (score >= 90) return 'brightgreen';
|
|
40
|
+
if (score >= 75) return 'yellow';
|
|
41
|
+
if (score >= 50) return 'orange';
|
|
42
|
+
return 'red';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatBadge(scan) {
|
|
46
|
+
const score = scan.score.score;
|
|
47
|
+
const maxScore = scan.score.maxScore;
|
|
48
|
+
const encodedValue = encodeURIComponent(`${score}/${maxScore}`);
|
|
49
|
+
return `})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function levelForSeverity(severity) {
|
|
53
|
+
if (severity === 'error') return 'error';
|
|
54
|
+
if (severity === 'warning') return 'warning';
|
|
55
|
+
return 'note';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function formatSarif(scan) {
|
|
59
|
+
const ruleIds = [...new Set(scan.findings.map((finding) => finding.ruleId))].sort();
|
|
60
|
+
return {
|
|
61
|
+
version: '2.1.0',
|
|
62
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
63
|
+
runs: [
|
|
64
|
+
{
|
|
65
|
+
tool: {
|
|
66
|
+
driver: {
|
|
67
|
+
name: 'contextdiet',
|
|
68
|
+
informationUri: 'https://github.com/Tehlikeli107/contextdiet',
|
|
69
|
+
rules: ruleIds.map((ruleId) => ({
|
|
70
|
+
id: ruleId,
|
|
71
|
+
name: ruleId,
|
|
72
|
+
shortDescription: {
|
|
73
|
+
text: ruleId
|
|
74
|
+
}
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
results: scan.findings.map((finding) => ({
|
|
79
|
+
ruleId: finding.ruleId,
|
|
80
|
+
level: levelForSeverity(finding.severity),
|
|
81
|
+
message: {
|
|
82
|
+
text: finding.message
|
|
83
|
+
},
|
|
84
|
+
locations: [
|
|
85
|
+
{
|
|
86
|
+
physicalLocation: {
|
|
87
|
+
artifactLocation: {
|
|
88
|
+
uri: finding.file
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}))
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { analyzeFiles } from './analyzers.js';
|
|
5
|
+
import { applyConfigToFindings, loadConfig } from './config.js';
|
|
6
|
+
import { calculateScore } from './scoring.js';
|
|
7
|
+
|
|
8
|
+
const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'coverage']);
|
|
9
|
+
|
|
10
|
+
function toPortablePath(path) {
|
|
11
|
+
return path.split(sep).join('/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function kindForPath(path) {
|
|
15
|
+
const portable = toPortablePath(path);
|
|
16
|
+
if (portable === 'AGENTS.md') return 'agents';
|
|
17
|
+
if (portable === 'CLAUDE.md') return 'claude';
|
|
18
|
+
if (portable === '.github/copilot-instructions.md') return 'copilot';
|
|
19
|
+
if (portable === '.mcp.json' || portable === '.cursor/mcp.json') return 'mcp';
|
|
20
|
+
if (portable.startsWith('.cursor/rules/') && (portable.endsWith('.md') || portable.endsWith('.mdc'))) return 'cursor-rule';
|
|
21
|
+
if (basename(portable) === 'SKILL.md') return 'skill';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function walk(root, directory, files) {
|
|
26
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
31
|
+
await walk(root, join(directory, entry.name), files);
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!entry.isFile()) continue;
|
|
37
|
+
|
|
38
|
+
const path = join(directory, entry.name);
|
|
39
|
+
const relativePath = relative(root, path);
|
|
40
|
+
const kind = kindForPath(relativePath);
|
|
41
|
+
if (!kind) continue;
|
|
42
|
+
|
|
43
|
+
files.push({
|
|
44
|
+
path,
|
|
45
|
+
relativePath: toPortablePath(relativePath),
|
|
46
|
+
kind,
|
|
47
|
+
content: await readFile(path, 'utf8')
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function discoverContextFiles(root) {
|
|
53
|
+
const files = [];
|
|
54
|
+
await walk(root, root, files);
|
|
55
|
+
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function scanRepository(root) {
|
|
59
|
+
const config = await loadConfig(root);
|
|
60
|
+
const files = await discoverContextFiles(root);
|
|
61
|
+
const findings = applyConfigToFindings(await analyzeFiles(root, files), config);
|
|
62
|
+
const score = calculateScore(findings);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
config,
|
|
67
|
+
files,
|
|
68
|
+
findings,
|
|
69
|
+
score
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/scoring.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const DEFAULT_WEIGHT = 5;
|
|
2
|
+
|
|
3
|
+
export function calculateScore(findings) {
|
|
4
|
+
const totalPenalty = findings.reduce((sum, finding) => sum + (finding.weight ?? DEFAULT_WEIGHT), 0);
|
|
5
|
+
const score = Math.max(0, 100 - totalPenalty);
|
|
6
|
+
const bySeverity = findings.reduce((counts, finding) => {
|
|
7
|
+
counts[finding.severity] = (counts[finding.severity] ?? 0) + 1;
|
|
8
|
+
return counts;
|
|
9
|
+
}, {});
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
score,
|
|
13
|
+
maxScore: 100,
|
|
14
|
+
findings: findings.length,
|
|
15
|
+
bySeverity
|
|
16
|
+
};
|
|
17
|
+
}
|