diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 980c355..ee51983 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -8,6 +8,30 @@ fi [ ! -x "$SQUEEZ" ] && exit 0 "$SQUEEZ" init +# Hook health check — warn if squeez hooks were removed from settings.json +# (e.g. by another tool like OMC that overwrites the file on setup). +_settings="$HOME/.claude/settings.json" +if [ -f "$_settings" ]; then + _has_squeez=$(python3 -c " +import json, sys +try: + d = json.load(open(sys.argv[1])) + hooks = d.get('hooks', {}) if isinstance(d, dict) else {} + for entries in hooks.values(): + for e in (entries if isinstance(entries, list) else []): + for h in (e.get('hooks', []) if isinstance(e, dict) else []): + if 'squeez' in str(h.get('command', '')): + print('ok'); sys.exit(0) + print('missing') +except Exception: + print('ok') +" "$_settings" 2>/dev/null || echo "ok") + if [ "$_has_squeez" = "missing" ]; then + printf '\n[squeez] WARNING: hooks not registered in %s\n' "$_settings" + printf '[squeez] Another tool may have overwritten your settings. Run: squeez setup\n\n' + fi +fi + # Rate-limited update check (at most once per day). # Outputs a notification when a new squeez version is available. _uc_ts="$HOME/.claude/squeez/.update-check-ts" diff --git a/src/commands/cloud.rs b/src/commands/cloud.rs index 2598ad6..2bfa91e 100644 --- a/src/commands/cloud.rs +++ b/src/commands/cloud.rs @@ -5,7 +5,13 @@ use crate::strategies::{smart_filter, truncation}; pub struct CloudHandler; impl Handler for CloudHandler { - fn compress(&self, _cmd: &str, lines: Vec, _config: &Config) -> Vec { + fn compress(&self, cmd: &str, lines: Vec, _config: &Config) -> Vec { + // az boards/repos work items return dense JSON — extract only key fields. + if is_az_workitem_cmd(cmd) { + if let Some(extracted) = extract_az_json(&lines) { + return extracted; + } + } let lines = smart_filter::apply(lines); let filtered: Vec = lines .into_iter() @@ -14,3 +20,67 @@ impl Handler for CloudHandler { truncation::apply(filtered, 100, truncation::Keep::Head) } } + +fn is_az_workitem_cmd(cmd: &str) -> bool { + let c = cmd.trim(); + (c.contains("az boards") || c.contains("az repos")) && !c.contains("az repos pr list") +} + +/// Parse lines as JSON and return a compact summary of key fields. +/// Keeps: id, rev, System.* fields, url (shortened). Drops: _links, relations, extensions. +fn extract_az_json(lines: &[String]) -> Option> { + let raw = lines.join("\n"); + // Quick pre-check — skip non-JSON output (table/tsv format). + let trimmed = raw.trim_start(); + if !trimmed.starts_with('{') && !trimmed.starts_with('[') { + return None; + } + + // Use a simple line-based extractor rather than a JSON parser (zero-dep constraint). + let mut out: Vec = Vec::new(); + let mut in_links = false; + let mut in_relations = false; + let mut brace_depth: i32 = 0; + + for line in lines { + let t = line.trim(); + + // Track nesting to suppress _links / relations blocks. + let opens = t.chars().filter(|&c| c == '{' || c == '[').count() as i32; + let closes = t.chars().filter(|&c| c == '}' || c == ']').count() as i32; + + if t.contains("\"_links\"") || t.contains("\"relations\"") || t.contains("\"extensions\"") { + in_links = true; + brace_depth = 0; + } + + if in_links || in_relations { + brace_depth += opens - closes; + if brace_depth <= 0 { + in_links = false; + in_relations = false; + } + continue; + } + + // Keep System.* fields, top-level id/rev/url, and structural braces. + let keep = t.contains("\"System.") + || t.contains("\"id\"") + || t.contains("\"rev\"") + || t.contains("\"url\"") + || t.contains("\"state\"") + || t.contains("\"title\"") + || t == "{" || t == "}" || t == "}," || t == "\"fields\": {"; + + if keep { + out.push(line.clone()); + } + } + + if out.is_empty() { + return None; + } + + out.insert(0, "[squeez: az — kept System.* fields; dropped links, relations, extensions]".to_string()); + Some(out) +} diff --git a/src/commands/database.rs b/src/commands/database.rs index 881ad7a..48536e3 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -5,7 +5,11 @@ use crate::strategies::{smart_filter, truncation}; pub struct DatabaseHandler; impl Handler for DatabaseHandler { - fn compress(&self, _cmd: &str, lines: Vec, config: &Config) -> Vec { + fn compress(&self, cmd: &str, lines: Vec, config: &Config) -> Vec { + // prisma generate emits ~5 boilerplate lines; only the ✔/error line matters. + if cmd.contains("prisma") && cmd.contains("generate") { + return prisma_generate_compress(lines); + } let lines = smart_filter::apply(lines); let filtered: Vec = lines .into_iter() @@ -18,3 +22,24 @@ impl Handler for DatabaseHandler { ) } } + +fn prisma_generate_compress(lines: Vec) -> Vec { + let lines = smart_filter::apply(lines); + // Keep lines that contain the generation result or an error. + let result: Vec = lines + .into_iter() + .filter(|l| { + let t = l.to_lowercase(); + t.contains("generated prisma client") + || t.contains("error") + || t.contains("warning") + || t.contains("✔") + || t.contains("✓") + }) + .collect(); + if result.is_empty() { + vec!["[squeez: prisma generate — no output captured]".to_string()] + } else { + result + } +} diff --git a/src/strategies/smart_filter.rs b/src/strategies/smart_filter.rs index 258685d..2c7ba69 100644 --- a/src/strategies/smart_filter.rs +++ b/src/strategies/smart_filter.rs @@ -10,6 +10,7 @@ pub fn apply(lines: Vec) -> Vec { .filter(|l| !is_progress_bar(l)) .filter(|l| !is_git_hint(l)) .filter(|l| !is_npm_noise(l)) + .filter(|l| !is_vite_plugin_noise(l)) .filter(|l| !is_node_modules_frame(l)) .map(strip_log_timestamp) .collect() @@ -58,6 +59,19 @@ fn is_npm_noise(s: &str) -> bool { || t.starts_with("npm warn EBADENGINE") } +fn is_vite_plugin_noise(s: &str) -> bool { + let t = s.trim_start(); + // vite-tsconfig-paths deprecation warning block — repeats once per vitest run, + // producing ~3KB of identical noise across 6 runs in the analyzed session. + t.starts_with("[tsconfig-paths]") + || t.starts_with("The plugin \"vite-tsconfig-paths\"") + || t.starts_with("The plugin \"@vitejs/plugin-react\"") + || t.starts_with("Vite now supports tsconfig paths resolution natively") + || t.starts_with("You can remove the plugin and set resolve.tsconfigPaths") + || t.starts_with("For new projects, use create-next-app to choose") + || t.starts_with("`next lint` is deprecated") +} + fn is_node_modules_frame(s: &str) -> bool { s.contains("node_modules/") && (s.trim_start().starts_with("at ") || s.contains("(/.")) } diff --git a/tests/test_cloud.rs b/tests/test_cloud.rs index 5faaffb..51a0da6 100644 --- a/tests/test_cloud.rs +++ b/tests/test_cloud.rs @@ -1,6 +1,44 @@ use squeez::commands::{cloud::CloudHandler, Handler}; use squeez::config::Config; +#[test] +fn az_workitem_extracts_system_fields_and_drops_links() { + let lines = vec![ + "{".to_string(), + " \"id\": 33479,".to_string(), + " \"rev\": 5,".to_string(), + " \"fields\": {".to_string(), + " \"System.Title\": \"[GOOGLE TRENDS] Webhook integration\",".to_string(), + " \"System.State\": \"Active\",".to_string(), + " \"System.Tags\": \"SALA DIGITAL\",".to_string(), + " \"Custom.SomeField\": \"ignored\",".to_string(), + " \"Microsoft.VSTS.Common.Priority\": 2".to_string(), + " },".to_string(), + " \"_links\": { \"self\": { \"href\": \"https://dev.azure.com/...\" } },".to_string(), + " \"relations\": [ { \"rel\": \"System.LinkTypes.Hierarchy-Reverse\" } ],".to_string(), + " \"url\": \"https://dev.azure.com/vibrateam/...\"".to_string(), + "}".to_string(), + ]; + let result = CloudHandler.compress("az boards work-item show --id 33479", lines, &Config::default()); + assert!(result.iter().any(|l| l.contains("System.Title"))); + assert!(result.iter().any(|l| l.contains("System.State"))); + assert!(result.iter().any(|l| l.contains("\"id\""))); + assert!(!result.iter().any(|l| l.contains("_links"))); + assert!(!result.iter().any(|l| l.contains("Hierarchy-Reverse"))); + assert!(!result.iter().any(|l| l.contains("Custom.SomeField"))); + assert!(result[0].contains("[squeez: az")); +} + +#[test] +fn az_non_json_output_falls_through_to_generic() { + let lines = vec![ + "ID Title State".to_string(), + "33479 [GOOGLE TRENDS]... Active".to_string(), + ]; + let result = CloudHandler.compress("az boards work-item list", lines, &Config::default()); + assert!(result.iter().any(|l| l.contains("Active"))); +} + #[test] fn kubectl_strips_separator_lines() { let lines = vec![ diff --git a/tests/test_database.rs b/tests/test_database.rs index 702a4ba..57f6e20 100644 --- a/tests/test_database.rs +++ b/tests/test_database.rs @@ -1,6 +1,45 @@ use squeez::commands::{database::DatabaseHandler, Handler}; use squeez::config::Config; +#[test] +fn prisma_generate_keeps_only_result_line() { + let lines = vec![ + "Prisma schema loaded from prisma/schema.prisma".to_string(), + "Environment variables loaded from .env".to_string(), + "Prisma schema loaded from prisma/schema.prisma".to_string(), + "✔ Generated Prisma Client (v5.14.0) to ./node_modules/@prisma/client in 234ms".to_string(), + "".to_string(), + "Run Prisma Migrate to update your database schema: https://pris.ly/d/migrate".to_string(), + ]; + let result = DatabaseHandler.compress("npx prisma generate", lines, &Config::default()); + assert_eq!(result.len(), 1); + assert!(result[0].contains("Generated Prisma Client")); +} + +#[test] +fn prisma_generate_passes_error_lines_through() { + let lines = vec![ + "Prisma schema loaded from prisma/schema.prisma".to_string(), + "error: Schema parsing error: Unknown field type `Foobar`".to_string(), + ]; + let result = DatabaseHandler.compress("prisma generate", lines, &Config::default()); + assert!(result.iter().any(|l| l.contains("error"))); +} + +#[test] +fn prisma_migrate_unaffected() { + let lines = vec![ + "+----+----------+".to_string(), + "| id | name |".to_string(), + "+----+----------+".to_string(), + "| 1 | Alice |".to_string(), + "+----+----------+".to_string(), + ]; + let result = DatabaseHandler.compress("prisma migrate status", lines, &Config::default()); + assert!(result.iter().any(|l| l.contains("Alice"))); + assert!(!result.iter().any(|l| l.starts_with('+'))); +} + #[test] fn strips_sql_border_lines() { let lines = vec![ diff --git a/tests/test_smart_filter.rs b/tests/test_smart_filter.rs index 96d34ac..d7439f8 100644 --- a/tests/test_smart_filter.rs +++ b/tests/test_smart_filter.rs @@ -70,3 +70,32 @@ fn passthrough_normal_lines() { let input = vec!["modified: src/auth.ts".to_string()]; assert_eq!(apply(input), vec!["modified: src/auth.ts"]); } + +#[test] +fn removes_vite_tsconfig_paths_warning_block() { + // This block repeated 6× in a single session (~27KB total noise). + let input = vec![ + "The plugin \"vite-tsconfig-paths\" is detected. Vite now supports tsconfig paths resolution natively via the resolve.tsconfigPaths option.".to_string(), + "You can remove the plugin and set resolve.tsconfigPaths: true in your Vite config instead.".to_string(), + "[tsconfig-paths] An error occurred while parsing \"/project/.codesandbox/templates/vue/tsconfig.json\".".to_string(), + "[tsconfig-paths] An error occurred while parsing \"/project/public/libs/tsconfig.json\".".to_string(), + " RUN v4.1.0 /project".to_string(), + ]; + let result = apply(input); + assert!(!result.iter().any(|l| l.contains("vite-tsconfig-paths"))); + assert!(!result.iter().any(|l| l.contains("[tsconfig-paths]"))); + assert!(!result.iter().any(|l| l.contains("resolve.tsconfigPaths"))); + assert!(result.iter().any(|l| l.contains("RUN v4.1.0"))); +} + +#[test] +fn removes_next_lint_deprecation() { + let input = vec![ + "`next lint` is deprecated and will be removed in Next.js 16.".to_string(), + "For new projects, use create-next-app to choose your preferred linter.".to_string(), + "✔ No ESLint warnings or errors".to_string(), + ]; + let result = apply(input); + assert!(!result.iter().any(|l| l.contains("deprecated"))); + assert!(result.iter().any(|l| l.contains("No ESLint"))); +}