@yawlabs/ctxlint 0.6.0 → 0.7.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/AGENT_SESSION_LINT_SPEC.md +366 -0
- package/README.md +1 -0
- package/agent-session-lint-rules.json +157 -0
- package/dist/{chunk-PLYBGRJD.js → chunk-DYPYGTPV.js} +539 -11
- package/dist/{cli-XQVUFK2D.js → cli-7DNRBGDO.js} +21 -7
- package/dist/index.js +2 -2
- package/dist/mcp/server.js +578 -12
- package/dist/{server-4SC5VMA7.js → server-ADUSCPPU.js} +42 -2
- package/package.json +12 -3
package/dist/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
if (process.argv.includes("--mcp-server")) {
|
|
5
|
-
await import("./server-
|
|
5
|
+
await import("./server-ADUSCPPU.js");
|
|
6
6
|
} else {
|
|
7
|
-
const { runCli } = await import("./cli-
|
|
7
|
+
const { runCli } = await import("./cli-7DNRBGDO.js");
|
|
8
8
|
await runCli();
|
|
9
9
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -223,20 +223,20 @@ async function scanForMcpConfigs(projectRoot) {
|
|
|
223
223
|
async function scanGlobalMcpConfigs() {
|
|
224
224
|
const found = [];
|
|
225
225
|
const seen = /* @__PURE__ */ new Set();
|
|
226
|
-
const
|
|
226
|
+
const home2 = process.env.HOME || process.env.USERPROFILE || "";
|
|
227
227
|
const globalPaths = [
|
|
228
|
-
path2.join(
|
|
229
|
-
path2.join(
|
|
230
|
-
path2.join(
|
|
231
|
-
path2.join(
|
|
232
|
-
path2.join(
|
|
228
|
+
path2.join(home2, ".claude.json"),
|
|
229
|
+
path2.join(home2, ".claude", "settings.json"),
|
|
230
|
+
path2.join(home2, ".cursor", "mcp.json"),
|
|
231
|
+
path2.join(home2, ".codeium", "windsurf", "mcp_config.json"),
|
|
232
|
+
path2.join(home2, ".aws", "amazonq", "mcp.json")
|
|
233
233
|
];
|
|
234
234
|
if (process.platform === "darwin") {
|
|
235
235
|
globalPaths.push(
|
|
236
|
-
path2.join(
|
|
236
|
+
path2.join(home2, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
237
237
|
);
|
|
238
238
|
} else if (process.platform === "win32") {
|
|
239
|
-
const appData = process.env.APPDATA || path2.join(
|
|
239
|
+
const appData = process.env.APPDATA || path2.join(home2, "AppData", "Roaming");
|
|
240
240
|
globalPaths.push(path2.join(appData, "Claude", "claude_desktop_config.json"));
|
|
241
241
|
}
|
|
242
242
|
for (const filePath of globalPaths) {
|
|
@@ -252,7 +252,7 @@ async function scanGlobalMcpConfigs() {
|
|
|
252
252
|
const target = symlink ? readSymlinkTarget(normalized) : void 0;
|
|
253
253
|
found.push({
|
|
254
254
|
absolutePath: normalized,
|
|
255
|
-
relativePath: "~/" + path2.relative(
|
|
255
|
+
relativePath: "~/" + path2.relative(home2, normalized).replace(/\\/g, "/"),
|
|
256
256
|
isSymlink: symlink,
|
|
257
257
|
symlinkTarget: target,
|
|
258
258
|
type: "mcp-config"
|
|
@@ -2311,9 +2311,494 @@ async function checkMcpRedundancy(configs) {
|
|
|
2311
2311
|
return issues;
|
|
2312
2312
|
}
|
|
2313
2313
|
|
|
2314
|
+
// src/core/session-scanner.ts
|
|
2315
|
+
import { readFile as readFile2, readdir, stat } from "fs/promises";
|
|
2316
|
+
import { dirname as dirname2, join as join4, resolve as resolve4 } from "path";
|
|
2317
|
+
import { existsSync } from "fs";
|
|
2318
|
+
import { simpleGit as simpleGit2 } from "simple-git";
|
|
2319
|
+
|
|
2320
|
+
// src/core/session-parser.ts
|
|
2321
|
+
import { readFile } from "fs/promises";
|
|
2322
|
+
var PATH_PATTERN2 = /(?:^|\s|['"`(])([.~/][^\s'"`),;:!?]+)/g;
|
|
2323
|
+
function decodeProjectDir(dirName) {
|
|
2324
|
+
const parts = dirName.split("--");
|
|
2325
|
+
if (parts.length <= 1) return dirName;
|
|
2326
|
+
if (parts[0].length === 1 && /^[A-Z]$/i.test(parts[0])) {
|
|
2327
|
+
return parts[0] + ":/" + parts.slice(1).join("/");
|
|
2328
|
+
}
|
|
2329
|
+
return "/" + parts.join("/");
|
|
2330
|
+
}
|
|
2331
|
+
function extractPaths(content) {
|
|
2332
|
+
const paths = [];
|
|
2333
|
+
for (const match of content.matchAll(PATH_PATTERN2)) {
|
|
2334
|
+
const p = match[1].replace(/[)}\]]+$/, "");
|
|
2335
|
+
if (p.length > 2 && !p.startsWith("http") && !p.startsWith("//")) {
|
|
2336
|
+
paths.push(p);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
return [...new Set(paths)];
|
|
2340
|
+
}
|
|
2341
|
+
function parseFrontmatter2(content) {
|
|
2342
|
+
const lines = content.split("\n");
|
|
2343
|
+
if (lines[0]?.trim() !== "---") {
|
|
2344
|
+
return { body: content };
|
|
2345
|
+
}
|
|
2346
|
+
const endIdx = lines.indexOf("---", 1);
|
|
2347
|
+
if (endIdx === -1) {
|
|
2348
|
+
return { body: content };
|
|
2349
|
+
}
|
|
2350
|
+
const frontmatter = {};
|
|
2351
|
+
for (let i = 1; i < endIdx; i++) {
|
|
2352
|
+
const line = lines[i];
|
|
2353
|
+
const colonIdx = line.indexOf(":");
|
|
2354
|
+
if (colonIdx > 0) {
|
|
2355
|
+
const key = line.slice(0, colonIdx).trim();
|
|
2356
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
2357
|
+
frontmatter[key] = value;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return {
|
|
2361
|
+
name: frontmatter["name"],
|
|
2362
|
+
description: frontmatter["description"],
|
|
2363
|
+
type: frontmatter["type"],
|
|
2364
|
+
body: lines.slice(endIdx + 1).join("\n")
|
|
2365
|
+
};
|
|
2366
|
+
}
|
|
2367
|
+
async function parseMemoryFile(filePath, projectDir) {
|
|
2368
|
+
const content = await readFile(filePath, "utf-8");
|
|
2369
|
+
const { name, description, type, body } = parseFrontmatter2(content);
|
|
2370
|
+
const referencedPaths = extractPaths(body);
|
|
2371
|
+
return {
|
|
2372
|
+
filePath,
|
|
2373
|
+
projectDir,
|
|
2374
|
+
name,
|
|
2375
|
+
description,
|
|
2376
|
+
type,
|
|
2377
|
+
content: body,
|
|
2378
|
+
referencedPaths
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// src/core/session-scanner.ts
|
|
2383
|
+
var home = process.env.HOME || process.env.USERPROFILE || "";
|
|
2384
|
+
var AGENT_DIRS = [
|
|
2385
|
+
{ provider: "claude-code", dir: join4(home, ".claude"), historyFile: "history.jsonl" },
|
|
2386
|
+
{ provider: "codex-cli", dir: join4(home, ".codex"), historyFile: "history.jsonl" },
|
|
2387
|
+
{ provider: "vibe-cli", dir: join4(home, ".vibe") },
|
|
2388
|
+
{
|
|
2389
|
+
provider: "amazon-q",
|
|
2390
|
+
dir: join4(home, ".aws", "amazonq")
|
|
2391
|
+
},
|
|
2392
|
+
{
|
|
2393
|
+
provider: "goose",
|
|
2394
|
+
dir: process.platform === "win32" ? join4(process.env.APPDATA || "", "Block", "goose") : join4(home, ".config", "goose")
|
|
2395
|
+
},
|
|
2396
|
+
{ provider: "continue", dir: join4(home, ".continue") },
|
|
2397
|
+
{
|
|
2398
|
+
provider: "windsurf",
|
|
2399
|
+
dir: join4(home, ".windsurf")
|
|
2400
|
+
}
|
|
2401
|
+
];
|
|
2402
|
+
function detectProviders() {
|
|
2403
|
+
return AGENT_DIRS.filter((a) => existsSync(a.dir)).map((a) => a.provider);
|
|
2404
|
+
}
|
|
2405
|
+
async function parseJsonlFiltered(filePath, filter) {
|
|
2406
|
+
if (!existsSync(filePath)) return [];
|
|
2407
|
+
const content = await readFile2(filePath, "utf-8");
|
|
2408
|
+
const results = [];
|
|
2409
|
+
for (const line of content.split("\n")) {
|
|
2410
|
+
if (!line.trim()) continue;
|
|
2411
|
+
try {
|
|
2412
|
+
const parsed = JSON.parse(line);
|
|
2413
|
+
const result = filter(parsed);
|
|
2414
|
+
if (result) results.push(result);
|
|
2415
|
+
} catch {
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return results;
|
|
2419
|
+
}
|
|
2420
|
+
async function readClaudeHistory() {
|
|
2421
|
+
const historyPath = join4(home, ".claude", "history.jsonl");
|
|
2422
|
+
return parseJsonlFiltered(historyPath, (entry) => {
|
|
2423
|
+
if (!entry.display || !entry.project) return null;
|
|
2424
|
+
return {
|
|
2425
|
+
display: entry.display,
|
|
2426
|
+
timestamp: entry.timestamp || 0,
|
|
2427
|
+
project: entry.project.replace(/\\/g, "/"),
|
|
2428
|
+
sessionId: entry.sessionId || "",
|
|
2429
|
+
provider: "claude-code"
|
|
2430
|
+
};
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
async function readCodexHistory() {
|
|
2434
|
+
const historyPath = join4(home, ".codex", "history.jsonl");
|
|
2435
|
+
return parseJsonlFiltered(historyPath, (entry) => {
|
|
2436
|
+
if (!entry.display && !entry.command) return null;
|
|
2437
|
+
return {
|
|
2438
|
+
display: entry.display || entry.command || "",
|
|
2439
|
+
timestamp: entry.timestamp || 0,
|
|
2440
|
+
project: (entry.project || entry.cwd || "").replace(/\\/g, "/"),
|
|
2441
|
+
sessionId: entry.sessionId || "",
|
|
2442
|
+
provider: "codex-cli"
|
|
2443
|
+
};
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
async function readClaudeMemories() {
|
|
2447
|
+
const projectsDir = join4(home, ".claude", "projects");
|
|
2448
|
+
if (!existsSync(projectsDir)) return [];
|
|
2449
|
+
const memories = [];
|
|
2450
|
+
const projectDirs = await readdir(projectsDir).catch(() => []);
|
|
2451
|
+
for (const projDir of projectDirs) {
|
|
2452
|
+
const memoryDir = join4(projectsDir, projDir, "memory");
|
|
2453
|
+
if (!existsSync(memoryDir)) continue;
|
|
2454
|
+
const decodedPath = decodeProjectDir(projDir);
|
|
2455
|
+
const files = await readdir(memoryDir).catch(() => []);
|
|
2456
|
+
for (const file of files) {
|
|
2457
|
+
if (!file.endsWith(".md") || file === "MEMORY.md") continue;
|
|
2458
|
+
try {
|
|
2459
|
+
const entry = await parseMemoryFile(join4(memoryDir, file), decodedPath);
|
|
2460
|
+
memories.push(entry);
|
|
2461
|
+
} catch {
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return memories;
|
|
2466
|
+
}
|
|
2467
|
+
function detectAiderInSiblings(siblings) {
|
|
2468
|
+
for (const sib of siblings) {
|
|
2469
|
+
if (existsSync(join4(sib.path, ".aider.chat.history.md"))) {
|
|
2470
|
+
return "aider";
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
async function detectSiblings(projectRoot) {
|
|
2476
|
+
const parentDir = dirname2(resolve4(projectRoot));
|
|
2477
|
+
const siblings = [];
|
|
2478
|
+
let entries;
|
|
2479
|
+
try {
|
|
2480
|
+
entries = await readdir(parentDir);
|
|
2481
|
+
} catch {
|
|
2482
|
+
return [];
|
|
2483
|
+
}
|
|
2484
|
+
for (const entry of entries) {
|
|
2485
|
+
const fullPath = join4(parentDir, entry);
|
|
2486
|
+
const entryPath = resolve4(fullPath);
|
|
2487
|
+
if (entryPath === resolve4(projectRoot)) continue;
|
|
2488
|
+
try {
|
|
2489
|
+
const s = await stat(fullPath);
|
|
2490
|
+
if (!s.isDirectory()) continue;
|
|
2491
|
+
} catch {
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
2495
|
+
const isProject = existsSync(join4(fullPath, ".git")) || existsSync(join4(fullPath, "package.json")) || existsSync(join4(fullPath, "Cargo.toml")) || existsSync(join4(fullPath, "go.mod")) || existsSync(join4(fullPath, "pyproject.toml"));
|
|
2496
|
+
if (!isProject) continue;
|
|
2497
|
+
const sibling = {
|
|
2498
|
+
path: entryPath.replace(/\\/g, "/"),
|
|
2499
|
+
name: entry
|
|
2500
|
+
};
|
|
2501
|
+
try {
|
|
2502
|
+
const git = simpleGit2(fullPath);
|
|
2503
|
+
const remotes = await git.getRemotes(true);
|
|
2504
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
2505
|
+
if (origin?.refs?.fetch) {
|
|
2506
|
+
sibling.gitRemoteUrl = origin.refs.fetch;
|
|
2507
|
+
const orgMatch = origin.refs.fetch.match(/github\.com[:/]([^/]+)\//);
|
|
2508
|
+
if (orgMatch) sibling.gitOrg = orgMatch[1];
|
|
2509
|
+
}
|
|
2510
|
+
} catch {
|
|
2511
|
+
}
|
|
2512
|
+
siblings.push(sibling);
|
|
2513
|
+
}
|
|
2514
|
+
if (siblings.length > 50) {
|
|
2515
|
+
const currentGit = simpleGit2(projectRoot);
|
|
2516
|
+
let currentOrg;
|
|
2517
|
+
try {
|
|
2518
|
+
const remotes = await currentGit.getRemotes(true);
|
|
2519
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
2520
|
+
const orgMatch = origin?.refs?.fetch?.match(/github\.com[:/]([^/]+)\//);
|
|
2521
|
+
if (orgMatch) currentOrg = orgMatch[1];
|
|
2522
|
+
} catch {
|
|
2523
|
+
}
|
|
2524
|
+
if (currentOrg) {
|
|
2525
|
+
return siblings.filter((s) => s.gitOrg === currentOrg);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return siblings;
|
|
2529
|
+
}
|
|
2530
|
+
async function scanSessionData(projectRoot) {
|
|
2531
|
+
const providers = detectProviders();
|
|
2532
|
+
const historyPromises = [];
|
|
2533
|
+
if (providers.includes("claude-code")) historyPromises.push(readClaudeHistory());
|
|
2534
|
+
if (providers.includes("codex-cli")) historyPromises.push(readCodexHistory());
|
|
2535
|
+
const [histories, memories, siblings] = await Promise.all([
|
|
2536
|
+
Promise.all(historyPromises).then((arrays) => arrays.flat()),
|
|
2537
|
+
providers.includes("claude-code") ? readClaudeMemories() : Promise.resolve([]),
|
|
2538
|
+
detectSiblings(projectRoot)
|
|
2539
|
+
]);
|
|
2540
|
+
const aider = detectAiderInSiblings(siblings);
|
|
2541
|
+
if (aider && !providers.includes("aider")) {
|
|
2542
|
+
providers.push("aider");
|
|
2543
|
+
}
|
|
2544
|
+
return {
|
|
2545
|
+
history: histories,
|
|
2546
|
+
memories,
|
|
2547
|
+
siblings,
|
|
2548
|
+
currentProject: resolve4(projectRoot).replace(/\\/g, "/"),
|
|
2549
|
+
providers
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/core/checks/session/missing-secret.ts
|
|
2554
|
+
import { resolve as resolve5 } from "path";
|
|
2555
|
+
var SECRET_SET_PATTERN = /gh\s+secret\s+set\s+(\S+)\s+(?:--repo\s+(\S+)|.*-b\s+)/;
|
|
2556
|
+
var SECRET_SET_SIMPLE = /gh\s+secret\s+set\s+(\S+)/;
|
|
2557
|
+
async function checkMissingSecret(ctx) {
|
|
2558
|
+
const issues = [];
|
|
2559
|
+
const secrets = [];
|
|
2560
|
+
for (const entry of ctx.history) {
|
|
2561
|
+
const match = entry.display.match(SECRET_SET_PATTERN) || entry.display.match(SECRET_SET_SIMPLE);
|
|
2562
|
+
if (!match) continue;
|
|
2563
|
+
secrets.push({
|
|
2564
|
+
name: match[1],
|
|
2565
|
+
repo: match[2],
|
|
2566
|
+
project: entry.project
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
if (secrets.length === 0) return issues;
|
|
2570
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2571
|
+
for (const s of secrets) {
|
|
2572
|
+
if (!byName.has(s.name)) byName.set(s.name, /* @__PURE__ */ new Set());
|
|
2573
|
+
byName.get(s.name).add(s.project);
|
|
2574
|
+
if (s.repo) byName.get(s.name).add(s.repo);
|
|
2575
|
+
}
|
|
2576
|
+
const currentNorm = resolve5(ctx.currentProject).replace(/\\/g, "/");
|
|
2577
|
+
for (const [secretName, projects] of byName) {
|
|
2578
|
+
const currentHas = [...projects].some(
|
|
2579
|
+
(p) => p.includes(currentNorm) || currentNorm.includes(p.replace(/\\/g, "/")) || p.includes(resolve5(ctx.currentProject).split(/[/\\]/).pop() || "")
|
|
2580
|
+
);
|
|
2581
|
+
if (currentHas) continue;
|
|
2582
|
+
const siblingMatches = ctx.siblings.filter(
|
|
2583
|
+
(sib) => [...projects].some(
|
|
2584
|
+
(p) => p.replace(/\\/g, "/").includes(sib.name) || p.includes(sib.path)
|
|
2585
|
+
)
|
|
2586
|
+
);
|
|
2587
|
+
if (siblingMatches.length >= 2) {
|
|
2588
|
+
const sibNames = siblingMatches.map((s) => s.name).join(", ");
|
|
2589
|
+
issues.push({
|
|
2590
|
+
severity: "error",
|
|
2591
|
+
check: "session-missing-secret",
|
|
2592
|
+
ruleId: "session/missing-secret",
|
|
2593
|
+
line: 0,
|
|
2594
|
+
message: `GitHub secret "${secretName}" is set on ${siblingMatches.length} sibling repos (${sibNames}) but not on this project`,
|
|
2595
|
+
suggestion: `Run: gh secret set ${secretName} --repo <owner>/<repo>`,
|
|
2596
|
+
detail: `Found in agent history: ${siblingMatches.length} sibling repos have this secret configured`
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
return issues;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// src/core/checks/session/diverged-file.ts
|
|
2604
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
2605
|
+
import { join as join5 } from "path";
|
|
2606
|
+
import { existsSync as existsSync2 } from "fs";
|
|
2607
|
+
var CANONICAL_FILES = [
|
|
2608
|
+
"release.sh",
|
|
2609
|
+
".github/workflows/ci.yml",
|
|
2610
|
+
".github/workflows/release.yml",
|
|
2611
|
+
"biome.json",
|
|
2612
|
+
".prettierrc",
|
|
2613
|
+
".eslintrc.json",
|
|
2614
|
+
"tsconfig.json",
|
|
2615
|
+
".gitignore"
|
|
2616
|
+
];
|
|
2617
|
+
function calculateOverlap(a, b) {
|
|
2618
|
+
const linesA = a.split("\n").map((l) => l.trim()).filter((l) => l.length > 3);
|
|
2619
|
+
const linesB = new Set(
|
|
2620
|
+
b.split("\n").map((l) => l.trim()).filter((l) => l.length > 3)
|
|
2621
|
+
);
|
|
2622
|
+
if (linesA.length === 0 && linesB.size === 0) return 1;
|
|
2623
|
+
if (linesA.length === 0 || linesB.size === 0) return 0;
|
|
2624
|
+
let matches = 0;
|
|
2625
|
+
for (const line of linesA) {
|
|
2626
|
+
if (linesB.has(line)) matches++;
|
|
2627
|
+
}
|
|
2628
|
+
return matches / Math.max(linesA.length, linesB.size);
|
|
2629
|
+
}
|
|
2630
|
+
async function checkDivergedFile(ctx) {
|
|
2631
|
+
const issues = [];
|
|
2632
|
+
for (const fileName of CANONICAL_FILES) {
|
|
2633
|
+
const currentPath = join5(ctx.currentProject, fileName);
|
|
2634
|
+
if (!existsSync2(currentPath)) continue;
|
|
2635
|
+
let currentContent;
|
|
2636
|
+
try {
|
|
2637
|
+
currentContent = await readFile3(currentPath, "utf-8");
|
|
2638
|
+
} catch {
|
|
2639
|
+
continue;
|
|
2640
|
+
}
|
|
2641
|
+
const diverged = [];
|
|
2642
|
+
for (const sib of ctx.siblings) {
|
|
2643
|
+
const sibPath = join5(sib.path, fileName);
|
|
2644
|
+
if (!existsSync2(sibPath)) continue;
|
|
2645
|
+
try {
|
|
2646
|
+
const sibContent = await readFile3(sibPath, "utf-8");
|
|
2647
|
+
const overlap = calculateOverlap(currentContent, sibContent);
|
|
2648
|
+
if (overlap >= 0.2 && overlap < 0.9) {
|
|
2649
|
+
diverged.push({ sibling: sib.name, overlap: Math.round(overlap * 100) });
|
|
2650
|
+
}
|
|
2651
|
+
} catch {
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
if (diverged.length > 0) {
|
|
2656
|
+
const details = diverged.map((d) => `${d.sibling} (${d.overlap}% overlap)`).join(", ");
|
|
2657
|
+
issues.push({
|
|
2658
|
+
severity: "warning",
|
|
2659
|
+
check: "session-diverged-file",
|
|
2660
|
+
ruleId: "session/diverged-file",
|
|
2661
|
+
line: 0,
|
|
2662
|
+
message: `${fileName} has diverged from sibling repos: ${details}`,
|
|
2663
|
+
suggestion: `Compare with sibling versions to identify unintentional drift`,
|
|
2664
|
+
detail: `Files with the same name across sibling repos should be kept in sync when they serve the same purpose`
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
return issues;
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// src/core/checks/session/missing-workflow.ts
|
|
2672
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
2673
|
+
import { join as join6 } from "path";
|
|
2674
|
+
import { existsSync as existsSync3 } from "fs";
|
|
2675
|
+
async function checkMissingWorkflow(ctx) {
|
|
2676
|
+
const issues = [];
|
|
2677
|
+
const currentWorkflowDir = join6(ctx.currentProject, ".github", "workflows");
|
|
2678
|
+
if (!existsSync3(join6(ctx.currentProject, ".github"))) return issues;
|
|
2679
|
+
const currentWorkflows = /* @__PURE__ */ new Set();
|
|
2680
|
+
if (existsSync3(currentWorkflowDir)) {
|
|
2681
|
+
try {
|
|
2682
|
+
const files = await readdir2(currentWorkflowDir);
|
|
2683
|
+
for (const f of files) {
|
|
2684
|
+
if (f.endsWith(".yml") || f.endsWith(".yaml")) currentWorkflows.add(f);
|
|
2685
|
+
}
|
|
2686
|
+
} catch {
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
const workflowMap = /* @__PURE__ */ new Map();
|
|
2690
|
+
for (const sib of ctx.siblings) {
|
|
2691
|
+
const sibWorkflowDir = join6(sib.path, ".github", "workflows");
|
|
2692
|
+
if (!existsSync3(sibWorkflowDir)) continue;
|
|
2693
|
+
try {
|
|
2694
|
+
const files = await readdir2(sibWorkflowDir);
|
|
2695
|
+
for (const f of files) {
|
|
2696
|
+
if (!(f.endsWith(".yml") || f.endsWith(".yaml"))) continue;
|
|
2697
|
+
if (!workflowMap.has(f)) workflowMap.set(f, []);
|
|
2698
|
+
workflowMap.get(f).push(sib.name);
|
|
2699
|
+
}
|
|
2700
|
+
} catch {
|
|
2701
|
+
continue;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
for (const [workflow, siblings] of workflowMap) {
|
|
2705
|
+
if (currentWorkflows.has(workflow)) continue;
|
|
2706
|
+
if (siblings.length < 2) continue;
|
|
2707
|
+
const sibNames = siblings.join(", ");
|
|
2708
|
+
issues.push({
|
|
2709
|
+
severity: "warning",
|
|
2710
|
+
check: "session-missing-workflow",
|
|
2711
|
+
ruleId: "session/missing-workflow",
|
|
2712
|
+
line: 0,
|
|
2713
|
+
message: `GitHub Actions workflow "${workflow}" exists in ${siblings.length} sibling repos (${sibNames}) but not in this project`,
|
|
2714
|
+
suggestion: `Consider adding .github/workflows/${workflow} for consistency`,
|
|
2715
|
+
detail: `Sibling repos with this workflow: ${sibNames}`
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
return issues;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// src/core/checks/session/stale-memory.ts
|
|
2722
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2723
|
+
import { resolve as resolve6, isAbsolute } from "path";
|
|
2724
|
+
async function checkStaleMemory(ctx) {
|
|
2725
|
+
const issues = [];
|
|
2726
|
+
const currentNorm = ctx.currentProject.replace(/\\/g, "/");
|
|
2727
|
+
const projectMemories = ctx.memories.filter(
|
|
2728
|
+
(m) => m.projectDir.replace(/\\/g, "/") === currentNorm
|
|
2729
|
+
);
|
|
2730
|
+
for (const mem of projectMemories) {
|
|
2731
|
+
const brokenPaths = [];
|
|
2732
|
+
for (const ref of mem.referencedPaths) {
|
|
2733
|
+
const fullPath = isAbsolute(ref) ? ref : resolve6(ctx.currentProject, ref);
|
|
2734
|
+
if (!existsSync4(fullPath)) {
|
|
2735
|
+
brokenPaths.push(ref);
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
if (brokenPaths.length > 0) {
|
|
2739
|
+
const name = mem.name || mem.filePath.split(/[/\\]/).pop() || "unknown";
|
|
2740
|
+
issues.push({
|
|
2741
|
+
severity: "info",
|
|
2742
|
+
check: "session-stale-memory",
|
|
2743
|
+
ruleId: "session/stale-memory",
|
|
2744
|
+
line: 0,
|
|
2745
|
+
message: `Memory "${name}" references ${brokenPaths.length} path(s) that no longer exist: ${brokenPaths.join(", ")}`,
|
|
2746
|
+
suggestion: `Update or remove the memory file: ${mem.filePath}`,
|
|
2747
|
+
detail: `Memory files with broken path references may cause the AI agent to follow stale instructions`
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return issues;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// src/core/checks/session/duplicate-memory.ts
|
|
2755
|
+
function calculateLineOverlap2(a, b) {
|
|
2756
|
+
const linesA = a.split("\n").map((l) => l.trim()).filter((l) => l.length > 5);
|
|
2757
|
+
const linesB = new Set(
|
|
2758
|
+
b.split("\n").map((l) => l.trim()).filter((l) => l.length > 5)
|
|
2759
|
+
);
|
|
2760
|
+
if (linesA.length === 0 || linesB.size === 0) return 0;
|
|
2761
|
+
let matches = 0;
|
|
2762
|
+
for (const line of linesA) {
|
|
2763
|
+
if (linesB.has(line)) matches++;
|
|
2764
|
+
}
|
|
2765
|
+
return matches / Math.max(linesA.length, linesB.size);
|
|
2766
|
+
}
|
|
2767
|
+
async function checkDuplicateMemory(ctx) {
|
|
2768
|
+
const issues = [];
|
|
2769
|
+
const reported = /* @__PURE__ */ new Set();
|
|
2770
|
+
for (let i = 0; i < ctx.memories.length; i++) {
|
|
2771
|
+
for (let j = i + 1; j < ctx.memories.length; j++) {
|
|
2772
|
+
const a = ctx.memories[i];
|
|
2773
|
+
const b = ctx.memories[j];
|
|
2774
|
+
if (a.projectDir.replace(/\\/g, "/") === b.projectDir.replace(/\\/g, "/")) continue;
|
|
2775
|
+
if (a.content.length < 50 || b.content.length < 50) continue;
|
|
2776
|
+
const overlap = calculateLineOverlap2(a.content, b.content);
|
|
2777
|
+
if (overlap < 0.6) continue;
|
|
2778
|
+
const pairKey = [a.filePath, b.filePath].sort().join("::");
|
|
2779
|
+
if (reported.has(pairKey)) continue;
|
|
2780
|
+
reported.add(pairKey);
|
|
2781
|
+
const nameA = a.name || a.filePath.split(/[/\\]/).pop() || "unknown";
|
|
2782
|
+
const nameB = b.name || b.filePath.split(/[/\\]/).pop() || "unknown";
|
|
2783
|
+
const projA = a.projectDir.split(/[/\\]/).pop() || a.projectDir;
|
|
2784
|
+
const projB = b.projectDir.split(/[/\\]/).pop() || b.projectDir;
|
|
2785
|
+
issues.push({
|
|
2786
|
+
severity: "info",
|
|
2787
|
+
check: "session-duplicate-memory",
|
|
2788
|
+
ruleId: "session/duplicate-memory",
|
|
2789
|
+
line: 0,
|
|
2790
|
+
message: `Memory "${nameA}" (${projA}) and "${nameB}" (${projB}) have ${Math.round(overlap * 100)}% overlap`,
|
|
2791
|
+
suggestion: `Consider consolidating into a shared memory or removing the duplicate`,
|
|
2792
|
+
detail: `Near-duplicate memories across projects waste context and may drift out of sync`
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
return issues;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2314
2799
|
// src/version.ts
|
|
2315
2800
|
function loadVersion() {
|
|
2316
|
-
if (true) return "0.
|
|
2801
|
+
if (true) return "0.7.0";
|
|
2317
2802
|
const fs6 = __require("fs");
|
|
2318
2803
|
const path9 = __require("path");
|
|
2319
2804
|
const pkgPath = path9.resolve(__dirname, "../package.json");
|
|
@@ -2342,13 +2827,24 @@ var ALL_MCP_CHECKS = [
|
|
|
2342
2827
|
"mcp-consistency",
|
|
2343
2828
|
"mcp-redundancy"
|
|
2344
2829
|
];
|
|
2830
|
+
var ALL_SESSION_CHECKS = [
|
|
2831
|
+
"session-missing-secret",
|
|
2832
|
+
"session-diverged-file",
|
|
2833
|
+
"session-missing-workflow",
|
|
2834
|
+
"session-stale-memory",
|
|
2835
|
+
"session-duplicate-memory"
|
|
2836
|
+
];
|
|
2345
2837
|
function hasMcpChecks(checks) {
|
|
2346
2838
|
return checks.some((c) => c.startsWith("mcp-"));
|
|
2347
2839
|
}
|
|
2840
|
+
function hasSessionChecks(checks) {
|
|
2841
|
+
return checks.some((c) => c.startsWith("session-"));
|
|
2842
|
+
}
|
|
2348
2843
|
async function runAudit(projectRoot, activeChecks, options = {}) {
|
|
2349
2844
|
const fileResults = [];
|
|
2350
|
-
const shouldRunContextChecks = !options.mcpOnly;
|
|
2845
|
+
const shouldRunContextChecks = !options.mcpOnly && !options.sessionOnly;
|
|
2351
2846
|
const shouldRunMcpChecks = options.mcp || options.mcpGlobal || options.mcpOnly || hasMcpChecks(activeChecks);
|
|
2847
|
+
const shouldRunSessionChecks = options.session || options.sessionOnly || hasSessionChecks(activeChecks);
|
|
2352
2848
|
if (shouldRunContextChecks) {
|
|
2353
2849
|
const discovered = await scanForContextFiles(projectRoot, {
|
|
2354
2850
|
depth: options.depth,
|
|
@@ -2450,6 +2946,37 @@ async function runAudit(projectRoot, activeChecks, options = {}) {
|
|
|
2450
2946
|
}
|
|
2451
2947
|
}
|
|
2452
2948
|
}
|
|
2949
|
+
if (shouldRunSessionChecks) {
|
|
2950
|
+
const activeSessionChecks = activeChecks.filter(
|
|
2951
|
+
(c) => c.startsWith("session-")
|
|
2952
|
+
);
|
|
2953
|
+
const sessionChecksToRun = activeSessionChecks.length > 0 ? activeSessionChecks : options.session || options.sessionOnly ? ALL_SESSION_CHECKS : [];
|
|
2954
|
+
if (sessionChecksToRun.length > 0) {
|
|
2955
|
+
const sessionCtx = await scanSessionData(projectRoot);
|
|
2956
|
+
const sessionPromises = [];
|
|
2957
|
+
if (sessionChecksToRun.includes("session-missing-secret"))
|
|
2958
|
+
sessionPromises.push(checkMissingSecret(sessionCtx));
|
|
2959
|
+
if (sessionChecksToRun.includes("session-diverged-file"))
|
|
2960
|
+
sessionPromises.push(checkDivergedFile(sessionCtx));
|
|
2961
|
+
if (sessionChecksToRun.includes("session-missing-workflow"))
|
|
2962
|
+
sessionPromises.push(checkMissingWorkflow(sessionCtx));
|
|
2963
|
+
if (sessionChecksToRun.includes("session-stale-memory"))
|
|
2964
|
+
sessionPromises.push(checkStaleMemory(sessionCtx));
|
|
2965
|
+
if (sessionChecksToRun.includes("session-duplicate-memory"))
|
|
2966
|
+
sessionPromises.push(checkDuplicateMemory(sessionCtx));
|
|
2967
|
+
const sessionResults = await Promise.all(sessionPromises);
|
|
2968
|
+
const sessionIssues = sessionResults.flat();
|
|
2969
|
+
if (sessionIssues.length > 0) {
|
|
2970
|
+
fileResults.push({
|
|
2971
|
+
path: "~/.claude/ (session audit)",
|
|
2972
|
+
isSymlink: false,
|
|
2973
|
+
tokens: 0,
|
|
2974
|
+
lines: 0,
|
|
2975
|
+
issues: sessionIssues
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2453
2980
|
let estimatedWaste = 0;
|
|
2454
2981
|
for (const fr of fileResults) {
|
|
2455
2982
|
for (const issue of fr.issues) {
|
|
@@ -2551,7 +3078,12 @@ var checkEnum = z.enum([
|
|
|
2551
3078
|
"mcp-env",
|
|
2552
3079
|
"mcp-urls",
|
|
2553
3080
|
"mcp-consistency",
|
|
2554
|
-
"mcp-redundancy"
|
|
3081
|
+
"mcp-redundancy",
|
|
3082
|
+
"session-missing-secret",
|
|
3083
|
+
"session-diverged-file",
|
|
3084
|
+
"session-missing-workflow",
|
|
3085
|
+
"session-stale-memory",
|
|
3086
|
+
"session-duplicate-memory"
|
|
2555
3087
|
]);
|
|
2556
3088
|
var server = new McpServer({
|
|
2557
3089
|
name: "ctxlint",
|
|
@@ -2760,5 +3292,39 @@ server.tool(
|
|
|
2760
3292
|
}
|
|
2761
3293
|
}
|
|
2762
3294
|
);
|
|
3295
|
+
server.tool(
|
|
3296
|
+
"ctxlint_session_audit",
|
|
3297
|
+
"Audit AI agent session data for cross-project consistency. Checks for missing GitHub secrets, diverged config files, missing workflows, stale memory entries, and duplicate memories across sibling repositories.",
|
|
3298
|
+
{
|
|
3299
|
+
projectPath: z.string().optional().describe("Path to the project root. Defaults to current working directory."),
|
|
3300
|
+
checks: z.array(checkEnum).optional().describe("Specific session checks to run (default: all session-* checks).")
|
|
3301
|
+
},
|
|
3302
|
+
{
|
|
3303
|
+
readOnlyHint: true,
|
|
3304
|
+
destructiveHint: false,
|
|
3305
|
+
idempotentHint: true,
|
|
3306
|
+
openWorldHint: true
|
|
3307
|
+
},
|
|
3308
|
+
async ({ projectPath, checks }) => {
|
|
3309
|
+
const root = path8.resolve(projectPath || process.cwd());
|
|
3310
|
+
const activeChecks = checks || ALL_SESSION_CHECKS;
|
|
3311
|
+
try {
|
|
3312
|
+
const result = await runAudit(root, activeChecks, {
|
|
3313
|
+
session: true,
|
|
3314
|
+
sessionOnly: true
|
|
3315
|
+
});
|
|
3316
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
3317
|
+
} catch (err) {
|
|
3318
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3319
|
+
return {
|
|
3320
|
+
content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
|
|
3321
|
+
isError: true
|
|
3322
|
+
};
|
|
3323
|
+
} finally {
|
|
3324
|
+
freeEncoder();
|
|
3325
|
+
resetGit();
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
);
|
|
2763
3329
|
var transport = new StdioServerTransport();
|
|
2764
3330
|
await server.connect(transport);
|