mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
* Add a platform axis (web / ios / android / adaptive) to the skill Orthogonal to register: register decides whether design IS or SERVES the product; platform decides the delivery target and which native conventions apply. Set `## Platform` in PRODUCT.md; a missing field defaults to `web`, so legacy projects are unaffected. - extractPlatform() in skill/scripts/context.mjs (mirrors extractRegister); the CLI appends a NEXT STEP directive to read the native reference(s). `adaptive` (Flutter / RN / KMP shipping both iOS and Android) loads both ios.md and android.md. - New reference/ios.md (Apple HIG distilled) and reference/android.md (Material 3 distilled); reference/web.md is a thin pointer. The native refs frame register's role as narrow: platform conformance is the bar, brand lives in the expressive layer the platform gives you, never by breaking the rails. - Setup step 5 loads the native reference(s) when platform is native. Live mode and the detect CLI stay web-only, gated off ios/android/adaptive. - init asks platform right after register; adapt/audit/animate/layout carry short platform divergence notes; all secondary spots thread `adaptive`. - a11y stays in audit.md (loading it at design time makes output timid), so the native refs carry no Accessibility section; audit.md's Platform section owns native a11y. - Tests: extractPlatform unit coverage + skill-behavior scenario 10 (PRODUCT.md platform ios -> agent loads ios.md). Source-first: only skill/, scripts/, tests/, CLAUDE.md, NOTICE.md, the changelog and version are committed; the sync workflow regenerates the provider trees and ./plugin on merge. ios.md / android.md are distilled from the MIT-licensed ehmo/platform-design-skills; attribution in NOTICE.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address review: gate web tools on native platforms, drop version churn Maintainer-review fixes applied with AI assistance (Claude Code), on top of the rebased platform-axis commit: - Design hook (post-edit and Cursor pre-edit) now resolves the project platform via loadContext + extractPlatform and skips its web rule scan for ios / android / adaptive projects, so React Native / Flutter code never draws web-shaped findings (new hook-lib resolveProjectPlatform / isNativePlatform helpers, covered by unit and subprocess tests). - context.mjs CLI warns on an unrecognized ## Platform value (e.g. a toolchain name like `flutter`) instead of silently defaulting to web; extractRegister / extractPlatform now share extractSectionValue. - Removed reference/web.md: nothing loaded it; CLAUDE.md carries the "web has no extra rulebook" explanation. - init.md: skip live-mode config (Step 6) for native platforms; note the per-app PRODUCT.md pattern for repos shipping web + native. - android.md: Material-everywhere apps that also ship on iPhone still owe iOS OS guarantees (safe areas, Reduce Motion, edge-swipe back). - ios.md: reworded a design-time line that framed Dynamic Type as an accessibility check (a11y stays owned by audit.md). - Renumbered the new skill-behavior scenario to 14 after main's 10-13; updated CLAUDE.md scenario list; added android + unrecognized-value CLI test cases. - No version or changelog changes: versioning happens at release time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten platform reference prose Editorial pass on the platform-axis text, applied with AI assistance (Claude Code) under maintainer direction: - ios.md / android.md rewritten to house style: single-line paragraphs (no hard wraps), one-sentence scope intro, deduplicated intro/slop-test, register-compression down to two sentences. In-file attribution paragraphs removed (NOTICE.md owns attribution); "read on top of the register reference" cruft removed (SKILL step 5 and the context.mjs directive already say it). Bans sections dropped: they restated the rules above them; the two additive items (tab-bar overload, hover-dependent affordances) folded into rules. ~40% smaller each. - Sub-command Platform sections (adapt, audit, animate, layout), SKILL step 5, init.md platform prose, and the context.mjs directive trimmed the same way. Build (prose validators, counts) and both test runners green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Treat an empty PRODUCT.md section as absent, not the next heading Copilot review catch: extractSectionValue read the next `## ...` heading as the section value when a field was left empty, which made the CLI warn "value `## Product Purpose` is not recognized". Stop at the next heading and return null instead. Regression tests for extractPlatform, extractRegister, and the CLI warning path. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Only read a token list of both native targets as adaptive Bugbot catch: after the exact platform tokens failed, any Platform line containing the words ios and android was classified adaptive, so negated or explanatory prose ("web only, not ios or android") silently loaded both native refs and skipped the hook, with no warning. The combo parse now accepts only list separators and the two platform words; anything else falls through to the CLI's unrecognized-value WARNING. Regression tests added. Applied with AI assistance (Claude Code) under maintainer direction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
517 lines
18 KiB
JavaScript
517 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Impeccable design hook — Cursor preToolUse write gate.
|
|
*
|
|
* Cursor's stop hook is not consistently dispatched by the headless agent, so
|
|
* this hook checks proposed Write/Edit content before it lands. It only denies
|
|
* writes when the real detector finds an issue in the proposed UI content.
|
|
*
|
|
* Contract: never break a turn accidentally. On malformed input or internal
|
|
* errors, allow the tool and exit 0.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
ALLOWED_EXTS,
|
|
EDIT_COUNT_THRESHOLD,
|
|
GENERATED_PATH,
|
|
SENSITIVE_PATH,
|
|
appendDesignSystemNote,
|
|
designSystemOptions,
|
|
filterFindings,
|
|
isNativePlatform,
|
|
loadDetector,
|
|
matchConfiguredExtension,
|
|
matchesAnyGlob,
|
|
persistCache,
|
|
readCache,
|
|
readConfig,
|
|
renderTemplate,
|
|
resolveCacheCwd,
|
|
resolveProjectCwd,
|
|
resolveProjectPlatform,
|
|
truthy,
|
|
writeAuditLog,
|
|
} from './hook-lib.mjs';
|
|
|
|
async function readStdin() {
|
|
if (process.stdin.isTTY) return '';
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
return Buffer.concat(chunks).toString('utf-8');
|
|
}
|
|
|
|
function done(payload = null) {
|
|
if (payload) process.stdout.write(JSON.stringify(payload));
|
|
process.exit(0);
|
|
}
|
|
|
|
function allow(extra = {}, payload = {}) {
|
|
writeAuditLog(process.env, {
|
|
ts: new Date().toISOString(),
|
|
event: 'preToolUse',
|
|
...extra,
|
|
});
|
|
return done({ permission: 'allow', ...payload });
|
|
}
|
|
|
|
function deny(message, audit) {
|
|
writeAuditLog(process.env, {
|
|
ts: new Date().toISOString(),
|
|
event: 'preToolUse',
|
|
blocked: true,
|
|
...audit,
|
|
});
|
|
return done({
|
|
permission: 'deny',
|
|
user_message: message,
|
|
agent_message: message,
|
|
});
|
|
}
|
|
|
|
function toolInput(event) {
|
|
return event?.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
|
|
}
|
|
|
|
function proposedFilePath(event, cwd) {
|
|
const input = toolInput(event);
|
|
const raw = input.file_path || input.path || input.target_file || event?.file_path;
|
|
const candidate = typeof raw === 'string' && raw.trim()
|
|
? raw
|
|
: shellWriteDestination(shellCommand(input));
|
|
if (typeof candidate !== 'string' || !candidate.trim()) return '';
|
|
return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
|
}
|
|
|
|
function proposedContent(event, cwd, filePath) {
|
|
const input = toolInput(event);
|
|
for (const key of ['content', 'streamContent', 'text']) {
|
|
if (typeof input[key] === 'string') return input[key];
|
|
}
|
|
|
|
const editProjection = projectedEditContent(input, filePath, cwd);
|
|
if (editProjection !== undefined) return editProjection;
|
|
|
|
if (hasFragmentEditContent(input)) {
|
|
return { skipped: 'fragment-only-edit' };
|
|
}
|
|
|
|
const command = shellCommand(input);
|
|
const pythonContent = shellPythonWriteContent(command);
|
|
if (pythonContent) return pythonContent;
|
|
const shellContent = shellHereDocContent(command);
|
|
if (shellContent) return shellContent;
|
|
const copiedContent = shellCopiedFileContent(command, cwd);
|
|
if (copiedContent) return copiedContent;
|
|
return '';
|
|
}
|
|
|
|
function hasFragmentEditContent(input) {
|
|
if (!input || typeof input !== 'object') return false;
|
|
if (typeof input.new_string === 'string' || typeof input.newString === 'string' || typeof input.new_str === 'string' || typeof input.replacement === 'string') {
|
|
return true;
|
|
}
|
|
return Array.isArray(input.edits) && input.edits.some((edit) => edit && typeof edit === 'object');
|
|
}
|
|
|
|
function projectedEditContent(input, filePath, cwd) {
|
|
if (!filePath) return undefined;
|
|
const singleOld = firstString(input, ['old_string', 'oldString', 'old_str', 'target']);
|
|
const singleNew = firstString(input, ['new_string', 'newString', 'new_str', 'replacement']);
|
|
if (singleOld !== undefined || singleNew !== undefined) {
|
|
if (singleOld === undefined || singleNew === undefined) return { skipped: 'fragment-only-edit' };
|
|
const original = readExistingProjectFile(filePath, cwd);
|
|
if (original === null) return { skipped: 'edit-original-unreadable' };
|
|
const projected = replaceOnce(original, singleOld, singleNew);
|
|
return projected === null ? { skipped: 'edit-old-string-missing' } : projected;
|
|
}
|
|
|
|
if (!Array.isArray(input.edits)) return undefined;
|
|
const original = readExistingProjectFile(filePath, cwd);
|
|
if (original === null) return { skipped: 'edit-original-unreadable' };
|
|
|
|
let projected = original;
|
|
for (const edit of input.edits) {
|
|
if (!edit || typeof edit !== 'object') return { skipped: 'fragment-only-edit' };
|
|
const oldString = firstString(edit, ['old_string', 'oldString', 'old_str', 'target']);
|
|
const newString = firstString(edit, ['new_string', 'newString', 'new_str', 'replacement']);
|
|
if (oldString === undefined || newString === undefined) return { skipped: 'fragment-only-edit' };
|
|
const next = replaceOnce(projected, oldString, newString);
|
|
if (next === null) return { skipped: 'edit-old-string-missing' };
|
|
projected = next;
|
|
}
|
|
return projected;
|
|
}
|
|
|
|
function firstString(obj, keys) {
|
|
for (const key of keys) {
|
|
if (typeof obj?.[key] === 'string') return obj[key];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function replaceOnce(original, oldString, newString) {
|
|
if (oldString === '') return null;
|
|
const index = original.indexOf(oldString);
|
|
if (index === -1) return null;
|
|
return `${original.slice(0, index)}${newString}${original.slice(index + oldString.length)}`;
|
|
}
|
|
|
|
function readExistingProjectFile(filePath, cwd) {
|
|
if (!isInsideProject(filePath, cwd)) return null;
|
|
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
|
return fs.readFileSync(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function shellCommand(input) {
|
|
if (typeof input.command === 'string') return input.command;
|
|
if (input.args && typeof input.args.command === 'string') return input.args.command;
|
|
return '';
|
|
}
|
|
|
|
function shellRedirectPath(command) {
|
|
if (!command || typeof command !== 'string') return '';
|
|
const match = command.match(/(?:^|[\s;&|])(?:>>?|1>>?)\s*(?:"([^"]+)"|'([^']+)'|([^<>\s]+))/);
|
|
return (match?.[1] || match?.[2] || match?.[3] || '').trim();
|
|
}
|
|
|
|
function shellWriteDestination(command) {
|
|
return shellRedirectPath(command) || shellTeeDestination(command) || shellCopyPaths(command)?.dest || shellPythonWriteDestination(command) || '';
|
|
}
|
|
|
|
function shellPythonWriteDestination(command) {
|
|
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
|
const directPath = firstMatch(command, /(?:^|[^\w.])(?:pathlib\.)?Path\(\s*(["'])(.*?)\1\s*\)\s*\.write_text\s*\(/);
|
|
if (directPath) return directPath;
|
|
|
|
const pathsByVar = new Map();
|
|
const assignmentRe = /\b([A-Za-z_]\w*)\s*=\s*(?:pathlib\.)?Path\(\s*(["'])(.*?)\2\s*\)/g;
|
|
let assignment;
|
|
while ((assignment = assignmentRe.exec(command))) {
|
|
pathsByVar.set(assignment[1], assignment[3]);
|
|
}
|
|
|
|
const writeVarRe = /\b([A-Za-z_]\w*)\.write_text\s*\(/g;
|
|
let writeVar;
|
|
while ((writeVar = writeVarRe.exec(command))) {
|
|
const candidate = pathsByVar.get(writeVar[1]);
|
|
if (candidate) return candidate;
|
|
}
|
|
|
|
return firstMatch(command, /\bopen\(\s*(["'])(.*?)\1\s*,\s*(["'])[wax](?:\+)?b?\3/);
|
|
}
|
|
|
|
function firstMatch(value, re) {
|
|
const match = String(value || '').match(re);
|
|
return (match?.[2] || '').trim();
|
|
}
|
|
|
|
function shellTeeDestination(command) {
|
|
const words = shellWords(command);
|
|
const teeIndex = words.findIndex((word) => path.basename(word) === 'tee');
|
|
if (teeIndex === -1) return '';
|
|
for (const word of words.slice(teeIndex + 1)) {
|
|
if (['&&', '||', ';', '|'].includes(word)) break;
|
|
if (word === '--') continue;
|
|
if (word.startsWith('-')) continue;
|
|
return word;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function shellCopiedFileContent(command, cwd) {
|
|
const source = shellCopyPaths(command)?.source;
|
|
if (!source) return '';
|
|
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
|
if (!isInsideProject(sourcePath, cwd)) return '';
|
|
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
|
try {
|
|
const stat = fs.statSync(sourcePath);
|
|
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
|
|
return fs.readFileSync(sourcePath, 'utf-8');
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function shellCopyPaths(command) {
|
|
const words = shellWords(command);
|
|
if (words.length < 3 || path.basename(words[0]) !== 'cp') return null;
|
|
const args = [];
|
|
for (const word of words.slice(1)) {
|
|
if (['&&', '||', ';', '|'].includes(word)) break;
|
|
if (word === '--') continue;
|
|
if (word.startsWith('-')) continue;
|
|
args.push(word);
|
|
}
|
|
if (args.length < 2) return null;
|
|
return { source: args[args.length - 2], dest: args[args.length - 1] };
|
|
}
|
|
|
|
function shellWords(command) {
|
|
if (!command || typeof command !== 'string') return [];
|
|
const words = [];
|
|
const re = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'|([^\s]+)/g;
|
|
let match;
|
|
while ((match = re.exec(command))) {
|
|
words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(["'])/g, '$1'));
|
|
}
|
|
return words;
|
|
}
|
|
|
|
function shellHereDocContent(command) {
|
|
if (!command || typeof command !== 'string') return '';
|
|
const markerMatch = command.match(/<<-?\s*['"]?([A-Za-z0-9_.-]+)['"]?[^\r\n]*\r?\n/);
|
|
if (!markerMatch) return '';
|
|
const marker = markerMatch[1];
|
|
const start = (markerMatch.index || 0) + markerMatch[0].length;
|
|
const rest = command.slice(start);
|
|
const endRe = new RegExp(`\\r?\\n${escapeRegExp(marker)}(?:\\r?\\n|$)`);
|
|
const end = rest.search(endRe);
|
|
return end >= 0 ? rest.slice(0, end) : '';
|
|
}
|
|
|
|
function shellPythonWriteContent(command) {
|
|
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
|
const script = shellHereDocContent(command) || command;
|
|
return pythonStringArg(script, /\.write_text\s*\(\s*/g) || pythonStringArg(script, /\.write\s*\(\s*/g);
|
|
}
|
|
|
|
function pythonStringArg(script, prefixRe) {
|
|
let prefix;
|
|
while ((prefix = prefixRe.exec(script))) {
|
|
const start = prefixRe.lastIndex;
|
|
const triple = script.slice(start, start + 3);
|
|
if (triple === "'''" || triple === '"""') {
|
|
const end = script.indexOf(triple, start + 3);
|
|
if (end !== -1) return script.slice(start + 3, end);
|
|
continue;
|
|
}
|
|
const quote = script[start];
|
|
if (quote !== '"' && quote !== "'") continue;
|
|
let out = '';
|
|
for (let i = start + 1; i < script.length; i++) {
|
|
const ch = script[i];
|
|
if (ch === '\\') {
|
|
out += script[i + 1] || '';
|
|
i += 1;
|
|
} else if (ch === quote) {
|
|
return out;
|
|
} else {
|
|
out += ch;
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function relativePath(filePath, cwd) {
|
|
try {
|
|
const rel = path.relative(cwd, filePath);
|
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
|
|
return rel.split(path.sep).join('/');
|
|
} catch {
|
|
return filePath;
|
|
}
|
|
}
|
|
|
|
function isInsideProject(filePath, cwd) {
|
|
try {
|
|
const rel = path.relative(cwd, filePath);
|
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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(
|
|
'[impeccable@1] Design hook findings requiring review',
|
|
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
|
|
);
|
|
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
|
}
|
|
|
|
function findingSignature(findings) {
|
|
return findings
|
|
.map((finding) => `${finding.antipattern || 'unknown'}:${finding.line || 0}`)
|
|
.sort()
|
|
.join('|');
|
|
}
|
|
|
|
function bumpCursorDenial(cache, sessionId, filePath, findings) {
|
|
const session = cache.sessions[sessionId] || { updatedAt: Date.now(), files: {} };
|
|
cache.sessions[sessionId] = session;
|
|
session.updatedAt = Date.now();
|
|
const fileEntry = session.files[filePath] || { editCount: 0, findings: [] };
|
|
session.files[filePath] = fileEntry;
|
|
const key = findingSignature(findings);
|
|
fileEntry.cursorDenials = fileEntry.cursorDenials && typeof fileEntry.cursorDenials === 'object'
|
|
? fileEntry.cursorDenials
|
|
: {};
|
|
fileEntry.cursorDenials[key] = (fileEntry.cursorDenials[key] || 0) + 1;
|
|
return { key, count: fileEntry.cursorDenials[key] };
|
|
}
|
|
|
|
async function main() {
|
|
if (truthy(process.env.IMPECCABLE_HOOK_DISABLED)) {
|
|
return allow({ skipped: 'env-disabled' });
|
|
}
|
|
|
|
let event = null;
|
|
try {
|
|
const raw = await readStdin();
|
|
if (raw) event = JSON.parse(raw);
|
|
} catch {
|
|
return allow({ skipped: 'stdin-malformed' });
|
|
}
|
|
|
|
if (!event || typeof event !== 'object') {
|
|
return allow({ skipped: 'stdin-empty' });
|
|
}
|
|
|
|
const sessionCwd = resolveProjectCwd(event);
|
|
const started = Date.now();
|
|
const filePath = proposedFilePath(event, sessionCwd);
|
|
// Re-key config/cache to the edited file's project root when the session
|
|
// was launched from a non-project umbrella directory (issue #305).
|
|
const cwd = resolveCacheCwd(filePath, sessionCwd);
|
|
const audit = {
|
|
harness: 'cursor',
|
|
cwd,
|
|
tool: event.tool_name || null,
|
|
file: filePath || null,
|
|
};
|
|
|
|
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
|
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
|
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();
|
|
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) {
|
|
return allow({ ...audit, skipped: contentResult.skipped, durationMs: Date.now() - started });
|
|
}
|
|
const content = typeof contentResult === 'string' ? contentResult : '';
|
|
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
|
|
|
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
|
|
|
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
|
|
const platform = resolveProjectPlatform(cwd);
|
|
if (isNativePlatform(platform)) {
|
|
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
|
}
|
|
|
|
const rel = relativePath(filePath, cwd);
|
|
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
|
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
|
|
}
|
|
|
|
const detector = await loadDetector();
|
|
if (!detector || typeof detector.detectText !== 'function') {
|
|
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
|
|
}
|
|
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 = 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 });
|
|
}
|
|
|
|
const filtered = filterFindings(findings || [], content, ext, config);
|
|
if (filtered.length === 0) {
|
|
return allow({
|
|
...audit,
|
|
findings: (findings || []).length,
|
|
blockedFindings: 0,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
}
|
|
|
|
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
|
|
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
|
const cache = readCache(cwd);
|
|
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
|
persistCache(cwd, cache);
|
|
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
|
const warning = `${message}\n\nThis is the ${denial.count}th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop. Reconsider the issue immediately after the tool runs.`;
|
|
return allow({
|
|
...audit,
|
|
findings: (findings || []).length,
|
|
blockedFindings: filtered.length,
|
|
cursorDenialKey: denial.key,
|
|
cursorDenialCount: denial.count,
|
|
downgraded: true,
|
|
chars: warning.length,
|
|
durationMs: Date.now() - started,
|
|
}, {
|
|
user_message: warning,
|
|
agent_message: warning,
|
|
});
|
|
}
|
|
return deny(message, {
|
|
...audit,
|
|
findings: (findings || []).length,
|
|
blockedFindings: filtered.length,
|
|
cursorDenialKey: denial.key,
|
|
cursorDenialCount: denial.count,
|
|
chars: message.length,
|
|
durationMs: Date.now() - started,
|
|
});
|
|
}
|
|
|
|
main().catch((err) => {
|
|
if (process.env.IMPECCABLE_HOOK_DEBUG) {
|
|
process.stderr.write(`[impeccable-hook-before-edit] ${err}\n`);
|
|
}
|
|
done({ permission: 'allow' });
|
|
});
|