diff --git a/scripts/framework-components/ProhibitedTermsScanner.js b/scripts/framework-components/ProhibitedTermsScanner.js new file mode 100644 index 00000000..ffd0e1bf --- /dev/null +++ b/scripts/framework-components/ProhibitedTermsScanner.js @@ -0,0 +1,440 @@ +/** + * ProhibitedTermsScanner + * + * Proactively scans codebase for violations of inst_016/017/018 + * Part of Framework Improvement Phase 1: Proactive Content Scanning + * + * Usage: + * const scanner = new ProhibitedTermsScanner(); + * const violations = await scanner.scan(); + * const fixed = await scanner.autoFix(violations); + * + * CLI: + * node scripts/framework-components/ProhibitedTermsScanner.js [--details] [--fix] [--staged] + */ + +const fs = require('fs').promises; +const path = require('path'); +const { glob } = require('glob'); +const { execSync } = require('child_process'); + +class ProhibitedTermsScanner { + constructor(options = {}) { + this.options = { + silent: options.silent || false, + fixMode: options.fixMode || false, + staged: options.staged || false, + basePath: options.basePath || process.cwd(), + ...options + }; + + // Pattern definitions from inst_016/017/018 + this.patterns = [ + { + id: 'inst_017', + name: 'Absolute Assurance Terms', + severity: 'HIGH', + patterns: [ + /\bguarantee(?:s|d|ing)?\b/gi, + /ensures?\s+100%/gi, + /eliminates?\s+all\b/gi, + /completely\s+prevents?\b/gi, + /never\s+fails?\b/gi, + /always\s+works?\b/gi + ], + suggestions: { + 'guarantee': 'enforcement', + 'guarantees': 'enforces', + 'guaranteed': 'enforced', + 'guaranteeing': 'enforcing', + 'ensures 100%': 'helps ensure', + 'ensure 100%': 'help ensure', + 'eliminates all': 'reduces', + 'eliminate all': 'reduce', + 'completely prevents': 'designed to prevent', + 'completely prevent': 'designed to prevent', + 'never fails': 'designed to prevent failures', + 'never fail': 'designed to prevent failures', + 'always works': 'designed to work', + 'always work': 'designed to work' + } + }, + { + id: 'inst_016', + name: 'Fabricated Statistics', + severity: 'HIGH', + patterns: [ + // Match percentage claims without [NEEDS VERIFICATION] or source citations + /\b\d+%\s+(?:faster|better|improvement|increase|decrease|reduction|more|less)\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi, + /\b(?:faster|better|improvement|increase|decrease|reduction)\s+of\s+\d+%\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi + ], + suggestions: { + 'default': 'Add [NEEDS VERIFICATION] or cite source' + } + }, + { + id: 'inst_018', + name: 'Unverified Readiness Claims', + severity: 'MEDIUM', + patterns: [ + /\bproduction-ready\b(?!\s+development\s+tool|\s+proof-of-concept)/gi, + /\bbattle-tested\b/gi, + /\benterprise-proven\b/gi, + /\bwidespread\s+adoption\b/gi, + /\bcustomer\s+base\b(?!\s+of\s+zero|\s+\(none\))/gi, + /\bmarket\s+validation\b(?!\s+pending|\s+not\s+yet)/gi + ], + suggestions: { + 'production-ready': 'proof-of-concept', + 'battle-tested': 'in development', + 'enterprise-proven': 'designed for', + 'widespread adoption': 'early development', + 'customer base': 'development project', + 'market validation': 'internal validation' + } + } + ]; + + // File inclusion patterns + this.includePatterns = [ + '**/*.md', + '**/*.html', + '**/*.js', + '**/*.json', + '**/*.jsx', + '**/*.tsx' + ]; + + // File exclusion patterns + this.excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/.claude/**', + '**/tests/**/*.test.js', + '**/tests/**/*.spec.js', + '**/docs/case-studies/**', + '**/GOVERNANCE-RULE-LIBRARY.md', + '**/.claude/instruction-history.json', + '**/dist/**', + '**/build/**', + '**/.next/**' + ]; + } + + /** + * Scan files for prohibited terms + * @param {Object} options - Scan options + * @returns {Promise} Array of violations + */ + async scan(options = {}) { + const scanOptions = { ...this.options, ...options }; + const violations = []; + + // Get files to scan + const files = await this.getFilesToScan(scanOptions.staged); + + if (!scanOptions.silent) { + console.log(`\nšŸ” Scanning ${files.length} files for prohibited terms...`); + } + + // Scan each file + for (const file of files) { + try { + const content = await fs.readFile(file, 'utf8'); + const lines = content.split('\n'); + + // Check each pattern type + for (const patternSet of this.patterns) { + for (const pattern of patternSet.patterns) { + lines.forEach((line, index) => { + const matches = line.match(pattern); + if (matches) { + matches.forEach(match => { + // Skip if in allowed context + if (this.isAllowedContext(line, match, file)) { + return; + } + + violations.push({ + file, + line: index + 1, + match, + rule: patternSet.id, + ruleName: patternSet.name, + severity: patternSet.severity, + context: line.trim(), + suggestion: this.getSuggestion(match, patternSet.suggestions) + }); + }); + } + }); + } + } + } catch (err) { + // Skip files that can't be read (binary files, etc.) + if (err.code !== 'ENOENT') { + console.error(`⚠ Error reading ${file}: ${err.message}`); + } + } + } + + return violations; + } + + /** + * Auto-fix simple violations + * @param {Array} violations - Violations to fix + * @returns {Promise} Fix results + */ + async autoFix(violations) { + const results = { + fixed: 0, + total: violations.length, + skipped: 0, + errors: [] + }; + + // Group violations by file + const fileGroups = violations.reduce((acc, v) => { + if (!acc[v.file]) acc[v.file] = []; + acc[v.file].push(v); + return acc; + }, {}); + + // Fix each file + for (const [file, fileViolations] of Object.entries(fileGroups)) { + try { + let content = await fs.readFile(file, 'utf8'); + let modified = false; + + // Apply fixes (reverse order to preserve line numbers) + for (const violation of fileViolations.reverse()) { + // Only auto-fix if we have a clear suggestion + if (violation.suggestion && violation.suggestion !== 'Add [NEEDS VERIFICATION] or cite source') { + const originalContent = content; + + // Simple case-preserving replacement + const regex = new RegExp(this.escapeRegex(violation.match), 'g'); + content = content.replace(regex, violation.suggestion); + + if (content !== originalContent) { + modified = true; + results.fixed++; + } + } else { + results.skipped++; + } + } + + // Write file if modified + if (modified) { + await fs.writeFile(file, content, 'utf8'); + console.log(`āœ“ Fixed ${file}`); + } + } catch (err) { + results.errors.push({ file, error: err.message }); + console.error(`āœ— Error fixing ${file}: ${err.message}`); + } + } + + return results; + } + + /** + * Get files to scan + * @param {boolean} stagedOnly - Only scan staged files + * @returns {Promise} Array of file paths + */ + async getFilesToScan(stagedOnly = false) { + if (stagedOnly) { + try { + const output = execSync('git diff --cached --name-only', { encoding: 'utf8' }); + return output.split('\n').filter(f => f.trim()); + } catch (err) { + console.error('⚠ Error getting staged files, falling back to all files'); + } + } + + // Use glob to find all matching files + const files = []; + for (const pattern of this.includePatterns) { + try { + const matches = await glob(pattern, { + ignore: this.excludePatterns, + nodir: true, + cwd: this.options.basePath + }); + // glob returns an array, so we can spread it + if (Array.isArray(matches)) { + // Prepend base path to make absolute paths + const absolutePaths = matches.map(f => path.join(this.options.basePath, f)); + files.push(...absolutePaths); + } + } catch (err) { + // Ignore glob errors (e.g., pattern doesn't match anything) + } + } + + // Remove duplicates + return [...new Set(files)]; + } + + /** + * Check if context allows the term + * @param {string} line - Line containing match + * @param {string} match - Matched term + * @param {string} file - File path + * @returns {boolean} True if allowed + */ + isAllowedContext(line, match, file) { + // Allow in comments about the rules themselves + if (line.includes('inst_017') || line.includes('inst_016') || line.includes('inst_018')) { + return true; + } + + // Allow in GOVERNANCE-RULE-LIBRARY.md + if (file.includes('GOVERNANCE-RULE-LIBRARY.md')) { + return true; + } + + // Allow in case studies + if (file.includes('case-studies')) { + return true; + } + + // Allow in test files (shouldn't reach here but double-check) + if (file.includes('.test.') || file.includes('.spec.')) { + return true; + } + + // Allow "production-ready development tool" or "production-ready proof-of-concept" + if (match.toLowerCase() === 'production-ready') { + if (line.includes('development tool') || line.includes('proof-of-concept')) { + return true; + } + } + + return false; + } + + /** + * Get suggestion for a match + * @param {string} match - Matched term + * @param {Object} suggestions - Suggestion map + * @returns {string} Suggestion + */ + getSuggestion(match, suggestions) { + const lowerMatch = match.toLowerCase(); + + // Try exact match first + if (suggestions[lowerMatch]) { + return suggestions[lowerMatch]; + } + + // Try partial matches + for (const [key, value] of Object.entries(suggestions)) { + if (lowerMatch.includes(key)) { + return value; + } + } + + return suggestions.default || 'Review and revise'; + } + + /** + * Format violations for display + * @param {Array} violations - Violations to format + * @param {boolean} detailed - Show detailed output + * @returns {string} Formatted output + */ + formatViolations(violations, detailed = false) { + if (violations.length === 0) { + return '\nāœ… No prohibited terms found\n'; + } + + // Group by rule + const byRule = violations.reduce((acc, v) => { + if (!acc[v.rule]) acc[v.rule] = []; + acc[v.rule].push(v); + return acc; + }, {}); + + let output = `\n⚠ Found ${violations.length} violation(s):\n`; + + // Summary + for (const [rule, items] of Object.entries(byRule)) { + output += ` ${rule}: ${items.length} violation(s)\n`; + } + + // Details + if (detailed) { + output += '\nDetails:\n'; + for (const v of violations) { + output += `\n ${v.file}:${v.line}\n`; + output += ` Rule: ${v.rule} (${v.severity})\n`; + output += ` Found: "${v.match}"\n`; + output += ` Context: ${v.context.substring(0, 80)}...\n`; + output += ` Suggestion: ${v.suggestion}\n`; + } + } else { + output += '\nRun with --details for full violation list\n'; + } + + output += '\nTo fix: node scripts/framework-components/ProhibitedTermsScanner.js --fix\n'; + + return output; + } + + /** + * Escape regex special characters + * @param {string} str - String to escape + * @returns {string} Escaped string + */ + escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} + +// CLI interface +async function main() { + const args = process.argv.slice(2); + const options = { + silent: false, + fixMode: args.includes('--fix'), + staged: args.includes('--staged'), + details: args.includes('--details') + }; + + const scanner = new ProhibitedTermsScanner(options); + + console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + console.log(' Tractatus Framework - Prohibited Terms Scanner'); + console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + const violations = await scanner.scan(); + + console.log(scanner.formatViolations(violations, options.details)); + + if (options.fixMode && violations.length > 0) { + console.log('\nšŸ”§ Applying auto-fixes...\n'); + const results = await scanner.autoFix(violations); + console.log(`\nāœ“ Fixed: ${results.fixed}`); + console.log(`⊘ Skipped: ${results.skipped} (manual review required)`); + if (results.errors.length > 0) { + console.log(`āœ— Errors: ${results.errors.length}`); + } + } + + // Exit with error code if violations found (for pre-commit hooks) + process.exit(violations.length > 0 ? 1 : 0); +} + +// Run if called directly +if (require.main === module) { + main().catch(err => { + console.error('Error:', err); + process.exit(1); + }); +} + +module.exports = ProhibitedTermsScanner; diff --git a/scripts/hook-validators/validate-file-write.js b/scripts/hook-validators/validate-file-write.js index 5d0ff1fc..f7537d4d 100755 --- a/scripts/hook-validators/validate-file-write.js +++ b/scripts/hook-validators/validate-file-write.js @@ -149,6 +149,42 @@ function checkCSPComplianceOnNewContent() { } /** + * Check 1b: Pre-action-check recency (inst_038) + */ +function checkPreActionCheckRecency() { + // Only enforce for major file changes + // Check if pre-action-check was run recently + + try { + if (!fs.existsSync(SESSION_STATE_PATH)) { + // No session state, allow but warn + warning('No session state - pre-action-check recommended (inst_038)'); + return { passed: true }; + } + + const sessionState = JSON.parse(fs.readFileSync(SESSION_STATE_PATH, 'utf8')); + const lastPreActionCheck = sessionState.last_pre_action_check; + const currentAction = sessionState.action_count || 0; + + // If never run, just warn (don't block on first action) + if (!lastPreActionCheck) { + warning('inst_038: Pre-action-check not run in this session'); + warning('Recommended: node scripts/pre-action-check.js write ' + FILE_PATH); + return { passed: true }; // Warning only + } + + // If last run was more than 10 actions ago, warn + if (currentAction - lastPreActionCheck > 10) { + warning(`inst_038: Pre-action-check last run ${currentAction - lastPreActionCheck} actions ago`); + warning('Consider running: node scripts/pre-action-check.js write ' + FILE_PATH); + } + + return { passed: true }; + } catch (error) { + warning(`Could not check pre-action-check recency: ${error.message}`); + return { passed: true }; + } +} /** * Check 2: Overwrite without read diff --git a/scripts/session-init.js b/scripts/session-init.js index a8984097..8dac890e 100755 --- a/scripts/session-init.js +++ b/scripts/session-init.js @@ -384,8 +384,41 @@ async function main() { process.exit(1); // Exit with failure code } + // Prohibited Terms Scan (Framework Phase 1) + section('7. Scanning for Prohibited Terms'); + try { + const ProhibitedTermsScanner = require('./framework-components/ProhibitedTermsScanner'); + const scanner = new ProhibitedTermsScanner({ silent: false }); + const violations = await scanner.scan(); + + if (violations.length === 0) { + success('No prohibited terms found (inst_016/017/018 compliant)'); + } else { + console.log(''); + warning(`Found ${violations.length} violation(s) in user-facing content:`); + + // Group by rule + const byRule = violations.reduce((acc, v) => { + if (!acc[v.rule]) acc[v.rule] = []; + acc[v.rule].push(v); + return acc; + }, {}); + + Object.entries(byRule).forEach(([rule, items]) => { + log(` ${rule}: ${items.length} violation(s)`, 'yellow'); + }); + + console.log(''); + log(' Run: node scripts/framework-components/ProhibitedTermsScanner.js --details', 'cyan'); + log(' Or: node scripts/framework-components/ProhibitedTermsScanner.js --fix', 'cyan'); + console.log(''); + } + } catch (err) { + warning(`Could not run prohibited terms scanner: ${err.message}`); + } + // CSP Compliance Scan - section('7. CSP Compliance Scan (inst_008)'); + section('8. CSP Compliance Scan (inst_008)'); try { const { scanForViolations, displayViolations } = require('./check-csp-violations'); const violations = scanForViolations(); @@ -416,7 +449,7 @@ async function main() { } // ENFORCEMENT: Local development server check - section('8. Development Environment Enforcement'); + section('9. Development Environment Enforcement'); const localServerRunning = checkLocalServer(); if (!localServerRunning) { @@ -447,7 +480,7 @@ async function main() { success('Development environment ready'); // Hook Architecture Status - section('9. Continuous Enforcement Architecture'); + section('10. Continuous Enforcement Architecture'); const hookValidatorsExist = fs.existsSync(path.join(__dirname, 'hook-validators')); if (hookValidatorsExist) { diff --git a/tests/unit/ProhibitedTermsScanner.test.js b/tests/unit/ProhibitedTermsScanner.test.js new file mode 100644 index 00000000..1e568194 --- /dev/null +++ b/tests/unit/ProhibitedTermsScanner.test.js @@ -0,0 +1,461 @@ +/** + * Unit Tests - ProhibitedTermsScanner + * Tests for proactive content scanning (Framework Phase 1) + */ + +const fs = require('fs').promises; +const path = require('path'); +const ProhibitedTermsScanner = require('../../scripts/framework-components/ProhibitedTermsScanner'); + +describe('ProhibitedTermsScanner', () => { + let scanner; + const testFilesDir = path.join(__dirname, '../tmp-scanner-test'); + + beforeEach(() => { + scanner = new ProhibitedTermsScanner({ + silent: true, + basePath: testFilesDir // Only scan test directory + }); + }); + + afterEach(async () => { + // Clean up test files + try { + await fs.rm(testFilesDir, { recursive: true, force: true }); + } catch (err) { + // Ignore cleanup errors + } + }); + + describe('Pattern Detection - inst_017 (Absolute Assurance)', () => { + test('should detect "guarantee" variations', async () => { + const testContent = ` + This guarantees safety. + We guarantee results. + This is guaranteed to work. + We are guaranteeing success. + `; + + // Create test file + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst017Violations = violations.filter(v => v.rule === 'inst_017'); + + expect(inst017Violations.length).toBeGreaterThanOrEqual(4); + expect(inst017Violations.some(v => v.match.toLowerCase().includes('guarantee'))).toBe(true); + }); + + test('should detect "ensures 100%"', async () => { + const testContent = 'This ensures 100% accuracy.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.html'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst017Violations = violations.filter(v => v.rule === 'inst_017'); + + expect(inst017Violations.length).toBeGreaterThan(0); + expect(inst017Violations.some(v => v.match.toLowerCase().includes('ensures'))).toBe(true); + }); + + test('should detect "eliminates all"', async () => { + const testContent = 'This eliminates all bugs.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.js'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + + expect(violations.length).toBeGreaterThan(0); + expect(violations.some(v => v.match.toLowerCase().includes('eliminates'))).toBe(true); + }); + + test('should detect "never fails"', async () => { + const testContent = 'This system never fails.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + + expect(violations.length).toBeGreaterThan(0); + expect(violations.some(v => v.match.toLowerCase().includes('never'))).toBe(true); + }); + }); + + describe('Pattern Detection - inst_018 (Unverified Claims)', () => { + test('should detect "production-ready" without context', async () => { + const testContent = 'This is production-ready software.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst018Violations = violations.filter(v => v.rule === 'inst_018'); + + expect(inst018Violations.length).toBeGreaterThan(0); + }); + + test('should detect "battle-tested"', async () => { + const testContent = 'Our battle-tested framework.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.html'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + + expect(violations.some(v => v.match.toLowerCase().includes('battle-tested'))).toBe(true); + }); + + test('should allow "production-ready development tool"', async () => { + const testContent = 'Tractatus is a production-ready development tool.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst018Violations = violations.filter(v => + v.rule === 'inst_018' && v.match.toLowerCase().includes('production-ready') + ); + + expect(inst018Violations.length).toBe(0); + }); + + test('should allow "production-ready proof-of-concept"', async () => { + const testContent = 'This is a production-ready proof-of-concept.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst018Violations = violations.filter(v => + v.rule === 'inst_018' && v.match.toLowerCase().includes('production-ready') + ); + + expect(inst018Violations.length).toBe(0); + }); + }); + + describe('Context Awareness', () => { + test('should allow prohibited terms in comments about rules', async () => { + const testContent = ` + // inst_017: Never use "guarantee" + // inst_017 prohibits guaranteed language + `; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.js'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + + // Should not flag violations in comments about the rules + expect(violations.length).toBe(0); + }); + + test('should exclude test files from scanning', async () => { + // Test files should be excluded by pattern + const scanner2 = new ProhibitedTermsScanner({ silent: true }); + + expect(scanner2.excludePatterns).toContain('**/tests/**/*.test.js'); + expect(scanner2.excludePatterns).toContain('**/tests/**/*.spec.js'); + }); + + test('should exclude GOVERNANCE-RULE-LIBRARY.md', async () => { + expect(scanner.excludePatterns).toContain('**/GOVERNANCE-RULE-LIBRARY.md'); + }); + + test('should exclude case studies', async () => { + expect(scanner.excludePatterns).toContain('**/docs/case-studies/**'); + }); + }); + + describe('Suggestions', () => { + test('should suggest "enforcement" for "guarantee"', () => { + const suggestions = scanner.patterns.find(p => p.id === 'inst_017').suggestions; + + expect(suggestions['guarantee']).toBe('enforcement'); + expect(suggestions['guarantees']).toBe('enforces'); + expect(suggestions['guaranteed']).toBe('enforced'); + }); + + test('should suggest replacements for "ensures 100%"', () => { + const suggestions = scanner.patterns.find(p => p.id === 'inst_017').suggestions; + + expect(suggestions['ensures 100%']).toBe('helps ensure'); + }); + + test('should suggest replacements for inst_018 terms', () => { + const suggestions = scanner.patterns.find(p => p.id === 'inst_018').suggestions; + + expect(suggestions['production-ready']).toBe('proof-of-concept'); + expect(suggestions['battle-tested']).toBe('in development'); + }); + + test('should get suggestion for matched term', () => { + const patternSet = scanner.patterns.find(p => p.id === 'inst_017'); + const suggestion = scanner.getSuggestion('guarantee', patternSet.suggestions); + + expect(suggestion).toBe('enforcement'); + }); + + test('should handle case-insensitive matches', () => { + const patternSet = scanner.patterns.find(p => p.id === 'inst_017'); + const suggestion = scanner.getSuggestion('GUARANTEE', patternSet.suggestions); + + expect(suggestion).toBe('enforcement'); + }); + }); + + describe('Auto-fix Functionality', () => { + test('should fix simple violations', async () => { + const testContent = 'This guarantees safety.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const results = await scanner.autoFix(violations); + + expect(results.fixed).toBeGreaterThan(0); + + const fixedContent = await fs.readFile(testFile, 'utf8'); + expect(fixedContent).toContain('enforces'); // guarantees → enforces + expect(fixedContent).not.toContain('guarantees'); + }); + + test('should preserve file structure during fix', async () => { + const testContent = `Line 1: Normal content +Line 2: This guarantees results +Line 3: More content +Line 4: This guaranteed safety +Line 5: Final line`; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + await scanner.autoFix(violations); + + const fixedContent = await fs.readFile(testFile, 'utf8'); + const lines = fixedContent.split('\n'); + + expect(lines.length).toBe(5); + expect(lines[0]).toBe('Line 1: Normal content'); + expect(lines[4]).toBe('Line 5: Final line'); + }); + + test('should handle multiple violations in same file', async () => { + const testContent = ` + We guarantee success. + This is guaranteed to work. + Our guarantees are strong. + `; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const results = await scanner.autoFix(violations); + + expect(results.fixed).toBeGreaterThan(0); + + const fixedContent = await fs.readFile(testFile, 'utf8'); + expect(fixedContent).not.toContain('guarantee'); + }); + + test('should skip violations without clear suggestions', async () => { + // inst_016 violations (fabricated statistics) require manual review + const testContent = '95% faster performance.'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + const inst016Violations = violations.filter(v => v.rule === 'inst_016'); + + const results = await scanner.autoFix(inst016Violations); + + // inst_016 requires source citation, can't auto-fix + expect(results.skipped).toBeGreaterThan(0); + }); + }); + + describe('Formatting', () => { + test('should format empty violations list', () => { + const output = scanner.formatViolations([]); + + expect(output).toContain('No prohibited terms found'); + }); + + test('should format violations summary', () => { + const violations = [ + { rule: 'inst_017', file: 'test.md', line: 1, match: 'guarantee', severity: 'HIGH' }, + { rule: 'inst_017', file: 'test.md', line: 2, match: 'guaranteed', severity: 'HIGH' }, + { rule: 'inst_018', file: 'test.html', line: 5, match: 'battle-tested', severity: 'MEDIUM' } + ]; + + const output = scanner.formatViolations(violations, false); + + expect(output).toContain('Found 3 violation'); + expect(output).toContain('inst_017: 2'); + expect(output).toContain('inst_018: 1'); + }); + + test('should format detailed violations', () => { + const violations = [ + { + rule: 'inst_017', + file: 'test.md', + line: 1, + match: 'guarantee', + severity: 'HIGH', + context: 'This guarantees safety', + suggestion: 'enforcement' + } + ]; + + const output = scanner.formatViolations(violations, true); + + expect(output).toContain('test.md:1'); + expect(output).toContain('Found: "guarantee"'); + expect(output).toContain('Suggestion: enforcement'); + }); + }); + + describe('Utility Functions', () => { + test('should escape regex special characters', () => { + const escaped = scanner.escapeRegex('test.file[0]'); + + expect(escaped).toBe('test\\.file\\[0\\]'); + }); + + test('should check allowed context for inst_017 references', () => { + const line = 'inst_017 prohibits guaranteed language'; + const result = scanner.isAllowedContext(line, 'guaranteed', 'test.md'); + + expect(result).toBe(true); + }); + + test('should check allowed context for case studies', () => { + const line = 'This guarantees results'; + const result = scanner.isAllowedContext(line, 'guarantees', 'docs/case-studies/example.md'); + + expect(result).toBe(true); + }); + + test('should reject prohibited terms in normal content', () => { + const line = 'This guarantees results'; + const result = scanner.isAllowedContext(line, 'guarantees', 'README.md'); + + expect(result).toBe(false); + }); + }); + + describe('File Inclusion/Exclusion', () => { + test('should include markdown files', () => { + expect(scanner.includePatterns).toContain('**/*.md'); + }); + + test('should include HTML files', () => { + expect(scanner.includePatterns).toContain('**/*.html'); + }); + + test('should include JavaScript files', () => { + expect(scanner.includePatterns).toContain('**/*.js'); + }); + + test('should exclude node_modules', () => { + expect(scanner.excludePatterns).toContain('**/node_modules/**'); + }); + + test('should exclude .git directory', () => { + expect(scanner.excludePatterns).toContain('**/.git/**'); + }); + + test('should exclude .claude directory', () => { + expect(scanner.excludePatterns).toContain('**/.claude/**'); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty files', async () => { + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'empty.md'); + await fs.writeFile(testFile, ''); + + const violations = await scanner.scan(); + + // Should not error on empty files + expect(Array.isArray(violations)).toBe(true); + }); + + test('should handle files with only whitespace', async () => { + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'whitespace.md'); + await fs.writeFile(testFile, '\n\n \n\t\n'); + + const violations = await scanner.scan(); + + expect(Array.isArray(violations)).toBe(true); + }); + + test('should handle very long lines', async () => { + const longLine = 'a'.repeat(10000) + ' guarantee ' + 'b'.repeat(10000); + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'long.md'); + await fs.writeFile(testFile, longLine); + + const violations = await scanner.scan(); + + expect(violations.length).toBeGreaterThan(0); + }); + + test('should handle violations at file end without newline', async () => { + const testContent = 'This guarantees results'; + + await fs.mkdir(testFilesDir, { recursive: true }); + const testFile = path.join(testFilesDir, 'test.md'); + await fs.writeFile(testFile, testContent); + + const violations = await scanner.scan(); + + expect(violations.length).toBeGreaterThan(0); + }); + }); + + describe('Silent Mode', () => { + test('should suppress console output in silent mode', async () => { + const silentScanner = new ProhibitedTermsScanner({ + silent: true, + basePath: testFilesDir + }); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await silentScanner.scan(); + + // In silent mode, should not call console.log for scanning message + const scanningCalls = consoleSpy.mock.calls.filter(call => + call.some(arg => typeof arg === 'string' && arg.includes('Scanning')) + ); + expect(scanningCalls.length).toBe(0); + + consoleSpy.mockRestore(); + }); + }); +});