From da049c5c40657d41d0166746d2c0942cb05986da Mon Sep 17 00:00:00 2001 From: Doug Fennell Date: Tue, 11 Nov 2025 08:59:22 -0600 Subject: [PATCH 1/2] feat: CI-safe scripts in init and improved ratchet messaging - init: add lint:ci and ratchet scripts to package.json (non-destructive) - README: CI snippet to run lint:ci then ratchet - ratchet: print examples of new violations and unlock hint when failing Refs: #210 --- README.md | 25 +++++++++++++++ lib/commands/init/index.js | 5 ++- lib/generators/package-scripts.js | 42 ++++++++++++++++++++++++ scripts/ratchet.js | 53 +++++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 lib/generators/package-scripts.js diff --git a/README.md b/README.md index 4268f20..08a551d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,31 @@ Running `init` creates: ## Installation +### CI-safe setup (recommended) + +Add scripts to your package.json: + +```json +{ + "scripts": { + "lint": "eslint .", + "lint:ci": "eslint . || true", + "lint:json": "eslint . -f json -o lint-results.json", + "analyze:current": "eslint-plugin-ai-code-snifftest analyze --input=lint-results.json --format=json --output=analysis-current.json", + "ratchet": "eslint-plugin-ai-code-snifftest ratchet" + } +} +``` + +GitHub Actions example: + +```yaml +- name: Lint (non-blocking) + run: npm run lint:ci +- name: Ratchet (blocks on regression) + run: npm run ci:ratchet +``` + ### Requirements - Node.js 18+ - ESLint 9+ diff --git a/lib/commands/init/index.js b/lib/commands/init/index.js index 15e4931..5fe122c 100644 --- a/lib/commands/init/index.js +++ b/lib/commands/init/index.js @@ -22,6 +22,7 @@ const { writeConfig } = require(path.join(__dirname, '..', '..', 'generators', ' const { writeAgentsMd } = require(path.join(__dirname, '..', '..', 'generators', 'agents-md')); const { writeCursorRules } = require(path.join(__dirname, '..', '..', 'generators', 'cursorrules')); const { writeEslintConfig } = require(path.join(__dirname, '..', '..', 'generators', 'eslint-config')); +const { ensurePackageScripts } = require(path.join(__dirname, '..', '..', 'generators', 'package-scripts')); // Utilities const { applyFingerprintToConfig } = require(path.join(__dirname, '..', '..', 'utils', 'fingerprint')); @@ -105,7 +106,9 @@ const external = shouldEnableExternalConstants(args); console.log('Found WARP.md — preserving it; generated AGENTS.md alongside.'); } } - if (shouldWriteEslint) writeEslintConfig(cwd, cfg); +if (shouldWriteEslint) writeEslintConfig(cwd, cfg); + // Add CI-safe scripts (non-destructive; only adds missing) + try { ensurePackageScripts(cwd); } catch { /* ignore */ } // Post-init guidance console.log('\nProject initialized.'); diff --git a/lib/generators/package-scripts.js b/lib/generators/package-scripts.js new file mode 100644 index 0000000..ef26f38 --- /dev/null +++ b/lib/generators/package-scripts.js @@ -0,0 +1,42 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function ensurePackageScripts(cwd) { + const pkgPath = path.join(cwd, 'package.json'); + if (!fs.existsSync(pkgPath)) return false; + let changed = false; + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + pkg.scripts = pkg.scripts || {}; + + const want = { + 'lint': 'eslint .', + 'lint:ci': 'eslint . || true', + 'lint:json': 'eslint . -f json -o lint-results.json', + 'analyze:current': 'eslint-plugin-ai-code-snifftest analyze --input=lint-results.json --format=json --output=analysis-current.json', + 'ratchet': 'eslint-plugin-ai-code-snifftest ratchet', + 'ratchet:context': 'eslint-plugin-ai-code-snifftest ratchet --mode=context', + 'ci:ratchet': 'npm run lint:json && npm run analyze:current && npm run ratchet' + }; + + for (const [k, v] of Object.entries(want)) { + if (!pkg.scripts[k]) { + pkg.scripts[k] = v; + changed = true; + } + } + + if (changed) { + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log('Updated package.json with CI-safe lint and ratchet scripts.'); + } + return changed; + } catch (e) { + console.warn(`Warning: could not update package.json scripts: ${e && e.message}`); + return false; + } +} + +module.exports = { ensurePackageScripts }; \ No newline at end of file diff --git a/scripts/ratchet.js b/scripts/ratchet.js index 7e8b8b2..24882dc 100755 --- a/scripts/ratchet.js +++ b/scripts/ratchet.js @@ -170,7 +170,20 @@ function detectIntentFromMetrics(base, curr) { return { intent, confidence, signals }; } +function formatExamples(messages, ruleId, max = 3) { + const out = []; + for (const m of messages || []) { + if (ruleId && m.ruleId !== ruleId) continue; + if (!m.ruleId) continue; + const loc = m.line != null ? `${m.line}:${m.column || 1}` : ''; + out.push(` • ${m.filePath || m.filename || 'file'}:${loc} – ${m.message}`); + if (out.length >= max) break; + } + return out.join('\n'); +} + function runContextMode(base, curr) { + console.log('\nšŸ“Š Context-Aware Telemetry'); console.log('\nšŸ“Š Context-Aware Telemetry'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); console.log('Mode: Non-blocking (burn-in period)\n'); @@ -209,6 +222,21 @@ const { intent, confidence, signals } = detectIntentFromMetrics(base, curr); } function runTraditionalMode(base, curr, args) { + // Optional lint file for better failure messaging + let lintMessages = null; + try { + const lintPath = args.lint || args.lintCurrent || process.env.AI_SNIFFTEST_LINT_CURRENT; + if (lintPath && fs.existsSync(lintPath)) { + const raw = readJson(lintPath); + if (Array.isArray(raw)) { + lintMessages = []; + for (const f of raw) { + const filePath = f.filePath || f.filename; + for (const m of (f.messages || [])) lintMessages.push({ ...m, filePath }); + } + } + } + } catch (_) { /* ignore */ } const { deltas, effortInc } = compare(base, curr); // Health telemetry + optional gating @@ -327,10 +355,31 @@ function runTraditionalMode(base, curr, args) { return 0; } - console.error('[ratchet] FAIL: new violations introduced'); - for (const d of deltas) { +console.error('[ratchet] FAIL: new violations introduced'); + const ruleMap = { + magicNumbers: 'ai-code-snifftest/no-redundant-calculations', + complexity: null, + domainTerms: null, + architecture: null, + eqeqeq: 'eqeqeq', + camelcase: 'camelcase', + empty: 'no-empty' + }; +for (const d of deltas) { console.error(` ${d.key}: ${d.base} -> ${d.current} (+${d.current - d.base})`); + try { + const ruleId = ruleMap[d.key]; + if (lintMessages && ruleId) { + const ex = formatExamples(lintMessages, ruleId, 3); + if (ex) { + console.error(' New occurrences:'); + console.error(ex); + } + } + } catch (_) { /* ignore */ } } + console.error('\nšŸ’” To fix: address new violations, or if intentional, unlock this check:'); + console.error(' npm run ratchet -- --unlock='); // Health telemetry (informational) and optional gate console.error(`[ratchet] Health (informational): overall=${scores.overall} structural=${scores.structural} semantic=${scores.semantic}`); if (gateFail) { From 135fc9360c7efe78fbb8568624aaf8527026239e Mon Sep 17 00:00:00 2001 From: Doug Fennell Date: Tue, 11 Nov 2025 09:04:44 -0600 Subject: [PATCH 2/2] chore: fix eqeqeq and duplicate log in ratchet messaging --- scripts/ratchet.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/ratchet.js b/scripts/ratchet.js index 24882dc..e24e373 100755 --- a/scripts/ratchet.js +++ b/scripts/ratchet.js @@ -175,7 +175,8 @@ function formatExamples(messages, ruleId, max = 3) { for (const m of messages || []) { if (ruleId && m.ruleId !== ruleId) continue; if (!m.ruleId) continue; - const loc = m.line != null ? `${m.line}:${m.column || 1}` : ''; + const hasLine = (m.line !== null && m.line !== undefined); + const loc = hasLine ? `${m.line}:${m.column || 1}` : ''; out.push(` • ${m.filePath || m.filename || 'file'}:${loc} – ${m.message}`); if (out.length >= max) break; } @@ -183,7 +184,6 @@ function formatExamples(messages, ruleId, max = 3) { } function runContextMode(base, curr) { - console.log('\nšŸ“Š Context-Aware Telemetry'); console.log('\nšŸ“Š Context-Aware Telemetry'); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); console.log('Mode: Non-blocking (burn-in period)\n');