Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
72 changes: 71 additions & 1 deletion src/commands/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ use crate::strategies::{smart_filter, truncation};
pub struct CloudHandler;

impl Handler for CloudHandler {
fn compress(&self, _cmd: &str, lines: Vec<String>, _config: &Config) -> Vec<String> {
fn compress(&self, cmd: &str, lines: Vec<String>, _config: &Config) -> Vec<String> {
// 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<String> = lines
.into_iter()
Expand All @@ -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<Vec<String>> {
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<String> = 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)
}
27 changes: 26 additions & 1 deletion src/commands/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ use crate::strategies::{smart_filter, truncation};
pub struct DatabaseHandler;

impl Handler for DatabaseHandler {
fn compress(&self, _cmd: &str, lines: Vec<String>, config: &Config) -> Vec<String> {
fn compress(&self, cmd: &str, lines: Vec<String>, config: &Config) -> Vec<String> {
// 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<String> = lines
.into_iter()
Expand All @@ -18,3 +22,24 @@ impl Handler for DatabaseHandler {
)
}
}

fn prisma_generate_compress(lines: Vec<String>) -> Vec<String> {
let lines = smart_filter::apply(lines);
// Keep lines that contain the generation result or an error.
let result: Vec<String> = 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
}
}
14 changes: 14 additions & 0 deletions src/strategies/smart_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub fn apply(lines: Vec<String>) -> Vec<String> {
.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()
Expand Down Expand Up @@ -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("(/."))
}
Expand Down
38 changes: 38 additions & 0 deletions tests/test_cloud.rs
Original file line number Diff line number Diff line change
@@ -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![
Expand Down
39 changes: 39 additions & 0 deletions tests/test_database.rs
Original file line number Diff line number Diff line change
@@ -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![
Expand Down
29 changes: 29 additions & 0 deletions tests/test_smart_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
}
Loading