Add configurable detector extensions for server-side templates (#347)

* Add configurable detector extensions for server-side templates (#316)

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use imperative voice for detector.extensions guidance in hooks.md

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route html-engine extensions through detectHtml in the Cursor pre-write gate

Co-authored-by: Cursor <cursoragent@cursor.com>

* Prefer the longest matching suffix in matchConfiguredExtension

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-07-06 14:17:23 -07:00
committed by GitHub
co-authored by Abdul Wahab Cursor
parent 9927634d4f
commit 95b67ffa83
4 changed files with 362 additions and 13 deletions
+65 -8
View File
@@ -14,10 +14,11 @@
* bumpEditCount(cache, sessionId, filePath) -> number
* suppressionNotice(filePath)
* filterFindings(findings, content, ext, config)
* matchConfiguredExtension(filePath, extensions)
* dedupeAgainstCache(findings, cache, sessionId, filePath)
* renderTemplate(findings, filePath, config, opts)
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
* shouldEmitAckForFile(filePath)
* shouldEmitAckForFile(filePath, config?)
* writeAuditLog(env, entry)
* loadDetector() -> Promise<{ detectText, detectHtml }>
* matchesAnyGlob(filePath, globs)
@@ -78,6 +79,7 @@ export const DEFAULT_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
limits: { maxFindings: 5, maxChars: 8000 },
});
@@ -202,6 +204,7 @@ function cloneDefaultConfig() {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
designSystem: { ...DEFAULT_CONFIG.designSystem },
limits: { ...DEFAULT_CONFIG.limits },
};
@@ -224,9 +227,55 @@ function applyDetectorConfigSource(config, raw) {
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
if (Array.isArray(raw.extensions)) {
config.extensions = mergeExtensions(config.extensions, raw.extensions);
}
return config;
}
// Extra scanned extensions from `detector.extensions` config. Entries are
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
// for server-side templates) or bare strings as shorthand. Extensions are
// matched against the end of the filename, not path.extname, so double
// extensions like `.blade.php` and `.html.erb` work (issue #316).
function normalizeExtensionEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
const raw = typeof entry === 'string' ? entry : entry?.ext;
if (typeof raw !== 'string') continue;
let ext = raw.trim().toLowerCase();
if (!ext) continue;
if (!ext.startsWith('.')) ext = `.${ext}`;
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
out.push({ ext, engine });
}
return out;
}
function mergeExtensions(existing, incoming) {
const map = new Map();
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
return Array.from(map.values());
}
export function matchConfiguredExtension(filePath, extensions) {
if (!Array.isArray(extensions) || extensions.length === 0) return null;
const name = path.basename(String(filePath || '')).toLowerCase();
if (!name) return null;
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
// entry regardless of config order.
let best = null;
for (const entry of normalizeExtensionEntries(extensions)) {
if (name.length > entry.ext.length && name.endsWith(entry.ext)
&& (!best || entry.ext.length > best.ext.length)) {
best = entry;
}
}
return best;
}
function applyConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
@@ -1353,8 +1402,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
export function shouldEmitAckForFile(filePath, config = null) {
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
// Configured html-engine extensions are declared UI markup, so they get the
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
const configured = matchConfiguredExtension(filePath, config?.extensions);
return Boolean(configured && configured.engine === 'html');
}
export function designSystemOptions(config, detector, projectCwd) {
@@ -1485,8 +1538,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) {
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
audit.ext = configuredExt ? configuredExt.ext : ext;
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
lastSkip = 'extension';
continue;
}
@@ -1520,7 +1574,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
const content = fs.readFileSync(filePath, 'utf-8');
let findings;
let detectorThrew = false;
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
@@ -1594,7 +1651,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
@@ -1628,7 +1685,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,