From e5cd7db4682fea51b7f044015e23146624e10b9e Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:11 +0500 Subject: [PATCH 1/7] chore: remove dead Redactor class src/redactor/masker.ts was never imported anywhere in src/ or scripts/ (verified via grep). Redaction is actually handled by PrivacyScanner.scan()/buildHookResponse() in engine.ts and response.ts. Dead code in a security-audited repo invites doubt about what's load-bearing. Closes #49 --- dist/redactor/masker.d.ts | 19 ------------- dist/redactor/masker.js | 49 -------------------------------- src/redactor/masker.ts | 60 --------------------------------------- 3 files changed, 128 deletions(-) delete mode 100644 dist/redactor/masker.d.ts delete mode 100644 dist/redactor/masker.js delete mode 100644 src/redactor/masker.ts diff --git a/dist/redactor/masker.d.ts b/dist/redactor/masker.d.ts deleted file mode 100644 index 086fa26..0000000 --- a/dist/redactor/masker.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Redaction and masking utilities - */ -import { Finding, ScanResult } from '../types/findings.js'; -export declare class Redactor { - /** - * Apply redaction to text based on findings - */ - static redact(scanResult: ScanResult): string; - /** - * Generate a human-readable summary of findings - */ - static summarize(scanResult: ScanResult): string; - /** - * Format findings for detailed display - */ - static formatFindings(findings: Finding[]): string; - private static truncate; -} diff --git a/dist/redactor/masker.js b/dist/redactor/masker.js deleted file mode 100644 index 6d33554..0000000 --- a/dist/redactor/masker.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Redaction and masking utilities - */ -export class Redactor { - /** - * Apply redaction to text based on findings - */ - static redact(scanResult) { - return scanResult.redactedText; - } - /** - * Generate a human-readable summary of findings - */ - static summarize(scanResult) { - if (scanResult.findings.length === 0) { - return 'No sensitive data detected.'; - } - const lines = ['Sensitive data detected:']; - // Group by category - for (const [category, count] of Object.entries(scanResult.summary)) { - lines.push(` - ${count} ${category.replace('-', ' ')} finding(s)`); - } - lines.push(''); - lines.push(`Risk Score: ${scanResult.riskScore}/100`); - return lines.join('\n'); - } - /** - * Format findings for detailed display - */ - static formatFindings(findings) { - if (findings.length === 0) - return 'No findings.'; - const lines = []; - for (let i = 0; i < findings.length; i++) { - const f = findings[i]; - lines.push(`${i + 1}. [${f.severity.toUpperCase()}] ${f.title}`); - lines.push(` Category: ${f.category}`); - lines.push(` Found: ${this.truncate(f.match, 50)}`); - lines.push(` Redacted: ${f.redactedValue}`); - lines.push(''); - } - return lines.join('\n'); - } - static truncate(text, maxLength) { - if (text.length <= maxLength) - return text; - return text.slice(0, maxLength) + '...'; - } -} diff --git a/src/redactor/masker.ts b/src/redactor/masker.ts deleted file mode 100644 index 52d2ad2..0000000 --- a/src/redactor/masker.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Redaction and masking utilities - */ - -import { Finding, ScanResult } from '../types/findings.js'; - -export class Redactor { - /** - * Apply redaction to text based on findings - */ - static redact(scanResult: ScanResult): string { - return scanResult.redactedText; - } - - /** - * Generate a human-readable summary of findings - */ - static summarize(scanResult: ScanResult): string { - if (scanResult.findings.length === 0) { - return 'No sensitive data detected.'; - } - - const lines: string[] = ['Sensitive data detected:']; - - // Group by category - for (const [category, count] of Object.entries(scanResult.summary)) { - lines.push(` - ${count} ${category.replace('-', ' ')} finding(s)`); - } - - lines.push(''); - lines.push(`Risk Score: ${scanResult.riskScore}/100`); - - return lines.join('\n'); - } - - /** - * Format findings for detailed display - */ - static formatFindings(findings: Finding[]): string { - if (findings.length === 0) return 'No findings.'; - - const lines: string[] = []; - - for (let i = 0; i < findings.length; i++) { - const f = findings[i]; - lines.push(`${i + 1}. [${f.severity.toUpperCase()}] ${f.title}`); - lines.push(` Category: ${f.category}`); - lines.push(` Found: ${this.truncate(f.match, 50)}`); - lines.push(` Redacted: ${f.redactedValue}`); - lines.push(''); - } - - return lines.join('\n'); - } - - private static truncate(text: string, maxLength: number): string { - if (text.length <= maxLength) return text; - return text.slice(0, maxLength) + '...'; - } -} From 67fc829a11dca94c01db697d07fd81ebcd5b7ae1 Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:15 +0500 Subject: [PATCH 2/7] fix: sync marketplace.json version and keep it synced on release .claude-plugin/marketplace.json was stuck at 0.1.2 while package.json/plugin.json were at 0.2.8 - eight releases of drift, because scripts/release.sh only ever bumped plugin.json. Also gave the marketplace listing's description a trailing period to match normal sentence punctuation. - Bump marketplace.json to the current version (0.2.8) - Have release.sh bump marketplace.json's plugin version alongside package.json/plugin.json so this can't drift again Closes #47 --- .claude-plugin/marketplace.json | 4 ++-- scripts/release.sh | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 63f5a94..8fa837d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "claude-code-privacy-guard", "source": "./", - "description": "🛡️ Prevent secrets and PII from being accidentally shared with Claude Code", - "version": "0.1.2", + "description": "🛡️ Prevent secrets and PII from being accidentally shared with Claude Code.", + "version": "0.2.8", "repository": "https://github.com/datumbrain/claude-code-privacy-guard" } ] diff --git a/scripts/release.sh b/scripts/release.sh index b2338ee..081abe2 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -71,6 +71,15 @@ node -e ' fs.writeFileSync(path, JSON.stringify(plugin, null, 2) + "\n"); ' "$new_version" +echo "Syncing .claude-plugin/marketplace.json version..." +node -e ' + const fs = require("fs"); + const path = ".claude-plugin/marketplace.json"; + const marketplace = JSON.parse(fs.readFileSync(path, "utf8")); + for (const plugin of marketplace.plugins) plugin.version = process.argv[1]; + fs.writeFileSync(path, JSON.stringify(marketplace, null, 2) + "\n"); +' "$new_version" + echo "Creating release commit..." git add -A git commit -m "release: v${new_version}" From ca54b0c1a0ea6c94365ba032fbecbaa5c3c55308 Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:25 +0500 Subject: [PATCH 3/7] fix: repair or drop external regexes rejected by safe-regex2 on every run Three entries in data/regex_list_1.json survive the coding-only keyword filter but were rejected by safe-regex2 on every single hook invocation, printing a warning and wasting load work while contributing zero detections: - "Password etc shadow": the /etc/shadow hash branch nested an optional group around unbounded .* repetitions (star height 2). Rewrote it as a non-optional, delimiter-bounded [^$:]* match - same intent (locked-account markers or a $id$salt$hash), no nesting. - "heroku_key": (?:-\w{4}){3} nested a bounded repetition around another repetition. Unrolled it to -\w{4}-\w{4}-\w{4} - identical match, no nesting. - "slack_api_token": (?:-[a-zA-Z0-9]+){4,} nests two unbounded repetitions, a genuine catastrophic-backtracking shape. Removed outright rather than reworked - it's a strict subset of the existing built-in 'slack-token' detector, which already covers xoxb/xoxp/xoxa/xoxr/xapp with better false-positive guards. Closes #48 --- data/regex_list_1.json | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/data/regex_list_1.json b/data/regex_list_1.json index 8146645..13ab1ea 100644 --- a/data/regex_list_1.json +++ b/data/regex_list_1.json @@ -254,7 +254,7 @@ { "name": "Password etc shadow", "description": "Password etc shadow", - "regex": "[a-zA-Z0-9\\-]+:(?:(?:!!?)|(?:\\*LOCK\\*?)|\\*|(?:\\*LCK\\*?)|(?:\\$.*\\$.*\\$.*?)?):\\d*:\\d*:\\d*:\\d*:\\d*:\\d*:", + "regex": "[a-zA-Z0-9\\-]+:(?:(?:!!?)|(?:\\*LOCK\\*?)|\\*|(?:\\*LCK\\*?)|(?:\\$[^$:]*\\$[^$:]*\\$[^$:]*)):\\d*:\\d*:\\d*:\\d*:\\d*:\\d*:", "risk": 8, "category": "Confidential" }, @@ -415,7 +415,7 @@ { "name": "heroku_key", "description": "heroku_key", - "regex": "(heroku_api_key|HEROKU_API_KEY|heroku_secret|HEROKU_SECRET)[a-z_ =\\s\"'\\:]{0,10}[^a-zA-Z0-9-]\\w{8}(?:-\\w{4}){3}-\\w{12}[^a-zA-Z0-9\\-]", + "regex": "(heroku_api_key|HEROKU_API_KEY|heroku_secret|HEROKU_SECRET)[a-z_ =\\s\"'\\:]{0,10}[^a-zA-Z0-9-]\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}[^a-zA-Z0-9\\-]", "risk": 7, "category": "Confidential" }, @@ -440,13 +440,6 @@ "risk": 7, "category": "Confidential" }, - { - "name": "slack_api_token", - "description": "slack_api_token", - "regex": "(xox[pb](?:-[a-zA-Z0-9]+){4,})", - "risk": 8, - "category": "Confidential" - }, { "name": "ssh_dss_public", "description": "ssh_dss_public", From 4f785e2e9797eb83c7e1dc0b0ed3df6aa0baddf1 Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:30 +0500 Subject: [PATCH 4/7] feat: warn on unrecognized config keys ConfigLoader only ever validated the "mode" field. Any other typo'd key (e.g. "alowedValues" instead of "allowedValues") was silently merged over the defaults and just as silently ignored - for a security tool, a config typo that silently weakens protection with no feedback is a real footgun. ConfigLoader now checks every top-level key against the known PrivacyGuardConfig fields and prints a console.warn naming the offending key. Unknown keys are still merged through unchanged (not stripped), preserving the config-UI's "unknown keys survive a save" behavior. Closes #50 --- dist/config/loader.js | 18 ++++++++++++++++++ src/config/loader.ts | 19 +++++++++++++++++++ tests/config-mode.test.ts | 35 ++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/dist/config/loader.js b/dist/config/loader.js index bed08cf..ff69e62 100644 --- a/dist/config/loader.js +++ b/dist/config/loader.js @@ -4,6 +4,19 @@ import * as fs from 'fs'; import * as path from 'path'; const VALID_MODES = ['block', 'redact', 'warn']; +// Every key PrivacyGuardConfig recognizes. Used only to warn on typos (e.g. +// "alowedValues") - unknown keys are still merged through untouched so the +// config-UI's "preserve unknown keys on save" behavior keeps working. +const KNOWN_CONFIG_KEYS = new Set([ + 'enabled', + 'mode', + 'allowedDomains', + 'disabledRules', + 'externalRulesJsonPath', + 'externalRulesMode', + 'allowedValues', + 'allowedPatterns', +]); const DEFAULT_CONFIG = { enabled: true, mode: 'block', @@ -28,6 +41,11 @@ export class ConfigLoader { if (fs.existsSync(configPath)) { const fileContent = fs.readFileSync(configPath, 'utf-8'); const userConfig = JSON.parse(fileContent); + for (const key of Object.keys(userConfig)) { + if (!KNOWN_CONFIG_KEYS.has(key)) { + console.warn(`Privacy Guard: unknown config key "${key}" in ${configPath} - check for a typo`); + } + } const merged = { ...DEFAULT_CONFIG, ...userConfig }; if (!VALID_MODES.includes(merged.mode)) { console.warn(`Privacy Guard: invalid "mode" value "${merged.mode}" in config, falling back to "block"`); diff --git a/src/config/loader.ts b/src/config/loader.ts index ba2e65d..4da5a51 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -8,6 +8,20 @@ import * as path from 'path'; const VALID_MODES = ['block', 'redact', 'warn']; +// Every key PrivacyGuardConfig recognizes. Used only to warn on typos (e.g. +// "alowedValues") - unknown keys are still merged through untouched so the +// config-UI's "preserve unknown keys on save" behavior keeps working. +const KNOWN_CONFIG_KEYS = new Set([ + 'enabled', + 'mode', + 'allowedDomains', + 'disabledRules', + 'externalRulesJsonPath', + 'externalRulesMode', + 'allowedValues', + 'allowedPatterns', +]); + const DEFAULT_CONFIG: PrivacyGuardConfig = { enabled: true, mode: 'block', @@ -37,6 +51,11 @@ export class ConfigLoader { if (fs.existsSync(configPath)) { const fileContent = fs.readFileSync(configPath, 'utf-8'); const userConfig = JSON.parse(fileContent); + for (const key of Object.keys(userConfig)) { + if (!KNOWN_CONFIG_KEYS.has(key)) { + console.warn(`Privacy Guard: unknown config key "${key}" in ${configPath} - check for a typo`); + } + } const merged = { ...DEFAULT_CONFIG, ...userConfig }; if (!VALID_MODES.includes(merged.mode)) { console.warn(`Privacy Guard: invalid "mode" value "${merged.mode}" in config, falling back to "block"`); diff --git a/tests/config-mode.test.ts b/tests/config-mode.test.ts index 1b4a8fc..09dd474 100644 --- a/tests/config-mode.test.ts +++ b/tests/config-mode.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, afterEach } from '@jest/globals'; +import { describe, expect, test, afterEach, jest } from '@jest/globals'; import { ConfigLoader } from '../src/config/loader'; import * as fs from 'fs'; import * as os from 'os'; @@ -43,3 +43,36 @@ describe('ConfigLoader mode', () => { expect(config.mode).toBe('block'); }); }); + +describe('ConfigLoader unknown keys', () => { + test('warns on an unrecognized key (e.g. a typo)', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const file = writeConfig({ alowedValues: ['oops'] }); + new ConfigLoader(file).getConfig(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('unknown config key "alowedValues"')); + } finally { + warnSpy.mockRestore(); + } + }); + + test('does not warn when every key is recognized', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const file = writeConfig({ + enabled: true, + mode: 'warn', + allowedDomains: ['example.com'], + disabledRules: ['some-rule'], + allowedValues: ['ok'], + allowedPatterns: ['^ok$'], + externalRulesJsonPath: './rules.json', + externalRulesMode: 'all', + }); + new ConfigLoader(file).getConfig(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); From a9c66bc11e1a4a1337a24cfff87ec56696d0357b Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:36 +0500 Subject: [PATCH 5/7] feat: notify once per session when Privacy Guard is disabled by config Setting "enabled": false in .privacy-guard.json silently let every prompt through unscanned with no in-session signal - a stale config line left over from debugging meant a user could be completely unprotected without any indication. The hook now emits a systemMessage the first time it sees a given session_id with the guard disabled, then stays quiet for the rest of that session (tracked via a small marker file in the existing cache dir, keyed on session_id) so it isn't repeated on every prompt. Falls back to notifying every time only when no session_id is available (e.g. the script invoked manually without the JSON envelope), since there's nothing to dedupe against. Closes #51 --- scripts/prompt-guard.js | 55 +++++++++++++++++++++++++++-- tests/disabled-notice.test.ts | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 tests/disabled-notice.test.ts diff --git a/scripts/prompt-guard.js b/scripts/prompt-guard.js index a91c369..a0228d9 100755 --- a/scripts/prompt-guard.js +++ b/scripts/prompt-guard.js @@ -12,7 +12,7 @@ */ import { PrivacyScanner } from '../dist/scanner/engine.js'; -import { readFileSync, mkdirSync, appendFileSync } from 'fs'; +import { readFileSync, mkdirSync, appendFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { BUILTIN_RULES, loadExternalRulesFromJson } from '../dist/scanner/detectors.js'; import { ConfigLoader } from '../dist/config/loader.js'; @@ -72,6 +72,53 @@ function extractPrompt(raw) { return raw; } +// Pull session_id out of the same envelope, used only to dedupe the +// "disabled by config" notice below (one notice per session, not per prompt). +function extractSessionId(raw) { + const trimmed = raw.trim(); + if (!trimmed.startsWith('{')) return undefined; + try { + const payload = JSON.parse(trimmed); + if (payload && typeof payload.session_id === 'string') return payload.session_id; + } catch { + // Not JSON - no session id available. + } + return undefined; +} + +// A config typo or a stale "enabled": false left over from debugging means +// every prompt goes through unscanned with no signal to the user. Emit a +// systemMessage the first time we see a given session_id, then stay quiet for +// the rest of that session so we're not repeating ourselves on every prompt. +function noticeIfDisabled(sessionId) { + const noticePath = path.join(getCacheDir(), 'disabled-notice.json'); + + if (sessionId) { + try { + const stored = JSON.parse(readFileSync(noticePath, 'utf-8')); + if (stored && stored.lastSessionId === sessionId) return; + } catch { + // No prior notice file (or unreadable) - treat as not yet notified. + } + } + + console.log( + JSON.stringify({ + systemMessage: + '🛡️ Privacy Guard is disabled ("enabled": false in .privacy-guard.json) - prompts are not being scanned.', + }) + ); + + if (sessionId) { + try { + mkdirSync(path.dirname(noticePath), { recursive: true }); + writeFileSync(noticePath, JSON.stringify({ lastSessionId: sessionId })); + } catch { + // Best-effort only; failing to persist just means we notice again next time. + } + } +} + debugLog([ `=== Hook Execution ${new Date().toISOString()} ===`, `CLAUDE_PLUGIN_ROOT: ${process.env.CLAUDE_PLUGIN_ROOT ?? ''}`, @@ -86,9 +133,10 @@ try { // envelope would both produce false positives on paths/ids and leak the JSON // into redact mode's copy-pasteable output. let promptText = ''; + let rawStdin = ''; try { - const raw = readFileSync(0, 'utf-8'); - promptText = extractPrompt(raw); + rawStdin = readFileSync(0, 'utf-8'); + promptText = extractPrompt(rawStdin); } catch (error) { // The shell wrapper discarded stderr when debug was off; keep stderr quiet // and route the error to the debug log instead. Exit non-zero as before - @@ -103,6 +151,7 @@ try { const config = new ConfigLoader(configPath ?? undefined).getConfig(); if (config.enabled === false) { + noticeIfDisabled(extractSessionId(rawStdin)); finish('disabled', 0); } diff --git a/tests/disabled-notice.test.ts b/tests/disabled-notice.test.ts new file mode 100644 index 0000000..387fedd --- /dev/null +++ b/tests/disabled-notice.test.ts @@ -0,0 +1,66 @@ +/** + * When "enabled": false is set, the hook passes every prompt through + * unscanned. Without a visible signal, a stale or forgotten config line can + * silently disable protection with no way to notice. The hook emits one + * systemMessage per session_id (not per prompt) so it isn't repeated on + * every submission within the same session. + */ + +import { describe, expect, test } from '@jest/globals'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const HOOK = path.resolve(__dirname, '../scripts/prompt-guard.js'); + +function runHook(sessionId: string, cacheDir: string, cwd: string): { systemMessage?: string } | null { + const stdout = execFileSync('node', [HOOK], { + input: JSON.stringify({ session_id: sessionId, prompt: 'refactor the parser' }), + cwd, + env: { ...process.env, XDG_CACHE_HOME: cacheDir }, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return stdout.trim() ? JSON.parse(stdout) : null; +} + +describe('disabled-by-config notice', () => { + test('emits a systemMessage the first time a session sees enabled: false, then stays quiet', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-disabled-')); + const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-cache-')); + writeFileSync(path.join(cwd, '.privacy-guard.json'), JSON.stringify({ enabled: false })); + + const first = runHook('session-a', cacheDir, cwd); + expect(first?.systemMessage).toContain('Privacy Guard is disabled'); + + const second = runHook('session-a', cacheDir, cwd); + expect(second).toBeNull(); + }); + + test('notices again for a new session_id', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-disabled-')); + const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-cache-')); + writeFileSync(path.join(cwd, '.privacy-guard.json'), JSON.stringify({ enabled: false })); + + runHook('session-b', cacheDir, cwd); + const differentSession = runHook('session-c', cacheDir, cwd); + expect(differentSession?.systemMessage).toContain('Privacy Guard is disabled'); + }); + + test('prompt still passes through unscanned while disabled', () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-disabled-')); + const cacheDir = mkdtempSync(path.join(os.tmpdir(), 'privacy-guard-cache-')); + writeFileSync(path.join(cwd, '.privacy-guard.json'), JSON.stringify({ enabled: false })); + + const stdout = execFileSync('node', [HOOK], { + input: JSON.stringify({ session_id: 'session-d', prompt: 'my email is john.doe@example.com' }), + cwd, + env: { ...process.env, XDG_CACHE_HOME: cacheDir }, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + const response = stdout.trim() ? JSON.parse(stdout) : null; + expect(response?.decision).toBeUndefined(); + }); +}); From 788dfcbf11c1f98705e22c5472abbe56299f7534 Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:30:42 +0500 Subject: [PATCH 6/7] ci: exercise windows-latest and macos-latest in CI Windows support is hand-coded (path handling, %LOCALAPPDATA% config resolution, no bash wrapper by design) and macOS is the primary dev platform for Claude Code users, but CI only ever ran ubuntu-latest. Untested platform claims rot silently. - Keep the full Node 18/20/22 matrix on ubuntu-latest, add one windows-latest and one macos-latest job on Node 20 via `include` (fail-fast: false so one platform's failure doesn't hide another's) - Force LF line endings repo-wide via .gitattributes (`* text=auto eol=lf`) - without this, Windows checkouts convert committed LF to CRLF via core.autocrlf, which would make the "dist/ matches src/" verification step fail spuriously on windows-latest even when nothing actually changed Closes #52 --- .gitattributes | 6 ++++++ .github/workflows/ci.yml | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index d0882d9..8e7170e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ +# Force LF line endings on checkout regardless of platform. Without this, +# Windows checkouts convert committed LF to CRLF via core.autocrlf, which +# would make the CI "dist/ matches src/" check below fail spuriously on +# windows-latest (tsc always emits LF) even when nothing actually changed. +* text=auto eol=lf + # dist/ is compiled output from src/ (tsc). It must stay committed because the # plugin is installed straight from this repo with no build step, but it is a # generated artifact and should never be reviewed or hand-edited. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab9a1c..6a56b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,17 @@ on: jobs: build-test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: + os: [ubuntu-latest] node-version: [18, 20, 22] + include: + - os: windows-latest + node-version: 20 + - os: macos-latest + node-version: 20 steps: - uses: actions/checkout@v4 From c5a96235a822e7dd9253dc6d626fb9565ffc6a8e Mon Sep 17 00:00:00 2001 From: Fahad Date: Thu, 23 Jul 2026 14:34:53 +0500 Subject: [PATCH 7/7] fix: Windows CI failure in dist/-verification step Two bugs surfaced by the new windows-latest job (#52): 1. The "Verify committed dist/ matches src/" step uses bash `if ! cmd; then` syntax, but Windows runners default `run:` steps to PowerShell - which fails to parse it outright (ParserError: Missing '(' after 'if' in if statement), unrelated to whether dist/ actually matched. Set `defaults.run.shell: bash` on the job so every step runs under bash on all three platforms. 2. Once that parses, the check would still have failed spuriously on Windows: tsc's `newLine` option defaults to the platform's native line ending, so a Windows build emits CRLF while the committed dist/ (LF) would not match even with nothing actually changed. Set `"newLine": "lf"` in tsconfig.json so tsc always emits LF regardless of OS, independent of the .gitattributes normalization added in the prior commit. --- .github/workflows/ci.yml | 6 ++++++ tsconfig.json | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a56b43..4852bea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ on: jobs: build-test: runs-on: ${{ matrix.os }} + defaults: + run: + # Windows runners default `run:` steps to PowerShell, which chokes on + # the bash `if ! cmd; then` syntax below. Force bash everywhere so the + # same steps behave identically on all three platforms. + shell: bash strategy: fail-fast: false matrix: diff --git a/tsconfig.json b/tsconfig.json index 4f319eb..f4dd6a0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "declaration": true, "declarationMap": false, "sourceMap": false, - "moduleResolution": "node" + "moduleResolution": "node", + "newLine": "lf" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"]