@x6txy/ctxscope 0.1.0 → 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 +19 -0
- package/README.md +91 -4
- package/dist/cli.js +585 -130
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-06-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added `ctxscope init` to create `ctxscope.config.json`.
|
|
8
|
+
- Added configurable `maxTokens`, `maxFileTokens`, `ignore`, and rule severities.
|
|
9
|
+
- Added diagnostics model with `off`, `warn`, and `error` severities.
|
|
10
|
+
- Added `ctxscope doctor` for lint-style context checks.
|
|
11
|
+
- Added `ctxscope doctor --ci` with exit code `1` on error diagnostics.
|
|
12
|
+
- Added `ctxscope doctor --json` with stable automation output.
|
|
13
|
+
- Added `CTX101` for conflicting package manager instructions.
|
|
14
|
+
- Added `CTX102` for package scripts referenced in context but missing from `package.json`.
|
|
15
|
+
- Added `CTX105` for total context budget violations.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `CTX001` now respects `maxFileTokens` from config.
|
|
20
|
+
- Human output now uses diagnostic wording for mixed warnings and errors.
|
|
21
|
+
|
|
3
22
|
## 0.1.0 - 2026-06-21
|
|
4
23
|
|
|
5
24
|
Published as `@x6txy/ctxscope` on npm. The installed binary is still `ctxscope`.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Inspect and lint coding-agent context files.
|
|
4
4
|
|
|
5
|
-
`ctxscope` helps you see the instructions your coding agents may read before they start working: `AGENTS.md`, `CLAUDE.md`, `SKILL.md`, OpenCode skills, Cursor rules, and GitHub Copilot instructions.
|
|
5
|
+
`ctxscope` helps you see and lint the instructions your coding agents may read before they start working: `AGENTS.md`, `CLAUDE.md`, `SKILL.md`, OpenCode skills, Cursor rules, and GitHub Copilot instructions.
|
|
6
6
|
|
|
7
7
|
It answers the first questions every agent-heavy repo eventually has:
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ It answers the first questions every agent-heavy repo eventually has:
|
|
|
10
10
|
- How much context do they add?
|
|
11
11
|
- Which agent is each file probably for?
|
|
12
12
|
- Which files are suspiciously large, empty, duplicated, or stale?
|
|
13
|
+
- Which context problems should fail CI?
|
|
13
14
|
|
|
14
15
|
## Install
|
|
15
16
|
|
|
@@ -31,9 +32,13 @@ ctxscope scan
|
|
|
31
32
|
```bash
|
|
32
33
|
ctxscope --help
|
|
33
34
|
ctxscope --version
|
|
35
|
+
ctxscope init
|
|
34
36
|
ctxscope scan [path]
|
|
35
37
|
ctxscope scan --agent <all|codex|opencode|claude|generic>
|
|
36
38
|
ctxscope scan --json
|
|
39
|
+
ctxscope doctor [path]
|
|
40
|
+
ctxscope doctor --ci
|
|
41
|
+
ctxscope doctor --json
|
|
37
42
|
```
|
|
38
43
|
|
|
39
44
|
Examples:
|
|
@@ -43,6 +48,8 @@ ctxscope scan
|
|
|
43
48
|
ctxscope scan apps/web
|
|
44
49
|
ctxscope scan --agent codex
|
|
45
50
|
ctxscope scan --agent opencode --json
|
|
51
|
+
ctxscope init
|
|
52
|
+
ctxscope doctor --ci
|
|
46
53
|
```
|
|
47
54
|
|
|
48
55
|
## Output
|
|
@@ -86,7 +93,7 @@ Ignored directories:
|
|
|
86
93
|
|
|
87
94
|
## Warning Codes
|
|
88
95
|
|
|
89
|
-
`ctxscope scan` reports objective hygiene
|
|
96
|
+
`ctxscope scan` reports objective hygiene diagnostics. `ctxscope doctor` adds CI-ready error rules.
|
|
90
97
|
|
|
91
98
|
| Code | Meaning |
|
|
92
99
|
| --- | --- |
|
|
@@ -96,6 +103,85 @@ Ignored directories:
|
|
|
96
103
|
| `CTX004` | Empty context file |
|
|
97
104
|
| `CTX005` | TODO, FIXME, or obsolete marker |
|
|
98
105
|
| `CTX006` | Repeated paragraph |
|
|
106
|
+
| `CTX101` | Conflicting package manager instructions |
|
|
107
|
+
| `CTX102` | Missing package script referenced by context |
|
|
108
|
+
| `CTX105` | Total context budget exceeded |
|
|
109
|
+
|
|
110
|
+
## Doctor
|
|
111
|
+
|
|
112
|
+
Use `doctor` when you want lint-style checks and CI exit codes:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
ctxscope doctor
|
|
116
|
+
ctxscope doctor --ci
|
|
117
|
+
ctxscope doctor --json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`--ci` exits with code `1` when any diagnostic has severity `error`.
|
|
121
|
+
|
|
122
|
+
Example output:
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
ctxscope doctor
|
|
126
|
+
|
|
127
|
+
Agent all
|
|
128
|
+
Target /repo
|
|
129
|
+
Status fail
|
|
130
|
+
|
|
131
|
+
Summary
|
|
132
|
+
4 files, ~9,200 tokens, 2 errors, 1 warnings
|
|
133
|
+
|
|
134
|
+
Errors (2)
|
|
135
|
+
ERROR CTX101 AGENTS.md
|
|
136
|
+
conflicting package managers: npm, pnpm
|
|
137
|
+
|
|
138
|
+
ERROR CTX102 AGENTS.md
|
|
139
|
+
references missing package script: test:e2e
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Config
|
|
143
|
+
|
|
144
|
+
Create a default config:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
ctxscope init
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This writes `ctxscope.config.json`:
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"maxTokens": 8000,
|
|
155
|
+
"maxFileTokens": 2500,
|
|
156
|
+
"ignore": ["node_modules", "dist", ".git"],
|
|
157
|
+
"rules": {
|
|
158
|
+
"CTX001": "warn",
|
|
159
|
+
"CTX002": "warn",
|
|
160
|
+
"CTX003": "warn",
|
|
161
|
+
"CTX004": "warn",
|
|
162
|
+
"CTX005": "warn",
|
|
163
|
+
"CTX006": "warn",
|
|
164
|
+
"CTX101": "error",
|
|
165
|
+
"CTX102": "error",
|
|
166
|
+
"CTX105": "error"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Rule severities can be:
|
|
172
|
+
|
|
173
|
+
- `off`
|
|
174
|
+
- `warn`
|
|
175
|
+
- `error`
|
|
176
|
+
|
|
177
|
+
## CI
|
|
178
|
+
|
|
179
|
+
GitHub Actions example:
|
|
180
|
+
|
|
181
|
+
```yaml
|
|
182
|
+
- name: Check agent context
|
|
183
|
+
run: npx @x6txy/ctxscope doctor --ci
|
|
184
|
+
```
|
|
99
185
|
|
|
100
186
|
## JSON Output
|
|
101
187
|
|
|
@@ -103,6 +189,7 @@ Use `--json` for automation:
|
|
|
103
189
|
|
|
104
190
|
```bash
|
|
105
191
|
ctxscope scan --json
|
|
192
|
+
ctxscope doctor --json
|
|
106
193
|
```
|
|
107
194
|
|
|
108
195
|
Shape:
|
|
@@ -120,8 +207,8 @@ Shape:
|
|
|
120
207
|
## Limitations
|
|
121
208
|
|
|
122
209
|
- Token counts are estimates: `ceil(character_count / 4)`.
|
|
123
|
-
- v0.
|
|
124
|
-
- Semantic
|
|
210
|
+
- v0.2 is discovery-based, not real session tracing.
|
|
211
|
+
- Semantic checks are intentionally conservative. They inspect explicit commands and context text, not model behavior.
|
|
125
212
|
- `diff` and `trace` are future commands.
|
|
126
213
|
|
|
127
214
|
## License
|
package/dist/cli.js
CHANGED
|
@@ -1,92 +1,109 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
var CONFIG_FILE_NAME = "ctxscope.config.json";
|
|
10
|
+
var DEFAULT_CONFIG = {
|
|
11
|
+
maxTokens: 8e3,
|
|
12
|
+
maxFileTokens: 2500,
|
|
13
|
+
ignore: ["node_modules", "dist", ".git"],
|
|
14
|
+
rules: {
|
|
15
|
+
CTX001: "warn",
|
|
16
|
+
CTX002: "warn",
|
|
17
|
+
CTX003: "warn",
|
|
18
|
+
CTX004: "warn",
|
|
19
|
+
CTX005: "warn",
|
|
20
|
+
CTX006: "warn",
|
|
21
|
+
CTX101: "error",
|
|
22
|
+
CTX102: "error",
|
|
23
|
+
CTX105: "error"
|
|
24
|
+
}
|
|
14
25
|
};
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function formatWarning(warning) {
|
|
35
|
-
return `${colors.yellow(warning.severity.toUpperCase())} ${colors.yellow(warning.code)} ${warning.path}
|
|
36
|
-
${colors.dim(warning.message)}`;
|
|
37
|
-
}
|
|
38
|
-
function formatMeta(result) {
|
|
39
|
-
return [
|
|
40
|
-
`${colors.dim("Agent")} ${result.agent}`,
|
|
41
|
-
`${colors.dim("Target")} ${result.target}`
|
|
42
|
-
].join("\n");
|
|
26
|
+
var ConfigError = class extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ConfigError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function loadConfig(cwd = process.cwd()) {
|
|
33
|
+
const configPath = resolve(cwd, CONFIG_FILE_NAME);
|
|
34
|
+
if (!existsSync(configPath)) {
|
|
35
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
36
|
+
}
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : "unknown parse error";
|
|
42
|
+
throw new ConfigError(`${CONFIG_FILE_NAME} is not valid JSON: ${message}`);
|
|
43
|
+
}
|
|
44
|
+
return normalizeConfig(parsed);
|
|
43
45
|
}
|
|
44
|
-
function
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
No context files found.`;
|
|
46
|
+
function normalizeConfig(value) {
|
|
47
|
+
if (!isObject(value)) {
|
|
48
|
+
throw new ConfigError(`${CONFIG_FILE_NAME} must contain a JSON object`);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
formatTokenCell(file.tokens, file.skippedBinary).padStart(tokenWidth),
|
|
59
|
-
file.agents.join(", ")
|
|
60
|
-
].join(" "));
|
|
61
|
-
return `${colors.bold("Files")} ${colors.dim(`(${result.files.length})`)}
|
|
62
|
-
${header}
|
|
63
|
-
${rows.join("\n")}`;
|
|
50
|
+
return {
|
|
51
|
+
maxTokens: readPositiveNumber(value, "maxTokens", DEFAULT_CONFIG.maxTokens),
|
|
52
|
+
maxFileTokens: readPositiveNumber(value, "maxFileTokens", DEFAULT_CONFIG.maxFileTokens),
|
|
53
|
+
ignore: readStringArray(value, "ignore", DEFAULT_CONFIG.ignore),
|
|
54
|
+
rules: {
|
|
55
|
+
...DEFAULT_CONFIG.rules,
|
|
56
|
+
...readRules(value)
|
|
57
|
+
}
|
|
58
|
+
};
|
|
64
59
|
}
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
function readPositiveNumber(value, key, fallback) {
|
|
61
|
+
const field = value[key];
|
|
62
|
+
if (field === void 0) {
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
if (typeof field !== "number" || !Number.isFinite(field) || field <= 0) {
|
|
66
|
+
throw new ConfigError(`${CONFIG_FILE_NAME}.${key} must be a positive number`);
|
|
67
|
+
}
|
|
68
|
+
return field;
|
|
69
69
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
function readStringArray(value, key, fallback) {
|
|
71
|
+
const field = value[key];
|
|
72
|
+
if (field === void 0) {
|
|
73
|
+
return [...fallback];
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
if (!Array.isArray(field) || field.some((item) => typeof item !== "string" || item.length === 0)) {
|
|
76
|
+
throw new ConfigError(`${CONFIG_FILE_NAME}.${key} must be an array of non-empty strings`);
|
|
77
|
+
}
|
|
78
|
+
return [...field];
|
|
76
79
|
}
|
|
77
|
-
function
|
|
78
|
-
|
|
80
|
+
function readRules(value) {
|
|
81
|
+
const field = value.rules;
|
|
82
|
+
if (field === void 0) {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
if (!isObject(field)) {
|
|
86
|
+
throw new ConfigError(`${CONFIG_FILE_NAME}.rules must be an object`);
|
|
87
|
+
}
|
|
88
|
+
const rules = {};
|
|
89
|
+
for (const [code, severity] of Object.entries(field)) {
|
|
90
|
+
if (!isRuleSeverity(severity)) {
|
|
91
|
+
throw new ConfigError(`${CONFIG_FILE_NAME}.rules.${code} must be one of: off, warn, error`);
|
|
92
|
+
}
|
|
93
|
+
rules[code] = severity;
|
|
94
|
+
}
|
|
95
|
+
return rules;
|
|
79
96
|
}
|
|
80
|
-
function
|
|
81
|
-
return
|
|
97
|
+
function isObject(value) {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
99
|
}
|
|
83
|
-
function
|
|
84
|
-
return
|
|
100
|
+
function isRuleSeverity(value) {
|
|
101
|
+
return value === "off" || value === "warn" || value === "error";
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
// src/scan.ts
|
|
88
105
|
import { statSync as statSync2 } from "fs";
|
|
89
|
-
import { dirname as dirname2, relative as relative2, resolve as
|
|
106
|
+
import { dirname as dirname2, relative as relative2, resolve as resolve4 } from "path";
|
|
90
107
|
|
|
91
108
|
// src/agents.ts
|
|
92
109
|
var AGENT_ORDER = ["codex", "opencode", "claude", "generic"];
|
|
@@ -138,10 +155,10 @@ function basename(path) {
|
|
|
138
155
|
|
|
139
156
|
// src/files.ts
|
|
140
157
|
import { readdirSync, statSync } from "fs";
|
|
141
|
-
import { relative, resolve } from "path";
|
|
158
|
+
import { relative, resolve as resolve2 } from "path";
|
|
142
159
|
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "dist", "node_modules"]);
|
|
143
160
|
function listFiles(targetPath) {
|
|
144
|
-
const absoluteTarget =
|
|
161
|
+
const absoluteTarget = resolve2(targetPath);
|
|
145
162
|
const stat = statSync(absoluteTarget);
|
|
146
163
|
if (stat.isFile()) {
|
|
147
164
|
return [absoluteTarget];
|
|
@@ -156,7 +173,7 @@ function listFiles(targetPath) {
|
|
|
156
173
|
function walk(directory, files) {
|
|
157
174
|
const entries = readdirSync(directory, { withFileTypes: true });
|
|
158
175
|
for (const entry of entries) {
|
|
159
|
-
const absolutePath =
|
|
176
|
+
const absolutePath = resolve2(directory, entry.name);
|
|
160
177
|
if (entry.isDirectory()) {
|
|
161
178
|
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
162
179
|
walk(absolutePath, files);
|
|
@@ -170,9 +187,9 @@ function walk(directory, files) {
|
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
// src/token.ts
|
|
173
|
-
import { readFileSync } from "fs";
|
|
190
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
174
191
|
function estimateFileTokens(path) {
|
|
175
|
-
const buffer =
|
|
192
|
+
const buffer = readFileSync2(path);
|
|
176
193
|
if (looksBinary(buffer)) {
|
|
177
194
|
return { tokens: 0, skipped: true };
|
|
178
195
|
}
|
|
@@ -205,65 +222,83 @@ function looksBinary(buffer) {
|
|
|
205
222
|
}
|
|
206
223
|
|
|
207
224
|
// src/warnings.ts
|
|
208
|
-
import { existsSync, readFileSync as
|
|
209
|
-
import { dirname, resolve as
|
|
210
|
-
|
|
225
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
226
|
+
import { dirname, resolve as resolve3 } from "path";
|
|
227
|
+
|
|
228
|
+
// src/diagnostics.ts
|
|
229
|
+
function createDiagnostic(input, config) {
|
|
230
|
+
const severity = config.rules[input.code] ?? input.defaultSeverity;
|
|
231
|
+
if (severity === "off") {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
code: input.code,
|
|
236
|
+
severity,
|
|
237
|
+
path: input.path,
|
|
238
|
+
message: input.message
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function sortDiagnostics(diagnostics) {
|
|
242
|
+
return diagnostics.sort((a, b) => {
|
|
243
|
+
const pathComparison = a.path.localeCompare(b.path);
|
|
244
|
+
return pathComparison === 0 ? a.code.localeCompare(b.code) : pathComparison;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/warnings.ts
|
|
211
249
|
var MARKER_PATTERN = /\b(TODO|FIXME|OBSOLETE)\b/i;
|
|
212
250
|
var MARKDOWN_LINK_PATTERN = /(?<!!)\[[^\]\n]+\]\(([^)]+)\)/g;
|
|
213
|
-
function
|
|
214
|
-
const
|
|
251
|
+
function collectDiagnostics(files, config) {
|
|
252
|
+
const diagnostics = [];
|
|
215
253
|
const headings = /* @__PURE__ */ new Map();
|
|
216
254
|
for (const input of files) {
|
|
217
|
-
|
|
255
|
+
diagnostics.push(...collectFileDiagnostics(input, config));
|
|
218
256
|
collectHeadings(input, headings);
|
|
219
257
|
}
|
|
220
|
-
|
|
221
|
-
return
|
|
222
|
-
const pathComparison = a.path.localeCompare(b.path);
|
|
223
|
-
return pathComparison === 0 ? a.code.localeCompare(b.code) : pathComparison;
|
|
224
|
-
});
|
|
258
|
+
diagnostics.push(...collectDuplicateHeadingDiagnostics(headings, config));
|
|
259
|
+
return sortDiagnostics(diagnostics);
|
|
225
260
|
}
|
|
226
|
-
function
|
|
227
|
-
const
|
|
261
|
+
function collectFileDiagnostics(input, config) {
|
|
262
|
+
const diagnostics = [];
|
|
228
263
|
const { file } = input;
|
|
229
264
|
if (file.skippedBinary) {
|
|
230
|
-
return
|
|
265
|
+
return diagnostics;
|
|
231
266
|
}
|
|
232
|
-
const content =
|
|
233
|
-
if (file.tokens >
|
|
234
|
-
|
|
267
|
+
const content = readFileSync3(input.absolutePath, "utf8");
|
|
268
|
+
if (file.tokens > config.maxFileTokens) {
|
|
269
|
+
pushDiagnostic(diagnostics, createDiagnostic({
|
|
235
270
|
code: "CTX001",
|
|
236
|
-
|
|
271
|
+
defaultSeverity: "warn",
|
|
237
272
|
path: file.path,
|
|
238
|
-
message: `larger than ${
|
|
239
|
-
});
|
|
273
|
+
message: `larger than ${config.maxFileTokens} estimated tokens`
|
|
274
|
+
}, config));
|
|
240
275
|
}
|
|
241
276
|
if (content.trim().length === 0) {
|
|
242
|
-
|
|
277
|
+
pushDiagnostic(diagnostics, createDiagnostic({
|
|
243
278
|
code: "CTX004",
|
|
244
|
-
|
|
279
|
+
defaultSeverity: "warn",
|
|
245
280
|
path: file.path,
|
|
246
281
|
message: "empty context file"
|
|
247
|
-
});
|
|
282
|
+
}, config));
|
|
248
283
|
}
|
|
249
284
|
if (MARKER_PATTERN.test(content)) {
|
|
250
|
-
|
|
285
|
+
pushDiagnostic(diagnostics, createDiagnostic({
|
|
251
286
|
code: "CTX005",
|
|
252
|
-
|
|
287
|
+
defaultSeverity: "warn",
|
|
253
288
|
path: file.path,
|
|
254
289
|
message: "contains TODO, FIXME, or obsolete markers"
|
|
255
|
-
});
|
|
290
|
+
}, config));
|
|
256
291
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return
|
|
292
|
+
diagnostics.push(...collectStaleLinkDiagnostics(input.absolutePath, file.path, content, config));
|
|
293
|
+
diagnostics.push(...collectRepeatedParagraphDiagnostics(file.path, content, config));
|
|
294
|
+
return diagnostics;
|
|
260
295
|
}
|
|
261
296
|
function collectHeadings(input, headings) {
|
|
262
297
|
var _a;
|
|
263
298
|
if (input.file.skippedBinary) {
|
|
264
299
|
return;
|
|
265
300
|
}
|
|
266
|
-
const content =
|
|
301
|
+
const content = readFileSync3(input.absolutePath, "utf8");
|
|
267
302
|
for (const line of content.split(/\r?\n/)) {
|
|
268
303
|
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
269
304
|
if (!match) {
|
|
@@ -278,27 +313,27 @@ function collectHeadings(input, headings) {
|
|
|
278
313
|
headings.set(heading, paths);
|
|
279
314
|
}
|
|
280
315
|
}
|
|
281
|
-
function
|
|
282
|
-
const
|
|
316
|
+
function collectDuplicateHeadingDiagnostics(headings, config) {
|
|
317
|
+
const diagnostics = [];
|
|
283
318
|
for (const [heading, paths] of headings.entries()) {
|
|
284
319
|
const uniquePaths = [...new Set(paths)];
|
|
285
320
|
if (uniquePaths.length < 2) {
|
|
286
321
|
continue;
|
|
287
322
|
}
|
|
288
323
|
for (const path of uniquePaths) {
|
|
289
|
-
|
|
324
|
+
pushDiagnostic(diagnostics, createDiagnostic({
|
|
290
325
|
code: "CTX002",
|
|
291
|
-
|
|
326
|
+
defaultSeverity: "warn",
|
|
292
327
|
path,
|
|
293
328
|
message: `heading "${heading}" appears in ${uniquePaths.length} context files`
|
|
294
|
-
});
|
|
329
|
+
}, config));
|
|
295
330
|
}
|
|
296
331
|
}
|
|
297
|
-
return
|
|
332
|
+
return diagnostics;
|
|
298
333
|
}
|
|
299
|
-
function
|
|
334
|
+
function collectStaleLinkDiagnostics(absolutePath, displayPath, content, config) {
|
|
300
335
|
var _a, _b;
|
|
301
|
-
const
|
|
336
|
+
const diagnostics = [];
|
|
302
337
|
const directory = dirname(absolutePath);
|
|
303
338
|
for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
304
339
|
const href = (_a = match[1]) == null ? void 0 : _a.trim();
|
|
@@ -306,28 +341,29 @@ function collectStaleLinkWarnings(absolutePath, displayPath, content) {
|
|
|
306
341
|
continue;
|
|
307
342
|
}
|
|
308
343
|
const pathOnly = ((_b = href.split("#")[0]) == null ? void 0 : _b.split("?")[0]) ?? "";
|
|
309
|
-
if (!pathOnly || !
|
|
310
|
-
|
|
344
|
+
if (!pathOnly || !existsSync2(resolve3(directory, pathOnly))) {
|
|
345
|
+
pushDiagnostic(diagnostics, createDiagnostic({
|
|
311
346
|
code: "CTX003",
|
|
312
|
-
|
|
347
|
+
defaultSeverity: "warn",
|
|
313
348
|
path: displayPath,
|
|
314
349
|
message: `links to missing file: ${href}`
|
|
315
|
-
});
|
|
350
|
+
}, config));
|
|
316
351
|
}
|
|
317
352
|
}
|
|
318
|
-
return
|
|
353
|
+
return diagnostics;
|
|
319
354
|
}
|
|
320
|
-
function
|
|
355
|
+
function collectRepeatedParagraphDiagnostics(displayPath, content, config) {
|
|
321
356
|
const paragraphs = content.split(/\n\s*\n/).map((paragraph) => paragraph.trim().replace(/\s+/g, " ")).filter((paragraph) => paragraph.length >= 40);
|
|
322
357
|
const seen = /* @__PURE__ */ new Set();
|
|
323
358
|
for (const paragraph of paragraphs) {
|
|
324
359
|
if (seen.has(paragraph)) {
|
|
325
|
-
|
|
360
|
+
const diagnostic = createDiagnostic({
|
|
326
361
|
code: "CTX006",
|
|
327
|
-
|
|
362
|
+
defaultSeverity: "warn",
|
|
328
363
|
path: displayPath,
|
|
329
364
|
message: "contains a repeated paragraph"
|
|
330
|
-
}
|
|
365
|
+
}, config);
|
|
366
|
+
return diagnostic ? [diagnostic] : [];
|
|
331
367
|
}
|
|
332
368
|
seen.add(paragraph);
|
|
333
369
|
}
|
|
@@ -336,9 +372,14 @@ function collectRepeatedParagraphWarnings(displayPath, content) {
|
|
|
336
372
|
function shouldSkipLink(href) {
|
|
337
373
|
return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("#");
|
|
338
374
|
}
|
|
375
|
+
function pushDiagnostic(diagnostics, diagnostic) {
|
|
376
|
+
if (diagnostic) {
|
|
377
|
+
diagnostics.push(diagnostic);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
339
380
|
|
|
340
381
|
// src/scan.ts
|
|
341
|
-
function scanContext(target, agent) {
|
|
382
|
+
function scanContext(target, agent, config = DEFAULT_CONFIG) {
|
|
342
383
|
const root = getScanRoot(target);
|
|
343
384
|
const absoluteFiles = listFiles(target);
|
|
344
385
|
const discoveredFiles = absoluteFiles.map((absolutePath) => {
|
|
@@ -365,11 +406,11 @@ function scanContext(target, agent) {
|
|
|
365
406
|
target,
|
|
366
407
|
files: discoveredFiles,
|
|
367
408
|
totalTokens: discoveredFiles.reduce((total, file) => total + file.tokens, 0),
|
|
368
|
-
warnings:
|
|
409
|
+
warnings: collectDiagnostics(warningInputs, config)
|
|
369
410
|
};
|
|
370
411
|
}
|
|
371
412
|
function getScanRoot(target) {
|
|
372
|
-
const absoluteTarget =
|
|
413
|
+
const absoluteTarget = resolve4(target);
|
|
373
414
|
const stat = statSync2(absoluteTarget);
|
|
374
415
|
return stat.isDirectory() ? absoluteTarget : dirname2(absoluteTarget);
|
|
375
416
|
}
|
|
@@ -378,6 +419,324 @@ function normalizeRelativePath(path) {
|
|
|
378
419
|
return normalized === "" ? "." : normalized;
|
|
379
420
|
}
|
|
380
421
|
|
|
422
|
+
// src/rules/budget.ts
|
|
423
|
+
function collectBudgetDiagnostics(scan, config) {
|
|
424
|
+
if (scan.totalTokens <= config.maxTokens) {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
const diagnostic = createDiagnostic({
|
|
428
|
+
code: "CTX105",
|
|
429
|
+
defaultSeverity: "error",
|
|
430
|
+
path: scan.target,
|
|
431
|
+
message: `total context is ~${scan.totalTokens} tokens, budget is ${config.maxTokens}`
|
|
432
|
+
}, config);
|
|
433
|
+
return diagnostic ? [diagnostic] : [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/rules/package-manager.ts
|
|
437
|
+
import { readFileSync as readFileSync4, statSync as statSync3 } from "fs";
|
|
438
|
+
import { dirname as dirname3, resolve as resolve5 } from "path";
|
|
439
|
+
var PACKAGE_MANAGER_PATTERNS = [
|
|
440
|
+
["npm", /\bnpm\s+(?:run\s+)?[a-z0-9:_-]+\b/i],
|
|
441
|
+
["pnpm", /\bpnpm\s+(?:run\s+)?[a-z0-9:_-]+\b/i],
|
|
442
|
+
["yarn", /\byarn\s+(?:run\s+)?[a-z0-9:_-]+\b/i],
|
|
443
|
+
["bun", /\bbun\s+(?:run\s+)?[a-z0-9:_-]+\b/i]
|
|
444
|
+
];
|
|
445
|
+
function collectPackageManagerDiagnostics(target, files, config) {
|
|
446
|
+
const root = getScanRoot2(target);
|
|
447
|
+
const mentions = files.flatMap((file) => collectPackageManagerMentions(root, file));
|
|
448
|
+
const managers = [...new Set(mentions.map((mention) => mention.manager))].sort();
|
|
449
|
+
if (managers.length < 2) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
const involvedPaths = [...new Set(mentions.map((mention) => mention.path))].sort();
|
|
453
|
+
const diagnostics = involvedPaths.map((path) => createDiagnostic({
|
|
454
|
+
code: "CTX101",
|
|
455
|
+
defaultSeverity: "error",
|
|
456
|
+
path,
|
|
457
|
+
message: `conflicting package managers: ${managers.join(", ")}`
|
|
458
|
+
}, config)).filter((diagnostic) => diagnostic !== null);
|
|
459
|
+
return sortDiagnostics(diagnostics);
|
|
460
|
+
}
|
|
461
|
+
function collectPackageManagerMentions(root, file) {
|
|
462
|
+
if (file.skippedBinary) {
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
const absolutePath = resolve5(root, file.path);
|
|
466
|
+
const content = readFileSync4(absolutePath, "utf8");
|
|
467
|
+
const mentions = [];
|
|
468
|
+
for (const [manager, pattern] of PACKAGE_MANAGER_PATTERNS) {
|
|
469
|
+
if (pattern.test(content)) {
|
|
470
|
+
mentions.push({ manager, path: file.path });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return mentions;
|
|
474
|
+
}
|
|
475
|
+
function getScanRoot2(target) {
|
|
476
|
+
const absoluteTarget = resolve5(target);
|
|
477
|
+
const stat = statSync3(absoluteTarget);
|
|
478
|
+
return stat.isDirectory() ? absoluteTarget : dirname3(absoluteTarget);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/rules/package-scripts.ts
|
|
482
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync4 } from "fs";
|
|
483
|
+
import { dirname as dirname4, resolve as resolve6 } from "path";
|
|
484
|
+
var SCRIPT_PATTERNS = [
|
|
485
|
+
/\bnpm\s+run\s+([a-z0-9:_-]+)\b/gi,
|
|
486
|
+
/\bpnpm\s+(?!run\b|install\b|add\b|remove\b|exec\b|dlx\b|create\b|init\b)([a-z0-9:_-]+)\b/gi,
|
|
487
|
+
/\byarn\s+(?!run\b|install\b|add\b|remove\b|exec\b|dlx\b|create\b|init\b)([a-z0-9:_-]+)\b/gi,
|
|
488
|
+
/\bbun\s+run\s+([a-z0-9:_-]+)\b/gi
|
|
489
|
+
];
|
|
490
|
+
function collectPackageScriptDiagnostics(target, files, config) {
|
|
491
|
+
const root = getScanRoot3(target);
|
|
492
|
+
const scripts = readPackageScripts(root);
|
|
493
|
+
if (!scripts) {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
const mentions = files.flatMap((file) => collectScriptMentions(root, file));
|
|
497
|
+
const seen = /* @__PURE__ */ new Set();
|
|
498
|
+
const diagnostics = [];
|
|
499
|
+
for (const mention of mentions) {
|
|
500
|
+
if (scripts.has(mention.script)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const key = `${mention.path}:${mention.script}`;
|
|
504
|
+
if (seen.has(key)) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
seen.add(key);
|
|
508
|
+
const diagnostic = createDiagnostic({
|
|
509
|
+
code: "CTX102",
|
|
510
|
+
defaultSeverity: "error",
|
|
511
|
+
path: mention.path,
|
|
512
|
+
message: `references missing package script: ${mention.script}`
|
|
513
|
+
}, config);
|
|
514
|
+
if (diagnostic) {
|
|
515
|
+
diagnostics.push(diagnostic);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return sortDiagnostics(diagnostics);
|
|
519
|
+
}
|
|
520
|
+
function collectScriptMentions(root, file) {
|
|
521
|
+
if (file.skippedBinary) {
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
const absolutePath = resolve6(root, file.path);
|
|
525
|
+
const content = readFileSync5(absolutePath, "utf8");
|
|
526
|
+
const mentions = [];
|
|
527
|
+
for (const pattern of SCRIPT_PATTERNS) {
|
|
528
|
+
pattern.lastIndex = 0;
|
|
529
|
+
for (const match of content.matchAll(pattern)) {
|
|
530
|
+
const script = match[1];
|
|
531
|
+
if (script) {
|
|
532
|
+
mentions.push({ path: file.path, script });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return mentions;
|
|
537
|
+
}
|
|
538
|
+
function readPackageScripts(root) {
|
|
539
|
+
const packageJsonPath = resolve6(root, "package.json");
|
|
540
|
+
if (!existsSync3(packageJsonPath)) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const parsed = JSON.parse(readFileSync5(packageJsonPath, "utf8"));
|
|
544
|
+
return new Set(Object.entries(parsed.scripts ?? {}).filter(([, value]) => typeof value === "string").map(([script]) => script));
|
|
545
|
+
}
|
|
546
|
+
function getScanRoot3(target) {
|
|
547
|
+
const absoluteTarget = resolve6(target);
|
|
548
|
+
const stat = statSync4(absoluteTarget);
|
|
549
|
+
return stat.isDirectory() ? absoluteTarget : dirname4(absoluteTarget);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/doctor.ts
|
|
553
|
+
function runDoctor(target, agent, config) {
|
|
554
|
+
const scan = scanContext(target, agent, config);
|
|
555
|
+
const diagnostics = sortDiagnostics([
|
|
556
|
+
...scan.warnings,
|
|
557
|
+
...collectBudgetDiagnostics(scan, config),
|
|
558
|
+
...collectPackageManagerDiagnostics(target, scan.files, config),
|
|
559
|
+
...collectPackageScriptDiagnostics(target, scan.files, config)
|
|
560
|
+
]);
|
|
561
|
+
const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
|
|
562
|
+
const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === "warn").length;
|
|
563
|
+
return {
|
|
564
|
+
agent: scan.agent,
|
|
565
|
+
target: scan.target,
|
|
566
|
+
status: errors > 0 ? "fail" : "pass",
|
|
567
|
+
summary: {
|
|
568
|
+
files: scan.files.length,
|
|
569
|
+
totalTokens: scan.totalTokens,
|
|
570
|
+
warnings,
|
|
571
|
+
errors
|
|
572
|
+
},
|
|
573
|
+
files: scan.files,
|
|
574
|
+
diagnostics
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/init.ts
|
|
579
|
+
import { existsSync as existsSync4, writeFileSync } from "fs";
|
|
580
|
+
import { resolve as resolve7 } from "path";
|
|
581
|
+
var InitError = class extends Error {
|
|
582
|
+
constructor(message) {
|
|
583
|
+
super(message);
|
|
584
|
+
this.name = "InitError";
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
function initConfig(cwd = process.cwd()) {
|
|
588
|
+
const path = resolve7(cwd, CONFIG_FILE_NAME);
|
|
589
|
+
if (existsSync4(path)) {
|
|
590
|
+
throw new InitError(`${CONFIG_FILE_NAME} already exists`);
|
|
591
|
+
}
|
|
592
|
+
writeFileSync(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
|
|
593
|
+
`, "utf8");
|
|
594
|
+
return { path };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// src/output.ts
|
|
598
|
+
var colorEnabled = process.env.NO_COLOR === void 0 && (process.stdout.isTTY || process.env.FORCE_COLOR !== void 0);
|
|
599
|
+
var colors = {
|
|
600
|
+
bold: (value) => color("\x1B[1m", value),
|
|
601
|
+
cyan: (value) => color("\x1B[36m", value),
|
|
602
|
+
dim: (value) => color("\x1B[2m", value),
|
|
603
|
+
green: (value) => color("\x1B[32m", value),
|
|
604
|
+
red: (value) => color("\x1B[31m", value),
|
|
605
|
+
yellow: (value) => color("\x1B[33m", value)
|
|
606
|
+
};
|
|
607
|
+
function formatHumanScanResult(result) {
|
|
608
|
+
const sections = [
|
|
609
|
+
colors.bold(colors.cyan("ctxscope scan")),
|
|
610
|
+
formatMeta(result),
|
|
611
|
+
formatFiles(result),
|
|
612
|
+
formatSummary(result),
|
|
613
|
+
formatWarnings(result)
|
|
614
|
+
];
|
|
615
|
+
return sections.filter(Boolean).join("\n\n");
|
|
616
|
+
}
|
|
617
|
+
function formatJsonScanResult(result) {
|
|
618
|
+
return JSON.stringify({
|
|
619
|
+
agent: result.agent,
|
|
620
|
+
target: result.target,
|
|
621
|
+
files: result.files,
|
|
622
|
+
totalTokens: result.totalTokens,
|
|
623
|
+
warnings: result.warnings
|
|
624
|
+
}, null, 2);
|
|
625
|
+
}
|
|
626
|
+
function formatHumanDoctorResult(result) {
|
|
627
|
+
const sections = [
|
|
628
|
+
colors.bold(colors.cyan("ctxscope doctor")),
|
|
629
|
+
formatDoctorMeta(result),
|
|
630
|
+
formatDoctorSummary(result),
|
|
631
|
+
formatDoctorDiagnostics(result)
|
|
632
|
+
];
|
|
633
|
+
return sections.filter(Boolean).join("\n\n");
|
|
634
|
+
}
|
|
635
|
+
function formatJsonDoctorResult(result) {
|
|
636
|
+
return JSON.stringify({
|
|
637
|
+
agent: result.agent,
|
|
638
|
+
target: result.target,
|
|
639
|
+
status: result.status,
|
|
640
|
+
summary: result.summary,
|
|
641
|
+
files: result.files,
|
|
642
|
+
diagnostics: result.diagnostics
|
|
643
|
+
}, null, 2);
|
|
644
|
+
}
|
|
645
|
+
function formatWarning(warning) {
|
|
646
|
+
const severity = colorSeverity(warning);
|
|
647
|
+
const code = warning.severity === "error" ? colors.red(warning.code) : colors.yellow(warning.code);
|
|
648
|
+
return `${severity} ${code} ${warning.path}
|
|
649
|
+
${colors.dim(warning.message)}`;
|
|
650
|
+
}
|
|
651
|
+
function colorSeverity(diagnostic) {
|
|
652
|
+
return diagnostic.severity === "error" ? colors.red(diagnostic.severity.toUpperCase()) : colors.yellow(diagnostic.severity.toUpperCase());
|
|
653
|
+
}
|
|
654
|
+
function formatMeta(result) {
|
|
655
|
+
return [
|
|
656
|
+
`${colors.dim("Agent")} ${result.agent}`,
|
|
657
|
+
`${colors.dim("Target")} ${result.target}`
|
|
658
|
+
].join("\n");
|
|
659
|
+
}
|
|
660
|
+
function formatFiles(result) {
|
|
661
|
+
if (result.files.length === 0) {
|
|
662
|
+
return `${colors.bold("Files")} ${colors.dim("(0)")}
|
|
663
|
+
No context files found.`;
|
|
664
|
+
}
|
|
665
|
+
const pathWidth = Math.max("Path".length, ...result.files.map((file) => file.path.length));
|
|
666
|
+
const tokenWidth = Math.max("Tokens".length, ...result.files.map((file) => formatTokenCell(file.tokens, file.skippedBinary).length));
|
|
667
|
+
const header = [
|
|
668
|
+
colors.dim("Path".padEnd(pathWidth)),
|
|
669
|
+
colors.dim("Tokens".padStart(tokenWidth)),
|
|
670
|
+
colors.dim("Agents")
|
|
671
|
+
].join(" ");
|
|
672
|
+
const rows = result.files.map((file) => [
|
|
673
|
+
file.path.padEnd(pathWidth),
|
|
674
|
+
formatTokenCell(file.tokens, file.skippedBinary).padStart(tokenWidth),
|
|
675
|
+
file.agents.join(", ")
|
|
676
|
+
].join(" "));
|
|
677
|
+
return `${colors.bold("Files")} ${colors.dim(`(${result.files.length})`)}
|
|
678
|
+
${header}
|
|
679
|
+
${rows.join("\n")}`;
|
|
680
|
+
}
|
|
681
|
+
function formatSummary(result) {
|
|
682
|
+
const errors = result.warnings.filter((warning) => warning.severity === "error").length;
|
|
683
|
+
const warnings = result.warnings.filter((warning) => warning.severity === "warn").length;
|
|
684
|
+
const diagnosticLabel = result.warnings.length === 0 ? colors.green("0 diagnostics") : [
|
|
685
|
+
errors > 0 ? colors.red(`${errors} errors`) : null,
|
|
686
|
+
warnings > 0 ? colors.yellow(`${warnings} warnings`) : null
|
|
687
|
+
].filter(Boolean).join(", ");
|
|
688
|
+
return `${colors.bold("Summary")}
|
|
689
|
+
${formatNumber(result.files.length)} files, ~${formatNumber(result.totalTokens)} tokens, ${diagnosticLabel}`;
|
|
690
|
+
}
|
|
691
|
+
function formatWarnings(result) {
|
|
692
|
+
if (result.warnings.length === 0) {
|
|
693
|
+
return "";
|
|
694
|
+
}
|
|
695
|
+
return `${colors.bold("Diagnostics")} ${colors.dim(`(${result.warnings.length})`)}
|
|
696
|
+
${result.warnings.map(formatWarning).join("\n")}`;
|
|
697
|
+
}
|
|
698
|
+
function formatDoctorMeta(result) {
|
|
699
|
+
return [
|
|
700
|
+
`${colors.dim("Agent")} ${result.agent}`,
|
|
701
|
+
`${colors.dim("Target")} ${result.target}`,
|
|
702
|
+
`${colors.dim("Status")} ${result.status === "pass" ? colors.green("pass") : colors.red("fail")}`
|
|
703
|
+
].join("\n");
|
|
704
|
+
}
|
|
705
|
+
function formatDoctorSummary(result) {
|
|
706
|
+
const diagnosticLabel = result.diagnostics.length === 0 ? colors.green("0 diagnostics") : [
|
|
707
|
+
result.summary.errors > 0 ? colors.red(`${result.summary.errors} errors`) : null,
|
|
708
|
+
result.summary.warnings > 0 ? colors.yellow(`${result.summary.warnings} warnings`) : null
|
|
709
|
+
].filter(Boolean).join(", ");
|
|
710
|
+
return `${colors.bold("Summary")}
|
|
711
|
+
${formatNumber(result.summary.files)} files, ~${formatNumber(result.summary.totalTokens)} tokens, ${diagnosticLabel}`;
|
|
712
|
+
}
|
|
713
|
+
function formatDoctorDiagnostics(result) {
|
|
714
|
+
if (result.diagnostics.length === 0) {
|
|
715
|
+
return "";
|
|
716
|
+
}
|
|
717
|
+
const errors = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
718
|
+
const warnings = result.diagnostics.filter((diagnostic) => diagnostic.severity === "warn");
|
|
719
|
+
const sections = [];
|
|
720
|
+
if (errors.length > 0) {
|
|
721
|
+
sections.push(`${colors.bold("Errors")} ${colors.dim(`(${errors.length})`)}
|
|
722
|
+
${errors.map(formatWarning).join("\n")}`);
|
|
723
|
+
}
|
|
724
|
+
if (warnings.length > 0) {
|
|
725
|
+
sections.push(`${colors.bold("Warnings")} ${colors.dim(`(${warnings.length})`)}
|
|
726
|
+
${warnings.map(formatWarning).join("\n")}`);
|
|
727
|
+
}
|
|
728
|
+
return sections.join("\n\n");
|
|
729
|
+
}
|
|
730
|
+
function formatTokenCell(tokens, skippedBinary) {
|
|
731
|
+
return skippedBinary ? "binary" : `~${formatNumber(tokens)}`;
|
|
732
|
+
}
|
|
733
|
+
function formatNumber(value) {
|
|
734
|
+
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
735
|
+
}
|
|
736
|
+
function color(code, value) {
|
|
737
|
+
return colorEnabled ? `${code}${value}\x1B[0m` : value;
|
|
738
|
+
}
|
|
739
|
+
|
|
381
740
|
// src/types.ts
|
|
382
741
|
var SUPPORTED_AGENTS = ["all", "codex", "opencode", "claude", "generic"];
|
|
383
742
|
|
|
@@ -385,7 +744,7 @@ var SUPPORTED_AGENTS = ["all", "codex", "opencode", "claude", "generic"];
|
|
|
385
744
|
function getVersion() {
|
|
386
745
|
try {
|
|
387
746
|
const packageJson = JSON.parse(
|
|
388
|
-
|
|
747
|
+
readFileSync6(new URL("../package.json", import.meta.url), "utf8")
|
|
389
748
|
);
|
|
390
749
|
return packageJson.version ?? "0.0.0";
|
|
391
750
|
} catch {
|
|
@@ -400,15 +759,20 @@ Inspect and lint coding-agent context files.
|
|
|
400
759
|
Usage:
|
|
401
760
|
ctxscope --help
|
|
402
761
|
ctxscope --version
|
|
762
|
+
ctxscope init
|
|
403
763
|
ctxscope scan [path] [--agent <agent>] [--json]
|
|
764
|
+
ctxscope doctor [path] [--agent <agent>] [--json] [--ci]
|
|
404
765
|
|
|
405
766
|
Commands:
|
|
767
|
+
init Create ctxscope.config.json.
|
|
406
768
|
scan Discover coding-agent context files for a path.
|
|
769
|
+
doctor Lint coding-agent context files.
|
|
407
770
|
|
|
408
771
|
Options:
|
|
409
772
|
--agent <agent> Agent profile: all, codex, opencode, claude, generic.
|
|
410
773
|
Default: all.
|
|
411
774
|
--json Print machine-readable JSON.
|
|
775
|
+
--ci Exit 1 when doctor finds errors.
|
|
412
776
|
-h, --help Show this help message.
|
|
413
777
|
-v, --version Show the package version.
|
|
414
778
|
`);
|
|
@@ -464,14 +828,73 @@ function parseScanOptions(args) {
|
|
|
464
828
|
}
|
|
465
829
|
return options;
|
|
466
830
|
}
|
|
831
|
+
function parseDoctorOptions(args) {
|
|
832
|
+
const options = {
|
|
833
|
+
agent: "all",
|
|
834
|
+
ci: false,
|
|
835
|
+
json: false,
|
|
836
|
+
target: "."
|
|
837
|
+
};
|
|
838
|
+
let targetSet = false;
|
|
839
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
840
|
+
const arg = args[index];
|
|
841
|
+
if (arg === "--help" || arg === "-h") {
|
|
842
|
+
printHelp();
|
|
843
|
+
process.exit(0);
|
|
844
|
+
}
|
|
845
|
+
if (arg === "--ci") {
|
|
846
|
+
options.ci = true;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (arg === "--json") {
|
|
850
|
+
options.json = true;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (arg === "--agent") {
|
|
854
|
+
options.agent = parseAgent(args[index + 1]);
|
|
855
|
+
index += 1;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (arg.startsWith("--agent=")) {
|
|
859
|
+
options.agent = parseAgent(arg.slice("--agent=".length));
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (arg.startsWith("-")) {
|
|
863
|
+
fail(`unknown option '${arg}'`);
|
|
864
|
+
}
|
|
865
|
+
if (targetSet) {
|
|
866
|
+
fail(`unexpected extra path '${arg}'`);
|
|
867
|
+
}
|
|
868
|
+
options.target = arg;
|
|
869
|
+
targetSet = true;
|
|
870
|
+
}
|
|
871
|
+
return options;
|
|
872
|
+
}
|
|
467
873
|
function runScan(options) {
|
|
468
|
-
const
|
|
874
|
+
const config = loadConfig();
|
|
875
|
+
const result = scanContext(options.target, options.agent, config);
|
|
469
876
|
if (options.json) {
|
|
470
877
|
console.log(formatJsonScanResult(result));
|
|
471
878
|
return;
|
|
472
879
|
}
|
|
473
880
|
console.log(formatHumanScanResult(result));
|
|
474
881
|
}
|
|
882
|
+
function runDoctorCommand(options) {
|
|
883
|
+
const config = loadConfig();
|
|
884
|
+
const result = runDoctor(options.target, options.agent, config);
|
|
885
|
+
if (options.json) {
|
|
886
|
+
console.log(formatJsonDoctorResult(result));
|
|
887
|
+
} else {
|
|
888
|
+
console.log(formatHumanDoctorResult(result));
|
|
889
|
+
}
|
|
890
|
+
if (options.ci && result.status === "fail") {
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function runInitCommand() {
|
|
895
|
+
const result = initConfig();
|
|
896
|
+
console.log(`Created ${result.path}`);
|
|
897
|
+
}
|
|
475
898
|
function main(argv) {
|
|
476
899
|
const [command, ...args] = argv;
|
|
477
900
|
if (!command || command === "--help" || command === "-h") {
|
|
@@ -483,7 +906,39 @@ function main(argv) {
|
|
|
483
906
|
return;
|
|
484
907
|
}
|
|
485
908
|
if (command === "scan") {
|
|
486
|
-
|
|
909
|
+
try {
|
|
910
|
+
runScan(parseScanOptions(args));
|
|
911
|
+
} catch (error) {
|
|
912
|
+
if (error instanceof ConfigError) {
|
|
913
|
+
fail(error.message);
|
|
914
|
+
}
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (command === "init") {
|
|
920
|
+
if (args.length > 0) {
|
|
921
|
+
fail(`init does not accept arguments: ${args.join(" ")}`);
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
runInitCommand();
|
|
925
|
+
} catch (error) {
|
|
926
|
+
if (error instanceof InitError) {
|
|
927
|
+
fail(error.message);
|
|
928
|
+
}
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (command === "doctor") {
|
|
934
|
+
try {
|
|
935
|
+
runDoctorCommand(parseDoctorOptions(args));
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (error instanceof ConfigError) {
|
|
938
|
+
fail(error.message);
|
|
939
|
+
}
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
487
942
|
return;
|
|
488
943
|
}
|
|
489
944
|
fail(`unknown command '${command}'`);
|