@x6txy/ctxscope 0.1.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 +16 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/cli.js +491 -0
- package/package.json +41 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-06-21
|
|
4
|
+
|
|
5
|
+
Published as `@x6txy/ctxscope` on npm. The installed binary is still `ctxscope`.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `ctxscope scan [path]` CLI command.
|
|
10
|
+
- Added agent filters for `all`, `codex`, `opencode`, `claude`, and `generic`.
|
|
11
|
+
- Added context file discovery for common coding-agent instruction surfaces.
|
|
12
|
+
- Added token estimates for discovered context files.
|
|
13
|
+
- Added warning codes `CTX001` through `CTX006` for safe context hygiene checks.
|
|
14
|
+
- Added human-readable terminal output with aligned columns and color support.
|
|
15
|
+
- Added stable JSON output via `--json`.
|
|
16
|
+
- Added README, MIT license, and npm package metadata.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 x6txy
|
|
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,129 @@
|
|
|
1
|
+
# ctxscope
|
|
2
|
+
|
|
3
|
+
Inspect and lint coding-agent context files.
|
|
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.
|
|
6
|
+
|
|
7
|
+
It answers the first questions every agent-heavy repo eventually has:
|
|
8
|
+
|
|
9
|
+
- What context files exist?
|
|
10
|
+
- How much context do they add?
|
|
11
|
+
- Which agent is each file probably for?
|
|
12
|
+
- Which files are suspiciously large, empty, duplicated, or stale?
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Run without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @x6txy/ctxscope scan
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install globally:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @x6txy/ctxscope
|
|
26
|
+
ctxscope scan
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
ctxscope --help
|
|
33
|
+
ctxscope --version
|
|
34
|
+
ctxscope scan [path]
|
|
35
|
+
ctxscope scan --agent <all|codex|opencode|claude|generic>
|
|
36
|
+
ctxscope scan --json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
ctxscope scan
|
|
43
|
+
ctxscope scan apps/web
|
|
44
|
+
ctxscope scan --agent codex
|
|
45
|
+
ctxscope scan --agent opencode --json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Output
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
ctxscope scan
|
|
52
|
+
|
|
53
|
+
Agent all
|
|
54
|
+
Target /repo
|
|
55
|
+
|
|
56
|
+
Files (3)
|
|
57
|
+
Path Tokens Agents
|
|
58
|
+
.opencode/skills/backend/SKILL.md ~6 opencode, generic
|
|
59
|
+
AGENTS.md ~13 codex, opencode, claude, generic
|
|
60
|
+
src/AGENTS.md ~3 codex, opencode, claude, generic
|
|
61
|
+
|
|
62
|
+
Summary
|
|
63
|
+
3 files, ~22 tokens, 4 warnings
|
|
64
|
+
|
|
65
|
+
Warnings (4)
|
|
66
|
+
WARN CTX002 AGENTS.md
|
|
67
|
+
heading "testing" appears in 2 context files
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Supported Agents
|
|
71
|
+
|
|
72
|
+
v0.1 uses pattern-based discovery. It does not claim perfect runtime tracing for every agent.
|
|
73
|
+
|
|
74
|
+
| Agent | Files detected |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| Codex | `AGENTS.md`, `**/AGENTS.md` |
|
|
77
|
+
| OpenCode | `AGENTS.md`, `**/AGENTS.md`, `.opencode/**/*.md`, `.opencode/skills/**/SKILL.md` |
|
|
78
|
+
| Claude Code | `CLAUDE.md`, `**/CLAUDE.md`, `AGENTS.md` |
|
|
79
|
+
| Generic | `AGENTS.md`, `CLAUDE.md`, `SKILL.md`, `**/SKILL.md`, `.cursor/rules/**`, `.github/copilot-instructions.md` |
|
|
80
|
+
|
|
81
|
+
Ignored directories:
|
|
82
|
+
|
|
83
|
+
- `.git`
|
|
84
|
+
- `node_modules`
|
|
85
|
+
- `dist`
|
|
86
|
+
|
|
87
|
+
## Warning Codes
|
|
88
|
+
|
|
89
|
+
`ctxscope scan` reports objective hygiene warnings only.
|
|
90
|
+
|
|
91
|
+
| Code | Meaning |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `CTX001` | Oversized context file |
|
|
94
|
+
| `CTX002` | Duplicate heading across context files |
|
|
95
|
+
| `CTX003` | Stale relative markdown link |
|
|
96
|
+
| `CTX004` | Empty context file |
|
|
97
|
+
| `CTX005` | TODO, FIXME, or obsolete marker |
|
|
98
|
+
| `CTX006` | Repeated paragraph |
|
|
99
|
+
|
|
100
|
+
## JSON Output
|
|
101
|
+
|
|
102
|
+
Use `--json` for automation:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ctxscope scan --json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Shape:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"agent": "all",
|
|
113
|
+
"target": ".",
|
|
114
|
+
"files": [],
|
|
115
|
+
"totalTokens": 0,
|
|
116
|
+
"warnings": []
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Limitations
|
|
121
|
+
|
|
122
|
+
- Token counts are estimates: `ceil(character_count / 4)`.
|
|
123
|
+
- v0.1 is discovery-based, not real session tracing.
|
|
124
|
+
- Semantic conflicts are not detected yet. For example, `npm` vs `pnpm` policy conflicts are planned for a future `doctor` command.
|
|
125
|
+
- `diff` and `trace` are future commands.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
|
|
6
|
+
// src/output.ts
|
|
7
|
+
var colorEnabled = process.env.NO_COLOR === void 0 && (process.stdout.isTTY || process.env.FORCE_COLOR !== void 0);
|
|
8
|
+
var colors = {
|
|
9
|
+
bold: (value) => color("\x1B[1m", value),
|
|
10
|
+
cyan: (value) => color("\x1B[36m", value),
|
|
11
|
+
dim: (value) => color("\x1B[2m", value),
|
|
12
|
+
green: (value) => color("\x1B[32m", value),
|
|
13
|
+
yellow: (value) => color("\x1B[33m", value)
|
|
14
|
+
};
|
|
15
|
+
function formatHumanScanResult(result) {
|
|
16
|
+
const sections = [
|
|
17
|
+
colors.bold(colors.cyan("ctxscope scan")),
|
|
18
|
+
formatMeta(result),
|
|
19
|
+
formatFiles(result),
|
|
20
|
+
formatSummary(result),
|
|
21
|
+
formatWarnings(result)
|
|
22
|
+
];
|
|
23
|
+
return sections.filter(Boolean).join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
function formatJsonScanResult(result) {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
agent: result.agent,
|
|
28
|
+
target: result.target,
|
|
29
|
+
files: result.files,
|
|
30
|
+
totalTokens: result.totalTokens,
|
|
31
|
+
warnings: result.warnings
|
|
32
|
+
}, null, 2);
|
|
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");
|
|
43
|
+
}
|
|
44
|
+
function formatFiles(result) {
|
|
45
|
+
if (result.files.length === 0) {
|
|
46
|
+
return `${colors.bold("Files")} ${colors.dim("(0)")}
|
|
47
|
+
No context files found.`;
|
|
48
|
+
}
|
|
49
|
+
const pathWidth = Math.max("Path".length, ...result.files.map((file) => file.path.length));
|
|
50
|
+
const tokenWidth = Math.max("Tokens".length, ...result.files.map((file) => formatTokenCell(file.tokens, file.skippedBinary).length));
|
|
51
|
+
const header = [
|
|
52
|
+
colors.dim("Path".padEnd(pathWidth)),
|
|
53
|
+
colors.dim("Tokens".padStart(tokenWidth)),
|
|
54
|
+
colors.dim("Agents")
|
|
55
|
+
].join(" ");
|
|
56
|
+
const rows = result.files.map((file) => [
|
|
57
|
+
file.path.padEnd(pathWidth),
|
|
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")}`;
|
|
64
|
+
}
|
|
65
|
+
function formatSummary(result) {
|
|
66
|
+
const warningLabel = result.warnings.length === 0 ? colors.green("0 warnings") : colors.yellow(`${result.warnings.length} warnings`);
|
|
67
|
+
return `${colors.bold("Summary")}
|
|
68
|
+
${formatNumber(result.files.length)} files, ~${formatNumber(result.totalTokens)} tokens, ${warningLabel}`;
|
|
69
|
+
}
|
|
70
|
+
function formatWarnings(result) {
|
|
71
|
+
if (result.warnings.length === 0) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
return `${colors.bold("Warnings")} ${colors.dim(`(${result.warnings.length})`)}
|
|
75
|
+
${result.warnings.map(formatWarning).join("\n")}`;
|
|
76
|
+
}
|
|
77
|
+
function formatTokenCell(tokens, skippedBinary) {
|
|
78
|
+
return skippedBinary ? "binary" : `~${formatNumber(tokens)}`;
|
|
79
|
+
}
|
|
80
|
+
function formatNumber(value) {
|
|
81
|
+
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
82
|
+
}
|
|
83
|
+
function color(code, value) {
|
|
84
|
+
return colorEnabled ? `${code}${value}\x1B[0m` : value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/scan.ts
|
|
88
|
+
import { statSync as statSync2 } from "fs";
|
|
89
|
+
import { dirname as dirname2, relative as relative2, resolve as resolve3 } from "path";
|
|
90
|
+
|
|
91
|
+
// src/agents.ts
|
|
92
|
+
var AGENT_ORDER = ["codex", "opencode", "claude", "generic"];
|
|
93
|
+
function agentsForPath(relativePath) {
|
|
94
|
+
const normalized = normalizePath(relativePath);
|
|
95
|
+
const fileName = basename(normalized);
|
|
96
|
+
const agents = /* @__PURE__ */ new Set();
|
|
97
|
+
if (normalized.startsWith(".opencode/")) {
|
|
98
|
+
agents.add("opencode");
|
|
99
|
+
if (fileName === "SKILL.md") {
|
|
100
|
+
agents.add("generic");
|
|
101
|
+
}
|
|
102
|
+
return AGENT_ORDER.filter((agent) => agents.has(agent));
|
|
103
|
+
}
|
|
104
|
+
if (fileName === "AGENTS.md") {
|
|
105
|
+
agents.add("codex");
|
|
106
|
+
agents.add("opencode");
|
|
107
|
+
agents.add("claude");
|
|
108
|
+
agents.add("generic");
|
|
109
|
+
}
|
|
110
|
+
if (fileName === "CLAUDE.md") {
|
|
111
|
+
agents.add("claude");
|
|
112
|
+
agents.add("generic");
|
|
113
|
+
}
|
|
114
|
+
if (fileName === "SKILL.md") {
|
|
115
|
+
agents.add("generic");
|
|
116
|
+
if (normalized.startsWith(".opencode/skills/")) {
|
|
117
|
+
agents.add("opencode");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (normalized.startsWith(".cursor/rules/")) {
|
|
121
|
+
agents.add("generic");
|
|
122
|
+
}
|
|
123
|
+
if (normalized === ".github/copilot-instructions.md") {
|
|
124
|
+
agents.add("generic");
|
|
125
|
+
}
|
|
126
|
+
return AGENT_ORDER.filter((agent) => agents.has(agent));
|
|
127
|
+
}
|
|
128
|
+
function matchesAgentFilter(agents, filter) {
|
|
129
|
+
return filter === "all" || agents.includes(filter);
|
|
130
|
+
}
|
|
131
|
+
function normalizePath(path) {
|
|
132
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
133
|
+
}
|
|
134
|
+
function basename(path) {
|
|
135
|
+
const parts = path.split("/");
|
|
136
|
+
return parts[parts.length - 1] ?? path;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/files.ts
|
|
140
|
+
import { readdirSync, statSync } from "fs";
|
|
141
|
+
import { relative, resolve } from "path";
|
|
142
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "dist", "node_modules"]);
|
|
143
|
+
function listFiles(targetPath) {
|
|
144
|
+
const absoluteTarget = resolve(targetPath);
|
|
145
|
+
const stat = statSync(absoluteTarget);
|
|
146
|
+
if (stat.isFile()) {
|
|
147
|
+
return [absoluteTarget];
|
|
148
|
+
}
|
|
149
|
+
if (!stat.isDirectory()) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const files = [];
|
|
153
|
+
walk(absoluteTarget, files);
|
|
154
|
+
return files.sort((a, b) => relative(process.cwd(), a).localeCompare(relative(process.cwd(), b)));
|
|
155
|
+
}
|
|
156
|
+
function walk(directory, files) {
|
|
157
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
const absolutePath = resolve(directory, entry.name);
|
|
160
|
+
if (entry.isDirectory()) {
|
|
161
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
162
|
+
walk(absolutePath, files);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (entry.isFile()) {
|
|
167
|
+
files.push(absolutePath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/token.ts
|
|
173
|
+
import { readFileSync } from "fs";
|
|
174
|
+
function estimateFileTokens(path) {
|
|
175
|
+
const buffer = readFileSync(path);
|
|
176
|
+
if (looksBinary(buffer)) {
|
|
177
|
+
return { tokens: 0, skipped: true };
|
|
178
|
+
}
|
|
179
|
+
const content = buffer.toString("utf8");
|
|
180
|
+
return {
|
|
181
|
+
tokens: estimateTextTokens(content),
|
|
182
|
+
skipped: false
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function estimateTextTokens(content) {
|
|
186
|
+
return Math.ceil(content.length / 4);
|
|
187
|
+
}
|
|
188
|
+
function looksBinary(buffer) {
|
|
189
|
+
if (buffer.includes(0)) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const sampleSize = Math.min(buffer.length, 1024);
|
|
193
|
+
if (sampleSize === 0) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
let suspiciousBytes = 0;
|
|
197
|
+
for (let index = 0; index < sampleSize; index += 1) {
|
|
198
|
+
const byte = buffer[index];
|
|
199
|
+
const isCommonTextByte = byte === 9 || byte === 10 || byte === 13 || byte >= 32 && byte <= 126 || byte >= 128;
|
|
200
|
+
if (!isCommonTextByte) {
|
|
201
|
+
suspiciousBytes += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return suspiciousBytes / sampleSize > 0.3;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/warnings.ts
|
|
208
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
209
|
+
import { dirname, resolve as resolve2 } from "path";
|
|
210
|
+
var OVERSIZED_TOKEN_LIMIT = 2500;
|
|
211
|
+
var MARKER_PATTERN = /\b(TODO|FIXME|OBSOLETE)\b/i;
|
|
212
|
+
var MARKDOWN_LINK_PATTERN = /(?<!!)\[[^\]\n]+\]\(([^)]+)\)/g;
|
|
213
|
+
function collectWarnings(files) {
|
|
214
|
+
const warnings = [];
|
|
215
|
+
const headings = /* @__PURE__ */ new Map();
|
|
216
|
+
for (const input of files) {
|
|
217
|
+
warnings.push(...collectFileWarnings(input));
|
|
218
|
+
collectHeadings(input, headings);
|
|
219
|
+
}
|
|
220
|
+
warnings.push(...collectDuplicateHeadingWarnings(headings));
|
|
221
|
+
return warnings.sort((a, b) => {
|
|
222
|
+
const pathComparison = a.path.localeCompare(b.path);
|
|
223
|
+
return pathComparison === 0 ? a.code.localeCompare(b.code) : pathComparison;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function collectFileWarnings(input) {
|
|
227
|
+
const warnings = [];
|
|
228
|
+
const { file } = input;
|
|
229
|
+
if (file.skippedBinary) {
|
|
230
|
+
return warnings;
|
|
231
|
+
}
|
|
232
|
+
const content = readFileSync2(input.absolutePath, "utf8");
|
|
233
|
+
if (file.tokens > OVERSIZED_TOKEN_LIMIT) {
|
|
234
|
+
warnings.push({
|
|
235
|
+
code: "CTX001",
|
|
236
|
+
severity: "warn",
|
|
237
|
+
path: file.path,
|
|
238
|
+
message: `larger than ${OVERSIZED_TOKEN_LIMIT} estimated tokens`
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (content.trim().length === 0) {
|
|
242
|
+
warnings.push({
|
|
243
|
+
code: "CTX004",
|
|
244
|
+
severity: "warn",
|
|
245
|
+
path: file.path,
|
|
246
|
+
message: "empty context file"
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (MARKER_PATTERN.test(content)) {
|
|
250
|
+
warnings.push({
|
|
251
|
+
code: "CTX005",
|
|
252
|
+
severity: "warn",
|
|
253
|
+
path: file.path,
|
|
254
|
+
message: "contains TODO, FIXME, or obsolete markers"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
warnings.push(...collectStaleLinkWarnings(input.absolutePath, file.path, content));
|
|
258
|
+
warnings.push(...collectRepeatedParagraphWarnings(file.path, content));
|
|
259
|
+
return warnings;
|
|
260
|
+
}
|
|
261
|
+
function collectHeadings(input, headings) {
|
|
262
|
+
var _a;
|
|
263
|
+
if (input.file.skippedBinary) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const content = readFileSync2(input.absolutePath, "utf8");
|
|
267
|
+
for (const line of content.split(/\r?\n/)) {
|
|
268
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
269
|
+
if (!match) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const heading = (_a = match[2]) == null ? void 0 : _a.trim().toLowerCase();
|
|
273
|
+
if (!heading) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const paths = headings.get(heading) ?? [];
|
|
277
|
+
paths.push(input.file.path);
|
|
278
|
+
headings.set(heading, paths);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function collectDuplicateHeadingWarnings(headings) {
|
|
282
|
+
const warnings = [];
|
|
283
|
+
for (const [heading, paths] of headings.entries()) {
|
|
284
|
+
const uniquePaths = [...new Set(paths)];
|
|
285
|
+
if (uniquePaths.length < 2) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const path of uniquePaths) {
|
|
289
|
+
warnings.push({
|
|
290
|
+
code: "CTX002",
|
|
291
|
+
severity: "warn",
|
|
292
|
+
path,
|
|
293
|
+
message: `heading "${heading}" appears in ${uniquePaths.length} context files`
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return warnings;
|
|
298
|
+
}
|
|
299
|
+
function collectStaleLinkWarnings(absolutePath, displayPath, content) {
|
|
300
|
+
var _a, _b;
|
|
301
|
+
const warnings = [];
|
|
302
|
+
const directory = dirname(absolutePath);
|
|
303
|
+
for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
304
|
+
const href = (_a = match[1]) == null ? void 0 : _a.trim();
|
|
305
|
+
if (!href || shouldSkipLink(href)) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const pathOnly = ((_b = href.split("#")[0]) == null ? void 0 : _b.split("?")[0]) ?? "";
|
|
309
|
+
if (!pathOnly || !existsSync(resolve2(directory, pathOnly))) {
|
|
310
|
+
warnings.push({
|
|
311
|
+
code: "CTX003",
|
|
312
|
+
severity: "warn",
|
|
313
|
+
path: displayPath,
|
|
314
|
+
message: `links to missing file: ${href}`
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return warnings;
|
|
319
|
+
}
|
|
320
|
+
function collectRepeatedParagraphWarnings(displayPath, content) {
|
|
321
|
+
const paragraphs = content.split(/\n\s*\n/).map((paragraph) => paragraph.trim().replace(/\s+/g, " ")).filter((paragraph) => paragraph.length >= 40);
|
|
322
|
+
const seen = /* @__PURE__ */ new Set();
|
|
323
|
+
for (const paragraph of paragraphs) {
|
|
324
|
+
if (seen.has(paragraph)) {
|
|
325
|
+
return [{
|
|
326
|
+
code: "CTX006",
|
|
327
|
+
severity: "warn",
|
|
328
|
+
path: displayPath,
|
|
329
|
+
message: "contains a repeated paragraph"
|
|
330
|
+
}];
|
|
331
|
+
}
|
|
332
|
+
seen.add(paragraph);
|
|
333
|
+
}
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
function shouldSkipLink(href) {
|
|
337
|
+
return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("#");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/scan.ts
|
|
341
|
+
function scanContext(target, agent) {
|
|
342
|
+
const root = getScanRoot(target);
|
|
343
|
+
const absoluteFiles = listFiles(target);
|
|
344
|
+
const discoveredFiles = absoluteFiles.map((absolutePath) => {
|
|
345
|
+
const path = normalizeRelativePath(relative2(root, absolutePath));
|
|
346
|
+
const agents = agentsForPath(path);
|
|
347
|
+
if (agents.length === 0 || !matchesAgentFilter(agents, agent)) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const estimate = estimateFileTokens(absolutePath);
|
|
351
|
+
return {
|
|
352
|
+
path,
|
|
353
|
+
agents,
|
|
354
|
+
tokens: estimate.tokens,
|
|
355
|
+
skippedBinary: estimate.skipped
|
|
356
|
+
};
|
|
357
|
+
}).filter((file) => file !== null).sort((a, b) => a.path.localeCompare(b.path));
|
|
358
|
+
const absoluteByPath = new Map(absoluteFiles.map((absolutePath) => [
|
|
359
|
+
normalizeRelativePath(relative2(root, absolutePath)),
|
|
360
|
+
absolutePath
|
|
361
|
+
]));
|
|
362
|
+
const warningInputs = discoveredFiles.map((file) => ({ file, absolutePath: absoluteByPath.get(file.path) })).filter((input) => input.absolutePath !== void 0);
|
|
363
|
+
return {
|
|
364
|
+
agent,
|
|
365
|
+
target,
|
|
366
|
+
files: discoveredFiles,
|
|
367
|
+
totalTokens: discoveredFiles.reduce((total, file) => total + file.tokens, 0),
|
|
368
|
+
warnings: collectWarnings(warningInputs)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function getScanRoot(target) {
|
|
372
|
+
const absoluteTarget = resolve3(target);
|
|
373
|
+
const stat = statSync2(absoluteTarget);
|
|
374
|
+
return stat.isDirectory() ? absoluteTarget : dirname2(absoluteTarget);
|
|
375
|
+
}
|
|
376
|
+
function normalizeRelativePath(path) {
|
|
377
|
+
const normalized = path.replaceAll("\\", "/");
|
|
378
|
+
return normalized === "" ? "." : normalized;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/types.ts
|
|
382
|
+
var SUPPORTED_AGENTS = ["all", "codex", "opencode", "claude", "generic"];
|
|
383
|
+
|
|
384
|
+
// src/cli.ts
|
|
385
|
+
function getVersion() {
|
|
386
|
+
try {
|
|
387
|
+
const packageJson = JSON.parse(
|
|
388
|
+
readFileSync3(new URL("../package.json", import.meta.url), "utf8")
|
|
389
|
+
);
|
|
390
|
+
return packageJson.version ?? "0.0.0";
|
|
391
|
+
} catch {
|
|
392
|
+
return "0.0.0";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function printHelp() {
|
|
396
|
+
console.log(`ctxscope ${getVersion()}
|
|
397
|
+
|
|
398
|
+
Inspect and lint coding-agent context files.
|
|
399
|
+
|
|
400
|
+
Usage:
|
|
401
|
+
ctxscope --help
|
|
402
|
+
ctxscope --version
|
|
403
|
+
ctxscope scan [path] [--agent <agent>] [--json]
|
|
404
|
+
|
|
405
|
+
Commands:
|
|
406
|
+
scan Discover coding-agent context files for a path.
|
|
407
|
+
|
|
408
|
+
Options:
|
|
409
|
+
--agent <agent> Agent profile: all, codex, opencode, claude, generic.
|
|
410
|
+
Default: all.
|
|
411
|
+
--json Print machine-readable JSON.
|
|
412
|
+
-h, --help Show this help message.
|
|
413
|
+
-v, --version Show the package version.
|
|
414
|
+
`);
|
|
415
|
+
}
|
|
416
|
+
function fail(message) {
|
|
417
|
+
console.error(`ctxscope: ${message}`);
|
|
418
|
+
console.error("Run `ctxscope --help` for usage.");
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
function parseAgent(value) {
|
|
422
|
+
if (!value) {
|
|
423
|
+
fail("missing value for --agent");
|
|
424
|
+
}
|
|
425
|
+
if (!SUPPORTED_AGENTS.includes(value)) {
|
|
426
|
+
fail(`unsupported agent '${value}'. Expected one of: ${SUPPORTED_AGENTS.join(", ")}`);
|
|
427
|
+
}
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
function parseScanOptions(args) {
|
|
431
|
+
const options = {
|
|
432
|
+
agent: "all",
|
|
433
|
+
json: false,
|
|
434
|
+
target: "."
|
|
435
|
+
};
|
|
436
|
+
let targetSet = false;
|
|
437
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
438
|
+
const arg = args[index];
|
|
439
|
+
if (arg === "--help" || arg === "-h") {
|
|
440
|
+
printHelp();
|
|
441
|
+
process.exit(0);
|
|
442
|
+
}
|
|
443
|
+
if (arg === "--json") {
|
|
444
|
+
options.json = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (arg === "--agent") {
|
|
448
|
+
options.agent = parseAgent(args[index + 1]);
|
|
449
|
+
index += 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (arg.startsWith("--agent=")) {
|
|
453
|
+
options.agent = parseAgent(arg.slice("--agent=".length));
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (arg.startsWith("-")) {
|
|
457
|
+
fail(`unknown option '${arg}'`);
|
|
458
|
+
}
|
|
459
|
+
if (targetSet) {
|
|
460
|
+
fail(`unexpected extra path '${arg}'`);
|
|
461
|
+
}
|
|
462
|
+
options.target = arg;
|
|
463
|
+
targetSet = true;
|
|
464
|
+
}
|
|
465
|
+
return options;
|
|
466
|
+
}
|
|
467
|
+
function runScan(options) {
|
|
468
|
+
const result = scanContext(options.target, options.agent);
|
|
469
|
+
if (options.json) {
|
|
470
|
+
console.log(formatJsonScanResult(result));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
console.log(formatHumanScanResult(result));
|
|
474
|
+
}
|
|
475
|
+
function main(argv) {
|
|
476
|
+
const [command, ...args] = argv;
|
|
477
|
+
if (!command || command === "--help" || command === "-h") {
|
|
478
|
+
printHelp();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (command === "--version" || command === "-v") {
|
|
482
|
+
console.log(getVersion());
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (command === "scan") {
|
|
486
|
+
runScan(parseScanOptions(args));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
fail(`unknown command '${command}'`);
|
|
490
|
+
}
|
|
491
|
+
main(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x6txy/ctxscope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Inspect and lint coding-agent context files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"agents",
|
|
8
|
+
"ai",
|
|
9
|
+
"codex",
|
|
10
|
+
"claude",
|
|
11
|
+
"opencode",
|
|
12
|
+
"cli",
|
|
13
|
+
"context",
|
|
14
|
+
"lint"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/x6txy/ctxscope.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/x6txy/ctxscope/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/x6txy/ctxscope#readme",
|
|
24
|
+
"bin": {
|
|
25
|
+
"ctxscope": "dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup src/cli.ts --format esm --clean",
|
|
35
|
+
"prepack": "pnpm build"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT"
|
|
41
|
+
}
|