mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2c4372abf |
Generated
+1
-78
@@ -111,22 +111,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -606,14 +590,12 @@ dependencies = [
|
||||
"impeccable-core",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"rustls-native-certs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tiny_http",
|
||||
"unicode-normalization",
|
||||
"ureq",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -848,12 +830,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1087,7 +1063,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1120,18 +1096,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -1158,15 +1122,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -1187,29 +1142,6 @@ dependencies = [
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -1671,15 +1603,6 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -81,7 +81,6 @@ impeccable detect [options] [file-or-dir-or-url...]
|
||||
|
||||
- Node.js 22.18+ to run `npx impeccable`. The engine itself is a self-contained binary and needs no runtime; the skill installed into your harness calls it directly.
|
||||
- For URL scans, an installed Chrome, Chromium, or Edge (set `IMPECCABLE_BROWSER` to point at one).
|
||||
- Behind a TLS-inspecting proxy, downloads trust your OS certificate store as well as the bundled Mozilla roots. Set `SSL_CERT_FILE` or `SSL_CERT_DIR` to use a specific CA bundle instead.
|
||||
|
||||
Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin/<version>/`, then a download of the pinned version into that cache. Set `IMPECCABLE_BIN` to a local build to skip all of that.
|
||||
|
||||
|
||||
@@ -21,7 +21,5 @@ once_cell = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
|
||||
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
|
||||
rustls-native-certs = "0.8"
|
||||
webpki-roots = "1"
|
||||
tiny_http = "0.12"
|
||||
unicode-normalization = "0.1.25"
|
||||
|
||||
@@ -47,7 +47,7 @@ fn card_base(env: &Env) -> String {
|
||||
}
|
||||
|
||||
fn agent(timeout: Duration) -> ureq::Agent {
|
||||
crate::http::agent_builder().timeout_connect(timeout).timeout(timeout).build()
|
||||
ureq::AgentBuilder::new().timeout_connect(timeout).timeout(timeout).build()
|
||||
}
|
||||
|
||||
/// URLSearchParams serialization (application/x-www-form-urlencoded).
|
||||
|
||||
@@ -468,7 +468,7 @@ fn fetch_latest_skill_version(env: &Env) -> Option<String> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "https://impeccable.style".to_string());
|
||||
let host = host.strip_suffix('/').unwrap_or(&host).to_string();
|
||||
let agent = crate::http::agent_builder()
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_millis(FETCH_TIMEOUT_MS))
|
||||
.build();
|
||||
let res = agent.get(&format!("{}/api/version", host)).call().ok()?;
|
||||
|
||||
@@ -167,11 +167,7 @@ fn walk(root: &str, dir: &str, depth: usize, hits: &mut Hits) {
|
||||
let Ok(bytes) = std::fs::read(&abs) else { continue };
|
||||
let slice = if bytes.len() > MAX_READ_BYTES { &bytes[..MAX_READ_BYTES] } else { &bytes[..] };
|
||||
let body = String::from_utf8_lossy(slice);
|
||||
// Candidate patterns use '/', while native Windows relative paths
|
||||
// use '\\'. Normalize once for classification and portable signals;
|
||||
// to_posix preserves literal backslashes in Unix filenames.
|
||||
let rel = jsp::to_posix(&jsp::relative("/", root, &abs));
|
||||
visit(root, &abs, &rel, &body, hits);
|
||||
visit(root, &abs, &jsp::relative("/", root, &abs), &body, hits);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,88 +197,3 @@ pub fn run(_args: &[String], io: &mut Io) -> i32 {
|
||||
io.out(&format!("{}\n", json_pretty(&v)));
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
loop {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"impeccable-csp-761-{}-{}",
|
||||
std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
match std::fs::create_dir(&root) {
|
||||
Ok(()) => return Self(root),
|
||||
// Never reuse or remove files left by another run.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => panic!("create CSP fixture: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&self, path: &str, body: &str) -> Value {
|
||||
let file = self.0.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, body).unwrap();
|
||||
detect_csp(self.0.to_str().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_csp_candidates_use_portable_paths() {
|
||||
// Exercise the filesystem walker and native path.relative semantics,
|
||||
// not just regexes with pre-normalized input. Windows CI reproduces #761.
|
||||
for (path, body, shape) in [
|
||||
("packages/app/src/security/csp.ts", "buildCSPConfig()", "append-arrays"),
|
||||
("packages/app/src/next-config.ts", "createBaseNextConfig()", "append-arrays"),
|
||||
("apps/web/svelte.config.js", "kit: { csp: { directives: {} } }", "append-arrays"),
|
||||
("apps/web/nuxt.config.ts", "'nuxt-security'; contentSecurityPolicy", "append-arrays"),
|
||||
("apps/web/next.config.mjs", "'Content-Security-Policy': 'script-src self; connect-src self'", "append-string"),
|
||||
("next.config.mjs", "'Content-Security-Policy': 'script-src self; connect-src self'", "append-string"),
|
||||
("apps/web/src/middleware.ts", "headers.set('Content-Security-Policy', policy)", "middleware"),
|
||||
("apps/web/src/layout.astro", "<meta http-equiv='Content-Security-Policy'>", "meta-tag"),
|
||||
] {
|
||||
assert_eq!(Fixture::new().scan(path, body), serde_json::json!({
|
||||
"shape": shape, "signals": [path],
|
||||
}), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_nested_files_are_not_csp_candidates() {
|
||||
for (path, body) in [
|
||||
("packages/app/src/utils/csp.ts", "buildCSPConfig()"),
|
||||
("apps/web/not-svelte.config.js", "kit: { csp: { directives: {} } }"),
|
||||
("apps/web/next.config.mjs", "'Content-Security-Policy': 'script-src self'"),
|
||||
] {
|
||||
assert_eq!(Fixture::new().scan(path, body), serde_json::json!({
|
||||
"shape": null, "signals": [],
|
||||
}), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_backslashes_remain_literal_filename_characters() {
|
||||
let result = Fixture::new().scan(
|
||||
"packages/app/src/config\\notes.ts", "buildCSPConfig()",
|
||||
);
|
||||
assert_eq!(result, serde_json::json!({
|
||||
"shape": "append-arrays", "signals": ["packages/app/src/config\\notes.ts"],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
}
|
||||
}
|
||||
}
|
||||
let agent = crate::http::agent_builder().build();
|
||||
let agent = ureq::AgentBuilder::new().build();
|
||||
let response = if !refs.is_empty() {
|
||||
let boundary = format!("----impeccable{:x}", crate::util::now_ms() as u64);
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//! One TLS trust configuration for every HTTPS request the engine makes.
|
||||
//!
|
||||
//! `ureq`'s default rustls config trusts only the Mozilla roots compiled in
|
||||
//! through `webpki-roots`. On a machine where an endpoint security agent
|
||||
//! inspects TLS (Aikido, Zscaler, Netskope: routine in managed corporate
|
||||
//! setups), every connection terminates at a proxy whose root lives in the
|
||||
//! OS trust store and nowhere else, so `update` and `install` failed with
|
||||
//! `invalid peer certificate: UnknownIssuer` while curl and npm on the same
|
||||
//! machine succeeded (#757).
|
||||
//!
|
||||
//! The store built here is the union of the OS trust store
|
||||
//! (`rustls-native-certs`: the macOS Keychain, the Windows store, the
|
||||
//! OpenSSL paths on Linux) and the bundled Mozilla roots. A union, not a
|
||||
//! replacement: a container without `ca-certificates`, or a store that
|
||||
//! fails to load, verifies against the bundled roots exactly as before.
|
||||
//! `SSL_CERT_FILE` / `SSL_CERT_DIR` stand in for the OS store, as they do
|
||||
//! for OpenSSL and curl; the bundled roots stay either way.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use ureq::rustls::pki_types::CertificateDer;
|
||||
use ureq::rustls::{self, ClientConfig, RootCertStore};
|
||||
|
||||
/// `ureq::AgentBuilder::new()` with the engine's trust store installed.
|
||||
/// Every HTTPS call site builds its agent from this; the plain-HTTP calls
|
||||
/// to the live server on localhost do not need it.
|
||||
pub fn agent_builder() -> ureq::AgentBuilder {
|
||||
ureq::AgentBuilder::new().tls_config(tls_config())
|
||||
}
|
||||
|
||||
fn tls_config() -> Arc<ClientConfig> {
|
||||
static CONFIG: Lazy<Arc<ClientConfig>> = Lazy::new(|| {
|
||||
// Mirrors ureq's own default config (provider and protocol versions);
|
||||
// only the root store differs.
|
||||
let config =
|
||||
ClientConfig::builder_with_provider(rustls::crypto::ring::default_provider().into())
|
||||
.with_protocol_versions(&[&rustls::version::TLS12, &rustls::version::TLS13])
|
||||
.expect("the ring provider supports TLS 1.2 and 1.3")
|
||||
.with_root_certificates(root_store(rustls_native_certs::load_native_certs().certs))
|
||||
.with_no_client_auth();
|
||||
Arc::new(config)
|
||||
});
|
||||
CONFIG.clone()
|
||||
}
|
||||
|
||||
/// The bundled Mozilla roots plus every parsable certificate in `native`.
|
||||
/// Unparsable entries are dropped, so one broken certificate in the OS
|
||||
/// store cannot take the bundled roots down with it.
|
||||
fn root_store(native: Vec<CertificateDer<'static>>) -> RootCertStore {
|
||||
let mut store = RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||
};
|
||||
store.add_parsable_certificates(native);
|
||||
store
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ureq::rustls::pki_types::pem::PemObject;
|
||||
|
||||
/// Self-signed CA minted for this test (P-256, v3, CA:TRUE): the shape
|
||||
/// of the root a TLS-inspecting proxy installs into the OS store.
|
||||
const PROXY_ROOT_PEM: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIBdTCCARugAwIBAgIJANhTZvQvv7HJMAoGCCqGSM49BAMCMB0xGzAZBgNVBAMM
|
||||
EmltcGVjY2FibGUgdGVzdCBDQTAgFw0yNjA5MDcwNjQxMjdaGA8yMTI2MDgxNDA2
|
||||
NDEyN1owHTEbMBkGA1UEAwwSaW1wZWNjYWJsZSB0ZXN0IENBMFkwEwYHKoZIzj0C
|
||||
AQYIKoZIzj0DAQcDQgAEYVZtCOXaZsY71/0Roy62iBVcyx8UfMDkPbEbf/IEw5Bm
|
||||
yNBfKTFS/8FbRBMWHXOwNE0Ns1BLVOB1oQ1XFC5Bz6NCMEAwDwYDVR0TAQH/BAUw
|
||||
AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFONzBxi7ewOfuP6cBIIqsxu
|
||||
3pEiMAoGCCqGSM49BAMCA0gAMEUCIQD98Q0ZRe8ceuopnUwQKYleZd5IzfWhhpmO
|
||||
tB0WGTOG3QIgdJa8gBPU9Y6WsrursItsnUeGTYHKDCZZ6MjlekLFuoc=
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
|
||||
fn bundled() -> usize {
|
||||
webpki_roots::TLS_SERVER_ROOTS.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_roots_alone_when_the_os_store_is_empty() {
|
||||
assert_eq!(root_store(Vec::new()).len(), bundled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_store_root_joins_the_bundled_roots() {
|
||||
let proxy = CertificateDer::from_pem_slice(PROXY_ROOT_PEM.as_bytes()).unwrap();
|
||||
assert_eq!(root_store(vec![proxy]).len(), bundled() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unparsable_os_store_entry_is_dropped() {
|
||||
let junk = CertificateDer::from(b"not a certificate".to_vec());
|
||||
assert_eq!(root_store(vec![junk]).len(), bundled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_builds_from_this_hosts_store() {
|
||||
// Runs the real rustls-native-certs load: it must not panic, and the
|
||||
// shared config must be accepted by a ureq agent.
|
||||
let _agent = agent_builder().build();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
pub mod jsp;
|
||||
pub mod util;
|
||||
pub mod url;
|
||||
pub mod http;
|
||||
pub mod provider;
|
||||
pub mod hook_markers;
|
||||
pub mod target_args;
|
||||
|
||||
@@ -800,7 +800,7 @@ fn main_flow(rt: &Runtime, stdin: &str) -> Out {
|
||||
{
|
||||
return skip(&audit, "config-ignore-file");
|
||||
}
|
||||
let scan = design_system_options_for_file(rt, &config, &cwd, &file_path);
|
||||
let scan = design_system_options(&config, &cwd);
|
||||
let use_html_engine = match configured {
|
||||
Some(c) => c.engine == "html",
|
||||
None => ext_name == ".html" || ext_name == ".htm",
|
||||
|
||||
+11
-22
@@ -5,7 +5,6 @@
|
||||
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;
|
||||
@@ -169,7 +168,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 mut scans = HashMap::new();
|
||||
let scan = design_system_options(&config, &project_cwd);
|
||||
let tiered = per_edit_tiering_active(&config, harness);
|
||||
|
||||
struct Pending {
|
||||
@@ -276,12 +275,9 @@ 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;
|
||||
@@ -289,7 +285,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);
|
||||
@@ -356,9 +352,8 @@ 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,
|
||||
@@ -370,7 +365,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();
|
||||
@@ -401,11 +396,10 @@ 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,
|
||||
@@ -416,11 +410,10 @@ 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,
|
||||
@@ -670,7 +663,7 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult {
|
||||
],
|
||||
);
|
||||
}
|
||||
let mut scans = HashMap::new();
|
||||
let scan = design_system_options(&config, &project_cwd);
|
||||
|
||||
let mut fresh_groups: Vec<Group> = Vec::new();
|
||||
let mut scanned = 0usize;
|
||||
@@ -711,20 +704,17 @@ 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);
|
||||
@@ -765,7 +755,6 @@ 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]"));
|
||||
@@ -812,7 +801,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, resolve_design_md_path, DesignSystem};
|
||||
use impeccable_detect::design_system::{load_design_system_for_cwd, DesignSystem};
|
||||
use impeccable_detect::detect_text::{detect_text, TextOptions};
|
||||
use impeccable_detect::engines::{HtmlEngine, ScanOptions};
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -1645,33 +1645,6 @@ 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,141 +124,6 @@ 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",
|
||||
|
||||
@@ -49,7 +49,7 @@ const RATIO_GUARD_FLOOR: u64 = 1024 * 1024;
|
||||
/// through `download_file`, which streams to disk. Reads are capped at
|
||||
/// [`MAX_DOWNLOAD_BYTES`]; a longer response is an error, not a truncation.
|
||||
pub fn download(url: &str) -> Result<Vec<u8>, String> {
|
||||
let agent = impeccable_context::http::agent_builder()
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(30))
|
||||
.build();
|
||||
match agent.get(url).call() {
|
||||
@@ -79,7 +79,7 @@ pub struct FetchResponse {
|
||||
}
|
||||
|
||||
fn ureq_fetch(url: &str) -> Result<FetchResponse, String> {
|
||||
let agent = impeccable_context::http::agent_builder()
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.redirects(0)
|
||||
|
||||
@@ -352,8 +352,6 @@ content, and failures fetching either asset exit nonzero, including when
|
||||
installed skill or hook files. Explicit `IMPECCABLE_BUNDLE_PATH` and `link`
|
||||
retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNING.md).
|
||||
|
||||
**TLS trust (#757):** every HTTPS request the engine makes (bundle and signature downloads, `/api/version`, `/api/commands`, the roll API, image generation) verifies against the OS trust store plus the bundled Mozilla roots, built once in `crates/context/src/http.rs`. `SSL_CERT_FILE` / `SSL_CERT_DIR` replace the OS store, never the bundled roots. The live server calls on localhost are plain HTTP and unaffected.
|
||||
|
||||
- **Invoked from**: README.md ("npx impeccable install / update"), README.npm.md Quick Start (`npx impeccable skills install`, `... install -y --providers=claude,codex --scope=project`, `... update`, `... install --no-hooks`, `... link --source=.impeccable --providers=claude,cursor`, `... skills help`), `README.md:360` (hook consent explanation).
|
||||
- `run(args)`: `args[0]` ∈ `undefined|help|--help|-h` → `showHelp()`; `install` → `install(rest)`; `link`; `update`; `check` (ignores flags); else `stderr> Unknown skills command: ${sub}` + `Run 'impeccable --help' for available commands.`, `exit 1`.
|
||||
- Constants: `API_BASE = 'https://impeccable.style'`; `PROVIDER_DIRS = ['.claude','.cursor','.dsh','.gemini','.agents','.agent','.github','.grok','.hermes','.kiro','.opencode','.pi','.qoder','.trae','.trae-cn','.rovodev','.vibe']`; aliases (`agent`→`.agent`, `agents`/`codex`→`.agents`, `antigravity`→`.agent`, `claude`/`claude-code`→`.claude`, `copilot`/`github`→`.github`, `cursor`, `deepseek`/`deepseek-harness`/`dsh`→`.dsh`, `gemini`, `grok`/`grok-build`/`xai`→`.grok`, `hermes`, `kiro`, `opencode`, `pi`, `qoder`, `rovo-dev`/`rovodev`→`.rovodev`, `trae`, `trae-cn`, `vibe`); leading `.` stripped and lowercased before alias lookup; a literal PROVIDER_DIR value is accepted as-is. `DEFAULT_TARGETS = ['.claude','.agents']`. User-scope skill dir overrides: `.agent`→`~/.gemini/config/skills`, `.dsh`→`$DSH_HOME/skills` (only when DSH_HOME under home) else `~/.dsh/skills`, `.hermes`→`$HERMES_HOME/skills` (only when HERMES_HOME under home) else `~/.hermes/skills`, `.pi`→`~/.pi/agent/skills`, `.opencode`→`$OPENCODE_CONFIG_DIR|$XDG_CONFIG_HOME/opencode|~/.config/opencode` + `/skills`; others `~/<provider>/skills`. Project scope: `<root>/<provider>/skills`.
|
||||
@@ -1026,8 +1024,6 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user