mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9000a7df1 | ||
|
|
50d130dd97 | ||
|
|
2bc2879276 | ||
|
|
599de0e949 | ||
|
|
ec0928d786 |
@@ -139,6 +139,16 @@ npx impeccable link --source=.impeccable --providers=claude,cursor
|
||||
|
||||
### Option 3: Plugin install
|
||||
|
||||
**GitHub Copilot in VS Code:**
|
||||
|
||||
Install [Impeccable from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=renaissance-geek.impeccable), or run:
|
||||
|
||||
```bash
|
||||
code --install-extension renaissance-geek.impeccable
|
||||
```
|
||||
|
||||
Requires VS Code 1.109.3+, Copilot Chat access, and a trusted local workspace. Open Chat in Agent mode and try `/impeccable polish`. This skill-only extension does not install automatic hooks; avoid a duplicate Impeccable skill in the same workspace/profile. See [VS Code distribution details](docs/VSCODE-EXTENSION.md).
|
||||
|
||||
**Claude Code:**
|
||||
```bash
|
||||
/plugin marketplace add pbakaus/impeccable
|
||||
|
||||
@@ -30,9 +30,39 @@ static LAUNCHER_HOOK_MARKER: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"skills/impeccable/scripts/impeccable(?:\.cmd|\.exe)?["']?\s+hook(?:-before-edit|-probe|-after-edit|-stop)?(?:\s|$|["'&|;)])"#).unwrap()
|
||||
});
|
||||
|
||||
/// User-scope Windows commands embed a JSON-quoted path whose backslashes are
|
||||
/// doubled in the command string (#784). #604's single `\`→`/` replace is not
|
||||
/// enough: `\\` becomes `//`, which breaks `skills/impeccable` matching.
|
||||
/// A leading `//` after a quote (or at the start of the string) is a UNC
|
||||
/// prefix and stays two slashes, so doctor still probes `//server/share/...`.
|
||||
fn normalize_hook_separators(command: &str) -> String {
|
||||
let mut out = String::with_capacity(command.len());
|
||||
let mut chars = command.chars().peekable();
|
||||
let mut prev: Option<char> = None;
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' && ch != '/' {
|
||||
out.push(ch);
|
||||
prev = Some(ch);
|
||||
continue;
|
||||
}
|
||||
let mut n = 1usize;
|
||||
while matches!(chars.peek(), Some('\\' | '/')) {
|
||||
chars.next();
|
||||
n += 1;
|
||||
}
|
||||
out.push('/');
|
||||
if n >= 2 && matches!(prev, None | Some('"' | '\'')) {
|
||||
out.push('/');
|
||||
}
|
||||
prev = Some('/');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when `command` invokes an Impeccable hook in either generation's spelling.
|
||||
pub fn is_impeccable_hook_command(command: &str) -> bool {
|
||||
LEGACY_HOOK_SCRIPT_MARKERS.iter().any(|m| command.contains(m)) || LAUNCHER_HOOK_MARKER.is_match(command)
|
||||
let command = normalize_hook_separators(command);
|
||||
LEGACY_HOOK_SCRIPT_MARKERS.iter().any(|m| command.contains(m)) || LAUNCHER_HOOK_MARKER.is_match(&command)
|
||||
}
|
||||
|
||||
/// True when `command` invokes an Impeccable hook in the launcher generation
|
||||
@@ -40,7 +70,7 @@ pub fn is_impeccable_hook_command(command: &str) -> bool {
|
||||
/// one: install/update use this to decide which manifests still need
|
||||
/// migrating to the launcher form.
|
||||
pub fn is_launcher_hook_command(command: &str) -> bool {
|
||||
LAUNCHER_HOOK_MARKER.is_match(command)
|
||||
LAUNCHER_HOOK_MARKER.is_match(&normalize_hook_separators(command))
|
||||
}
|
||||
|
||||
/// The launcher-era markers `context` and `doctor` treat as the design hook
|
||||
@@ -54,9 +84,10 @@ static LAUNCHER_DESIGN_HOOK: Lazy<Regex> = Lazy::new(|| {
|
||||
/// `hook-before-edit`) in either spelling; the JS `context.mjs` scan and
|
||||
/// `staleness-deep` HOOK_SCRIPT_MARKERS both meant exactly these two.
|
||||
pub fn is_design_hook_command(command: &str) -> bool {
|
||||
let command = normalize_hook_separators(command);
|
||||
command.contains("skills/impeccable/scripts/hook.mjs")
|
||||
|| command.contains("skills/impeccable/scripts/hook-before-edit.mjs")
|
||||
|| LAUNCHER_DESIGN_HOOK.is_match(command)
|
||||
|| LAUNCHER_DESIGN_HOOK.is_match(&command)
|
||||
}
|
||||
|
||||
/// True when `command` runs the design hook (`hook` / `hook-before-edit`) in
|
||||
@@ -66,7 +97,7 @@ pub fn is_design_hook_command(command: &str) -> bool {
|
||||
/// so the hook is dead and `MANUAL_DETECTOR_REQUIRED` must fire until an
|
||||
/// install/update repairs it.
|
||||
pub fn is_launcher_design_hook_command(command: &str) -> bool {
|
||||
LAUNCHER_DESIGN_HOOK.is_match(command)
|
||||
LAUNCHER_DESIGN_HOOK.is_match(&normalize_hook_separators(command))
|
||||
}
|
||||
|
||||
/// The shell token that names the hook program inside `command`, for
|
||||
@@ -74,7 +105,11 @@ pub fn is_launcher_design_hook_command(command: &str) -> bool {
|
||||
/// path in the binary form. `None` when the command carries no marker or
|
||||
/// the token cannot be isolated (a `'\''` escape sequence, for instance).
|
||||
pub fn hook_program_token(command: &str) -> Option<String> {
|
||||
if !is_design_hook_command(command) {
|
||||
if command.contains("'\\''") {
|
||||
return None;
|
||||
}
|
||||
let command = normalize_hook_separators(command);
|
||||
if !is_design_hook_command(&command) {
|
||||
return None;
|
||||
}
|
||||
static QUOTED: Lazy<Regex> = Lazy::new(|| {
|
||||
@@ -86,16 +121,13 @@ pub fn hook_program_token(command: &str) -> Option<String> {
|
||||
static BARE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"([^\s"'|&;()]*skills/impeccable/scripts/(?:hook(?:-before-edit)?\.mjs|impeccable(?:\.cmd|\.exe)?))"#).unwrap()
|
||||
});
|
||||
if let Some(m) = QUOTED.captures(command) {
|
||||
if let Some(m) = QUOTED.captures(&command) {
|
||||
return Some(m[1].to_string());
|
||||
}
|
||||
if command.contains("'\\''") {
|
||||
return None;
|
||||
}
|
||||
if let Some(m) = SINGLE.captures(command) {
|
||||
if let Some(m) = SINGLE.captures(&command) {
|
||||
return Some(m[1].to_string());
|
||||
}
|
||||
BARE.captures(command).map(|m| m[1].to_string())
|
||||
BARE.captures(&command).map(|m| m[1].to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -183,4 +215,45 @@ mod tests {
|
||||
assert_eq!(hook_program_token("'/x/it'\\''s/.claude/skills/impeccable/scripts/impeccable' hook"), None);
|
||||
assert_eq!(hook_program_token("echo hi"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_json_escaped_windows_launcher_path() {
|
||||
let launcher = r"C:\Users\alice\.claude\skills\impeccable\scripts\impeccable";
|
||||
let quoted = serde_json::to_string(launcher).unwrap();
|
||||
let cmd = format!("[ ! -f {quoted} ] || {quoted} hook");
|
||||
assert!(is_impeccable_hook_command(&cmd), "{cmd}");
|
||||
assert!(is_launcher_hook_command(&cmd), "{cmd}");
|
||||
assert!(is_design_hook_command(&cmd), "{cmd}");
|
||||
assert_eq!(
|
||||
hook_program_token(&cmd).as_deref(),
|
||||
Some("C:/Users/alice/.claude/skills/impeccable/scripts/impeccable")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_single_backslash_windows_path() {
|
||||
let cmd = r#"[ ! -f "C:\Users\alice\.claude\skills\impeccable\scripts\impeccable" ] || "C:\Users\alice\.claude\skills\impeccable\scripts\impeccable" hook"#;
|
||||
assert!(is_impeccable_hook_command(cmd), "{cmd}");
|
||||
assert!(is_launcher_hook_command(cmd), "{cmd}");
|
||||
assert!(is_design_hook_command(cmd), "{cmd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_unc_prefix_in_program_token() {
|
||||
let launcher = r"\\server\share\.claude\skills\impeccable\scripts\impeccable";
|
||||
let quoted = serde_json::to_string(launcher).unwrap();
|
||||
let json_escaped = format!("[ ! -f {quoted} ] || {quoted} hook");
|
||||
assert!(is_impeccable_hook_command(&json_escaped), "{json_escaped}");
|
||||
assert_eq!(
|
||||
hook_program_token(&json_escaped).as_deref(),
|
||||
Some("//server/share/.claude/skills/impeccable/scripts/impeccable")
|
||||
);
|
||||
|
||||
let single = r#"[ ! -f "\\server\share\.claude\skills\impeccable\scripts\impeccable" ] || "\\server\share\.claude\skills\impeccable\scripts\impeccable" hook"#;
|
||||
assert!(is_impeccable_hook_command(single), "{single}");
|
||||
assert_eq!(
|
||||
hook_program_token(single).as_deref(),
|
||||
Some("//server/share/.claude/skills/impeccable/scripts/impeccable")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,7 +800,7 @@ fn main_flow(rt: &Runtime, stdin: &str) -> Out {
|
||||
{
|
||||
return skip(&audit, "config-ignore-file");
|
||||
}
|
||||
let scan = design_system_options(&config, &cwd);
|
||||
let scan = design_system_options_for_file(rt, &config, &cwd, &file_path);
|
||||
let use_html_engine = match configured {
|
||||
Some(c) => c.engine == "html",
|
||||
None => ext_name == ".html" || ext_name == ".htm",
|
||||
|
||||
+22
-11
@@ -5,6 +5,7 @@
|
||||
use impeccable_core::findings::Finding;
|
||||
use impeccable_core::js;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::hook_lib::*;
|
||||
use crate::stop_baseline;
|
||||
@@ -168,7 +169,7 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
|
||||
let mut cache = read_cache(&project_cwd);
|
||||
let session_id = session_key(&session_value);
|
||||
let scan = design_system_options(&config, &project_cwd);
|
||||
let mut scans = HashMap::new();
|
||||
let tiered = per_edit_tiering_active(&config, harness);
|
||||
|
||||
struct Pending {
|
||||
@@ -275,9 +276,12 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
};
|
||||
}
|
||||
};
|
||||
let scan = scans.entry(file_path.clone()).or_insert_with(|| {
|
||||
design_system_options_for_file(rt, &config, &project_cwd, file_path)
|
||||
});
|
||||
let mut detector_threw = false;
|
||||
let findings: Vec<Finding> = if use_html_engine {
|
||||
match detector_detect_html(rt, file_path, &scan) {
|
||||
match detector_detect_html(rt, file_path, scan) {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
detector_threw = true;
|
||||
@@ -285,7 +289,7 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
detector_detect_text(&content, file_path, &scan)
|
||||
detector_detect_text(&content, file_path, scan)
|
||||
};
|
||||
if !detector_threw && !use_html_engine {
|
||||
stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings);
|
||||
@@ -352,8 +356,9 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
}
|
||||
|
||||
if !fresh_groups.is_empty() {
|
||||
let scan = &scans[&fresh_groups[0].file_path];
|
||||
let short = footer_mode_short(&mut cache, &session_id);
|
||||
let reserve = design_note_reserve(rt, &scan, &mut cache, &session_id);
|
||||
let reserve = design_note_reserve(rt, scan, &mut cache, &session_id);
|
||||
let rendered = render_grouped_template(
|
||||
rt,
|
||||
&fresh_groups,
|
||||
@@ -365,7 +370,7 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
},
|
||||
);
|
||||
let text =
|
||||
append_design_system_note_once(rt, &rendered, &scan, &mut cache, &session_id, &config);
|
||||
append_design_system_note_once(rt, &rendered, scan, &mut cache, &session_id, &config);
|
||||
commit_footer_shown(rt, &mut cache, &session_id, &text);
|
||||
persist_cache(rt, &project_cwd, &cache);
|
||||
let all: usize = fresh_groups.iter().map(|g| g.findings.len()).sum();
|
||||
@@ -396,10 +401,11 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
.filter(|p| should_emit_ack_for_file(&p.file_path, &config))
|
||||
{
|
||||
let base = render_pending_ack(rt, &p.file_path, &p.known, &project_cwd);
|
||||
let scan = &scans[&p.file_path];
|
||||
ack = Some(Ack::Pending(append_design_system_note_once(
|
||||
rt,
|
||||
&base,
|
||||
&scan,
|
||||
scan,
|
||||
&mut cache,
|
||||
&session_id,
|
||||
&config,
|
||||
@@ -410,10 +416,11 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
.filter(|c| should_emit_ack_for_file(c, &config))
|
||||
{
|
||||
let base = render_clean_ack(rt, c, &project_cwd);
|
||||
let scan = &scans[c];
|
||||
ack = Some(Ack::Clean(append_design_system_note_once(
|
||||
rt,
|
||||
&base,
|
||||
&scan,
|
||||
scan,
|
||||
&mut cache,
|
||||
&session_id,
|
||||
&config,
|
||||
@@ -663,7 +670,7 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
],
|
||||
);
|
||||
}
|
||||
let scan = design_system_options(&config, &project_cwd);
|
||||
let mut scans = HashMap::new();
|
||||
|
||||
let mut fresh_groups: Vec<Group> = Vec::new();
|
||||
let mut scanned = 0usize;
|
||||
@@ -704,17 +711,20 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
Some(c) => c.engine == "html",
|
||||
None => ext == ".html" || ext == ".htm",
|
||||
};
|
||||
let scan = scans.entry(file_path.clone()).or_insert_with(|| {
|
||||
design_system_options_for_file(rt, &config, &project_cwd, file_path)
|
||||
});
|
||||
// JS: a detector failure tells us nothing about the file. Leave
|
||||
// whatever was remembered alone rather than recording an empty scan
|
||||
// as truth. (detectText cannot throw here: the Rust engine returns
|
||||
// findings directly.)
|
||||
let findings = if use_html_engine {
|
||||
match detector_detect_html(rt, file_path, &scan) {
|
||||
match detector_detect_html(rt, file_path, scan) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
detector_detect_text(&content, file_path, &scan)
|
||||
detector_detect_text(&content, file_path, scan)
|
||||
};
|
||||
if !use_html_engine {
|
||||
stop_baseline::reconcile(&mut cache, &session_id, file_path, &findings);
|
||||
@@ -755,6 +765,7 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
],
|
||||
);
|
||||
}
|
||||
let scan = &scans[&fresh_groups[0].file_path];
|
||||
let short = footer_mode_short(&mut cache, &session_id);
|
||||
let first_unknown = fresh_groups.iter().flat_map(|group| &group.findings)
|
||||
.position(|f| f.name.starts_with("[attribution unknown]"));
|
||||
@@ -801,7 +812,7 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
}
|
||||
let text = if shows_unknown { format!("{attribution_note}\n\n{rendered}") } else { rendered };
|
||||
let text =
|
||||
append_design_system_note_once(rt, &text, &scan, &mut cache, &session_id, &config);
|
||||
append_design_system_note_once(rt, &text, scan, &mut cache, &session_id, &config);
|
||||
commit_footer_shown(rt, &mut cache, &session_id, &text);
|
||||
persist_cache(rt, &project_cwd, &cache);
|
||||
let all: usize = fresh_groups.iter().map(|g| g.findings.len()).sum();
|
||||
|
||||
@@ -16,7 +16,7 @@ use impeccable_detect::config::{
|
||||
normalize_ignore_rule, normalize_ignore_value, normalize_ignore_value_entries, DetectionConfig,
|
||||
IgnoreValueEntry,
|
||||
};
|
||||
use impeccable_detect::design_system::{load_design_system_for_cwd, DesignSystem};
|
||||
use impeccable_detect::design_system::{load_design_system_for_cwd, resolve_design_md_path, DesignSystem};
|
||||
use impeccable_detect::detect_text::{detect_text, TextOptions};
|
||||
use impeccable_detect::engines::{HtmlEngine, ScanOptions};
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -1645,6 +1645,33 @@ pub fn design_system_options(config: &HookConfig, project_cwd: &str) -> HookScan
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve design rules for the edited workspace without moving hook state.
|
||||
pub fn design_system_options_for_file(
|
||||
rt: &Runtime,
|
||||
config: &HookConfig,
|
||||
project_cwd: &str,
|
||||
file_path: &str,
|
||||
) -> HookScanOptions {
|
||||
if !config.design_system_enabled {
|
||||
return HookScanOptions::default();
|
||||
}
|
||||
let project = impeccable_context::context::resolve_project(
|
||||
project_cwd,
|
||||
&impeccable_context::target_args::TargetOptions {
|
||||
target_path: Some(file_path.to_string()),
|
||||
},
|
||||
&rt.env,
|
||||
);
|
||||
// A local DESIGN.md owns the scope even if it has no usable frontmatter.
|
||||
// Fall back only when the app has no document, never to a sibling app.
|
||||
let root = if resolve_design_md_path(&project.project_root).is_some() {
|
||||
&project.project_root
|
||||
} else {
|
||||
&project.repo_root
|
||||
};
|
||||
design_system_options(config, root)
|
||||
}
|
||||
|
||||
/// The detector the hook drives: the regex engine from `impeccable-detect`
|
||||
/// and the static HTML engine through the `HtmlEngine` seam.
|
||||
pub fn detector_detect_text(
|
||||
|
||||
@@ -124,6 +124,141 @@ fn stop_event(cwd: &str, session: &str) -> String {
|
||||
const GRADIENT_CSS: &str = ".title { background: linear-gradient(90deg, #f472b6, #a78bfa); -webkit-background-clip: text; color: transparent; }\n";
|
||||
const SIDE_TAB_CSS: &str = ".card { border-left: 4px solid #6366f1; border-radius: 8px; }\n";
|
||||
|
||||
fn monorepo_design_fixture(root_design: bool) -> Tmp {
|
||||
let t = Tmp::new();
|
||||
t.write("package.json", r#"{"workspaces":["apps/*"]}"#);
|
||||
t.write("apps/a/package.json", "{}");
|
||||
t.write("apps/b/package.json", "{}");
|
||||
t.write("apps/a/DESIGN.md", "---\ncolors:\n primary: '#112233'\n---\n");
|
||||
if root_design {
|
||||
t.write("DESIGN.md", "---\ncolors:\n primary: '#224466'\n---\n");
|
||||
}
|
||||
t.write(".impeccable/config.json", r#"{"hook":{"perEditRules":"all"},"detector":{"advisoryRules":"include"}}"#);
|
||||
t
|
||||
}
|
||||
|
||||
// Run identical cases through all three hook entry points. The probe that is
|
||||
// allowed by the repo palette must still fail against app A's own palette.
|
||||
fn check_monorepo_design_hook(mode: &str) {
|
||||
for (root_design, app, color, expected) in [
|
||||
(false, "a", "#ff00aa", true),
|
||||
(false, "b", "#ff00aa", false),
|
||||
(true, "a", "#224466", true),
|
||||
(true, "b", "#ff00aa", true),
|
||||
(true, "b", "#224466", false),
|
||||
] {
|
||||
let t = monorepo_design_fixture(root_design);
|
||||
let cwd = t.path();
|
||||
let source = format!(".probe {{ color: {color}; }}\n");
|
||||
let file = t.write(&format!("apps/{app}/src/probe.css"), &source);
|
||||
let r = rt(&cwd);
|
||||
let out = match mode {
|
||||
"post" => hook::run_hook(&r, &edit_event(&cwd, &file, "s1")).stdout,
|
||||
"before" => {
|
||||
// A proposed new file must resolve its owning app too.
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
hbe(&r, &cursor(&cwd, "Write", json!({
|
||||
"file_path": file, "content": source,
|
||||
}))).0
|
||||
}
|
||||
"stop" => {
|
||||
let mut cache = read_cache(&cwd);
|
||||
touch_file(&mut cache, "s1", &file);
|
||||
persist_cache(&r, &cwd, &cache);
|
||||
hook::run_stop_hook(&r, &stop_event(&cwd, "s1")).stdout
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(out.contains("design-system-color"), expected,
|
||||
"{mode}: root_design={root_design}, app={app}, color={color}: {out}");
|
||||
assert!(!t.exists(&format!("apps/{app}/.impeccable/hook.cache.json")),
|
||||
"design resolution must not relocate hook state");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_post_edit() { check_monorepo_design_hook("post"); }
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_before_edit() { check_monorepo_design_hook("before"); }
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_stop() { check_monorepo_design_hook("stop"); }
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_document_locations_and_sidecars() {
|
||||
for location in ["DESIGN.md", "docs/DESIGN.md", ".agents/context/DESIGN.md"] {
|
||||
let t = monorepo_design_fixture(true);
|
||||
let cwd = t.path();
|
||||
let file = t.write("apps/b/src/probe.css", ".probe {}\n");
|
||||
let md = t.write(&format!("apps/b/{location}"),
|
||||
"---\ntypography:\n body:\n fontFamily: Georgia\nrounded:\n md: 8px\ncolors:\n primary: '#abcdef'\n---\n");
|
||||
let sidecar = t.write("apps/b/.impeccable/design.json", "{}");
|
||||
let scan = design_system_options_for_file(&rt(&cwd), &read_config(&cwd), &cwd, &file);
|
||||
let ds = scan.design_system.as_ref().unwrap();
|
||||
assert_eq!(ds.source_path.as_deref(), Some(md.as_str()));
|
||||
assert_eq!(ds.sidecar_path.as_deref(), Some(sidecar.as_str()));
|
||||
let findings = detector_detect_text(
|
||||
".probe { color: #ff0000; font-family: Verdana; border-radius: 19px; }", &file, &scan);
|
||||
for rule in ["design-system-color", "design-system-font", "design-system-radius"] {
|
||||
assert!(findings.iter().any(|f| f.antipattern == rule), "{location}: {rule}");
|
||||
}
|
||||
let allowed = detector_detect_text(
|
||||
".probe { color: #abcdef; font-family: Georgia; border-radius: 8px; }", &file, &scan);
|
||||
assert!(allowed.iter().all(|f| !f.antipattern.starts_with("design-system-")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_local_document_and_disabled_config_do_not_inherit() {
|
||||
let t = monorepo_design_fixture(true);
|
||||
let cwd = t.path();
|
||||
let file = t.write("apps/a/src/probe.css", ".probe {}\n");
|
||||
t.write("apps/a/DESIGN.md", "# App-specific prose, with no machine-readable tokens\n");
|
||||
let r = rt(&cwd);
|
||||
let mut config = read_config(&cwd);
|
||||
assert!(design_system_options_for_file(&r, &config, &cwd, &file).design_system.is_none());
|
||||
config.design_system_enabled = false;
|
||||
let sibling = t.write("apps/b/src/probe.css", ".probe {}\n");
|
||||
assert!(design_system_options_for_file(&r, &config, &cwd, &sibling).design_system.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monorepo_design_batch_notes_follow_the_displayed_file() {
|
||||
for mode in ["post-fresh", "post-pending", "post-clean", "stop"] {
|
||||
for stale_app in ["a", "b"] {
|
||||
let t = monorepo_design_fixture(true);
|
||||
let cwd = t.path();
|
||||
let source = if mode == "post-clean" { ".probe { color: #112233; }" }
|
||||
else { ".probe { color: #ff00aa; }" };
|
||||
let a = t.write("apps/a/src/probe.css", source);
|
||||
let b = t.write("apps/b/src/probe.css", ".probe { color: #224466; }");
|
||||
let r = rt(&cwd);
|
||||
if mode == "post-pending" {
|
||||
hook::run_hook(&r, &edit_event(&cwd, &a, "s1"));
|
||||
}
|
||||
let sidecar = t.write(if stale_app == "a" { "apps/a/.impeccable/design.json" }
|
||||
else { ".impeccable/design.json" }, "{}");
|
||||
std::fs::File::options().write(true).open(sidecar).unwrap()
|
||||
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000)).unwrap();
|
||||
let out = if mode == "stop" {
|
||||
let mut cache = read_cache(&cwd);
|
||||
touch_file(&mut cache, "s1", &a);
|
||||
touch_file(&mut cache, "s1", &b);
|
||||
persist_cache(&r, &cwd, &cache);
|
||||
hook::run_stop_hook(&r, &stop_event(&cwd, "s1")).stdout
|
||||
} else {
|
||||
let event = json!({"session_id":"s1", "cwd":cwd, "hook_event_name":"PostToolUse",
|
||||
"tool_name":"apply_patch", "tool_input":{"command":format!(
|
||||
"*** Begin Patch\n*** Update File: {a}\n*** Update File: {b}\n*** End Patch")}});
|
||||
hook::run_hook(&r, &event.to_string()).stdout
|
||||
};
|
||||
assert!(out.contains("apps/a/src/probe.css"), "{mode}: {out}");
|
||||
assert_eq!(out.contains("DESIGN.md is newer"), stale_app == "a", "{mode}: {out}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_with_original(cwd: &str, file: &str, session: &str, before: &str, old: &str, new: &str) -> String {
|
||||
json!({
|
||||
"session_id": session, "cwd": cwd, "hook_event_name": "PostToolUse",
|
||||
|
||||
@@ -219,13 +219,10 @@ fn rewrite_value(value: &Value, provider: &str, quoted: &QuotedPath, win32: bool
|
||||
}
|
||||
}
|
||||
|
||||
/// JS: valueHasImpeccableHookMarker(value). Command separators are
|
||||
/// normalized to `/` first so a legacy Windows-path guard is still
|
||||
/// recognized as ours and replaced instead of duplicated (upstream
|
||||
/// 665c51b9, #604).
|
||||
/// JS: valueHasImpeccableHookMarker(value).
|
||||
pub fn value_has_impeccable_hook_marker(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::String(s) => is_impeccable_hook_command(&s.replace('\\', "/")),
|
||||
Value::String(s) => is_impeccable_hook_command(s),
|
||||
Value::Array(a) => a.iter().any(value_has_impeccable_hook_marker),
|
||||
Value::Object(o) => o.values().any(value_has_impeccable_hook_marker),
|
||||
_ => false,
|
||||
@@ -233,12 +230,9 @@ pub fn value_has_impeccable_hook_marker(value: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// True when `value` names an Impeccable hook in the launcher generation.
|
||||
/// Separators are normalized to `/` first, matching
|
||||
/// `value_has_impeccable_hook_marker`, so a legacy Windows-path launcher
|
||||
/// command is still recognized.
|
||||
pub fn value_has_launcher_hook_marker(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::String(s) => is_launcher_hook_command(&s.replace('\\', "/")),
|
||||
Value::String(s) => is_launcher_hook_command(s),
|
||||
Value::Array(a) => a.iter().any(value_has_launcher_hook_marker),
|
||||
Value::Object(o) => o.values().any(value_has_launcher_hook_marker),
|
||||
_ => false,
|
||||
|
||||
@@ -35,6 +35,10 @@ impl Prompt {
|
||||
self.stdin_tty && self.stdout_tty && cfg!(unix)
|
||||
}
|
||||
|
||||
fn uses_tty_readline(&self, io: &Io) -> bool {
|
||||
cfg!(unix) && self.stdout_tty && io.env("TERM") != Some("dumb")
|
||||
}
|
||||
|
||||
fn ansi(&self, open: &str, close: &str, value: &str) -> String {
|
||||
if self.style {
|
||||
format!("{open}{value}{close}")
|
||||
@@ -70,7 +74,7 @@ impl Prompt {
|
||||
let next = self.piped.as_mut().and_then(|v| v.pop()).unwrap_or_default();
|
||||
return Ok(next.trim().to_lowercase());
|
||||
}
|
||||
if self.stdout_tty && io.env("TERM") != Some("dumb") {
|
||||
if self.uses_tty_readline(io) {
|
||||
return self.tty_readline(io, question);
|
||||
}
|
||||
io.out(question);
|
||||
@@ -624,6 +628,9 @@ fn terminal_rows() -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -637,4 +644,29 @@ mod tests {
|
||||
assert_eq!(visible_window(15, 16, 10), (6, 16));
|
||||
assert_eq!(visible_window(2, 3, 10), (0, 3));
|
||||
}
|
||||
|
||||
fn tty_prompt() -> Prompt {
|
||||
Prompt { stdin_tty: true, stdout_tty: true, style: false, piped: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_uses_raw_readline_only_on_unix() {
|
||||
let prompt = tty_prompt();
|
||||
let (io, _) = Io::captured("", PathBuf::from("."), HashMap::new());
|
||||
assert_eq!(prompt.uses_tty_readline(&io), cfg!(unix));
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[test]
|
||||
fn ask_on_windows_tty_does_not_throw_unsupported() {
|
||||
// Line fallback reads process stdin. Skip on a live console so the
|
||||
// test cannot hang; CI pipes EOF and gets Ok("").
|
||||
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
|
||||
return;
|
||||
}
|
||||
let mut prompt = tty_prompt();
|
||||
let (mut io, _) = Io::captured("", PathBuf::from("."), HashMap::new());
|
||||
let result = prompt.ask(&mut io, "Update skills in 1 provider folder(s)? (Y/n) ");
|
||||
assert_eq!(result, Ok(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,3 +357,57 @@ fn hook_artifacts_map_providers_to_manifest_files() {
|
||||
assert_eq!(c[0].dest, jsp::join(&["/p", ".codex", "hooks.json"]));
|
||||
assert!(c[0].shared_dest.is_none());
|
||||
}
|
||||
|
||||
fn windows_user_scope_hook_command() -> String {
|
||||
let launcher = r"C:\Users\alice\.claude\skills\impeccable\scripts\impeccable";
|
||||
let q = json_string(launcher);
|
||||
format!("[ ! -f {q} ] || {q} hook")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_json_escaped_windows_launcher_is_idempotent() {
|
||||
let cmd = windows_user_scope_hook_command();
|
||||
let hook_entry = |cmd: String| {
|
||||
json!({ "matcher": "Edit", "hooks": [{ "type": "command", "command": cmd }] })
|
||||
};
|
||||
let stop_entry = |cmd: String| {
|
||||
json!({ "hooks": [{ "type": "command", "command": cmd, "timeout": 30 }] })
|
||||
};
|
||||
let existing = json!({
|
||||
"hooks": {
|
||||
"PostToolUse": [hook_entry(cmd.clone())],
|
||||
"Stop": [stop_entry(cmd.clone())]
|
||||
}
|
||||
});
|
||||
let fresh = json!({
|
||||
"description": "fresh",
|
||||
"hooks": {
|
||||
"PostToolUse": [hook_entry(cmd.clone())],
|
||||
"Stop": [stop_entry(cmd.clone())]
|
||||
}
|
||||
});
|
||||
let merged = merge_hook_manifests(&existing, &fresh);
|
||||
assert_eq!(merged["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(merged["hooks"]["Stop"].as_array().unwrap().len(), 1);
|
||||
let merged2 = merge_hook_manifests(&merged, &fresh);
|
||||
assert_eq!(merged2["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(merged2["hooks"]["Stop"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_heals_triplicated_stop_groups() {
|
||||
let cmd = windows_user_scope_hook_command();
|
||||
let stop_entry = json!({ "hooks": [{ "type": "command", "command": cmd.clone(), "timeout": 30 }] });
|
||||
let existing = json!({
|
||||
"hooks": {
|
||||
"Stop": [stop_entry.clone(), stop_entry.clone(), stop_entry]
|
||||
}
|
||||
});
|
||||
let fresh = json!({
|
||||
"hooks": {
|
||||
"Stop": [json!({ "hooks": [{ "type": "command", "command": cmd, "timeout": 30 }] })]
|
||||
}
|
||||
});
|
||||
let merged = merge_hook_manifests(&existing, &fresh);
|
||||
assert_eq!(merged["hooks"]["Stop"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
@@ -1026,6 +1026,8 @@ Note the global cap across groups is `maxFindings` (5) TOTAL, so later files may
|
||||
|
||||
`designSystemOptions(config, det, projectCwd)`: `{}` if `config.designSystem.enabled === false` or detector lacks `loadDesignSystemForCwd`; else `{designSystem}` if `det.loadDesignSystemForCwd(projectCwd)` returns truthy (DESIGN.md found walking up to a project boundary; object includes `mdNewerThanJson` = DESIGN.md mtime > `.impeccable/design.json` mtime + 1000ms).
|
||||
|
||||
The Rust post-edit, before-edit, and Stop hooks resolve design rules per target file using the shared context project resolver. An app's DESIGN.md (including the usual `.agents/context` and `docs` locations) takes precedence; an app with no document falls back to its repository's document, never a sibling's. The sidecar comes from the selected design scope, and batch notices follow the displayed file's scope. Hook configuration, platform gating, and session cache locations are unchanged.
|
||||
|
||||
`appendDesignSystemNote(text, scanOptions)` → `text + '\n\n' + DESIGN_STALE_NOTE` when `scanOptions.designSystem.mdNewerThanJson`.
|
||||
`appendDesignSystemNoteOnce(text, scanOptions, cache, sid, config)`: same, but only if `text.length + NOTE.length + 2 <= max(500, limits.maxChars)` and session flag `designNoteShown` not yet set (sets it).
|
||||
`designNoteReserve(scanOptions, cache, sid)` = `NOTE.length + 2` when note pending and not yet shown, else 0.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
The VS Code extension is a declarative delivery channel for the GitHub Copilot skill. `bun run build` stages `dist/vscode/` from the GitHub provider output; `bun run package:vscode` builds and packages a VSIX with pinned `@vscode/vsce` tooling. Nothing is published by either command.
|
||||
|
||||
The package version follows `.claude-plugin/plugin.json`. Do not independently bump it for feature work. The Marketplace identifier is `renaissance-geek.impeccable`, under the registered Renaissance Geek publisher. Initial extension publication is a separate maintainer step; publisher registration alone does not publish the extension.
|
||||
Install [Impeccable from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=renaissance-geek.impeccable), published by Renaissance Geek, or run `code --install-extension renaissance-geek.impeccable`. Requires VS Code 1.109.3+, Copilot Chat access, and a trusted local workspace. Use Chat in Agent mode, for example `/impeccable polish`. Avoid duplicate Impeccable skill installations in the same workspace/profile.
|
||||
|
||||
The package version follows `.claude-plugin/plugin.json`. Do not independently bump it for feature work. Publication and updates are separate maintainer steps; building or packaging does not publish.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -37,7 +39,7 @@ Check that `/impeccable` is discoverable, the loaded SKILL.md is inside the inst
|
||||
|
||||
Test the oldest supported editor as well as current stable before publication. Windows and remote workspaces need separate smoke checks; do not infer them from a macOS local run. Plain browser-only VS Code cannot run the native launcher.
|
||||
|
||||
Initial macOS packaging checks: the VSIX installs in VS Code 1.109.3 (which resolves Copilot Chat 0.37.9) and 1.136.1. The installed launcher loads the synthetic project's context correctly. The older editor has only been install-tested, not behavior-tested.
|
||||
Initial macOS packaging checks: the VSIX installs in VS Code 1.109.3 (which resolves Copilot Chat 0.37.9) and 1.136.1. Both editor versions also passed the read-only Copilot behavior smoke below.
|
||||
|
||||
### Recorded Copilot smoke (September 7, 2026)
|
||||
|
||||
@@ -48,4 +50,8 @@ VS Code 1.136.1, Copilot Chat 0.64.1, Auto routed to GPT-5.6 Luna. A single read
|
||||
- The loader resolved the synthetic project root and its PRODUCT.md and DESIGN.md. Copilot then read `reference/polish.md` from the same extension, followed by the three fixture files.
|
||||
- The completed report correctly described the fixture. The project still contained only its original three files, with unchanged contents. No workspace skill copy, server, or image-generation call was created.
|
||||
|
||||
This is one packaging/path-resolution smoke, not an activation-reliability or design-quality evaluation. It does not establish Windows, remote-host, or minimum-version behavior.
|
||||
The final `renaissance-geek.impeccable` 4.2.2 VSIX also passed the same read-only smoke in VS Code 1.109.3 / Copilot Chat 0.37.9 (Auto selected GPT-5.3-Codex): slash discovery, the installed launcher, project context, and the polish reference all resolved correctly. One scoped command approval was granted, and all three fixture files remained unchanged.
|
||||
|
||||
After publication, `code --install-extension renaissance-geek.impeccable` installed 4.2.2 into fresh isolated profile/extensions directories in VS Code 1.136.1. All 55 extension payload files matched the tested VSIX (ignoring VS Code's added `package.json` installation metadata). Its installed launcher loaded the same fixture context successfully; fixture hashes and file inventory stayed unchanged. This Marketplace check verified download/install and payload identity, not an additional Copilot conversation.
|
||||
|
||||
These are packaging/path-resolution smokes, not activation-reliability or design-quality evaluations. They do not establish Windows or remote-host behavior.
|
||||
|
||||
Reference in New Issue
Block a user