From 969d233977bd8f2fc3fbc19f40f8e8a21ce4e023 Mon Sep 17 00:00:00 2001 From: Jeff Yaw Date: Sun, 19 Apr 2026 14:57:17 -0700 Subject: [PATCH 1/4] style: format with prettier Applies prettier formatting to 7 files flagged by CI's `prettier --check src/` step. Co-Authored-By: Claude Opus 4.7 --- src/core/audit.ts | 3 +-- src/core/checks/mcph/__tests__/lists.test.ts | 4 +++- src/core/checks/mcph/lists.ts | 3 +-- src/core/checks/mcph/schema-conformance.ts | 6 ++---- src/core/checks/mcph/token-security.ts | 12 ++---------- src/core/mcph-parser.ts | 6 +----- src/core/types.ts | 4 +++- 7 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/core/audit.ts b/src/core/audit.ts index dc50447..e9b8104 100644 --- a/src/core/audit.ts +++ b/src/core/audit.ts @@ -128,8 +128,7 @@ export async function runAudit( ): Promise { 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 = diff --git a/src/core/checks/mcph/__tests__/lists.test.ts b/src/core/checks/mcph/__tests__/lists.test.ts index 08dd68b..4432857 100644 --- a/src/core/checks/mcph/__tests__/lists.test.ts +++ b/src/core/checks/mcph/__tests__/lists.test.ts @@ -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 () => { diff --git a/src/core/checks/mcph/lists.ts b/src/core/checks/mcph/lists.ts index 7cf485c..57640a9 100644 --- a/src/core/checks/mcph/lists.ts +++ b/src/core/checks/mcph/lists.ts @@ -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.`, }); } } diff --git a/src/core/checks/mcph/schema-conformance.ts b/src/core/checks/mcph/schema-conformance.ts index 0f85c43..7372637 100644 --- a/src/core/checks/mcph/schema-conformance.ts +++ b/src/core/checks/mcph/schema-conformance.ts @@ -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").`, }); } @@ -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.`, }); } diff --git a/src/core/checks/mcph/token-security.ts b/src/core/checks/mcph/token-security.ts index 1469768..2279476 100644 --- a/src/core/checks/mcph/token-security.ts +++ b/src/core/checks/mcph/token-security.ts @@ -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', @@ -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'; diff --git a/src/core/mcph-parser.ts b/src/core/mcph-parser.ts index 1fb80a3..a843a6b 100644 --- a/src/core/mcph-parser.ts +++ b/src/core/mcph-parser.ts @@ -3,11 +3,7 @@ import * as path from 'node:path'; import { parseTree, type Node } from 'jsonc-parser'; import { readFileContent } from '../utils/fs.js'; import { getGit } from '../utils/git.js'; -import type { - ParsedMchpConfig, - MchpConfigScope, - MchpFieldPosition, -} from './types.js'; +import type { ParsedMchpConfig, MchpConfigScope, MchpFieldPosition } from './types.js'; import type { DiscoveredFile } from './scanner.js'; const KNOWN_FIELDS = new Set(['$schema', 'version', 'token', 'apiBase', 'servers', 'blocked']); diff --git a/src/core/types.ts b/src/core/types.ts index 9bdd031..e4c69f8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -165,7 +165,9 @@ export interface ParsedMchpConfig { // Raw parsed object; null if parse failed. raw: Record | null; // Per-field positions for precise diagnostics. Missing entries = field absent. - positions: Partial>; + positions: Partial< + Record<'$schema' | 'version' | 'token' | 'apiBase' | 'servers' | 'blocked', MchpFieldPosition> + >; // Positions for individual array entries of `servers` and `blocked`. listEntries: { servers: { value: string; position: MchpFieldPosition }[]; From 516ac26d7fc2fad4564877d93bc05386d6f2c717 Mon Sep 17 00:00:00 2001 From: Jeff Yaw Date: Thu, 23 Apr 2026 11:53:09 -0700 Subject: [PATCH 2/4] chore: sync .pre-commit-hooks.yaml pin to 0.9.17 Drifted since v0.9.15/0.9.16 bumps skipped release.sh, leaving consumers who install via `rev: vX.Y.Z` executing @0.9.14 through the pre-commit framework. Co-Authored-By: Claude Opus 4.7 --- .pre-commit-hooks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 0c5b6ff..58883fa 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -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 From accabfd21d49f9d4a95ebae6764816eaf901aaf3 Mon Sep 17 00:00:00 2001 From: Jeff Yaw Date: Thu, 23 Apr 2026 11:53:33 -0700 Subject: [PATCH 3/4] fix: punch-list from full-pass review - mcp-env: skip unset-variable for Continue (its ${{ secrets.X }} refs resolve from GitHub Actions secrets, not process.env, so every correct Continue config false-positived) - ci-secrets: tighten contextMentionsSecret regex -- require a separator for `_` and anchor with \b, so `NPM_TOKEN` no longer matches bare prose like `npmtoken` anywhere in a file - mcph-gitignore: drop the inert `fix` action (oldText: '', line: 0 was silently skipped by the fixer's bounds check; suggestion stays) - config: add mcph/mcphOnly/mcphGlobal/mcphStrictEnvToken/session/ sessionOnly/mcpOnly to CtxlintConfig + KNOWN_CONFIG_KEYS; route --config through shared parser so it reports JSON line/col and warns on unknown keys - cli: fold config-sourced booleans into the local flag vars so effectiveMcp/Mcph/Session and the ignore filter stay consistent when only a config enables a domain - git: track current commit header while scanning findRenames so multi-rename commits attribute the right hash; drop unused getCommitsSince (superseded by getCommitsSinceBatch) - session/diverged-file, session/duplicate-memory: switch to Jaccard (|A n B| / |A u B|) to match the redundancy check; the prior matches/max(|A|,|B|) was asymmetric (array vs. set on the two sides) - session/duplicate-memory: scope pairs to those touching the current project so `ctxlint --session` doesn't echo unrelated cross-project duplicates every run - mcph-parser: delegate checkGitignored to `git check-ignore` so the full gitignore grammar (globs, negation, ancestor files) is honored - fixer: remove the misleading descending line sort (we mutate lines in place, so fix order across lines can't shift indices) - reporter/fixer/loop-detection: swap user-facing Unicode icons (checkmark/cross/warning/info/arrow/box-draw) for ASCII so Windows ConPTY doesn't render mojibake Co-Authored-By: Claude Opus 4.7 --- src/cli.ts | 32 ++++++---- src/core/checks/ci-secrets.ts | 8 ++- src/core/checks/mcp/env.ts | 29 +++++---- .../checks/mcph/__tests__/gitignore.test.ts | 6 +- src/core/checks/mcph/gitignore.ts | 8 +-- .../__tests__/duplicate-memory.test.ts | 31 ++++++++- src/core/checks/session/diverged-file.ts | 29 +++++---- src/core/checks/session/duplicate-memory.ts | 34 +++++++--- src/core/checks/session/loop-detection.ts | 2 +- src/core/config.ts | 64 ++++++++++++++----- src/core/fixer.ts | 11 ++-- src/core/mcph-parser.ts | 30 ++++----- src/core/reporter.ts | 16 ++--- src/utils/git.ts | 54 +++++++--------- 14 files changed, 215 insertions(+), 139 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7ee3de5..ad34970 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, @@ -88,15 +88,22 @@ 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 @@ -152,7 +159,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, @@ -489,11 +496,10 @@ async function promptYesNo(question: string): Promise { 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); } } diff --git a/src/core/checks/ci-secrets.ts b/src/core/checks/ci-secrets.ts index d8cad34..911d24c 100644 --- a/src/core/checks/ci-secrets.ts +++ b/src/core/checks/ci-secrets.ts @@ -61,9 +61,13 @@ async function findSecretUsages(projectRoot: string): Promise { } 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)); } diff --git a/src/core/checks/mcp/env.ts b/src/core/checks/mcp/env.ts index c2b1855..440c56a 100644 --- a/src/core/checks/mcp/env.ts +++ b/src/core/checks/mcp/env.ts @@ -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`, + }); + } } } } diff --git a/src/core/checks/mcph/__tests__/gitignore.test.ts b/src/core/checks/mcph/__tests__/gitignore.test.ts index 3af9c4a..607f298 100644 --- a/src/core/checks/mcph/__tests__/gitignore.test.ts +++ b/src/core/checks/mcph/__tests__/gitignore.test.ts @@ -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 () => { diff --git a/src/core/checks/mcph/gitignore.ts b/src/core/checks/mcph/gitignore.ts index e7115db..442afc9 100644 --- a/src/core/checks/mcph/gitignore.ts +++ b/src/core/checks/mcph/gitignore.ts @@ -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({ @@ -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`, - }, }); } diff --git a/src/core/checks/session/__tests__/duplicate-memory.test.ts b/src/core/checks/session/__tests__/duplicate-memory.test.ts index c0d86ac..4219ac8 100644 --- a/src/core/checks/session/__tests__/duplicate-memory.test.ts +++ b/src/core/checks/session/__tests__/duplicate-memory.test.ts @@ -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'], }; } @@ -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', diff --git a/src/core/checks/session/diverged-file.ts b/src/core/checks/session/diverged-file.ts index 43bdeac..94d2586 100644 --- a/src/core/checks/session/diverged-file.ts +++ b/src/core/checks/session/diverged-file.ts @@ -16,14 +16,19 @@ 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') @@ -31,15 +36,15 @@ function calculateOverlap(a: string, b: string): number { .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; } /** diff --git a/src/core/checks/session/duplicate-memory.ts b/src/core/checks/session/duplicate-memory.ts index 8366a26..bba033f 100644 --- a/src/core/checks/session/duplicate-memory.ts +++ b/src/core/checks/session/duplicate-memory.ts @@ -1,13 +1,18 @@ 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') @@ -15,19 +20,23 @@ function calculateLineOverlap(a: string, b: string): number { .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 { const issues: LintIssue[] = []; @@ -41,6 +50,11 @@ export async function checkDuplicateMemory(ctx: SessionContext): Promise (c.length > 40 ? c.slice(0, 37) + '...' : c)).join(' → '); + const cycleStr = cycle.map((c) => (c.length > 40 ? c.slice(0, 37) + '...' : c)).join(' -> '); issues.push({ severity: 'warning', check: 'session-loop-detection', diff --git a/src/core/config.ts b/src/core/config.ts index dab7308..02cd004 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -18,7 +18,14 @@ export interface CtxlintConfig { }; contextFiles?: string[]; mcp?: boolean; + mcpOnly?: boolean; mcpGlobal?: boolean; + mcph?: boolean; + mcphOnly?: boolean; + mcphGlobal?: boolean; + mcphStrictEnvToken?: boolean; + session?: boolean; + sessionOnly?: boolean; } const KNOWN_CONFIG_KEYS: Array = [ @@ -28,7 +35,14 @@ const KNOWN_CONFIG_KEYS: Array = [ 'tokenThresholds', 'contextFiles', 'mcp', + 'mcpOnly', 'mcpGlobal', + 'mcph', + 'mcphOnly', + 'mcphGlobal', + 'mcphStrictEnvToken', + 'session', + 'sessionOnly', ]; const CONFIG_FILENAMES = ['.ctxlintrc', '.ctxlintrc.json']; @@ -137,6 +151,24 @@ function warnUnknownKeys(config: unknown, source: string): void { } } +function parseConfigContent(content: string, source: string): CtxlintConfig { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (err) { + throw new Error(`Invalid JSON in ${source}: ${formatJsonError(content, err)}`, { + cause: err, + }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error( + `Invalid config in ${source}: expected a JSON object at the root, got ${Array.isArray(parsed) ? 'an array' : typeof parsed}`, + ); + } + warnUnknownKeys(parsed, source); + return parsed as CtxlintConfig; +} + export function loadConfig(projectRoot: string): CtxlintConfig | null { for (const filename of CONFIG_FILENAMES) { const filePath = path.join(projectRoot, filename); @@ -146,22 +178,22 @@ export function loadConfig(projectRoot: string): CtxlintConfig | null { } catch { continue; // file doesn't exist, try next } - // File exists — parse errors should be reported - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch (err) { - throw new Error(`Invalid JSON in ${filePath}: ${formatJsonError(content, err)}`, { - cause: err, - }); - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error( - `Invalid config in ${filePath}: expected a JSON object at the root, got ${Array.isArray(parsed) ? 'an array' : typeof parsed}`, - ); - } - warnUnknownKeys(parsed, filePath); - return parsed as CtxlintConfig; + return parseConfigContent(content, filePath); } return null; } + +/** + * Load a config from an explicit `--config `. Shares the same + * JSON-error reporting + unknown-key warnings as the auto-discovered path. + */ +export function loadConfigFromExplicitPath(configPath: string): CtxlintConfig { + let content: string; + try { + content = fs.readFileSync(configPath, 'utf-8'); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`could not load config from ${configPath}: ${detail}`, { cause: err }); + } + return parseConfigContent(content, configPath); +} diff --git a/src/core/fixer.ts b/src/core/fixer.ts index 757f974..d662c11 100644 --- a/src/core/fixer.ts +++ b/src/core/fixer.ts @@ -80,13 +80,12 @@ export function applyFixes(result: LintResult, options: FixOptions = {}): FixSum const lines = content.split('\n'); let modified = false; - // Sort fixes by line number descending so replacements don't shift line numbers - const sortedFixes = [...fixes].sort((a, b) => b.line - a.line); - // Group fixes by line so we can apply multiple fixes to the same line - // against the original content before any modifications + // against the original content before any modifications. (No line sort + // needed — fixes are applied via in-place `lines[lineIdx] = ...`, not + // by splicing, so fix order across lines doesn't shift indices.) const fixesByLine = new Map(); - for (const fix of sortedFixes) { + for (const fix of fixes) { const existing = fixesByLine.get(fix.line) || []; existing.push(fix); fixesByLine.set(fix.line, existing); @@ -109,7 +108,7 @@ export function applyFixes(result: LintResult, options: FixOptions = {}): FixSum const prefix = dryRun ? chalk.cyan(' Would fix') : chalk.green(' Fixed'); log( prefix + - ` Line ${fix.line}: ${chalk.dim(fix.oldText)} ${chalk.dim('\u2192')} ${fix.newText}`, + ` Line ${fix.line}: ${chalk.dim(fix.oldText)} ${chalk.dim('->')} ${fix.newText}`, ); } } diff --git a/src/core/mcph-parser.ts b/src/core/mcph-parser.ts index a843a6b..09d4869 100644 --- a/src/core/mcph-parser.ts +++ b/src/core/mcph-parser.ts @@ -1,5 +1,3 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import { parseTree, type Node } from 'jsonc-parser'; import { readFileContent } from '../utils/fs.js'; import { getGit } from '../utils/git.js'; @@ -16,7 +14,7 @@ export async function parseMchpConfig( const content = readFileContent(file.absolutePath); const scope = scopeOverride ?? detectScope(file.relativePath); const isGitTracked = await checkGitTracked(file.absolutePath, projectRoot); - const isGitignored = checkGitignored(file.absolutePath, projectRoot); + const isGitignored = await checkGitignored(file.absolutePath, projectRoot); const result: ParsedMchpConfig = { filePath: file.absolutePath, @@ -177,23 +175,19 @@ async function checkGitTracked(filePath: string, projectRoot: string): Promise { + // Delegate to `git check-ignore`, which handles the full gitignore + // grammar (globs, negation, directory scoping, ancestor .gitignore files). + // A tracked file is never reported ignored by this command — which lines + // up with what `scope === 'project-local'` callers need anyway. try { - content = fs.readFileSync(gitignorePath, 'utf-8'); + const git = getGit(projectRoot); + const result = await git.raw(['check-ignore', '--', filePath]); + return result.trim().length > 0; } catch { + // Non-zero exit: either no ignore rule matches the file (exit 1) or + // we're outside a git repo (exit 128). Both mean "not ignored" for our + // purposes. return false; } - const basename = path.basename(filePath); - const patterns = content - .split(/\r?\n/) - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith('#')); - return patterns.some((p) => p === basename || p === `/${basename}` || p === `./${basename}`); } diff --git a/src/core/reporter.ts b/src/core/reporter.ts index b0c9854..64962b1 100644 --- a/src/core/reporter.ts +++ b/src/core/reporter.ts @@ -25,7 +25,7 @@ export function formatText(result: LintResult, verbose: boolean = false): string for (const file of contextFiles) { let desc = ` ${file.path} (${file.tokens.toLocaleString()} tokens, ${file.lines} lines)`; if (file.isSymlink && file.symlinkTarget) { - desc = ` ${file.path} ${chalk.dim(`\u2192 ${file.symlinkTarget} (symlink)`)}`; + desc = ` ${file.path} ${chalk.dim(`-> ${file.symlinkTarget} (symlink)`)}`; } lines.push(desc); } @@ -52,7 +52,7 @@ export function formatText(result: LintResult, verbose: boolean = false): string lines.push(chalk.underline(file.path)); if (fileIssues.length === 0) { - lines.push(chalk.green(' \u2713 All checks passed')); + lines.push(chalk.green(' [ok] All checks passed')); } else { for (const issue of fileIssues) { lines.push(formatIssue(issue)); @@ -123,7 +123,7 @@ export function formatTokenReport(result: LintResult): string { lines.push( ` ${chalk.dim('File'.padEnd(maxPathLen))} ${chalk.dim('Tokens'.padStart(8))} ${chalk.dim('Lines'.padStart(6))}`, ); - lines.push(` ${'─'.repeat(maxPathLen)} ${'─'.repeat(8)} ${'─'.repeat(6)}`); + lines.push(` ${'-'.repeat(maxPathLen)} ${'-'.repeat(8)} ${'-'.repeat(6)}`); for (const file of result.files) { const tokenStr = file.tokens.toLocaleString().padStart(8); @@ -131,7 +131,7 @@ export function formatTokenReport(result: LintResult): string { lines.push(` ${file.path.padEnd(maxPathLen)} ${tokenStr} ${lineStr}`); } - lines.push(` ${'─'.repeat(maxPathLen)} ${'─'.repeat(8)} ${'─'.repeat(6)}`); + lines.push(` ${'-'.repeat(maxPathLen)} ${'-'.repeat(8)} ${'-'.repeat(6)}`); lines.push( ` ${'Total'.padEnd(maxPathLen)} ${result.summary.totalTokens.toLocaleString().padStart(8)}`, ); @@ -421,16 +421,16 @@ interface SarifLogicalLocation { function formatIssue(issue: LintIssue): string { const icon = issue.severity === 'error' - ? chalk.red('\u2717') + ? chalk.red('x') : issue.severity === 'warning' - ? chalk.yellow('\u26A0') - : chalk.blue('\u2139'); + ? chalk.yellow('!') + : chalk.blue('i'); const lineRef = issue.line > 0 ? `Line ${issue.line}: ` : ''; let line = ` ${icon} ${lineRef}${issue.message}`; if (issue.suggestion) { - line += `\n ${chalk.dim('\u2192')} ${chalk.dim(issue.suggestion)}`; + line += `\n ${chalk.dim('->')} ${chalk.dim(issue.suggestion)}`; } if (issue.detail) { line += `\n ${chalk.dim(issue.detail)}`; diff --git a/src/utils/git.ts b/src/utils/git.ts index edb2bab..a6f3065 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -42,28 +42,11 @@ export async function getFileLastModified( } } -export async function getCommitsSince( - projectRoot: string, - filePath: string, - since: Date, -): Promise { - try { - const git = getGit(projectRoot); - const log = await git.log({ - file: filePath, - '--since': since.toISOString(), - }); - return log.total; - } catch { - return 0; - } -} - /** - * Batched version of getCommitsSince: runs a single `git log --name-only` - * against the repo and returns a map of {path → commit count since } - * for all requested paths. Much faster than N individual `getCommitsSince` - * calls because each of those spawns a `git` subprocess (20-80ms on Windows). + * Runs a single `git log --name-only` against the repo and returns a map of + * {path -> commit count since } for all requested paths. Batched so + * one subprocess handles N paths (fork+exec is 20-80ms on Windows, so the + * savings matter on files with many referenced paths). */ export async function getCommitsSinceBatch( projectRoot: string, @@ -160,24 +143,33 @@ export async function findRenames( if (!result.trim()) return null; + // Track the most recent commit header as we scan. A single commit can + // contain multiple rename entries; peeking at `lines[i - 1]` broke when + // that previous line was itself an `R\told\tnew` row, causing + // commitHash to fall back to 'unknown'. + let currentHash = 'unknown'; + let currentDateStr: string | undefined; + const lines = result.trim().split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; + for (const line of lines) { + const headerMatch = line.match(/^([a-f0-9]{7,40})\s+(.+)$/); + if (headerMatch) { + currentHash = headerMatch[1].substring(0, 7); + currentDateStr = headerMatch[2]; + continue; + } if (line.startsWith('R')) { const parts = line.split('\t'); if (parts.length >= 3) { - const hashLine = lines[i - 1] || ''; - const hashMatch = hashLine.match(/^([a-f0-9]+)\s+(.+)/); - const commitHash = hashMatch?.[1]?.substring(0, 7) || 'unknown'; - const dateStr = hashMatch?.[2]; - const daysAgo = dateStr - ? Math.floor((Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24)) + const daysAgo = currentDateStr + ? Math.floor( + (Date.now() - new Date(currentDateStr).getTime()) / (1000 * 60 * 60 * 24), + ) : 0; - return { oldPath: parts[1], newPath: parts[2], - commitHash, + commitHash: currentHash, daysAgo, }; } From f8aedb4e32eb1b38dbd32b3820122d4900a064fd Mon Sep 17 00:00:00 2001 From: Jeff Yaw Date: Thu, 23 Apr 2026 11:58:59 -0700 Subject: [PATCH 4/4] style: apply prettier to cli.ts and git.ts Prior fix commit introduced formatting drift the CI prettier check caught. Co-Authored-By: Claude Opus 4.7 --- src/cli.ts | 3 +-- src/utils/git.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index ad34970..619eee5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -98,8 +98,7 @@ export async function runCli() { const mcpFlag = (opts.mcp as boolean) || mcpGlobal || mcpOnly || config?.mcp || 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 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; diff --git a/src/utils/git.ts b/src/utils/git.ts index a6f3065..4d4b89b 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -162,9 +162,7 @@ export async function findRenames( const parts = line.split('\t'); if (parts.length >= 3) { const daysAgo = currentDateStr - ? Math.floor( - (Date.now() - new Date(currentDateStr).getTime()) / (1000 * 60 * 60 * 24), - ) + ? Math.floor((Date.now() - new Date(currentDateStr).getTime()) / (1000 * 60 * 60 * 24)) : 0; return { oldPath: parts[1],