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 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
+ [![CI](https://github.com/Tehlikeli107/contextdiet/actions/workflows/ci.yml/badge.svg)](https://github.com/Tehlikeli107/contextdiet/actions/workflows/ci.yml)
4
+ ![Agent Context Score](https://img.shields.io/badge/agent_context-100%2F100-brightgreen)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.js';
3
+
4
+ const result = await runCli(process.argv.slice(2));
5
+ process.exitCode = result.exitCode;
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
+ }
@@ -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 `![Agent Context Score](https://img.shields.io/badge/agent_context-${encodedValue}-${badgeColor(score)})`;
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
+ }