@x6txy/ctxscope 0.2.1 → 0.3.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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Added
6
+
7
+ - Added Agent Context Score to `ctxscope doctor`.
8
+ - Added diagnostic line and column metadata where rules can locate the issue.
9
+ - Added recommendations and safe fix metadata to diagnostics.
10
+ - Added `ctxscope fix` with `--dry-run`, `--json`, and `--agent` support.
11
+ - Added deterministic package manager normalization for context files when exactly one lockfile identifies the package manager.
12
+ - Added exact duplicate paragraph removal.
13
+ - Added `node:test` coverage for doctor metadata, scoring, and fix behavior.
14
+
15
+ ### Changed
16
+
17
+ - Repositioned README around repairing stale agent instructions.
18
+ - `CTX102` missing script references are recommendation-only and are not autofixed by default.
19
+
3
20
  ## 0.2.0 - 2026-06-21
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -1,30 +1,31 @@
1
1
  # ctxscope
2
2
 
3
- Inspect and lint coding-agent context files.
3
+ Make AI coding agents follow the right repository instructions.
4
4
 
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.
5
+ `ctxscope` finds and fixes stale, conflicting, duplicated, and wasteful instructions across `AGENTS.md`, `CLAUDE.md`, `SKILL.md`, OpenCode skills, Cursor rules, and GitHub Copilot instructions.
6
6
 
7
- It answers the first questions every agent-heavy repo eventually has:
7
+ It answers the questions every agent-heavy repo eventually has:
8
8
 
9
9
  - What context files exist?
10
10
  - How much context do they add?
11
11
  - Which agent is each file probably for?
12
- - Which files are suspiciously large, empty, duplicated, or stale?
12
+ - Which files are suspiciously large, stale, duplicated, or wrong?
13
13
  - Which context problems should fail CI?
14
+ - Which problems can be fixed safely right now?
14
15
 
15
16
  ## Install
16
17
 
17
18
  Run without installing:
18
19
 
19
20
  ```bash
20
- npx @x6txy/ctxscope scan
21
+ npx @x6txy/ctxscope doctor
21
22
  ```
22
23
 
23
24
  Or install globally:
24
25
 
25
26
  ```bash
26
27
  npm install -g @x6txy/ctxscope
27
- ctxscope scan
28
+ ctxscope doctor
28
29
  ```
29
30
 
30
31
  ## Usage
@@ -32,13 +33,16 @@ ctxscope scan
32
33
  ```bash
33
34
  ctxscope --help
34
35
  ctxscope --version
35
- ctxscope init
36
- ctxscope scan [path]
37
- ctxscope scan --agent <all|codex|opencode|claude|generic>
38
- ctxscope scan --json
39
- ctxscope doctor [path]
40
- ctxscope doctor --ci
41
- ctxscope doctor --json
36
+ ctxscope init
37
+ ctxscope scan [path]
38
+ ctxscope scan --agent <all|codex|opencode|claude|generic>
39
+ ctxscope scan --json
40
+ ctxscope doctor [path]
41
+ ctxscope doctor --ci
42
+ ctxscope doctor --json
43
+ ctxscope fix [path]
44
+ ctxscope fix --dry-run
45
+ ctxscope fix --json
42
46
  ```
43
47
 
44
48
  Examples:
@@ -50,28 +54,57 @@ ctxscope scan --agent codex
50
54
  ctxscope scan --agent opencode --json
51
55
  ctxscope init
52
56
  ctxscope doctor --ci
57
+ ctxscope fix --dry-run
58
+ ctxscope fix
53
59
  ```
54
60
 
55
61
  ## Output
56
62
 
57
63
  ```text
58
- ctxscope scan
64
+ ctxscope doctor
59
65
 
60
66
  Agent all
61
67
  Target /repo
68
+ Status fail
62
69
 
63
- Files (3)
64
- Path Tokens Agents
65
- .opencode/skills/backend/SKILL.md ~6 opencode, generic
66
- AGENTS.md ~13 codex, opencode, claude, generic
67
- src/AGENTS.md ~3 codex, opencode, claude, generic
70
+ Agent Context Score 64/100
71
+ Correctness 56
72
+ Freshness 90
73
+ Efficiency 76
74
+ Consistency 78
75
+ Coverage 100
68
76
 
69
77
  Summary
70
- 3 files, ~22 tokens, 4 warnings
78
+ 4 files, ~9,200 tokens, 2 errors, 3 warnings
79
+ Run ctxscope fix to apply 1 safe fix.
80
+
81
+ Errors (2)
82
+ ERROR CTX101 AGENTS.md:18
83
+ conflicting package managers: npm, pnpm
84
+ Fix: Normalize package manager commands
71
85
 
72
- Warnings (4)
73
- WARN CTX002 AGENTS.md
74
- heading "testing" appears in 2 context files
86
+ ERROR CTX102 AGENTS.md:24
87
+ references missing package script: test:e2e
88
+ Recommendation: remove the command or replace it with an existing package.json script
89
+ ```
90
+
91
+ `ctxscope fix` applies only deterministic safe fixes:
92
+
93
+ ```text
94
+ ctxscope fix
95
+
96
+ Target /repo
97
+ Mode write
98
+
99
+ Summary
100
+ Agent Context Score 64 -> 82
101
+ Applied 1 safe fix
102
+ Skipped 1 fix
103
+ Saved ~420 tokens per session
104
+
105
+ Applied (1)
106
+ CTX101 AGENTS.md
107
+ Normalize package manager commands to pnpm
75
108
  ```
76
109
 
77
110
  ## Supported Agents
@@ -119,6 +152,8 @@ ctxscope doctor --json
119
152
 
120
153
  `--ci` exits with code `1` when any diagnostic has severity `error`.
121
154
 
155
+ `doctor` also reports Agent Context Score, line numbers, safe fix hints, and recommendations.
156
+
122
157
  Example output:
123
158
 
124
159
  ```text
@@ -130,15 +165,37 @@ Status fail
130
165
 
131
166
  Summary
132
167
  4 files, ~9,200 tokens, 2 errors, 1 warnings
168
+ Run ctxscope fix to apply 1 safe fix.
133
169
 
134
170
  Errors (2)
135
- ERROR CTX101 AGENTS.md
171
+ ERROR CTX101 AGENTS.md:18
136
172
  conflicting package managers: npm, pnpm
173
+ Fix: Normalize package manager commands
137
174
 
138
- ERROR CTX102 AGENTS.md
175
+ ERROR CTX102 AGENTS.md:24
139
176
  references missing package script: test:e2e
177
+ Recommendation: remove the command or replace it with an existing package.json script
140
178
  ```
141
179
 
180
+ ## Fix
181
+
182
+ Use `fix` to apply deterministic repairs:
183
+
184
+ ```bash
185
+ ctxscope fix --dry-run
186
+ ctxscope fix
187
+ ctxscope fix --json
188
+ ```
189
+
190
+ Safe v0.3 autofixes:
191
+
192
+ - Normalize package manager command prefixes when exactly one lockfile identifies the repo package manager.
193
+ - Remove exact duplicate paragraphs after the first occurrence.
194
+
195
+ Recommendation-only in v0.3:
196
+
197
+ - `CTX102` missing package script references are not autofixed because replacing commands can break workflows.
198
+
142
199
  ## Config
143
200
 
144
201
  Create a default config:
@@ -207,9 +264,9 @@ Shape:
207
264
  ## Limitations
208
265
 
209
266
  - Token counts are estimates: `ceil(character_count / 4)`.
210
- - v0.2 is discovery-based, not real session tracing.
267
+ - ctxscope is discovery-based, not real session tracing.
211
268
  - Semantic checks are intentionally conservative. They inspect explicit commands and context text, not model behavior.
212
- - `diff` and `trace` are future commands.
269
+ - `trace`, GitHub Actions, AI fixes, and dashboards are future work.
213
270
 
214
271
  ## License
215
272
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync6 } from "fs";
4
+ import { readFileSync as readFileSync7 } from "fs";
5
5
 
6
6
  // src/config.ts
7
7
  import { existsSync, readFileSync } from "fs";
@@ -235,7 +235,11 @@ function createDiagnostic(input, config) {
235
235
  code: input.code,
236
236
  severity,
237
237
  path: input.path,
238
- message: input.message
238
+ message: input.message,
239
+ line: input.line,
240
+ column: input.column,
241
+ recommendation: input.recommendation,
242
+ fix: input.fix
239
243
  };
240
244
  }
241
245
  function sortDiagnostics(diagnostics) {
@@ -245,6 +249,29 @@ function sortDiagnostics(diagnostics) {
245
249
  });
246
250
  }
247
251
 
252
+ // src/locations.ts
253
+ function locationFromOffset(content, offset) {
254
+ const safeOffset = Math.max(0, Math.min(offset, content.length));
255
+ let line = 1;
256
+ let column = 1;
257
+ for (let index = 0; index < safeOffset; index += 1) {
258
+ if (content[index] === "\n") {
259
+ line += 1;
260
+ column = 1;
261
+ } else {
262
+ column += 1;
263
+ }
264
+ }
265
+ return { line, column };
266
+ }
267
+ function firstRegexLocation(content, pattern) {
268
+ const match = pattern.exec(content);
269
+ if (!match || match.index === void 0) {
270
+ return void 0;
271
+ }
272
+ return locationFromOffset(content, match.index);
273
+ }
274
+
248
275
  // src/warnings.ts
249
276
  var MARKER_PATTERN = /\b(TODO|FIXME|OBSOLETE)\b/i;
250
277
  var MARKDOWN_LINK_PATTERN = /(?<!!)\[[^\]\n]+\]\(([^)]+)\)/g;
@@ -282,11 +309,15 @@ function collectFileDiagnostics(input, config) {
282
309
  }, config));
283
310
  }
284
311
  if (MARKER_PATTERN.test(content)) {
312
+ const location = firstRegexLocation(content, new RegExp(MARKER_PATTERN.source, MARKER_PATTERN.flags));
285
313
  pushDiagnostic(diagnostics, createDiagnostic({
286
314
  code: "CTX005",
287
315
  defaultSeverity: "warn",
288
316
  path: file.path,
289
- message: "contains TODO, FIXME, or obsolete markers"
317
+ message: "contains TODO, FIXME, or obsolete markers",
318
+ line: location == null ? void 0 : location.line,
319
+ column: location == null ? void 0 : location.column,
320
+ recommendation: "remove stale TODO, FIXME, or obsolete instructions from agent context"
290
321
  }, config));
291
322
  }
292
323
  diagnostics.push(...collectStaleLinkDiagnostics(input.absolutePath, file.path, content, config));
@@ -346,29 +377,55 @@ function collectStaleLinkDiagnostics(absolutePath, displayPath, content, config)
346
377
  code: "CTX003",
347
378
  defaultSeverity: "warn",
348
379
  path: displayPath,
349
- message: `links to missing file: ${href}`
380
+ message: `links to missing file: ${href}`,
381
+ line: match.index === void 0 ? void 0 : locationFromOffset(content, match.index).line,
382
+ column: match.index === void 0 ? void 0 : locationFromOffset(content, match.index).column,
383
+ recommendation: "remove the stale link or update it to an existing file"
350
384
  }, config));
351
385
  }
352
386
  }
353
387
  return diagnostics;
354
388
  }
355
389
  function collectRepeatedParagraphDiagnostics(displayPath, content, config) {
356
- const paragraphs = content.split(/\n\s*\n/).map((paragraph) => paragraph.trim().replace(/\s+/g, " ")).filter((paragraph) => paragraph.length >= 40);
390
+ const paragraphs = collectParagraphs(content);
357
391
  const seen = /* @__PURE__ */ new Set();
358
392
  for (const paragraph of paragraphs) {
359
- if (seen.has(paragraph)) {
393
+ if (seen.has(paragraph.normalized)) {
394
+ const location = locationFromOffset(content, paragraph.offset);
360
395
  const diagnostic = createDiagnostic({
361
396
  code: "CTX006",
362
397
  defaultSeverity: "warn",
363
398
  path: displayPath,
364
- message: "contains a repeated paragraph"
399
+ message: "contains a repeated paragraph",
400
+ line: location.line,
401
+ column: location.column,
402
+ fix: {
403
+ title: "Remove repeated paragraph",
404
+ kind: "delete",
405
+ safe: true
406
+ }
365
407
  }, config);
366
408
  return diagnostic ? [diagnostic] : [];
367
409
  }
368
- seen.add(paragraph);
410
+ seen.add(paragraph.normalized);
369
411
  }
370
412
  return [];
371
413
  }
414
+ function collectParagraphs(content) {
415
+ var _a;
416
+ const paragraphs = [];
417
+ const pattern = /(^|\n)([^\S\r\n]*\S[\s\S]*?)(?=\n\s*\n|$)/g;
418
+ for (const match of content.matchAll(pattern)) {
419
+ const raw = match[2] ?? "";
420
+ const trimmedStart = raw.search(/\S/);
421
+ const offset = (match.index ?? 0) + (((_a = match[1]) == null ? void 0 : _a.length) ?? 0) + Math.max(trimmedStart, 0);
422
+ const normalized = raw.trim().replace(/\s+/g, " ");
423
+ if (normalized.length >= 40) {
424
+ paragraphs.push({ normalized, offset });
425
+ }
426
+ }
427
+ return paragraphs;
428
+ }
372
429
  function shouldSkipLink(href) {
373
430
  return href.startsWith("http://") || href.startsWith("https://") || href.startsWith("mailto:") || href.startsWith("#");
374
431
  }
@@ -428,13 +485,14 @@ function collectBudgetDiagnostics(scan, config) {
428
485
  code: "CTX105",
429
486
  defaultSeverity: "error",
430
487
  path: scan.target,
431
- message: `total context is ~${scan.totalTokens} tokens, budget is ${config.maxTokens}`
488
+ message: `total context is ~${scan.totalTokens} tokens, budget is ${config.maxTokens}`,
489
+ recommendation: "remove duplicated or low-value instructions, or raise maxTokens in ctxscope.config.json"
432
490
  }, config);
433
491
  return diagnostic ? [diagnostic] : [];
434
492
  }
435
493
 
436
494
  // src/rules/package-manager.ts
437
- import { readFileSync as readFileSync4, statSync as statSync3 } from "fs";
495
+ import { existsSync as existsSync3, readFileSync as readFileSync4, statSync as statSync3 } from "fs";
438
496
  import { dirname as dirname3, resolve as resolve5 } from "path";
439
497
  var PACKAGE_MANAGER_PATTERNS = [
440
498
  ["npm", /\bnpm\s+(?:run\s+)?[a-z0-9:_-]+\b/i],
@@ -442,22 +500,46 @@ var PACKAGE_MANAGER_PATTERNS = [
442
500
  ["yarn", /\byarn\s+(?:run\s+)?[a-z0-9:_-]+\b/i],
443
501
  ["bun", /\bbun\s+(?:run\s+)?[a-z0-9:_-]+\b/i]
444
502
  ];
503
+ var LOCKFILES = [
504
+ ["pnpm", "pnpm-lock.yaml"],
505
+ ["npm", "package-lock.json"],
506
+ ["yarn", "yarn.lock"],
507
+ ["bun", "bun.lock"],
508
+ ["bun", "bun.lockb"]
509
+ ];
445
510
  function collectPackageManagerDiagnostics(target, files, config) {
446
511
  const root = getScanRoot2(target);
447
512
  const mentions = files.flatMap((file) => collectPackageManagerMentions(root, file));
448
513
  const managers = [...new Set(mentions.map((mention) => mention.manager))].sort();
514
+ const lockfileManager = detectPackageManagerFromLockfiles(root);
449
515
  if (managers.length < 2) {
450
516
  return [];
451
517
  }
452
518
  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);
519
+ const diagnostics = involvedPaths.map((path) => {
520
+ const mention = mentions.find((candidate) => candidate.path === path);
521
+ return createDiagnostic({
522
+ code: "CTX101",
523
+ defaultSeverity: "error",
524
+ path,
525
+ message: `conflicting package managers: ${managers.join(", ")}`,
526
+ line: mention == null ? void 0 : mention.line,
527
+ column: mention == null ? void 0 : mention.column,
528
+ recommendation: "use one package manager consistently in agent instructions",
529
+ fix: lockfileManager ? {
530
+ title: "Normalize package manager commands",
531
+ kind: "replace",
532
+ safe: true
533
+ } : void 0
534
+ }, config);
535
+ }).filter((diagnostic) => diagnostic !== null);
459
536
  return sortDiagnostics(diagnostics);
460
537
  }
538
+ function detectPackageManagerFromLockfiles(root) {
539
+ const detected = LOCKFILES.filter(([, lockfile]) => existsSync3(resolve5(root, lockfile))).map(([manager]) => manager);
540
+ const unique = [...new Set(detected)];
541
+ return unique.length === 1 ? unique[0] ?? null : null;
542
+ }
461
543
  function collectPackageManagerMentions(root, file) {
462
544
  if (file.skippedBinary) {
463
545
  return [];
@@ -466,8 +548,9 @@ function collectPackageManagerMentions(root, file) {
466
548
  const content = readFileSync4(absolutePath, "utf8");
467
549
  const mentions = [];
468
550
  for (const [manager, pattern] of PACKAGE_MANAGER_PATTERNS) {
469
- if (pattern.test(content)) {
470
- mentions.push({ manager, path: file.path });
551
+ const location = firstRegexLocation(content, new RegExp(pattern.source, pattern.flags));
552
+ if (location) {
553
+ mentions.push({ manager, path: file.path, line: location.line, column: location.column });
471
554
  }
472
555
  }
473
556
  return mentions;
@@ -479,7 +562,7 @@ function getScanRoot2(target) {
479
562
  }
480
563
 
481
564
  // src/rules/package-scripts.ts
482
- import { existsSync as existsSync3, readFileSync as readFileSync5, statSync as statSync4 } from "fs";
565
+ import { existsSync as existsSync4, readFileSync as readFileSync5, statSync as statSync4 } from "fs";
483
566
  import { dirname as dirname4, resolve as resolve6 } from "path";
484
567
  var SCRIPT_PATTERNS = [
485
568
  /\bnpm\s+run\s+([a-z0-9:_-]+)\b/gi,
@@ -509,7 +592,10 @@ function collectPackageScriptDiagnostics(target, files, config) {
509
592
  code: "CTX102",
510
593
  defaultSeverity: "error",
511
594
  path: mention.path,
512
- message: `references missing package script: ${mention.script}`
595
+ message: `references missing package script: ${mention.script}`,
596
+ line: mention.line,
597
+ column: mention.column,
598
+ recommendation: "remove the command or replace it with an existing package.json script"
513
599
  }, config);
514
600
  if (diagnostic) {
515
601
  diagnostics.push(diagnostic);
@@ -529,7 +615,8 @@ function collectScriptMentions(root, file) {
529
615
  for (const match of content.matchAll(pattern)) {
530
616
  const script = match[1];
531
617
  if (script) {
532
- mentions.push({ path: file.path, script });
618
+ const location = locationFromOffset(content, match.index ?? 0);
619
+ mentions.push({ path: file.path, script, line: location.line, column: location.column });
533
620
  }
534
621
  }
535
622
  }
@@ -537,7 +624,7 @@ function collectScriptMentions(root, file) {
537
624
  }
538
625
  function readPackageScripts(root) {
539
626
  const packageJsonPath = resolve6(root, "package.json");
540
- if (!existsSync3(packageJsonPath)) {
627
+ if (!existsSync4(packageJsonPath)) {
541
628
  return null;
542
629
  }
543
630
  const parsed = JSON.parse(readFileSync5(packageJsonPath, "utf8"));
@@ -549,6 +636,39 @@ function getScanRoot3(target) {
549
636
  return stat.isDirectory() ? absoluteTarget : dirname4(absoluteTarget);
550
637
  }
551
638
 
639
+ // src/score.ts
640
+ var CATEGORY_RULES = {
641
+ correctness: ["CTX101", "CTX102"],
642
+ freshness: ["CTX003", "CTX005"],
643
+ efficiency: ["CTX001", "CTX006", "CTX105"],
644
+ consistency: ["CTX002", "CTX101"],
645
+ coverage: []
646
+ };
647
+ function calculateContextScore(scan, diagnostics, config) {
648
+ const errors = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
649
+ const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === "warn").length;
650
+ const budgetPenalty = scan.totalTokens > config.maxTokens ? Math.min(Math.ceil((scan.totalTokens - config.maxTokens) / config.maxTokens * 20), 20) : 0;
651
+ const duplicationPenalty = Math.min(diagnostics.filter((diagnostic) => diagnostic.code === "CTX006").length * 8, 16);
652
+ const overall = clampScore(100 - Math.min(errors * 18, 54) - Math.min(warnings * 6, 30) - budgetPenalty - duplicationPenalty);
653
+ return {
654
+ overall,
655
+ correctness: categoryScore(diagnostics, CATEGORY_RULES.correctness),
656
+ freshness: categoryScore(diagnostics, CATEGORY_RULES.freshness),
657
+ efficiency: clampScore(categoryScore(diagnostics, CATEGORY_RULES.efficiency) - budgetPenalty - duplicationPenalty),
658
+ consistency: categoryScore(diagnostics, CATEGORY_RULES.consistency),
659
+ coverage: scan.files.length === 0 ? 70 : 100
660
+ };
661
+ }
662
+ function categoryScore(diagnostics, codes) {
663
+ const relevant = diagnostics.filter((diagnostic) => codes.includes(diagnostic.code));
664
+ const errors = relevant.filter((diagnostic) => diagnostic.severity === "error").length;
665
+ const warnings = relevant.filter((diagnostic) => diagnostic.severity === "warn").length;
666
+ return clampScore(100 - Math.min(errors * 22, 66) - Math.min(warnings * 10, 40));
667
+ }
668
+ function clampScore(score) {
669
+ return Math.max(0, Math.min(100, Math.round(score)));
670
+ }
671
+
552
672
  // src/doctor.ts
553
673
  function runDoctor(target, agent, config) {
554
674
  const scan = scanContext(target, agent, config);
@@ -570,14 +690,159 @@ function runDoctor(target, agent, config) {
570
690
  warnings,
571
691
  errors
572
692
  },
693
+ score: calculateContextScore(scan, diagnostics, config),
573
694
  files: scan.files,
574
695
  diagnostics
575
696
  };
576
697
  }
577
698
 
699
+ // src/fix.ts
700
+ import { readFileSync as readFileSync6, statSync as statSync5, writeFileSync } from "fs";
701
+ import { dirname as dirname5, resolve as resolve7 } from "path";
702
+ function runFix(options, config) {
703
+ const root = getScanRoot4(options.target);
704
+ const before = runDoctor(options.target, options.agent, config);
705
+ const skipped = collectSkippedFixes(before);
706
+ const edits = collectSafeEdits(root, before.files);
707
+ const applied = [];
708
+ for (const edit of edits) {
709
+ applied.push({
710
+ code: edit.code,
711
+ path: edit.path,
712
+ title: edit.title,
713
+ dryRun: options.dryRun
714
+ });
715
+ if (!options.dryRun) {
716
+ writeFileSync(resolve7(root, edit.path), edit.content);
717
+ }
718
+ }
719
+ const after = options.dryRun ? before : runDoctor(options.target, options.agent, config);
720
+ return {
721
+ target: options.target,
722
+ applied,
723
+ skipped,
724
+ before,
725
+ after
726
+ };
727
+ }
728
+ function collectSafeEdits(root, files) {
729
+ const edits = /* @__PURE__ */ new Map();
730
+ for (const edit of collectPackageManagerEdits(root, files)) {
731
+ edits.set(edit.path, edit);
732
+ }
733
+ for (const edit of collectDuplicateParagraphEdits(root, files)) {
734
+ const current = edits.get(edit.path);
735
+ edits.set(edit.path, current ? { ...edit, content: removeDuplicateParagraphs(current.content) } : edit);
736
+ }
737
+ return [...edits.values()];
738
+ }
739
+ function collectPackageManagerEdits(root, files) {
740
+ const packageManager = detectPackageManagerFromLockfiles(root);
741
+ if (!packageManager) {
742
+ return [];
743
+ }
744
+ return files.flatMap((file) => {
745
+ if (file.skippedBinary) {
746
+ return [];
747
+ }
748
+ const absolutePath = resolve7(root, file.path);
749
+ const content = readFileSync6(absolutePath, "utf8");
750
+ const next = normalizePackageManagerCommands(content, packageManager);
751
+ return next === content ? [] : [{
752
+ code: "CTX101",
753
+ path: file.path,
754
+ title: `Normalize package manager commands to ${packageManager}`,
755
+ content: next
756
+ }];
757
+ });
758
+ }
759
+ function collectDuplicateParagraphEdits(root, files) {
760
+ return files.flatMap((file) => {
761
+ if (file.skippedBinary) {
762
+ return [];
763
+ }
764
+ const absolutePath = resolve7(root, file.path);
765
+ const content = readFileSync6(absolutePath, "utf8");
766
+ const next = removeDuplicateParagraphs(content);
767
+ return next === content ? [] : [{
768
+ code: "CTX006",
769
+ path: file.path,
770
+ title: "Remove repeated paragraph",
771
+ content: next
772
+ }];
773
+ });
774
+ }
775
+ function collectSkippedFixes(before) {
776
+ return before.diagnostics.filter((diagnostic) => diagnostic.code === "CTX102").map((diagnostic) => ({
777
+ code: diagnostic.code,
778
+ path: diagnostic.path,
779
+ title: "Replace missing package script reference",
780
+ reason: "missing package script replacements are recommendations only in v0.3"
781
+ }));
782
+ }
783
+ function normalizePackageManagerCommands(content, packageManager) {
784
+ return content.replace(/\b(npm|pnpm|yarn|bun)\s+(run\s+)?([a-z0-9:_-]+)\b/gi, (match, current, runPrefix, command) => {
785
+ const lowerCommand = command.toLowerCase();
786
+ if (current.toLowerCase() === packageManager && commandFormMatches(packageManager, runPrefix, lowerCommand)) {
787
+ return match;
788
+ }
789
+ if (["install", "add", "remove"].includes(lowerCommand)) {
790
+ return `${packageManager} ${command}`;
791
+ }
792
+ if (["exec", "dlx", "create", "init"].includes(lowerCommand)) {
793
+ return match;
794
+ }
795
+ if (packageManager === "npm") {
796
+ return `npm run ${command}`;
797
+ }
798
+ if (packageManager === "bun") {
799
+ return `bun run ${command}`;
800
+ }
801
+ return `${packageManager} ${command}`;
802
+ });
803
+ }
804
+ function removeDuplicateParagraphs(content) {
805
+ const parts = content.split(/(\n\s*\n)/);
806
+ const seen = /* @__PURE__ */ new Set();
807
+ const kept = [];
808
+ for (let index = 0; index < parts.length; index += 1) {
809
+ const part = parts[index] ?? "";
810
+ if (index % 2 === 1) {
811
+ kept.push(part);
812
+ continue;
813
+ }
814
+ const normalized = part.trim().replace(/\s+/g, " ");
815
+ if (normalized.length >= 40 && seen.has(normalized)) {
816
+ if (kept.length > 0 && /^\n\s*\n$/.test(kept[kept.length - 1] ?? "")) {
817
+ kept.pop();
818
+ }
819
+ continue;
820
+ }
821
+ if (normalized.length >= 40) {
822
+ seen.add(normalized);
823
+ }
824
+ kept.push(part);
825
+ }
826
+ return kept.join("");
827
+ }
828
+ function commandFormMatches(packageManager, runPrefix, command) {
829
+ if (["install", "add", "remove", "exec", "dlx", "create", "init"].includes(command)) {
830
+ return !runPrefix;
831
+ }
832
+ if (packageManager === "npm" || packageManager === "bun") {
833
+ return Boolean(runPrefix);
834
+ }
835
+ return !runPrefix;
836
+ }
837
+ function getScanRoot4(target) {
838
+ const absoluteTarget = resolve7(target);
839
+ const stat = statSync5(absoluteTarget);
840
+ return stat.isDirectory() ? absoluteTarget : dirname5(absoluteTarget);
841
+ }
842
+
578
843
  // src/init.ts
579
- import { existsSync as existsSync4, writeFileSync } from "fs";
580
- import { resolve as resolve7 } from "path";
844
+ import { existsSync as existsSync5, writeFileSync as writeFileSync2 } from "fs";
845
+ import { resolve as resolve8 } from "path";
581
846
  var InitError = class extends Error {
582
847
  constructor(message) {
583
848
  super(message);
@@ -585,11 +850,11 @@ var InitError = class extends Error {
585
850
  }
586
851
  };
587
852
  function initConfig(cwd = process.cwd()) {
588
- const path = resolve7(cwd, CONFIG_FILE_NAME);
589
- if (existsSync4(path)) {
853
+ const path = resolve8(cwd, CONFIG_FILE_NAME);
854
+ if (existsSync5(path)) {
590
855
  throw new InitError(`${CONFIG_FILE_NAME} already exists`);
591
856
  }
592
- writeFileSync(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
857
+ writeFileSync2(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}
593
858
  `, "utf8");
594
859
  return { path };
595
860
  }
@@ -627,6 +892,7 @@ function formatHumanDoctorResult(result) {
627
892
  const sections = [
628
893
  colors.bold(colors.cyan("ctxscope doctor")),
629
894
  formatDoctorMeta(result),
895
+ formatDoctorScore(result),
630
896
  formatDoctorSummary(result),
631
897
  formatDoctorDiagnostics(result)
632
898
  ];
@@ -638,15 +904,43 @@ function formatJsonDoctorResult(result) {
638
904
  target: result.target,
639
905
  status: result.status,
640
906
  summary: result.summary,
907
+ score: result.score,
641
908
  files: result.files,
642
909
  diagnostics: result.diagnostics
643
910
  }, null, 2);
644
911
  }
912
+ function formatHumanFixResult(result) {
913
+ const sections = [
914
+ colors.bold(colors.cyan("ctxscope fix")),
915
+ formatFixMeta(result),
916
+ formatFixSummary(result),
917
+ formatAppliedFixes(result),
918
+ formatSkippedFixes(result)
919
+ ];
920
+ return sections.filter(Boolean).join("\n\n");
921
+ }
922
+ function formatJsonFixResult(result) {
923
+ return JSON.stringify({
924
+ target: result.target,
925
+ applied: result.applied,
926
+ skipped: result.skipped,
927
+ before: result.before,
928
+ after: result.after
929
+ }, null, 2);
930
+ }
645
931
  function formatWarning(warning) {
932
+ var _a;
646
933
  const severity = colorSeverity(warning);
647
934
  const code = warning.severity === "error" ? colors.red(warning.code) : colors.yellow(warning.code);
648
- return `${severity} ${code} ${warning.path}
649
- ${colors.dim(warning.message)}`;
935
+ const location = warning.line === void 0 ? warning.path : `${warning.path}:${warning.line}`;
936
+ const details = [colors.dim(warning.message)];
937
+ if ((_a = warning.fix) == null ? void 0 : _a.safe) {
938
+ details.push(`${colors.dim("Fix:")} ${warning.fix.title}`);
939
+ } else if (warning.recommendation) {
940
+ details.push(`${colors.dim("Recommendation:")} ${warning.recommendation}`);
941
+ }
942
+ return `${severity} ${code} ${location}
943
+ ${details.join("\n ")}`;
650
944
  }
651
945
  function colorSeverity(diagnostic) {
652
946
  return diagnostic.severity === "error" ? colors.red(diagnostic.severity.toUpperCase()) : colors.yellow(diagnostic.severity.toUpperCase());
@@ -707,8 +1001,33 @@ function formatDoctorSummary(result) {
707
1001
  result.summary.errors > 0 ? colors.red(`${result.summary.errors} errors`) : null,
708
1002
  result.summary.warnings > 0 ? colors.yellow(`${result.summary.warnings} warnings`) : null
709
1003
  ].filter(Boolean).join(", ");
1004
+ const safeFixes = result.diagnostics.filter((diagnostic) => {
1005
+ var _a;
1006
+ return (_a = diagnostic.fix) == null ? void 0 : _a.safe;
1007
+ }).length;
1008
+ const fixLine = safeFixes > 0 ? `
1009
+ Run ctxscope fix to apply ${safeFixes} safe ${safeFixes === 1 ? "fix" : "fixes"}.` : "";
710
1010
  return `${colors.bold("Summary")}
711
- ${formatNumber(result.summary.files)} files, ~${formatNumber(result.summary.totalTokens)} tokens, ${diagnosticLabel}`;
1011
+ ${formatNumber(result.summary.files)} files, ~${formatNumber(result.summary.totalTokens)} tokens, ${diagnosticLabel}${fixLine}`;
1012
+ }
1013
+ function formatDoctorScore(result) {
1014
+ return [
1015
+ `${colors.bold("Agent Context Score")} ${scoreColor(result.score.overall)(`${result.score.overall}/100`)}`,
1016
+ ` Correctness ${scoreColor(result.score.correctness)(String(result.score.correctness))}`,
1017
+ ` Freshness ${scoreColor(result.score.freshness)(String(result.score.freshness))}`,
1018
+ ` Efficiency ${scoreColor(result.score.efficiency)(String(result.score.efficiency))}`,
1019
+ ` Consistency ${scoreColor(result.score.consistency)(String(result.score.consistency))}`,
1020
+ ` Coverage ${scoreColor(result.score.coverage)(String(result.score.coverage))}`
1021
+ ].join("\n");
1022
+ }
1023
+ function scoreColor(score) {
1024
+ if (score >= 80) {
1025
+ return colors.green;
1026
+ }
1027
+ if (score >= 60) {
1028
+ return colors.yellow;
1029
+ }
1030
+ return colors.red;
712
1031
  }
713
1032
  function formatDoctorDiagnostics(result) {
714
1033
  if (result.diagnostics.length === 0) {
@@ -727,6 +1046,44 @@ ${warnings.map(formatWarning).join("\n")}`);
727
1046
  }
728
1047
  return sections.join("\n\n");
729
1048
  }
1049
+ function formatFixMeta(result) {
1050
+ const dryRun = result.applied.some((fix) => fix.dryRun);
1051
+ return [
1052
+ `${colors.dim("Target")} ${result.target}`,
1053
+ `${colors.dim("Mode")} ${dryRun ? "dry-run" : "write"}`
1054
+ ].join("\n");
1055
+ }
1056
+ function formatFixSummary(result) {
1057
+ const beforeScore = result.before.score.overall;
1058
+ const afterScore = result.after.score.overall;
1059
+ const beforeTokens = result.before.summary.totalTokens;
1060
+ const afterTokens = result.after.summary.totalTokens;
1061
+ const savedTokens = Math.max(0, beforeTokens - afterTokens);
1062
+ const action = result.applied.some((fix) => fix.dryRun) ? "Would apply" : "Applied";
1063
+ return [
1064
+ `${colors.bold("Summary")}`,
1065
+ ` Agent Context Score ${scoreColor(afterScore)(`${beforeScore} -> ${afterScore}`)}`,
1066
+ ` ${action} ${result.applied.length} safe ${result.applied.length === 1 ? "fix" : "fixes"}`,
1067
+ ` Skipped ${result.skipped.length} ${result.skipped.length === 1 ? "fix" : "fixes"}`,
1068
+ ` Saved ~${formatNumber(savedTokens)} tokens per session`
1069
+ ].join("\n");
1070
+ }
1071
+ function formatAppliedFixes(result) {
1072
+ if (result.applied.length === 0) {
1073
+ return "";
1074
+ }
1075
+ return `${colors.bold(result.applied.some((fix) => fix.dryRun) ? "Would Apply" : "Applied")} ${colors.dim(`(${result.applied.length})`)}
1076
+ ${result.applied.map((fix) => `${fix.code} ${fix.path}
1077
+ ${colors.dim(fix.title)}`).join("\n")}`;
1078
+ }
1079
+ function formatSkippedFixes(result) {
1080
+ if (result.skipped.length === 0) {
1081
+ return "";
1082
+ }
1083
+ return `${colors.bold("Skipped")} ${colors.dim(`(${result.skipped.length})`)}
1084
+ ${result.skipped.map((fix) => `${fix.code} ${fix.path}
1085
+ ${colors.dim(`${fix.title}: ${fix.reason}`)}`).join("\n")}`;
1086
+ }
730
1087
  function formatTokenCell(tokens, skippedBinary) {
731
1088
  return skippedBinary ? "binary" : `~${formatNumber(tokens)}`;
732
1089
  }
@@ -744,7 +1101,7 @@ var SUPPORTED_AGENTS = ["all", "codex", "opencode", "claude", "generic"];
744
1101
  function getVersion() {
745
1102
  try {
746
1103
  const packageJson = JSON.parse(
747
- readFileSync6(new URL("../package.json", import.meta.url), "utf8")
1104
+ readFileSync7(new URL("../package.json", import.meta.url), "utf8")
748
1105
  );
749
1106
  return packageJson.version ?? "0.0.0";
750
1107
  } catch {
@@ -762,17 +1119,20 @@ Usage:
762
1119
  ctxscope init
763
1120
  ctxscope scan [path] [--agent <agent>] [--json]
764
1121
  ctxscope doctor [path] [--agent <agent>] [--json] [--ci]
1122
+ ctxscope fix [path] [--agent <agent>] [--dry-run] [--json]
765
1123
 
766
1124
  Commands:
767
1125
  init Create ctxscope.config.json.
768
1126
  scan Discover coding-agent context files for a path.
769
1127
  doctor Lint coding-agent context files.
1128
+ fix Apply safe deterministic context fixes.
770
1129
 
771
1130
  Options:
772
1131
  --agent <agent> Agent profile: all, codex, opencode, claude, generic.
773
1132
  Default: all.
774
1133
  --json Print machine-readable JSON.
775
1134
  --ci Exit 1 when doctor finds errors.
1135
+ --dry-run Show fixes without writing files.
776
1136
  -h, --help Show this help message.
777
1137
  -v, --version Show the package version.
778
1138
  `);
@@ -870,6 +1230,48 @@ function parseDoctorOptions(args) {
870
1230
  }
871
1231
  return options;
872
1232
  }
1233
+ function parseFixOptions(args) {
1234
+ const options = {
1235
+ agent: "all",
1236
+ dryRun: false,
1237
+ json: false,
1238
+ target: "."
1239
+ };
1240
+ let targetSet = false;
1241
+ for (let index = 0; index < args.length; index += 1) {
1242
+ const arg = args[index];
1243
+ if (arg === "--help" || arg === "-h") {
1244
+ printHelp();
1245
+ process.exit(0);
1246
+ }
1247
+ if (arg === "--dry-run") {
1248
+ options.dryRun = true;
1249
+ continue;
1250
+ }
1251
+ if (arg === "--json") {
1252
+ options.json = true;
1253
+ continue;
1254
+ }
1255
+ if (arg === "--agent") {
1256
+ options.agent = parseAgent(args[index + 1]);
1257
+ index += 1;
1258
+ continue;
1259
+ }
1260
+ if (arg.startsWith("--agent=")) {
1261
+ options.agent = parseAgent(arg.slice("--agent=".length));
1262
+ continue;
1263
+ }
1264
+ if (arg.startsWith("-")) {
1265
+ fail(`unknown option '${arg}'`);
1266
+ }
1267
+ if (targetSet) {
1268
+ fail(`unexpected extra path '${arg}'`);
1269
+ }
1270
+ options.target = arg;
1271
+ targetSet = true;
1272
+ }
1273
+ return options;
1274
+ }
873
1275
  function runScan(options) {
874
1276
  const config = loadConfig();
875
1277
  const result = scanContext(options.target, options.agent, config);
@@ -891,6 +1293,15 @@ function runDoctorCommand(options) {
891
1293
  process.exit(1);
892
1294
  }
893
1295
  }
1296
+ function runFixCommand(options) {
1297
+ const config = loadConfig();
1298
+ const result = runFix(options, config);
1299
+ if (options.json) {
1300
+ console.log(formatJsonFixResult(result));
1301
+ return;
1302
+ }
1303
+ console.log(formatHumanFixResult(result));
1304
+ }
894
1305
  function runInitCommand() {
895
1306
  const result = initConfig();
896
1307
  console.log(`Created ${result.path}`);
@@ -941,6 +1352,17 @@ function main(argv) {
941
1352
  }
942
1353
  return;
943
1354
  }
1355
+ if (command === "fix") {
1356
+ try {
1357
+ runFixCommand(parseFixOptions(args));
1358
+ } catch (error) {
1359
+ if (error instanceof ConfigError) {
1360
+ fail(error.message);
1361
+ }
1362
+ throw error;
1363
+ }
1364
+ return;
1365
+ }
944
1366
  fail(`unknown command '${command}'`);
945
1367
  }
946
1368
  main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x6txy/ctxscope",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Inspect and lint coding-agent context files",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -32,6 +32,7 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "build": "tsup src/cli.ts --format esm --clean",
35
+ "test": "pnpm build && tsup test/*.test.ts --format esm --platform node --out-dir dist-test --clean && node --test dist-test/*.test.js",
35
36
  "prepack": "pnpm build"
36
37
  },
37
38
  "engines": {