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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .ai-coding-guide.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,15 @@
"errorHandling": "explicit",
"asyncStyle": "async-await"
}
},
"ratchet": {
"strategy": "hybrid",
"tolerance": { "total": 5 },
"hybrid": {
"critical": ["complexity", "architecture"],
"minor": ["domainTerms", "magicNumbers"],
"tolerance": { "criticalDensity": 1.05, "minorDensity": 1.2 }
},
"health": { "enabled": false }
}
}
4 changes: 4 additions & 0 deletions .github/workflows/ci-ratchet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:

jobs:
ratchet-and-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
Expand Down Expand Up @@ -43,6 +46,7 @@ jobs:
run: npm test

- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: analysis-and-lint-${{ github.sha }}
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ export default [
'max-lines': ["warn",{"max":300,"skipBlankLines":true,"skipComments":true}]
}
},
{
files: ["scripts/**/*.js"],
rules: {
// Exclude support scripts from architecture/complexity metrics to keep product telemetry stable
'complexity': 'off',
'max-statements': 'off',
'max-lines-per-function': 'off',
'max-depth': 'off',
'max-lines': 'off'
}
},
{
files: ["lib/rules/**/*.js"],
rules: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"analyze:current": "node bin/cli.js analyze --input=lint-results.json --format=json --output=analysis-current.json",
"analyze:baseline": "node bin/cli.js analyze --input=lint-results.json --format=json --output=analysis-baseline.json",
"ratchet": "node scripts/ratchet.js --baseline=analysis-baseline.json --current=analysis-current.json",
"ratchet:context": "node scripts/ratchet.js --mode=context --baseline=analysis-baseline.json --current=analysis-current.json",
"ci:ratchet": "npm run lint:json && npm run analyze:current && npm run ratchet",
"test": "mocha tests --recursive",
"test:coverage": "c8 --reporter=text --reporter=lcov npm test",
Expand Down
217 changes: 190 additions & 27 deletions scripts/ratchet.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ function summarize(payload) {
complexity: len(cat.complexity),
domainTerms: len(cat.domainTerms),
architecture: len(cat.architecture),
// Optional counts (informational)
counts: {
errors: num(cat.counts && cat.counts.errors),
warnings: num(cat.counts && cat.counts.warnings),
Expand Down Expand Up @@ -133,7 +132,6 @@ function compare(base, curr) {
const c = num(curr[f]);
if (c > b) deltas.push({ key: f, base: b, current: c, type: 'count' });
}
// Effort ratchet (optional, do not fail but report increases)
const effortInc = [];
for (const f of fields) {
const b = num(base.effortByCategory && base.effortByCategory[f]);
Expand All @@ -143,34 +141,80 @@ function compare(base, curr) {
return { deltas, effortInc };
}

function main() {
const args = parseArgs(process.argv);
const baselinePath = args.baseline || args._[0] || 'analysis-baseline.json';
const currentPath = args.current || args._[1] || 'analysis-current.json';
function detectIntentFromMetrics(base, curr) {
const totalDelta = (curr.magicNumbers - base.magicNumbers) +
(curr.complexity - base.complexity) +
(curr.domainTerms - base.domainTerms) +
(curr.architecture - base.architecture);

const base = readJson(baselinePath);
if (!base) {
console.log(`[ratchet] Baseline not found at ${baselinePath}.\n` +
'Run: npm run lint:json && npm run analyze:baseline\n' +
'Skipping ratchet (non-blocking)');
process.exit(0);
return;
let intent = 'neutral';
let confidence = 0.5;
const signals = [];

if (totalDelta < -10) {
intent = 'cleanup';
confidence = 0.7;
signals.push('Violations decreased significantly');
} else if (totalDelta > 20 && curr.domainTerms > base.domainTerms * 1.3) {
intent = 'ai-generation-suspect';
confidence = 0.6;
signals.push('Large increase in domain term violations');
signals.push('Rapid violation growth pattern');
} else if (curr.complexity < base.complexity * 0.8 && curr.architecture < base.architecture * 0.8) {
intent = 'refactoring';
confidence = 0.65;
signals.push('Complexity decreased');
signals.push('Architecture violations decreased');
}
const curr = readJson(currentPath);
if (!curr) {
console.error(`[ratchet] Current analysis not found at ${currentPath}. Run: npm run lint:json && npm run analyze:current`);
process.exit(1);
return;

return { intent, confidence, signals };
}

function runContextMode(base, curr) {
console.log('\n📊 Context-Aware Telemetry');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Mode: Non-blocking (burn-in period)\n');

console.log('Category Counts:');
const categories = [
{ key: 'magicNumbers', label: 'Magic Numbers' },
{ key: 'complexity', label: 'Complexity' },
{ key: 'domainTerms', label: 'Domain Terms' },
{ key: 'architecture', label: 'Architecture' }
];

for (const cat of categories) {
const baseVal = base[cat.key];
const currVal = curr[cat.key];
const delta = currVal - baseVal;
const emoji = delta > 0 ? '⚠️' : (delta < 0 ? '✅' : '➖');
const sign = delta > 0 ? '+' : '';
console.log(` ${cat.label.padEnd(15)} ${baseVal} → ${currVal} (${sign}${delta}) ${emoji}`);
}

const b = summarize(base);
const c = summarize(curr);
const { deltas, effortInc } = compare(b, c);
console.log();

const { intent, confidence, signals } = detectIntentFromMetrics(base, curr);

console.log('Intent Detection:');
console.log(` Detected: ${intent}`);
console.log(` Confidence: ${(confidence * 100).toFixed(0)}%`);
if (signals.length > 0) {
console.log(' Signals:');
signals.forEach(s => console.log(` • ${s}`));
}

console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n✅ Telemetry complete (non-blocking)\n');
}

function runTraditionalMode(base, curr, args) {
const { deltas, effortInc } = compare(base, curr);

// Health telemetry + optional gating
const cfg = loadProjectConfig();
const healthCfg = (cfg && cfg.ratchet && cfg.ratchet.health) || { enabled: false };
const scores = computeHealth(c);
const scores = computeHealth(curr);
const gateOn = String(healthCfg.gateOn || 'overall').toLowerCase();
const minOverall = Number(healthCfg.minOverall || 70);
const intent = detectIntent(args);
Expand All @@ -181,6 +225,74 @@ function main() {
const gateActive = !!healthCfg.enabled && !shouldBypass(healthCfg);
const gateFail = gateActive && currentScore < threshold;

const strategy = (cfg && cfg.ratchet && cfg.ratchet.strategy) || 'classic';

// Hybrid strategy: density-based for critical vs minor categories (per 1K LOC)
if (strategy === 'hybrid') {
const hybridCfg = (cfg && cfg.ratchet && cfg.ratchet.hybrid) || {};
const critical = Array.isArray(hybridCfg.critical) && hybridCfg.critical.length ? hybridCfg.critical : ['complexity', 'architecture'];
const minor = Array.isArray(hybridCfg.minor) && hybridCfg.minor.length ? hybridCfg.minor : ['domainTerms', 'magicNumbers'];
const tol = (hybridCfg.tolerance) || {};
const critTol = Number(tol.criticalDensity || 1.05); // allow 5% increase
const minorTol = Number(tol.minorDensity || 1.20); // allow 20% increase

const baseLoc = Math.max(1, Number(base.lines && base.lines.executable) || Number(base.lines && base.lines.physical) || 1);
const currLoc = Math.max(1, Number(curr.lines && curr.lines.executable) || Number(curr.lines && curr.lines.physical) || 1);
const perK = (count, loc) => (count / (loc / 1000));

let failed = false;

console.log('\n[ratchet] Hybrid density check (per 1K LOC)');
console.log(`LOC: ${baseLoc} → ${currLoc} (${currLoc - baseLoc >= 0 ? '+' : ''}${currLoc - baseLoc})`);

console.log('\nCritical (strict):');
for (const cat of critical) {
const b = Number(base[cat]) || 0;
const c = Number(curr[cat]) || 0;
const bd = perK(b, baseLoc);
const cd = perK(c, currLoc);
const ratio = bd === 0 ? (cd === 0 ? 1 : Infinity) : (cd / bd);
const status = ratio > critTol ? '❌' : '✅';
console.log(` ${String(cat).padEnd(15)} ${b} → ${c} (density: ${bd.toFixed(2)} → ${cd.toFixed(2)}) ${status}`);
if (ratio > critTol) failed = true;
}

console.log('\nMinor (relaxed):');
for (const cat of minor) {
const b = Number(base[cat]) || 0;
const c = Number(curr[cat]) || 0;
const bd = perK(b, baseLoc);
const cd = perK(c, currLoc);
const ratio = bd === 0 ? (cd === 0 ? 1 : Infinity) : (cd / bd);
const status = ratio > minorTol ? '⚠️' : '✅';
console.log(` ${String(cat).padEnd(15)} ${b} → ${c} (density: ${bd.toFixed(2)} → ${cd.toFixed(2)}) ${status}`);
}

// Health telemetry (informational)
console.log(`\n[ratchet] Health (informational): overall=${scores.overall} structural=${scores.structural} semantic=${scores.semantic}`);

if (gateFail) {
console.error(`[ratchet] HEALTH-GATE FAIL: ${failureMessage}`);
console.error(` gateOn=${gateOn} threshold=${threshold} actual=${currentScore} intent=${intent}`);
return 1;
}

if (failed) {
console.error('\n❌ [ratchet] FAIL: Critical violation density increased');
console.error('Complexity and architecture are strictly controlled.');
console.error('Refactor to reduce complexity or update baseline if intentional.');
return 1;
}

console.log('\n✅ [ratchet] PASS: Quality maintained or improved');
return 0;
}

// Classic strategy with tolerance band
// Tolerance band (configurable) to avoid blocking normal fluctuation
const tolCfg = (cfg && cfg.ratchet && cfg.ratchet.tolerance) || {};
const totalTolerance = Number(tolCfg.total || 0);

if (deltas.length === 0) {
console.log('[ratchet] OK: no increases in analyzer categories');
if (effortInc.length) {
Expand All @@ -192,11 +304,27 @@ function main() {
if (gateFail) {
console.error(`[ratchet] HEALTH-GATE FAIL: ${failureMessage}`);
console.error(` gateOn=${gateOn} threshold=${threshold} actual=${currentScore} intent=${intent}`);
process.exit(1);
return;
return 1;
}
process.exit(0);
return;
return 0;
}

// If within tolerance, allow pass with notice
const totalIncrease = deltas.reduce((sum, d) => sum + (d.current - d.base), 0);
if (totalIncrease <= totalTolerance) {
console.log(`[ratchet] OK: within tolerance (+${totalIncrease} violations, tolerance: ${totalTolerance})`);
for (const d of deltas) {
console.log(` ${d.key}: ${d.base} → ${d.current} (+${d.current - d.base})`);
}
console.log('\nSmall increases accepted during active development.');
// Health telemetry (informational) and optional gate still apply
console.log(`[ratchet] Health (informational): overall=${scores.overall} structural=${scores.structural} semantic=${scores.semantic}`);
if (gateFail) {
console.error(`[ratchet] HEALTH-GATE FAIL: ${failureMessage}`);
console.error(` gateOn=${gateOn} threshold=${threshold} actual=${currentScore} intent=${intent}`);
return 1;
}
return 0;
}

console.error('[ratchet] FAIL: new violations introduced');
Expand All @@ -214,7 +342,42 @@ function main() {
console.error(' - compare with analysis-baseline.json');
console.error('\nIf intentional reductions were made and counts decreased overall, refresh baseline:');
console.error(' npm run analyze:baseline');
process.exit(1);
return 1;
}

function main() {
const args = parseArgs(process.argv);
const mode = args.mode || 'traditional';
const baselinePath = args.baseline || args._[0] || 'analysis-baseline.json';
const currentPath = args.current || args._[1] || 'analysis-current.json';

const base = readJson(baselinePath);
if (!base) {
console.log(`[ratchet] Baseline not found at ${baselinePath}.\n` +
'Run: npm run lint:json && npm run analyze:baseline\n' +
'Skipping ratchet (non-blocking)');
process.exit(0);
return;
}

const curr = readJson(currentPath);
if (!curr) {
console.error(`[ratchet] Current analysis not found at ${currentPath}. Run: npm run lint:json && npm run analyze:current`);
process.exit(1);
return;
}

const b = summarize(base);
const c = summarize(curr);

if (mode === 'context') {
runContextMode(b, c);
process.exit(0);
return;
}

const exitCode = runTraditionalMode(b, c, args);
process.exit(exitCode);
}

main();
Loading