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
9 changes: 1 addition & 8 deletions .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@
"homepage": "https://github.com/bitflight-devops/hallucination-detector",
"repository": "https://github.com/bitflight-devops/hallucination-detector",
"license": "MIT",
"keywords": [
"hallucination",
"speculation",
"causality",
"verification",
"stop-hook",
"quality"
],
"keywords": ["hallucination", "speculation", "causality", "verification", "stop-hook", "quality"],
"commands": "./commands/"
}
9 changes: 1 addition & 8 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@
"homepage": "https://github.com/bitflight-devops/hallucination-detector",
"repository": "https://github.com/bitflight-devops/hallucination-detector",
"license": "MIT",
"keywords": [
"hallucination",
"speculation",
"causality",
"verification",
"stop-hook",
"quality"
],
"keywords": ["hallucination", "speculation", "causality", "verification", "stop-hook", "quality"],
"commands": "./commands/"
}
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"homepage": "https://github.com/bitflight-devops/hallucination-detector#readme",
"engines": {
"node": ">=22.5.0"
"node": ">=22.6.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
Expand Down
2 changes: 1 addition & 1 deletion scripts/hallucination-annotate.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
'use strict';

const fs = require('node:fs');
const { DEFAULT_WEIGHTS } = require('./hallucination-config.cjs');
const { DEFAULT_WEIGHTS } = require('./hallucination-config-defaults.cjs');

// ---------------------------------------------------------------------------
// Argument parsing
Expand Down
49 changes: 33 additions & 16 deletions scripts/hallucination-audit-stop.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,33 @@ try {
}

const {
loadConfig,
loadWeights,
safeLoadConfig,
safeLoadWeights,
DEFAULT_WEIGHTS,
DEFAULT_THRESHOLDS,
DEFAULT_CONFIDENCE_WEIGHTS,
isValidCategoryThreshold,
} = require('./hallucination-config.cjs');
const {
validateClaimStructure,
CLAIM_LABEL_ALTERNATION,
} = require('./hallucination-claim-structure.cjs');
} = require('./hallucination-config-safe.cjs');

let _isValidCategoryThreshold = null;
try {
({
isValidCategoryThreshold: _isValidCategoryThreshold,
} = require('./hallucination-config-validate.cjs'));
} catch {
/* validation unavailable — per-category thresholds will use global defaults */
}
const isValidCategoryThreshold = _isValidCategoryThreshold || (() => false);

let _claim_structure = null;
try {
_claim_structure = require('./hallucination-claim-structure.cjs');
} catch {
/* claim structure analysis unavailable */
}
const validateClaimStructure =
_claim_structure?.validateClaimStructure ||
(() => ({ structured: false, valid: true, claims: [], errors: [] }));
const CLAIM_LABEL_ALTERNATION = _claim_structure?.CLAIM_LABEL_ALTERNATION || '';

// =============================================================================
// Telemetry
Expand Down Expand Up @@ -2315,7 +2331,7 @@ function getLabelForScore(score, thresholds) {
return 'HALLUCINATED';
}

// loadWeights and loadConfig imported from ./hallucination-config.cjs
// safeLoadWeights and safeLoadConfig imported from ./hallucination-config-safe.cjs

/**
* Score every sentence in a block of text.
Expand Down Expand Up @@ -2424,9 +2440,10 @@ function emitJson(obj) {
* @returns {string}
*/
// Built from CLAIM_LABEL_ALTERNATION so adding a new label requires one edit.
const LABELED_CLAIM_LINE_RE = new RegExp(
`^\\s*-?\\s*(?:\\[(?:${CLAIM_LABEL_ALTERNATION})\\])+\\[c\\d+\\].*`,
);
// Falls back to a never-match regex when the claim-structure module is unavailable.
const LABELED_CLAIM_LINE_RE = CLAIM_LABEL_ALTERNATION
? new RegExp(`^\\s*-?\\s*(?:\\[(?:${CLAIM_LABEL_ALTERNATION})\\])+\\[c\\d+\\].*`)
: /(?!)/;
const METADATA_LINE_RE = /^\s+(?:Evidence|Basis|Missing|Contradicted by):\s*/i;

function stripLabeledClaimLines(text) {
Expand Down Expand Up @@ -2733,7 +2750,7 @@ function main() {
// returns defaults — that is acceptable.
const assistantMeta = getLastAssistantMeta(entries);

const config = loadConfig();
const config = safeLoadConfig();
const maxBlocks = config.maxBlocksPerSession ?? 2;

// Template validation: check for observation template blocks before structural
Expand Down Expand Up @@ -3171,9 +3188,9 @@ module.exports = {
scoreSentence,
aggregateWeightedScore,
getLabelForScore,
// Re-exported from hallucination-config.cjs for backward compatibility
loadWeights,
loadConfig,
// Re-exported from hallucination-config-safe.cjs for backward compatibility
loadWeights: safeLoadWeights,
loadConfig: safeLoadConfig,
scoreText,
DEFAULT_WEIGHTS,
DEFAULT_THRESHOLDS,
Expand Down
96 changes: 96 additions & 0 deletions scripts/hallucination-config-defaults.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Default configuration constants for hallucination-detector hooks.
* Zero dependencies — Node.js built-ins only.
*/

'use strict';

/**
* Default weights for each detection category.
* Weights are relative severity signals; `aggregateWeightedScore` normalizes
* by their sum so aggregate scores always remain in [0, 1] regardless of
* whether the weights themselves sum to 1.0.
*/
const DEFAULT_WEIGHTS = {
speculation_language: 0.25,
causality_language: 0.3,
pseudo_quantification: 0.15,
completeness_claim: 0.2,
// fabricated_source: reserved for future implementation (issue #18)
evaluative_design_claim: 0.4,
internal_contradiction: 0.35,
unsupported_absence: 0.7,
ungrounded_behavioral_assertion: 0.5,
};

/**
* Default score thresholds for three-tier label classification.
* - uncertain: scores >= this value are labelled UNCERTAIN (not GROUNDED)
* - hallucinated: scores > this value are labelled HALLUCINATED
*/
const DEFAULT_THRESHOLDS = {
uncertain: 0.3,
hallucinated: 0.6,
};

/**
* Default weights for the four confidence-score components.
* These control how much each factor contributes to the per-match
* confidence integer in [0, 100].
*
* - patternStrength: contribution of the pattern's inherent severity
* - evidenceProximity: contribution of evidence markers near the match
* - categoryStacking: bonus when multiple categories fire in the same sentence
* - contextDensity: bonus when multiple matches cluster within 200 chars
*/
const DEFAULT_CONFIDENCE_WEIGHTS = {
patternStrength: 0.4,
evidenceProximity: 0.25,
categoryStacking: 0.2,
contextDensity: 0.15,
};

/**
* Default full configuration object.
*/
const DEFAULT_CONFIG = {
weights: DEFAULT_WEIGHTS,
thresholds: DEFAULT_THRESHOLDS,
introspect: false,
introspectOutputPath: null,
// Shadow mode: log would-block events without actually blocking.
dryRun: false,
// Global settings
severity: 'error',
maxTriggersPerResponse: 20,
maxBlocksPerSession: null,
outputFormat: 'text',
debug: false,
// Per-category settings (keyed by category name)
categories: {},
// Filtering settings
ignorePatterns: [],
ignoreBlocks: [],
evidenceMarkers: [],
allowlist: [],
// Response settings
responseTemplates: {},
includeContext: true,
contextLines: 2,
// Session-type gating
warnOnly: false, // log telemetry but never emit a block to stdout
ignoreCategories: [], // category names skipped entirely (still written to telemetry with was_ignored=1)
blockSubagents: false, // block when hook_event_name is SubagentStop
blockUserSessions: true, // block when hook_event_name is Stop (user-facing session)
// Confidence scoring
confidenceWeights: DEFAULT_CONFIDENCE_WEIGHTS,
reportingThreshold: 50, // minimum confidence [0,100] for a match to appear in block reason text
};

module.exports = {
DEFAULT_WEIGHTS,
DEFAULT_THRESHOLDS,
DEFAULT_CONFIDENCE_WEIGHTS,
DEFAULT_CONFIG,
};
100 changes: 100 additions & 0 deletions scripts/hallucination-config-merge.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Deep freeze and deep merge utilities for hallucination-detector config objects.
* Zero dependencies — Node.js built-ins only.
*/

'use strict';

/**
* Recursively freeze an object and all nested plain objects / arrays.
* @param {*} obj
* @returns {*} The frozen value.
*/
function deepFreeze(obj) {
if (obj === null || typeof obj !== 'object') return obj;
for (const val of Object.values(obj)) {
deepFreeze(val);
}
return Object.freeze(obj);
}

/**
* Deep-merge two config objects. Rules:
* - Plain objects are merged recursively.
* - `categories.<name>.customPatterns` arrays are concatenated unless the override
* has `replacePatterns: true` for that category.
* - All other arrays are replaced by the override value.
* - Scalar values are replaced by the override value.
*
* Neither argument is mutated; a new object is returned.
*
* @param {object} base - Lower-priority config.
* @param {object} override - Higher-priority config (wins on conflict).
* @returns {object} Merged config.
*/
function mergeConfig(base, override) {
if (!override || typeof override !== 'object' || Array.isArray(override)) return base;
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;

const result = { ...base };

for (const key of Object.keys(override)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const overVal = override[key];
const baseVal = base[key];

if (key === 'categories') {
// Merge categories map, with special customPatterns concatenation logic.
const baseCats =
typeof baseVal === 'object' && baseVal !== null && !Array.isArray(baseVal) ? baseVal : {};
const overCats =
typeof overVal === 'object' && overVal !== null && !Array.isArray(overVal) ? overVal : {};
const merged = { ...baseCats };
for (const catName of Object.keys(overCats)) {
if (catName === '__proto__' || catName === 'constructor' || catName === 'prototype')
continue;
const baseCat = baseCats[catName] || {};
const overCat = overCats[catName];
if (typeof overCat !== 'object' || overCat === null) {
merged[catName] = overCat;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
}
// Extract customPatterns and replacePatterns before spreading to handle
// replacePatterns:true correctly even when overCat.customPatterns is absent.
const { customPatterns: basePatterns, ...baseCatRest } = baseCat;
const { customPatterns: overPatterns, replacePatterns, ...overCatRest } = overCat;
const mergedCat = { ...baseCatRest, ...overCatRest };
Comment on lines +59 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle array category overrides as non-mergeable values.

At Line 59, overCat arrays currently flow into object-destructuring/spread logic, which can yield malformed merged category objects (numeric keys, unexpected carry-over fields). Arrays should be treated like other non-plain override values.

Suggested patch
-        if (typeof overCat !== 'object' || overCat === null) {
+        if (typeof overCat !== 'object' || overCat === null || Array.isArray(overCat)) {
           merged[catName] = overCat;
           continue;
         }
+        const baseCatObj =
+          typeof baseCat === 'object' && baseCat !== null && !Array.isArray(baseCat)
+            ? baseCat
+            : {};
         // Extract customPatterns and replacePatterns before spreading to handle
         // replacePatterns:true correctly even when overCat.customPatterns is absent.
-        const { customPatterns: basePatterns, ...baseCatRest } = baseCat;
+        const { customPatterns: basePatterns, ...baseCatRest } = baseCatObj;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/hallucination-config-merge.cjs` around lines 59 - 67, The current
logic destructures and spreads overCat assuming it's a plain object, but arrays
passed as overCat produce malformed merged category objects; update the early
non-mergeable check in the merge loop to treat arrays the same as non-objects by
changing the condition to also test Array.isArray(overCat) (i.e., if typeof
overCat !== 'object' || overCat === null || Array.isArray(overCat)) then set
merged[catName] = overCat and continue; ensure this check occurs before any
destructuring of baseCat/overCat and before constructing mergedCat (references:
overCat, baseCat, merged[catName], customPatterns, replacePatterns, mergedCat).

if (replacePatterns) {
mergedCat.customPatterns = overPatterns !== undefined ? overPatterns : [];
mergedCat.replacePatterns = true;
} else if (Array.isArray(basePatterns) && Array.isArray(overPatterns)) {
mergedCat.customPatterns = [...basePatterns, ...overPatterns];
} else if (Array.isArray(basePatterns)) {
mergedCat.customPatterns = basePatterns;
} else if (Array.isArray(overPatterns)) {
mergedCat.customPatterns = overPatterns;
}
merged[catName] = mergedCat;
}
result[key] = merged;
} else if (
typeof overVal === 'object' &&
overVal !== null &&
!Array.isArray(overVal) &&
typeof baseVal === 'object' &&
baseVal !== null &&
!Array.isArray(baseVal)
) {
// Both are plain objects — recurse.
result[key] = mergeConfig(baseVal, overVal);
} else {
// Scalar, array, or null — override wins.
result[key] = overVal;
}
}

return result;
}

module.exports = { mergeConfig, deepFreeze };
Loading
Loading