mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Sync generated provider output
This commit is contained in:
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks$impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks$impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks$impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks$impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.t
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
@@ -333,6 +335,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -398,9 +416,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -409,7 +431,6 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
@@ -423,9 +444,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user