@x6txy/ctxscope 0.1.0 → 0.2.1

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 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 warnings only.
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.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.
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 readFileSync3 } from "fs";
4
+ import { readFileSync as readFileSync6 } from "fs";
5
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)
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
- 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");
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 formatFiles(result) {
45
- if (result.files.length === 0) {
46
- return `${colors.bold("Files")} ${colors.dim("(0)")}
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
- 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")}`;
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 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}`;
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 formatWarnings(result) {
71
- if (result.warnings.length === 0) {
72
- return "";
70
+ function readStringArray(value, key, fallback) {
71
+ const field = value[key];
72
+ if (field === void 0) {
73
+ return [...fallback];
73
74
  }
74
- return `${colors.bold("Warnings")} ${colors.dim(`(${result.warnings.length})`)}
75
- ${result.warnings.map(formatWarning).join("\n")}`;
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 formatTokenCell(tokens, skippedBinary) {
78
- return skippedBinary ? "binary" : `~${formatNumber(tokens)}`;
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 formatNumber(value) {
81
- return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
97
+ function isObject(value) {
98
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
99
  }
83
- function color(code, value) {
84
- return colorEnabled ? `${code}${value}\x1B[0m` : value;
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 resolve3 } from "path";
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 = resolve(targetPath);
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 = resolve(directory, entry.name);
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 = readFileSync(path);
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 readFileSync2 } from "fs";
209
- import { dirname, resolve as resolve2 } from "path";
210
- var OVERSIZED_TOKEN_LIMIT = 2500;
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 collectWarnings(files) {
214
- const warnings = [];
251
+ function collectDiagnostics(files, config) {
252
+ const diagnostics = [];
215
253
  const headings = /* @__PURE__ */ new Map();
216
254
  for (const input of files) {
217
- warnings.push(...collectFileWarnings(input));
255
+ diagnostics.push(...collectFileDiagnostics(input, config));
218
256
  collectHeadings(input, headings);
219
257
  }
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
- });
258
+ diagnostics.push(...collectDuplicateHeadingDiagnostics(headings, config));
259
+ return sortDiagnostics(diagnostics);
225
260
  }
226
- function collectFileWarnings(input) {
227
- const warnings = [];
261
+ function collectFileDiagnostics(input, config) {
262
+ const diagnostics = [];
228
263
  const { file } = input;
229
264
  if (file.skippedBinary) {
230
- return warnings;
265
+ return diagnostics;
231
266
  }
232
- const content = readFileSync2(input.absolutePath, "utf8");
233
- if (file.tokens > OVERSIZED_TOKEN_LIMIT) {
234
- warnings.push({
267
+ const content = readFileSync3(input.absolutePath, "utf8");
268
+ if (file.tokens > config.maxFileTokens) {
269
+ pushDiagnostic(diagnostics, createDiagnostic({
235
270
  code: "CTX001",
236
- severity: "warn",
271
+ defaultSeverity: "warn",
237
272
  path: file.path,
238
- message: `larger than ${OVERSIZED_TOKEN_LIMIT} estimated tokens`
239
- });
273
+ message: `larger than ${config.maxFileTokens} estimated tokens`
274
+ }, config));
240
275
  }
241
276
  if (content.trim().length === 0) {
242
- warnings.push({
277
+ pushDiagnostic(diagnostics, createDiagnostic({
243
278
  code: "CTX004",
244
- severity: "warn",
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
- warnings.push({
285
+ pushDiagnostic(diagnostics, createDiagnostic({
251
286
  code: "CTX005",
252
- severity: "warn",
287
+ defaultSeverity: "warn",
253
288
  path: file.path,
254
289
  message: "contains TODO, FIXME, or obsolete markers"
255
- });
290
+ }, config));
256
291
  }
257
- warnings.push(...collectStaleLinkWarnings(input.absolutePath, file.path, content));
258
- warnings.push(...collectRepeatedParagraphWarnings(file.path, content));
259
- return warnings;
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 = readFileSync2(input.absolutePath, "utf8");
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 collectDuplicateHeadingWarnings(headings) {
282
- const warnings = [];
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
- warnings.push({
324
+ pushDiagnostic(diagnostics, createDiagnostic({
290
325
  code: "CTX002",
291
- severity: "warn",
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 warnings;
332
+ return diagnostics;
298
333
  }
299
- function collectStaleLinkWarnings(absolutePath, displayPath, content) {
334
+ function collectStaleLinkDiagnostics(absolutePath, displayPath, content, config) {
300
335
  var _a, _b;
301
- const warnings = [];
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 || !existsSync(resolve2(directory, pathOnly))) {
310
- warnings.push({
344
+ if (!pathOnly || !existsSync2(resolve3(directory, pathOnly))) {
345
+ pushDiagnostic(diagnostics, createDiagnostic({
311
346
  code: "CTX003",
312
- severity: "warn",
347
+ defaultSeverity: "warn",
313
348
  path: displayPath,
314
349
  message: `links to missing file: ${href}`
315
- });
350
+ }, config));
316
351
  }
317
352
  }
318
- return warnings;
353
+ return diagnostics;
319
354
  }
320
- function collectRepeatedParagraphWarnings(displayPath, content) {
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
- return [{
360
+ const diagnostic = createDiagnostic({
326
361
  code: "CTX006",
327
- severity: "warn",
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: collectWarnings(warningInputs)
409
+ warnings: collectDiagnostics(warningInputs, config)
369
410
  };
370
411
  }
371
412
  function getScanRoot(target) {
372
- const absoluteTarget = resolve3(target);
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
- readFileSync3(new URL("../package.json", import.meta.url), "utf8")
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 result = scanContext(options.target, options.agent);
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
- runScan(parseScanOptions(args));
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}'`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x6txy/ctxscope",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Inspect and lint coding-agent context files",
5
5
  "type": "module",
6
6
  "keywords": [