BREAKING CHANGE: the {NNN} in CA-{SCANNER}-{NNN} identifies the check that
produced the finding. It used to be the finding's position in that scanner's
output for that run, which made it unstable across CONFIGURATIONS, not just
across releases as STATE framed it. Measured on two fixtures: "No custom
subagents" was CA-GAP-007 on minimal-project and CA-GAP-004 on healthy-project.
A user who fixed an unrelated earlier gap silently renumbered every later one,
so a .config-audit-ignore pin retargeted to a neighbouring finding with no
version change at all.
Second measured arm: README already documented the opposite scheme. It and the
scanner headers describe ~20 numbers as check codes (CA-SKL-003 = oversized
body, CA-PLH-015 = folder shadowing, CA-TOK-006 = schema deferral), and the
counter could only produce those in the all-fire case -- source-order positions
are 4, 3 and 8. The documentation described the scheme; the implementation was
what was wrong. Every published number is preserved by construction and pinned
exhaustively in tests/lib/finding-codes.test.mjs.
scanners/lib/finding-codes.mjs is the single authority. Every finding() call
passes a `code`; an undeclared or missing one THROWS. No counter fallback --
that would reproduce D1's findGapId -> 'unknown' silent degradation and let a
half-converted scanner ship IDs that look valid. findingCounter/resetCounter
are deleted outright, not left as no-ops. Retirement is now a mechanism:
RETIRED_CODES tombstones a withdrawn key so its number is never reissued,
seeded with GAP t3_8 -- the D1 removal that opened this chunk.
IDs are consequently NOT unique per finding: one check failing in three files
emits three findings sharing an ID. That inverts which consumer is correct, so
every f.id/findingId site was classified before the change. diff-engine and
most of fix-engine already keyed on scanner+title+file (drift was never lying);
fix-engine's verification did not, and keyed on the ID alone -- fixing one of
two sibling instances marked both fixed, and the untouched one, still present
in the re-scan, was reported as a REGRESSION. Red test first, then keyed on
(findingId, file), which both planFixes and applyFixes already carry.
plugin-health's crossIds Set was measured and is a clean negative: cross
findings are allFindings.slice(crossPluginStart) and codes 18/19 are emitted
only in that tail, so the partition holds by construction.
unknownSuppressions() reports a pin that names no declared check, in the
--output-file payload (ux-rules rule 2 -- a stderr-only warning is invisible to
the commands) and only when one exists, so a clean config is byte-identical.
That is what makes the break safe: a stale pin goes loud instead of dying quiet.
Frozen tests/snapshots/v5.0.0/ untouched on disk. IDs are masked out of that
comparison (mask-finding-ids.mjs) rather than re-derived -- re-deriving
positional IDs would assert the retired scheme against itself, and #58's
isGapEntry off-by-one is the measured example of that misfiring. The dead
re-derivation is removed from strip-retired-gap.mjs. default-output snapshots
re-approved after confirming the diff is IDs and nothing else.
Guards, each seen red against its own defect: a missing code (scanner errors
out mid-sweep), an orphan declaration, a resurrected retired key, and a
documented ID naming no check. The sweep asserts the union across all 16
scanners, never per scanner -- a per-scanner assertion goes green on a partial
conversion.
Fasit written before implementation: docs/mbug28-id-semantics-fasit.local.md,
including one correction made before running (CML has 12 checks over 13 call
sites -- the anchored and calibrated char-budget arms are one check, which a
repeated-title sweep found and my call-site count had missed).
Suite 1535 -> 1573, 0 failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MyqCQKK2ornJ1jFWwqx17E
690 lines
20 KiB
JavaScript
690 lines
20 KiB
JavaScript
/**
|
|
* Config-Audit Fix Engine
|
|
* Deterministic fix engine: maps scanner findings to concrete file changes.
|
|
* Zero external dependencies.
|
|
*/
|
|
|
|
import { readFile, writeFile, rename, stat } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { parseJson, parseFrontmatter } from './lib/yaml-parser.mjs';
|
|
import { createBackup } from './lib/backup.mjs';
|
|
import { runAllScanners } from './scan-orchestrator.mjs';
|
|
import { VALID_EFFORT_LEVELS as SETTINGS_EFFORT_LEVELS } from './settings-validator.mjs';
|
|
|
|
/**
|
|
* Fix type constants.
|
|
*/
|
|
const FIX_TYPES = {
|
|
JSON_KEY_ADD: 'json-key-add',
|
|
JSON_KEY_REMOVE: 'json-key-remove',
|
|
JSON_KEY_TYPE_FIX: 'json-key-type-fix',
|
|
JSON_RESTRUCTURE: 'json-restructure',
|
|
FRONTMATTER_RENAME: 'frontmatter-rename',
|
|
FILE_RENAME: 'file-rename',
|
|
};
|
|
|
|
/** Valid effortLevel values for nearest-match — the validator's list, not a copy. */
|
|
const VALID_EFFORT_LEVELS = [...SETTINGS_EFFORT_LEVELS];
|
|
|
|
/**
|
|
* Plan fixes from a scanner envelope.
|
|
* @param {object} envelope - Full scanner envelope from scan-orchestrator
|
|
* @returns {{ fixes: object[], skipped: object[], manual: object[] }}
|
|
*/
|
|
export function planFixes(envelope) {
|
|
const fixes = [];
|
|
const skipped = [];
|
|
const manual = [];
|
|
|
|
for (const scanner of envelope.scanners) {
|
|
for (const finding of scanner.findings) {
|
|
if (!finding.autoFixable) {
|
|
manual.push({
|
|
findingId: finding.id,
|
|
title: finding.title,
|
|
file: finding.file,
|
|
recommendation: finding.recommendation,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const fixPlan = createFixPlan(finding);
|
|
if (fixPlan) {
|
|
fixes.push(fixPlan);
|
|
} else {
|
|
skipped.push(finding);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort fixes by severity weight (critical first), but a file-rename always
|
|
// sorts after every other fix. A rename moves the file out from under any
|
|
// later fix that still addresses the old path: a rule file with both
|
|
// `globs:` and a non-.md extension had the rename applied first, and the
|
|
// frontmatter fix then failed with ENOENT while the run still exited 0.
|
|
// `?? 4`, not `|| 4`: critical weighs 0, and `0 || 4` evaluates to 4 — so
|
|
// critical fixes sorted LAST, the exact opposite of this function's contract
|
|
// (M-BUG-30). The old test used the same falsy fallback and agreed with the bug.
|
|
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
fixes.sort((a, b) => {
|
|
const aRename = a.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
|
const bRename = b.type === FIX_TYPES.FILE_RENAME ? 1 : 0;
|
|
if (aRename !== bRename) return aRename - bRename;
|
|
return (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4);
|
|
});
|
|
|
|
return { fixes, skipped, manual };
|
|
}
|
|
|
|
/**
|
|
* Create a fix plan for a single finding.
|
|
* @param {object} finding
|
|
* @returns {object|null}
|
|
*/
|
|
function createFixPlan(finding) {
|
|
if (!finding.file) return null;
|
|
|
|
const base = {
|
|
findingId: finding.id,
|
|
file: finding.file,
|
|
severity: finding.severity,
|
|
description: '',
|
|
before: null,
|
|
after: null,
|
|
type: null,
|
|
};
|
|
|
|
const scanner = finding.scanner;
|
|
const title = finding.title;
|
|
|
|
// --- SET scanner fixes ---
|
|
if (scanner === 'SET') {
|
|
if (title === 'Missing $schema reference') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_KEY_ADD,
|
|
description: 'Add $schema reference for IDE autocomplete',
|
|
before: '(no $schema key)',
|
|
after: '"$schema": "https://json.schemastore.org/claude-code-settings.json"',
|
|
key: '$schema',
|
|
value: 'https://json.schemastore.org/claude-code-settings.json',
|
|
};
|
|
}
|
|
|
|
if (title === 'Deprecated settings key') {
|
|
const key = extractKeyFromEvidence(finding.evidence);
|
|
if (!key) return null;
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_KEY_REMOVE,
|
|
description: `Remove deprecated key "${key}"`,
|
|
before: finding.evidence,
|
|
after: '(key removed)',
|
|
key,
|
|
};
|
|
}
|
|
|
|
if (title === 'Type mismatch in settings') {
|
|
const key = extractKeyFromEvidence(finding.evidence);
|
|
if (!key) return null;
|
|
const expectedType = extractExpectedType(finding.description);
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
|
description: `Fix type of "${key}" to ${expectedType}`,
|
|
before: finding.evidence,
|
|
after: `(converted to ${expectedType})`,
|
|
key,
|
|
expectedType,
|
|
};
|
|
}
|
|
|
|
if (title === 'Invalid effortLevel value') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
|
description: 'Fix effortLevel to nearest valid value',
|
|
before: finding.evidence,
|
|
after: '(nearest valid effortLevel)',
|
|
key: 'effortLevel',
|
|
expectedType: 'effortLevel',
|
|
};
|
|
}
|
|
|
|
if (title === 'Hooks configured as array instead of object') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_RESTRUCTURE,
|
|
description: 'Convert hooks from array to object format',
|
|
before: '"hooks": [...]',
|
|
after: '"hooks": { ... }',
|
|
restructureType: 'hooks-array-to-object',
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- HKV scanner fixes ---
|
|
if (scanner === 'HKV') {
|
|
if (title === 'Matcher must be a string, not an object') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_RESTRUCTURE,
|
|
description: 'Convert matcher from object to string',
|
|
before: finding.evidence,
|
|
after: '"matcher": "ToolName"',
|
|
restructureType: 'matcher-object-to-string',
|
|
event: extractEventFromDescription(finding.description),
|
|
};
|
|
}
|
|
|
|
if (title === 'Hook timeout must be a number') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.JSON_KEY_TYPE_FIX,
|
|
description: 'Convert timeout to number',
|
|
before: finding.evidence,
|
|
after: '(parsed to number)',
|
|
key: 'timeout',
|
|
expectedType: 'number',
|
|
event: extractEventFromDescription(finding.description),
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- RUL scanner fixes ---
|
|
if (scanner === 'RUL') {
|
|
if (title === 'Rule uses "globs" instead of documented "paths"') {
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.FRONTMATTER_RENAME,
|
|
description: 'Rename "globs" to "paths" in frontmatter',
|
|
before: 'globs:',
|
|
after: 'paths:',
|
|
oldField: 'globs',
|
|
newField: 'paths',
|
|
};
|
|
}
|
|
|
|
if (title === 'Rule file is not .md') {
|
|
const newPath = finding.file.replace(/\.[^.]+$/, '.md');
|
|
return {
|
|
...base,
|
|
type: FIX_TYPES.FILE_RENAME,
|
|
description: `Rename to .md extension`,
|
|
before: finding.file,
|
|
after: newPath,
|
|
newPath,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Apply planned fixes to files.
|
|
* @param {object[]} fixPlans - Array of fix plans from planFixes()
|
|
* @param {object} opts
|
|
* @param {boolean} [opts.dryRun=false]
|
|
* @param {string} [opts.backupDir] - Required if not dryRun
|
|
* @returns {Promise<{ applied: object[], failed: object[] }>}
|
|
*/
|
|
export async function applyFixes(fixPlans, opts = {}) {
|
|
const applied = [];
|
|
const failed = [];
|
|
|
|
if (!opts.dryRun && !opts.backupDir) {
|
|
throw new Error('backupDir is required when not in dryRun mode');
|
|
}
|
|
|
|
for (const plan of fixPlans) {
|
|
if (opts.dryRun) {
|
|
applied.push({
|
|
findingId: plan.findingId,
|
|
file: plan.file,
|
|
status: 'dry-run',
|
|
type: plan.type,
|
|
description: plan.description,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await applyFix(plan);
|
|
applied.push({
|
|
findingId: plan.findingId,
|
|
file: plan.file,
|
|
status: 'applied',
|
|
type: plan.type,
|
|
description: plan.description,
|
|
});
|
|
} catch (err) {
|
|
failed.push({
|
|
findingId: plan.findingId,
|
|
file: plan.file,
|
|
status: 'failed',
|
|
error: err.message,
|
|
type: plan.type,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { applied, failed };
|
|
}
|
|
|
|
/**
|
|
* Apply a single fix.
|
|
* @param {object} plan
|
|
*/
|
|
async function applyFix(plan) {
|
|
switch (plan.type) {
|
|
case FIX_TYPES.JSON_KEY_ADD:
|
|
await applyJsonKeyAdd(plan);
|
|
break;
|
|
case FIX_TYPES.JSON_KEY_REMOVE:
|
|
await applyJsonKeyRemove(plan);
|
|
break;
|
|
case FIX_TYPES.JSON_KEY_TYPE_FIX:
|
|
await applyJsonKeyTypeFix(plan);
|
|
break;
|
|
case FIX_TYPES.JSON_RESTRUCTURE:
|
|
await applyJsonRestructure(plan);
|
|
break;
|
|
case FIX_TYPES.FRONTMATTER_RENAME:
|
|
await applyFrontmatterRename(plan);
|
|
break;
|
|
case FIX_TYPES.FILE_RENAME:
|
|
await applyFileRename(plan);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown fix type: ${plan.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a key to a JSON file (as first key for $schema).
|
|
*/
|
|
async function applyJsonKeyAdd(plan) {
|
|
const content = await readFile(plan.file, 'utf-8');
|
|
const parsed = parseJson(content);
|
|
if (parsed === null) throw new Error('Invalid JSON');
|
|
|
|
// For $schema, insert as first key
|
|
if (plan.key === '$schema') {
|
|
const newObj = { $schema: plan.value, ...parsed };
|
|
await writeJsonFile(plan.file, newObj);
|
|
} else {
|
|
parsed[plan.key] = plan.value;
|
|
await writeJsonFile(plan.file, parsed);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a key from a JSON file.
|
|
*/
|
|
async function applyJsonKeyRemove(plan) {
|
|
const content = await readFile(plan.file, 'utf-8');
|
|
const parsed = parseJson(content);
|
|
if (parsed === null) throw new Error('Invalid JSON');
|
|
|
|
delete parsed[plan.key];
|
|
await writeJsonFile(plan.file, parsed);
|
|
}
|
|
|
|
/**
|
|
* Fix the type of a JSON key value.
|
|
*/
|
|
async function applyJsonKeyTypeFix(plan) {
|
|
const content = await readFile(plan.file, 'utf-8');
|
|
const parsed = parseJson(content);
|
|
if (parsed === null) throw new Error('Invalid JSON');
|
|
|
|
// Handle nested hook timeout fixes
|
|
if (plan.key === 'timeout' && plan.event) {
|
|
fixTimeoutInHooks(parsed, plan.event);
|
|
await writeJsonFile(plan.file, parsed);
|
|
return;
|
|
}
|
|
|
|
// Handle effortLevel special case
|
|
if (plan.key === 'effortLevel' && plan.expectedType === 'effortLevel') {
|
|
parsed.effortLevel = findNearestEffortLevel(parsed.effortLevel);
|
|
await writeJsonFile(plan.file, parsed);
|
|
return;
|
|
}
|
|
|
|
// Generic type conversion
|
|
if (parsed[plan.key] !== undefined) {
|
|
parsed[plan.key] = convertType(parsed[plan.key], plan.expectedType);
|
|
await writeJsonFile(plan.file, parsed);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restructure JSON (hooks array→object, matcher object→string).
|
|
*/
|
|
async function applyJsonRestructure(plan) {
|
|
const content = await readFile(plan.file, 'utf-8');
|
|
const parsed = parseJson(content);
|
|
if (parsed === null) throw new Error('Invalid JSON');
|
|
|
|
if (plan.restructureType === 'hooks-array-to-object') {
|
|
restructureHooksArrayToObject(parsed);
|
|
await writeJsonFile(plan.file, parsed);
|
|
return;
|
|
}
|
|
|
|
if (plan.restructureType === 'matcher-object-to-string') {
|
|
restructureMatcherObjectToString(parsed, plan.event);
|
|
await writeJsonFile(plan.file, parsed);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unknown restructure type: ${plan.restructureType}`);
|
|
}
|
|
|
|
/**
|
|
* Rename a frontmatter field in a markdown file.
|
|
*/
|
|
async function applyFrontmatterRename(plan) {
|
|
const content = await readFile(plan.file, 'utf-8');
|
|
|
|
// Replace the field name in the frontmatter section only
|
|
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
|
|
if (!fmMatch) throw new Error('No frontmatter found');
|
|
|
|
const before = fmMatch[2];
|
|
const regex = new RegExp(`^(${plan.oldField})(\\s*:)`, 'gm');
|
|
const after = before.replace(regex, `${plan.newField}$2`);
|
|
|
|
if (before === after) throw new Error(`Field "${plan.oldField}" not found in frontmatter`);
|
|
|
|
const newContent = fmMatch[1] + after + fmMatch[3] + content.slice(fmMatch[0].length);
|
|
await writeFile(plan.file, newContent, 'utf-8');
|
|
|
|
// Validate frontmatter still parses
|
|
const { frontmatter } = parseFrontmatter(newContent);
|
|
if (!frontmatter) throw new Error('Frontmatter parse failed after rename');
|
|
}
|
|
|
|
/**
|
|
* Rename a file (change extension to .md).
|
|
*/
|
|
async function applyFileRename(plan) {
|
|
try {
|
|
await stat(plan.file);
|
|
} catch {
|
|
throw new Error(`Source file not found: ${plan.file}`);
|
|
}
|
|
|
|
// Check target doesn't already exist
|
|
try {
|
|
await stat(plan.newPath);
|
|
throw new Error(`Target already exists: ${plan.newPath}`);
|
|
} catch (err) {
|
|
if (err.message.startsWith('Target already exists')) throw err;
|
|
// File doesn't exist — good
|
|
}
|
|
|
|
await rename(plan.file, plan.newPath);
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
/**
|
|
* Write a JSON object to a file with 2-space indent.
|
|
*/
|
|
async function writeJsonFile(filePath, obj) {
|
|
const json = JSON.stringify(obj, null, 2) + '\n';
|
|
// Validate the JSON we're about to write
|
|
const reparsed = parseJson(json);
|
|
if (reparsed === null) throw new Error('Generated invalid JSON');
|
|
await writeFile(filePath, json, 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Convert a value to the expected type.
|
|
*/
|
|
function convertType(value, expectedType) {
|
|
switch (expectedType) {
|
|
case 'boolean':
|
|
if (typeof value === 'string') {
|
|
if (value.toLowerCase() === 'true' || value === '1') return true;
|
|
if (value.toLowerCase() === 'false' || value === '0') return false;
|
|
}
|
|
if (typeof value === 'number') return value !== 0;
|
|
return Boolean(value);
|
|
case 'number':
|
|
if (typeof value === 'string') {
|
|
const num = Number(value);
|
|
return isNaN(num) ? 10000 : num; // default 10000 for timeouts
|
|
}
|
|
return Number(value);
|
|
case 'string':
|
|
return String(value);
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the nearest valid effortLevel.
|
|
*/
|
|
function findNearestEffortLevel(value) {
|
|
if (typeof value !== 'string') return 'medium';
|
|
const lower = value.toLowerCase();
|
|
// Simple distance-based matching
|
|
let best = 'medium';
|
|
let bestDist = Infinity;
|
|
for (const level of VALID_EFFORT_LEVELS) {
|
|
const dist = levenshtein(lower, level);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
best = level;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Simple Levenshtein distance.
|
|
*/
|
|
function levenshtein(a, b) {
|
|
const m = a.length, n = b.length;
|
|
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
dp[i][j] = Math.min(
|
|
dp[i - 1][j] + 1,
|
|
dp[i][j - 1] + 1,
|
|
dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0),
|
|
);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
}
|
|
|
|
/**
|
|
* Convert hooks array to object format.
|
|
* Best-effort: wraps array items under "PreToolUse" if they have event fields,
|
|
* otherwise groups by event property.
|
|
*/
|
|
function restructureHooksArrayToObject(parsed) {
|
|
if (!Array.isArray(parsed.hooks)) return;
|
|
|
|
const hooksObj = {};
|
|
for (const item of parsed.hooks) {
|
|
const event = item.event || 'PreToolUse';
|
|
if (!hooksObj[event]) hooksObj[event] = [];
|
|
|
|
// Build the handler group
|
|
const group = {};
|
|
if (item.matcher) group.matcher = typeof item.matcher === 'string' ? item.matcher : String(item.matcher);
|
|
group.hooks = [];
|
|
|
|
if (item.command) {
|
|
group.hooks.push({
|
|
type: item.type || 'command',
|
|
command: item.command,
|
|
...(item.timeout !== undefined ? { timeout: typeof item.timeout === 'number' ? item.timeout : Number(item.timeout) || 10000 } : {}),
|
|
});
|
|
} else if (item.hooks && Array.isArray(item.hooks)) {
|
|
group.hooks = item.hooks;
|
|
}
|
|
|
|
hooksObj[event].push(group);
|
|
}
|
|
|
|
parsed.hooks = hooksObj;
|
|
}
|
|
|
|
/**
|
|
* Convert matcher from object to string in hooks config.
|
|
*/
|
|
function restructureMatcherObjectToString(parsed, event) {
|
|
const hooks = parsed.hooks || parsed;
|
|
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
|
|
|
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
|
if (event && eventKey !== event) continue;
|
|
if (!Array.isArray(handlers)) continue;
|
|
|
|
for (const group of handlers) {
|
|
if (group.matcher && typeof group.matcher === 'object') {
|
|
// Extract tool name from object — common patterns: { tool: "Bash" }, { name: "Bash" }
|
|
const tool = group.matcher.tool || group.matcher.name || group.matcher.type || Object.values(group.matcher)[0];
|
|
group.matcher = typeof tool === 'string' ? tool : 'Bash';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fix timeout type in nested hooks config.
|
|
*/
|
|
function fixTimeoutInHooks(parsed, event) {
|
|
const hooks = parsed.hooks || parsed;
|
|
if (typeof hooks !== 'object' || Array.isArray(hooks)) return;
|
|
|
|
for (const [eventKey, handlers] of Object.entries(hooks)) {
|
|
if (event && eventKey !== event) continue;
|
|
if (!Array.isArray(handlers)) continue;
|
|
|
|
for (const group of handlers) {
|
|
if (!group.hooks || !Array.isArray(group.hooks)) continue;
|
|
for (const hook of group.hooks) {
|
|
if (hook.timeout !== undefined && typeof hook.timeout !== 'number') {
|
|
const num = Number(hook.timeout);
|
|
hook.timeout = isNaN(num) ? 10000 : num;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract key name from evidence string like: 'someKey: "value"'
|
|
*/
|
|
function extractKeyFromEvidence(evidence) {
|
|
if (!evidence) return null;
|
|
const match = evidence.match(/^(\w+)\s*:/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Extract expected type from description string like: 'should be boolean, got string'
|
|
*/
|
|
function extractExpectedType(description) {
|
|
const match = description.match(/should be (\w+)/);
|
|
return match ? match[1] : 'string';
|
|
}
|
|
|
|
/**
|
|
* Extract event name from description like: '"PreToolUse" has a matcher...'
|
|
*/
|
|
function extractEventFromDescription(description) {
|
|
const match = description.match(/"(\w+)"/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Verify fixes by re-running affected scanners.
|
|
* @param {object} originalEnvelope - Original scanner envelope
|
|
* @param {object[]} appliedResults - Results from applyFixes()
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.includeGlobal=false] - Must match the scope the fix run scanned
|
|
* @returns {Promise<{ verified: string[], regressions: string[], newFindings: object[] }>}
|
|
*/
|
|
export async function verifyFixes(originalEnvelope, appliedResults, opts = {}) {
|
|
const targetPath = originalEnvelope.meta.target;
|
|
const verified = [];
|
|
const regressions = [];
|
|
const newFindings = [];
|
|
|
|
// Re-scan the target in the SAME scope the fix run used. This was hardcoded
|
|
// to includeGlobal:false: after a --global run, every global-scope finding
|
|
// was absent from the re-scan and therefore counted as verified — a clean
|
|
// "fixed" report for files nothing had touched.
|
|
const newEnvelope = await runAllScanners(targetPath, { includeGlobal: opts.includeGlobal === true });
|
|
|
|
// Build the set of fixed finding INSTANCES. A finding ID names the check, so
|
|
// one check failing in two files yields two findings sharing an ID; keying on
|
|
// the ID alone marks both fixed when one was, and the untouched sibling — still
|
|
// present in the re-scan — is then reported as a regression (M-BUG-28).
|
|
const instanceKey = (findingId, file) => `${findingId}::${file || ''}`;
|
|
const fixedInstances = new Set(
|
|
appliedResults.filter(r => r.status === 'applied').map(r => instanceKey(r.findingId, r.file)),
|
|
);
|
|
|
|
// Build set of new finding titles for comparison
|
|
const newFindingMap = new Map();
|
|
for (const scanner of newEnvelope.scanners) {
|
|
for (const f of scanner.findings) {
|
|
newFindingMap.set(`${f.scanner}:${f.title}:${f.file}`, f);
|
|
}
|
|
}
|
|
|
|
// Check that fixed findings are gone
|
|
for (const scanner of originalEnvelope.scanners) {
|
|
for (const f of scanner.findings) {
|
|
if (!fixedInstances.has(instanceKey(f.id, f.file))) continue;
|
|
|
|
const key = `${f.scanner}:${f.title}:${f.file}`;
|
|
// For file-rename fixes, the original file path won't exist anymore
|
|
const fixResult = appliedResults.find(
|
|
r => instanceKey(r.findingId, r.file) === instanceKey(f.id, f.file),
|
|
);
|
|
if (fixResult && fixResult.type === 'file-rename') {
|
|
// Check that the finding doesn't reappear at the new path
|
|
verified.push(f.id);
|
|
continue;
|
|
}
|
|
|
|
if (newFindingMap.has(key)) {
|
|
regressions.push(f.id);
|
|
} else {
|
|
verified.push(f.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for any completely new findings not in original
|
|
const originalKeys = new Set();
|
|
for (const scanner of originalEnvelope.scanners) {
|
|
for (const f of scanner.findings) {
|
|
originalKeys.add(`${f.scanner}:${f.title}:${f.file}`);
|
|
}
|
|
}
|
|
|
|
for (const [key, f] of newFindingMap) {
|
|
if (!originalKeys.has(key)) {
|
|
newFindings.push(f);
|
|
}
|
|
}
|
|
|
|
return { verified, regressions, newFindings };
|
|
}
|
|
|
|
export { FIX_TYPES };
|