Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Version-pinned so a checkout at `rev: vX.Y.Z` runs exactly that release
# of ctxlint — matches the pinning done by `ctxlint init`. release.sh keeps
# this in sync with package.json on each bump.
entry: npx @yawlabs/ctxlint@0.9.14 --strict
entry: npx @yawlabs/ctxlint@0.9.17 --strict
language: node
always_run: true
pass_filenames: false
31 changes: 18 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { applyFixes } from './core/fixer.js';
import { freeEncoder } from './utils/tokens.js';
import { resetGit } from './utils/git.js';
import { resetPackageJsonCache } from './utils/fs.js';
import { loadConfig } from './core/config.js';
import { loadConfig, loadConfigFromExplicitPath } from './core/config.js';
import {
runAudit,
ALL_CHECKS,
Expand Down Expand Up @@ -88,15 +88,21 @@ export async function runCli() {
const configPath = opts.config ? path.resolve(opts.config as string) : undefined;
const config = configPath ? loadConfigFromPath(configPath) : loadConfig(resolvedPath);

const mcpGlobal = (opts.mcpGlobal as boolean) || false;
const mcpOnly = (opts.mcpOnly as boolean) || false;
// Fold config-sourced booleans into the local vars so everything
// downstream (effectiveMcp/effectiveMcph/effectiveSession and the
// final `checks` array) sees them consistently. Without this, a
// config-only `mcpGlobal: true` would run MCP checks via runAudit's
// fallback while bypassing the cli-side `ignore` filter.
const mcpGlobal = (opts.mcpGlobal as boolean) || config?.mcpGlobal || false;
const mcpOnly = (opts.mcpOnly as boolean) || config?.mcpOnly || false;
const mcpFlag = (opts.mcp as boolean) || mcpGlobal || mcpOnly || config?.mcp || false;
const mcphGlobal = (opts.mcphGlobal as boolean) || false;
const mcphOnly = (opts.mcphOnly as boolean) || false;
const mcphFlag = (opts.mcph as boolean) || mcphGlobal || mcphOnly || false;
const mcphStrictEnvToken = (opts.mcphStrictEnvToken as boolean) || false;
const sessionFlag = (opts.session as boolean) || false;
const sessionOnly = (opts.sessionOnly as boolean) || false;
const mcphGlobal = (opts.mcphGlobal as boolean) || config?.mcphGlobal || false;
const mcphOnly = (opts.mcphOnly as boolean) || config?.mcphOnly || false;
const mcphFlag = (opts.mcph as boolean) || mcphGlobal || mcphOnly || config?.mcph || false;
const mcphStrictEnvToken =
(opts.mcphStrictEnvToken as boolean) || config?.mcphStrictEnvToken || false;
const sessionOnly = (opts.sessionOnly as boolean) || config?.sessionOnly || false;
const sessionFlag = (opts.session as boolean) || sessionOnly || config?.session || false;

// Build checks list: if explicit --checks includes mcp-* or session-*, imply the flag
let explicitChecks = opts.checks
Expand Down Expand Up @@ -152,7 +158,7 @@ export async function runCli() {
depth: Math.max(0, Math.min(parseInt(opts.depth as string, 10) || 2, 10)),
mcp: effectiveMcp,
mcpOnly,
mcpGlobal: mcpGlobal || config?.mcpGlobal || false,
mcpGlobal,
mcph: effectiveMcph,
mcphOnly,
mcphGlobal,
Expand Down Expand Up @@ -489,11 +495,10 @@ async function promptYesNo(question: string): Promise<boolean> {

function loadConfigFromPath(configPath: string) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(content);
return loadConfigFromExplicitPath(configPath);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
console.error(`Error: could not load config from ${configPath}: ${detail}`);
console.error(`Error: ${detail}`);
process.exit(2);
}
}
3 changes: 1 addition & 2 deletions src/core/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ export async function runAudit(
): Promise<LintResult> {
const fileResults: FileResult[] = [];

const shouldRunContextChecks =
!options.mcpOnly && !options.mcphOnly && !options.sessionOnly;
const shouldRunContextChecks = !options.mcpOnly && !options.mcphOnly && !options.sessionOnly;
const shouldRunMcpChecks =
options.mcp || options.mcpGlobal || options.mcpOnly || hasMcpChecks(activeChecks);
const shouldRunMcphChecks =
Expand Down
8 changes: 6 additions & 2 deletions src/core/checks/ci-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,13 @@ async function findSecretUsages(projectRoot: string): Promise<SecretUsage[]> {
}

function contextMentionsSecret(files: ParsedContextFile[], secretName: string): boolean {
// Escape regex-special chars, then make underscores flexible (match _, space, or hyphen)
// Escape regex-special chars, then allow `_`, space, or hyphen (but require
// at least one separator — making it optional matches `npmtoken` for
// `NPM_TOKEN`, and the previous empty-match collapsed to substring hits
// anywhere in prose like `awsaccesskey`). Anchor with `\b` so the full
// name must stand alone as a word, not a substring.
const escaped = secretName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(escaped.replace(/_/g, '[_\\s-]?'), 'i');
const pattern = new RegExp(`\\b${escaped.replace(/_/g, '[_\\s-]')}\\b`, 'i');
return files.some((f) => pattern.test(f.content));
}

Expand Down
29 changes: 17 additions & 12 deletions src/core/checks/mcp/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,23 @@ export async function checkMcpEnv(
}
}

// unset-variable: check if referenced env vars are set
for (const value of allValues) {
const refs = extractEnvVarRefs(value);
for (const ref of refs) {
if (!(ref.varName in process.env)) {
issues.push({
severity: 'info',
check: 'mcp-env',
ruleId: 'unset-variable',
line: server.line,
message: `Server "${server.name}": environment variable "${ref.varName}" is not set`,
});
// unset-variable: check if referenced env vars are set.
// Skip for Continue — its ${{ secrets.VAR }} refs resolve from GitHub
// Actions secrets, not process.env, so every correct Continue config
// would false-positive here.
if (config.client !== 'continue') {
for (const value of allValues) {
const refs = extractEnvVarRefs(value);
for (const ref of refs) {
if (!(ref.varName in process.env)) {
issues.push({
severity: 'info',
check: 'mcp-env',
ruleId: 'unset-variable',
line: server.line,
message: `Server "${server.name}": environment variable "${ref.varName}" is not set`,
});
}
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/core/checks/mcph/__tests__/gitignore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ describe('checkMcphGitignore', () => {
expect(issue).toBeDefined();
expect(issue!.severity).toBe('error');
expect(issue!.message).toContain('.mcph.local.json');
expect(issue!.fix).toBeDefined();
expect(issue!.fix!.newText).toContain('.mcph.local.json');
// No auto-fix: appending to a sibling .gitignore is a different-file
// side effect the current line-in-place fixer can't safely express.
expect(issue!.fix).toBeUndefined();
expect(issue!.suggestion).toContain('.gitignore');
});

it('does not fire when project-local file is already gitignored', async () => {
Expand Down
4 changes: 3 additions & 1 deletion src/core/checks/mcph/__tests__/lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ describe('checkMcphLists', () => {
},
});
const issues = await checkMcphLists(config, '/project');
expect(issues.find((i) => i.ruleId === 'mcph-config/allowlist-denylist-conflict')).toBeUndefined();
expect(
issues.find((i) => i.ruleId === 'mcph-config/allowlist-denylist-conflict'),
).toBeUndefined();
});

it('fires duplicate-entries for repeated servers entry', async () => {
Expand Down
8 changes: 2 additions & 6 deletions src/core/checks/mcph/gitignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export async function checkMcphGitignore(
// --- Rule: mcph-config/local-file-not-gitignored ---
// Only applies to the .mcph.local.json scope — it exists precisely so
// teammates don't share machine-local overrides, so it MUST be gitignored.
// No auto-fix: appending to .gitignore is a different-file side effect the
// current fixer (line-in-place replace on oldText) can't express safely.
if (config.scope === 'project-local' && !config.isGitignored) {
const basename = path.basename(config.filePath);
issues.push({
Expand All @@ -19,12 +21,6 @@ export async function checkMcphGitignore(
line: 1,
message: `${basename} is not covered by .gitignore — machine-local overrides can leak via git`,
suggestion: `Add "${basename}" to .gitignore in your project root.`,
fix: {
file: path.join(path.dirname(config.filePath), '.gitignore'),
line: 0,
oldText: '',
newText: `\n# mcph CLI local overrides (machine-specific, never commit)\n${basename}\n`,
},
});
}

Expand Down
3 changes: 1 addition & 2 deletions src/core/checks/mcph/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ export async function checkMcphLists(
ruleId: 'mcph-config/allowlist-denylist-conflict',
line: entry.position.line,
message: `server "${entry.value}" is in both "servers" (allow-list) and "blocked" (deny-list)`,
suggestion:
`Remove "${entry.value}" from one of the two lists. "blocked" wins in practice (deny > allow), so the allow-list entry is dead weight.`,
suggestion: `Remove "${entry.value}" from one of the two lists. "blocked" wins in practice (deny > allow), so the allow-list entry is dead weight.`,
});
}
}
Expand Down
6 changes: 2 additions & 4 deletions src/core/checks/mcph/schema-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ export async function checkMcphSchemaConformance(
ruleId: 'mcph-config/unknown-field',
line: field.position.line,
message: `unknown field "${field.name}" — not in the mcph config schema`,
suggestion:
`Known fields: $schema, version, token, apiBase, servers, blocked. Check for typos (e.g. "tokens" vs "token", "blockList" vs "blocked").`,
suggestion: `Known fields: $schema, version, token, apiBase, servers, blocked. Check for typos (e.g. "tokens" vs "token", "blockList" vs "blocked").`,
});
}

Expand All @@ -32,8 +31,7 @@ export async function checkMcphSchemaConformance(
ruleId: 'mcph-config/stale-version',
line: versionPos.line,
message: `"version": ${version} is older than the current schema version (${CURRENT_SCHEMA_VERSION})`,
suggestion:
`Update to "version": ${CURRENT_SCHEMA_VERSION}. Older versions continue to load but may miss newer fields.`,
suggestion: `Update to "version": ${CURRENT_SCHEMA_VERSION}. Older versions continue to load but may miss newer fields.`,
});
}

Expand Down
12 changes: 2 additions & 10 deletions src/core/checks/mcph/token-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,7 @@ export async function checkMcphTokenSecurity(

// --- Rule: mcph-config/token-in-project-scope ---
// Project-scope + git-tracked + token present = live PAT at risk of leak.
if (
tokenPos &&
tokenValue !== null &&
config.scope === 'project' &&
config.isGitTracked
) {
if (tokenPos && tokenValue !== null && config.scope === 'project' && config.isGitTracked) {
issues.push({
severity: 'error',
check: 'mcph-token-security',
Expand All @@ -73,10 +68,7 @@ export async function checkMcphTokenSecurity(
// double-counting the same bad line) — that rule's remediation already
// covers the env-var path.
const alreadyFlaggedAsProjectLeak =
tokenPos &&
tokenValue !== null &&
config.scope === 'project' &&
config.isGitTracked;
tokenPos && tokenValue !== null && config.scope === 'project' && config.isGitTracked;

if (tokenPos && tokenValue !== null && !alreadyFlaggedAsProjectLeak) {
const severity = options.strictEnvToken ? 'error' : 'warning';
Expand Down
31 changes: 29 additions & 2 deletions src/core/checks/session/__tests__/duplicate-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ function makeMemory(content: string, projectDir: string, name = 'test-memory'):
};
}

function makeCtx(memories: MemoryEntry[]): SessionContext {
function makeCtx(memories: MemoryEntry[], currentProject = 'project-a'): SessionContext {
// currentProject is compared against projectDir via projectDirMatchesPath,
// which normalizes both via `encodeProjectDir`. For simple single-segment
// names with no `:`, `/`, `\`, or `.`, the encoding is the identity, so
// passing `'project-a'` as currentProject matches any memory whose
// projectDir is `'project-a'` — which is what the tests below need to
// exercise the "one side is current project" scoping rule.
return {
history: [],
memories,
siblings: [],
currentProject: '/repos/current',
currentProject,
providers: ['claude-code'],
};
}
Expand Down Expand Up @@ -86,6 +92,27 @@ describe('checkDuplicateMemory', () => {
expect(issues).toHaveLength(0);
});

it('ignores duplicate pairs where neither side is the current project', async () => {
const sharedContent = [
'This project uses TypeScript for everything',
'Always run pnpm test before committing',
'The main entry point is src/index.ts',
'Use vitest for testing with describe/it/expect',
'Format with prettier before pushing',
].join('\n');

// Both memories are in OTHER projects relative to current — should not fire.
const ctx = makeCtx(
[
makeMemory(sharedContent, 'project-b', 'conventions'),
makeMemory(sharedContent, 'project-c', 'conventions'),
],
'project-a',
);
const issues = await checkDuplicateMemory(ctx);
expect(issues).toHaveLength(0);
});

it('does not report the same pair twice', async () => {
const content = [
'Shared convention line one is here',
Expand Down
29 changes: 17 additions & 12 deletions src/core/checks/session/diverged-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,35 @@ const CANONICAL_FILES = [
];

/**
* Calculate line-level overlap between two text contents.
* Returns a ratio 0-1 where 1 means identical.
* Jaccard similarity over non-trivial lines. Returns |A ∩ B| / |A ∪ B| in
* [0, 1], where 1 means identical. An earlier "matches / max(|A|, |B|)"
* variant was asymmetric (linesA as an array counted duplicates, linesB as a
* set did not) and inflated/deflated similarity based on file size rather
* than actual shared content.
*/
function calculateOverlap(a: string, b: string): number {
const linesA = a
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 3);
const linesA = new Set(
a
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 3),
);
const linesB = new Set(
b
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 3),
);

if (linesA.length === 0 && linesB.size === 0) return 1;
if (linesA.length === 0 || linesB.size === 0) return 0;
if (linesA.size === 0 && linesB.size === 0) return 1;
if (linesA.size === 0 || linesB.size === 0) return 0;

let matches = 0;
let intersection = 0;
for (const line of linesA) {
if (linesB.has(line)) matches++;
if (linesB.has(line)) intersection++;
}

return matches / Math.max(linesA.length, linesB.size);
const unionSize = linesA.size + linesB.size - intersection;
return intersection / unionSize;
}

/**
Expand Down
34 changes: 24 additions & 10 deletions src/core/checks/session/duplicate-memory.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import type { LintIssue, SessionContext } from '../../types.js';
import { projectDirMatchesPath } from '../../session-parser.js';

/**
* Calculate line-level overlap between two text contents.
* Jaccard similarity over non-trivial lines. Returns |A ∩ B| / |A ∪ B|.
* Earlier "matches / max(|A|, |B|)" was asymmetric because linesA was an
* array (duplicates counted) and linesB was a set (deduplicated).
*/
function calculateLineOverlap(a: string, b: string): number {
const linesA = a
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 5);
const linesA = new Set(
a
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 5),
);
const linesB = new Set(
b
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 5),
);

if (linesA.length === 0 || linesB.size === 0) return 0;
if (linesA.size === 0 || linesB.size === 0) return 0;

let matches = 0;
let intersection = 0;
for (const line of linesA) {
if (linesB.has(line)) matches++;
if (linesB.has(line)) intersection++;
}

return matches / Math.max(linesA.length, linesB.size);
const unionSize = linesA.size + linesB.size - intersection;
return intersection / unionSize;
}

/**
* Detect near-duplicate memory entries across different projects.
* Memories with >60% line overlap are flagged for consolidation.
*
* Scoped to pairs where at least one side belongs to the current project —
* otherwise every `ctxlint --session` invocation from any repo would
* surface the same unrelated cross-project duplicates.
*/
export async function checkDuplicateMemory(ctx: SessionContext): Promise<LintIssue[]> {
const issues: LintIssue[] = [];
Expand All @@ -41,6 +50,11 @@ export async function checkDuplicateMemory(ctx: SessionContext): Promise<LintIss
// Only compare memories from different projects (projectDir is an encoded dir name)
if (a.projectDir === b.projectDir) continue;

// Require at least one side to belong to the current project.
const aIsCurrent = projectDirMatchesPath(a.projectDir, ctx.currentProject);
const bIsCurrent = projectDirMatchesPath(b.projectDir, ctx.currentProject);
if (!aIsCurrent && !bIsCurrent) continue;

// Skip very short memories (not meaningful to compare)
if (a.content.length < 50 || b.content.length < 50) continue;

Expand Down
Loading
Loading