mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
Sync generated provider output
This commit is contained in:
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
## Triage findings
|
||||
|
||||
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
|
||||
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
|
||||
|
||||
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
|
||||
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
|
||||
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
|
||||
|
||||
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
|
||||
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
@@ -67,10 +73,10 @@ Example value-specific exception:
|
||||
node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
|
||||
```
|
||||
|
||||
Example intentional motion exception:
|
||||
Example self-served exception, with the evidence named:
|
||||
|
||||
```bash
|
||||
node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
|
||||
node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
|
||||
```
|
||||
|
||||
Example whole-rule font exception:
|
||||
|
||||
@@ -16,11 +16,15 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
ALLOWED_EXTS,
|
||||
DEFAULT_CONFIG,
|
||||
EDIT_COUNT_THRESHOLD,
|
||||
GENERATED_PATH,
|
||||
SENSITIVE_PATH,
|
||||
appendDesignSystemNote,
|
||||
appendDesignSystemNoteOnce,
|
||||
commitFooterShown,
|
||||
designNoteReserve,
|
||||
designSystemOptions,
|
||||
footerModeForSession,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
isScanTargetInsideProject,
|
||||
@@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
// Cursor caps deny messages around 4000 chars. The cap feeds through the
|
||||
// renderer's clamp, which preserves the policy footer, rather than tail-
|
||||
// slicing the rendered text, which cut the footer off any message the
|
||||
// default 8000-char budget let past 4000.
|
||||
const CURSOR_DENY_LIMIT = 4000;
|
||||
const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. ';
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) {
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
// Charge the prefix via reserveChars, not by subtracting from maxChars:
|
||||
// renderTemplate's 500-char floor re-raises any maxChars pushed below it,
|
||||
// un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508).
|
||||
// reserveChars comes off after the floor, so the prefix is charged at every
|
||||
// config tier and the final prefixed message plus a pending staleness note
|
||||
// fits the binding limit. Default-config output is byte-identical.
|
||||
const budget = Math.min(
|
||||
limits.maxChars || DEFAULT_CONFIG.limits.maxChars,
|
||||
CURSOR_DENY_LIMIT,
|
||||
);
|
||||
const rendered = renderTemplate(findings, filePath,
|
||||
{ ...config, limits: { ...limits, maxChars: budget } },
|
||||
{ cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length });
|
||||
return 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',
|
||||
`[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`,
|
||||
);
|
||||
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
||||
}
|
||||
|
||||
function findingSignature(findings) {
|
||||
@@ -468,9 +491,16 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
|
||||
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||
const cache = readCache(cwd);
|
||||
// Repeated denials for the same session repeat the findings, not the
|
||||
// policy: the full footer emits once per session, the short form after.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const message = appendDesignSystemNoteOnce(
|
||||
cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, message);
|
||||
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||
persistCache(cwd, cache);
|
||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
|
||||
* designNoteReserve(scanOptions, cache, sessionId)
|
||||
* footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return '';
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
// reserveChars holds back room for a note the caller appends after render
|
||||
// (the DESIGN.md staleness note), so the final payload stays inside the
|
||||
// configured budget. It comes off after the 500-char floor, so at floor
|
||||
// configs the note keeps guaranteed delivery room; the clamp budget can
|
||||
// therefore sit below 500, which clampLastLine's footer-preserving
|
||||
// fallback handles (Bugbot on PR #508).
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const remaining = total - shown.length;
|
||||
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const seenRules = new Set();
|
||||
const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
|
||||
const blocks = [header, ...lines];
|
||||
if (more) blocks.push(more);
|
||||
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
|
||||
const limits = config?.limits || DEFAULT_CONFIG.limits;
|
||||
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
|
||||
const lines = [];
|
||||
let shownCount = 0;
|
||||
// One seen-set across all groups: a rule already described under one file
|
||||
// is not re-described under the next.
|
||||
const seenRules = new Set();
|
||||
|
||||
for (const group of realGroups) {
|
||||
const display = relativize(group.filePath, cwd);
|
||||
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
const remainingCap = Math.max(0, cap - shownCount);
|
||||
const shown = group.findings.slice(0, remainingCap);
|
||||
for (const finding of shown) {
|
||||
lines.push(formatFindingLine(finding));
|
||||
lines.push(formatDedupedFindingLine(finding, seenRules));
|
||||
}
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
@@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const footer = directiveFooter('the affected files', { grouped: true });
|
||||
const footer = directiveFooter({ mode: opts.footer });
|
||||
let text = [header, ...lines, '', footer].join('\n');
|
||||
if (text.length > maxChars) {
|
||||
text = clampGroupedToBudget(header, lines, footer, maxChars);
|
||||
@@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// The clamp contract, shared by both budget functions: the footer is policy,
|
||||
// not detail, so it survives every clamp. Try the requested footer first;
|
||||
// when it cannot fit even after dropping finding lines, retry with the short
|
||||
// policy rather than sacrifice findings that fit beside it. A result that
|
||||
// dropped every finding line (a grouped render can fit a bare file header)
|
||||
// does not count as a fit: findings are why the emission exists.
|
||||
const isFindingLine = (line) => line.startsWith('- ');
|
||||
|
||||
function footerFallbacks(footer) {
|
||||
const short = directiveFooter({ mode: 'short' });
|
||||
return footer === short ? [footer] : [footer, short];
|
||||
}
|
||||
|
||||
function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
const assemble = (linesArr, omitted, footerText) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
footerText,
|
||||
].join('\n');
|
||||
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted);
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let omitted = false;
|
||||
let assembled = assemble(working, omitted, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
omitted = true;
|
||||
assembled = assemble(working, omitted, footerText);
|
||||
}
|
||||
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
const assemble = (linesArr, moreText) => {
|
||||
const assemble = (linesArr, moreText, footerText) => {
|
||||
const blocks = [header, ...linesArr];
|
||||
if (moreText) blocks.push(moreText);
|
||||
blocks.push('');
|
||||
blocks.push(footer);
|
||||
blocks.push(footerText);
|
||||
return blocks.join('\n');
|
||||
};
|
||||
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
let lastMore = more;
|
||||
for (const footerText of footerFallbacks(footer)) {
|
||||
let working = lines.slice();
|
||||
let moreText = more;
|
||||
let assembled = assemble(working, moreText, footerText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText, footerText);
|
||||
}
|
||||
lastMore = moreText;
|
||||
if (assembled.length <= maxChars) return assembled;
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
assembled = `${assembled.slice(0, maxChars - 1)}…`;
|
||||
}
|
||||
return assembled;
|
||||
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
|
||||
lines.find(isFindingLine) || lines[0], maxChars);
|
||||
}
|
||||
|
||||
function formatFindingLine(f) {
|
||||
// Last resort with one finding line left: the short policy gets the budget
|
||||
// first, the line is clipped to what remains. The pre-fix tail-slice cut
|
||||
// whatever happened to be last, which was always the footer.
|
||||
function clampLastLine(build, line, maxChars) {
|
||||
const footerText = directiveFooter({ mode: 'short' });
|
||||
const bare = build([], footerText);
|
||||
// +1 for the newline the line itself brings when it joins the blocks.
|
||||
const room = maxChars - bare.length - 1;
|
||||
if (room >= 24) {
|
||||
const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line;
|
||||
return build([clipped], footerText);
|
||||
}
|
||||
// No room for even a clipped finding line: the note reservation can pull
|
||||
// the budget below the 500-char floor, and a deep file path can push the
|
||||
// header past what remains beside the short policy (Bugbot on PR #508).
|
||||
// Drop the line, and if the bare header + policy still overflow, clip the
|
||||
// head. Never tail-slice: the footer sits at the end, so a tail slice is
|
||||
// exactly the footer cut this renderer exists to prevent.
|
||||
if (bare.length <= maxChars) return bare;
|
||||
const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
|
||||
return `${head}…\n\n${footerText}`;
|
||||
}
|
||||
|
||||
// `compact` drops the registry description: within one emission the first
|
||||
// occurrence of a rule carries the full description and repeats keep only the
|
||||
// rule id, name, and their own ignore hint (values differ per line, so the
|
||||
// hint must survive the dedupe).
|
||||
function formatFindingLine(f, opts = {}) {
|
||||
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
|
||||
const desc = (f.description || '').trim();
|
||||
const desc = opts.compact ? '' : (f.description || '').trim();
|
||||
const name = (f.name || '').trim();
|
||||
// Description from the registry already ends in punctuation; join with a
|
||||
// single space. `name` may have a trailing period already, keep it clean.
|
||||
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
|
||||
const ignoreCommand = formatFindingIgnoreCommand(f);
|
||||
const ignoreSegment = ignoreCommand
|
||||
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
|
||||
: '';
|
||||
const ignoreHint = formatFindingIgnoreHint(f);
|
||||
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
|
||||
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function formatFindingIgnoreCommand(finding) {
|
||||
// Dedupe applied in shown-line order, so the first rendered occurrence of a
|
||||
// rule always carries the description. The budget clamps pop lines from the
|
||||
// end, which can never orphan a compact repeat before its described first
|
||||
// occurrence.
|
||||
function formatDedupedFindingLine(finding, seenRules) {
|
||||
const rule = normalizeIgnoreRule(finding?.antipattern);
|
||||
const compact = rule ? seenRules.has(rule) : false;
|
||||
if (rule) seenRules.add(rule);
|
||||
return formatFindingLine(finding, { compact });
|
||||
}
|
||||
|
||||
// The rule/value pair the footer's `hook-admin.mjs ignore-value` command
|
||||
// takes. Deliberately just the args: the executable prefix, the --reason
|
||||
// contract, and the disclosure rule live in the directive footer, stated once
|
||||
// instead of per line.
|
||||
function formatFindingIgnoreHint(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return '';
|
||||
const normalizedValue = extractFindingIgnoreValue(finding);
|
||||
if (!normalizedValue) return '';
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
|
||||
return `ignore-value ${rule} ${valueArg}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
@@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
}
|
||||
}
|
||||
|
||||
const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${DESIGN_STALE_NOTE}`;
|
||||
}
|
||||
|
||||
// Session-scoped once-only gate for repeat-prone message parts. Returns true
|
||||
// the first time a flag is consumed in a session and false after, mirroring
|
||||
// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
|
||||
// change between edits, so re-stating them on every emission spends context
|
||||
// to say nothing new. Callers must persist the cache for the flag to stick.
|
||||
function consumeSessionNoticeFlag(cache, sessionId, flag) {
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session[flag]) return false;
|
||||
session[flag] = true;
|
||||
session.updatedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Once-per-session variant of appendDesignSystemNote for the emission paths
|
||||
// that have cache access. The staleness note names standing project state,
|
||||
// not new information, so one mention per session is enough. The note is
|
||||
// appended after the renderer has clamped to the configured budget: render
|
||||
// paths reserve room for it via designNoteReserve, and the size check here
|
||||
// is the safety net for the ack paths, deferring (without consuming the
|
||||
// flag) to a later emission rather than busting maxChars.
|
||||
export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
|
||||
if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
|
||||
if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
|
||||
return appendDesignSystemNote(text, scanOptions);
|
||||
}
|
||||
|
||||
// Render-time reservation for the note above: how many characters the
|
||||
// renderer must hold back so a pending staleness note still fits inside the
|
||||
// configured budget. Zero once the session has seen the note. Without the
|
||||
// reservation, a session whose every emission fills the budget would defer
|
||||
// the note forever.
|
||||
export function designNoteReserve(scanOptions, cache, sessionId) {
|
||||
if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
|
||||
if (ensureSession(cache, sessionId).designNoteShown) return 0;
|
||||
return DESIGN_STALE_NOTE.length + 2;
|
||||
}
|
||||
|
||||
// Full directive footer once per session, the short reminder after. Fresh
|
||||
// emissions and Cursor denials share the session flag (`footerShown`), so a
|
||||
// session pays the full policy exactly once however it first fires. The mode
|
||||
// is a peek: the clamp can downgrade a requested full footer under a tight
|
||||
// budget, so the flag commits only when the complete full policy actually
|
||||
// reached the output. Matching the whole footer text (not a sentinel) keeps
|
||||
// the flag honest against any truncation that spares the opening words.
|
||||
export function footerModeForSession(cache, sessionId) {
|
||||
return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
|
||||
}
|
||||
|
||||
export function commitFooterShown(cache, sessionId, text) {
|
||||
if (!text || !text.includes(directiveFooter())) return;
|
||||
const session = ensureSession(cache, sessionId);
|
||||
if (session.footerShown) return;
|
||||
session.footerShown = true;
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
// behavior. Three intentional moves:
|
||||
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
|
||||
// revising..." which the model treats as a soft suggestion it can
|
||||
// override when the user asked for any kind of throwaway / demo UI.
|
||||
// 2. **Explicit judgment clause.** Without it, the model will try to
|
||||
// "fix" intentional motion, bad fixtures, anti-pattern examples in
|
||||
// docs, or test cases. Naming the judgment inline beats hoping the
|
||||
// model infers it from context.
|
||||
// 3. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, not a chat turn, so the user never sees the
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
||||
// for the path forever, which is far more than one noisy rule on a real UI
|
||||
// surface justifies, and it was previously the only option named here.
|
||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
||||
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
|
||||
// behavior. Intentional moves, in order:
|
||||
// 1. **Imperative, not advisory.** "Triage each finding..." beats
|
||||
// "Consider revising...", which the model treats as a soft suggestion.
|
||||
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
|
||||
// suppress branch names the calibration examples (demo, fixture,
|
||||
// documented bad design, user-confirmed choice) because the agent now
|
||||
// acts on its own confidence and needs the bar stated.
|
||||
// 3. **Executable ignore path.** The old footer named only the slash
|
||||
// command, which an agent reacting to hook output cannot run; the
|
||||
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
|
||||
// hand-editing config.json.
|
||||
// 4. **Honest provenance.** The --reason is the audit trail; "user
|
||||
// confirmed" appears only when the user actually did.
|
||||
// 5. **Acknowledgement instruction.** Hook output is injected as
|
||||
// developer-role context, so the reply is where the user sees the
|
||||
// resolution, including any ignore the agent persisted.
|
||||
// 6. **Once per session.** The full policy emits on the session's first
|
||||
// fire; later emissions carry the one-line short form (mode 'short').
|
||||
function directiveFooter(opts = {}) {
|
||||
if (opts.mode === 'short') {
|
||||
// No command path here: the session's first emission already gave the
|
||||
// runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
|
||||
// path on every repeat is the duplication this mode exists to cut.
|
||||
return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
|
||||
}
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
|
||||
'- Real design problem: fix it. Keep intentional design as designed.',
|
||||
`- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
|
||||
'- Unsure: leave it as is and ask the user in one line.',
|
||||
`Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), deferred findings do
|
||||
// too (the Stop deep pass needs the touched-file list to surface them),
|
||||
// and an already-present `.impeccable/` dir marks a project that opted
|
||||
// in. A non-UI edit, or a clean UI edit in a project with no Impeccable
|
||||
// footprint, must be a no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0 || deferredTotal > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
// The session notice flags mutate the cache, so they must settle before
|
||||
// the persist that makes them stick across events.
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
// Fresh findings always earn the cache write, including creating
|
||||
// `.impeccable/`: dedup, suppression, and the notice flags need it.
|
||||
persistCache(projectCwd, cache);
|
||||
const allFindings = freshGroups.flatMap((group) => group.findings);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the ack emission before the persist below: appendDesignSystem-
|
||||
// NoteOnce consumes a session flag, and the flag only sticks when the
|
||||
// write happens after it. Quiet mode emits nothing, so it consumes
|
||||
// nothing. The clean arm mirrors the branch order further down: pending
|
||||
// outranks suppression, suppression outranks clean.
|
||||
let ack = null;
|
||||
if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'pending',
|
||||
text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
} else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
ack = {
|
||||
kind: 'clean',
|
||||
text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
|
||||
};
|
||||
}
|
||||
|
||||
// Persist only when the write is earned: deferred findings need the
|
||||
// touched-file list for the Stop deep pass, and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
|
||||
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
@@ -1901,8 +2068,8 @@ 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, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'pending') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
if (ack?.kind === 'clean') {
|
||||
const text = ack.text;
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'PostToolUse', harness),
|
||||
@@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear.
|
||||
persistCache(projectCwd, cache);
|
||||
// A per-edit fire earlier in this session already consumed the footer
|
||||
// flag, so the Stop wall of text carries the one-line short footer.
|
||||
const footerMode = footerModeForSession(cache, sessionId);
|
||||
const text = appendDesignSystemNoteOnce(
|
||||
renderGroupedTemplate(freshGroups, config, {
|
||||
cwd: projectCwd,
|
||||
footer: footerMode,
|
||||
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
|
||||
}),
|
||||
scanOptions, cache, sessionId, config,
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'Stop', harness),
|
||||
|
||||
Reference in New Issue
Block a user