From 93bfe70c58008d70489280b764ee04fa0802f0c1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 7 Sep 2026 11:40:05 -0700 Subject: [PATCH] Fix truncated surface-brief slug collisions (#774) * Fix truncated slug collisions Prepared with AI assistance under maintainer-authorized automation. * Preserve legacy long-slug reads Prepared with AI assistance under maintainer-authorized automation. * Harden legacy slug compatibility Require target metadata before reading collision-prone legacy brief and critique paths. Add regressions for two long targets with the same pre-hash suffix.\n\nPrepared with AI assistance. * Keep explicit access to legacy critiques Allow identity-less pre-hash snapshots to be read by their exact legacy slug while keeping path and URL fallback identity-gated. Document the compatibility boundary and extend collision coverage.\n\nPrepared with AI assistance. --- crates/context/src/critique_storage.rs | 184 ++++++++++++++++++-- crates/context/src/surface_briefs.rs | 96 +++++++++- crates/context/src/target_slug.rs | 82 ++++++++- docs/CLI-CONTRACT.md | 6 +- tests/oracle/golden/critique-slug-long.json | 2 +- 5 files changed, 335 insertions(+), 35 deletions(-) diff --git a/crates/context/src/critique_storage.rs b/crates/context/src/critique_storage.rs index 7cc88fade..fc514c9e5 100644 --- a/crates/context/src/critique_storage.rs +++ b/crates/context/src/critique_storage.rs @@ -3,7 +3,7 @@ use crate::context::resolve_project_root; use crate::jsp; use crate::target_args::TargetOptions; -use crate::target_slug::slug_from_target; +use crate::target_slug::{legacy_slug_from_target, slug_from_target}; use crate::util::{exists, iso_now, js_trim, json_pretty, node_read_error, read_dir_names, safe_read, Env}; use impeccable_common::Io; use serde_json::{Map, Value}; @@ -393,6 +393,51 @@ fn coerce_slug(value: Option<&str>, cwd: &str) -> Option { slug_from_target(Some(v), cwd) } +fn slug_candidates(value: Option<&str>, cwd: &str) -> Vec { + let Some(v) = value.filter(|v| !v.is_empty()) else { + return vec![]; + }; + if is_ready_slug(v) { + return vec![v.to_string()]; + } + let mut slugs = Vec::new(); + if let Some(slug) = slug_from_target(Some(v), cwd) { + slugs.push(slug); + } + if let Some(legacy) = legacy_slug_from_target(Some(v), cwd) { + if !slugs.contains(&legacy) { + slugs.push(legacy); + } + } + slugs +} + +fn read_newest_safe_snapshot_for_slugs( + slugs: &[String], + target_identity: Option<&str>, + cwd: &str, + env: &Env, +) -> Option { + let current = slugs.first().and_then(|slug| read_newest_snapshot(slug, cwd, env)); + let legacy = target_identity.and_then(|identity| { + slugs.get(1).and_then(|slug| read_newest_snapshot_for_identity(slug, Some(identity), cwd, env)) + }); + current.into_iter().chain(legacy).max_by(|a, b| a.path.cmp(&b.path)) +} + +fn read_newest_snapshot_for_identity_slugs( + slugs: &[String], + target_identity: Option<&str>, + cwd: &str, + env: &Env, +) -> Option { + let current = slugs.first().and_then(|slug| read_newest_snapshot_for_identity(slug, target_identity, cwd, env)); + let legacy = target_identity.and_then(|identity| { + slugs.get(1).and_then(|slug| read_newest_snapshot_for_identity(slug, Some(identity), cwd, env)) + }); + current.into_iter().chain(legacy).max_by(|a, b| a.path.cmp(&b.path)) +} + pub fn run(args: &[String], io: &mut Io) -> i32 { let cwd = io.cwd.to_string_lossy().into_owned(); let env = io.env.clone(); @@ -493,26 +538,30 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { "latest" => { let target = rest.first().map(String::as_str).unwrap_or(""); let format = rest.get(1).map(String::as_str); - let slug_opt = coerce_slug(rest.first().map(String::as_str), &cwd); + let slugs = slug_candidates(rest.first().map(String::as_str), &cwd); // JS: format && format !== '--json' (format truthy = non-empty) let bad_format = format.map(|f| !f.is_empty() && f != "--json").unwrap_or(false); - let Some(slug) = slug_opt.filter(|_| !bad_format) else { + if slugs.is_empty() || bad_format { io.err("usage: latest [--json]\n"); return 1; - }; + } let format_is_json = format == Some("--json"); let target_fingerprint = fingerprint_target(target, &cwd); let target_path = resolve_local_target_path(target, &cwd); let target_identity = resolve_target_identity(target, &cwd); let ready_slug = is_ready_slug(target); - let Some(newest_for_slug) = read_newest_snapshot(&slug, &cwd, &env) else { + let Some(newest_for_slug) = + read_newest_safe_snapshot_for_slugs(&slugs, target_identity.as_deref(), &cwd, &env) + else { return 2; }; - let mut latest = read_newest_snapshot_for_identity(&slug, target_identity.as_deref(), &cwd, &env); + let mut latest = + read_newest_snapshot_for_identity_slugs(&slugs, target_identity.as_deref(), &cwd, &env); if latest.is_none() && !ready_slug { - // Legacy snapshots have no identity; preserve their old explicit - // path/URL behavior only when no known identity was selected. - latest = read_newest_snapshot_for_identity(&slug, None, &cwd, &env); + // Identity-less snapshots are safe only under the new/current + // collision-resistant slug. A pre-hash slug suffix alone cannot + // prove which long target owned the historical snapshot. + latest = slugs.first().and_then(|slug| read_newest_snapshot_for_identity(slug, None, &cwd, &env)); } let latest = latest.unwrap_or(newest_for_slug); if meta_closed(&latest) { @@ -520,10 +569,6 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } let recorded_target_identity = snapshot_target_identity(&latest); let matching_identity = recorded_target_identity == target_identity; - if ready_slug && recorded_target_identity.is_none() { - io.err("ambiguous legacy snapshot target; use an explicit ./path or full URL\n"); - return 2; - } if ready_slug && target_path.as_deref().map(exists).unwrap_or(false) && !matching_identity { io.err("ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n"); return 2; @@ -564,17 +609,16 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { "close" => { let slug_arg = rest.first().map(String::as_str).unwrap_or(""); let snapshot_file = rest.get(1).map(String::as_str); - let slug = coerce_slug(rest.first().map(String::as_str), &cwd); + let slugs = slug_candidates(rest.first().map(String::as_str), &cwd); let snapshot_file_ok = snapshot_file.map(|s| !s.is_empty()).unwrap_or(false); - if slug.is_none() || !snapshot_file_ok || rest.len() > 2 { + if slugs.is_empty() || !snapshot_file_ok || rest.len() > 2 { io.err("usage: close \n"); return 1; } - let slug = slug.unwrap(); let snapshot_file = snapshot_file.unwrap(); if jsp::basename(snapshot_file) != snapshot_file || !is_snapshot_name(snapshot_file) - || !snapshot_file.ends_with(&format!("__{}.md", slug)) + || !slugs.iter().any(|slug| snapshot_file.ends_with(&format!("__{}.md", slug))) { return 2; } @@ -592,6 +636,10 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { // JS #660: a slug + filename does not prove ownership; require the // resolved target to match a modern snapshot's recorded identity. let recorded_target_identity = snapshot_target_identity(&snapshot); + let legacy_match = slugs.get(1).is_some_and(|slug| snapshot_file.ends_with(&format!("__{}.md", slug))); + if legacy_match && recorded_target_identity.is_none() { + return 2; + } if let Some(rid) = &recorded_target_identity { if Some(rid.clone()) != resolve_target_identity(slug_arg, &cwd) { return 2; @@ -610,12 +658,34 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } } "trend" => { - let slug = coerce_slug(rest.first().map(String::as_str), &cwd).unwrap_or_else(|| "null".to_string()); + let mut slugs = slug_candidates(rest.first().map(String::as_str), &cwd); + if slugs.is_empty() { + slugs.push("null".to_string()); + } let limit: f64 = match rest.get(1).filter(|s| !s.is_empty()) { Some(l) => js_number(l), None => 5.0, }; - let all = list_snapshots(&format!("__{}.md", slug), &cwd, &env); + let target = rest.first().map(String::as_str).unwrap_or(""); + let target_identity = resolve_target_identity(target, &cwd); + let mut all: Vec = slugs + .first() + .into_iter() + .flat_map(|slug| list_snapshots(&format!("__{}.md", slug), &cwd, &env)) + .collect(); + if let Some(legacy) = slugs.get(1) { + all.extend( + list_snapshots(&format!("__{}.md", legacy), &cwd, &env) + .into_iter() + .filter(|path| { + read_snapshot_at(path) + .and_then(|snapshot| snapshot_target_identity(&snapshot)) + .as_deref() + == target_identity.as_deref() + }), + ); + } + all.sort(); let slice = js_slice_last(&all, limit); let rows: Vec = slice .iter() @@ -807,4 +877,80 @@ mod tests_660 { assert!(out.contains("\"body\":")); let _ = std::fs::remove_dir_all(&cwd); } + + #[test] + fn explicit_long_targets_find_and_close_pre_hash_snapshots() { + let cwd = tmp(); + let dir = jsp::join(&[&cwd, ".impeccable", "critique"]); + std::fs::create_dir_all(&dir).unwrap(); + let target = "https://example.com/a-very-long-directory-structure-with-many-segments/component-name"; + let legacy_slug = legacy_slug_from_target(Some(target), &cwd).unwrap(); + assert_ne!(legacy_slug, slug_from_target(Some(target), &cwd).unwrap()); + let name = format!("2026-05-12T18-30-00Z__{legacy_slug}.md"); + let identity = resolve_target_identity(target, &cwd).unwrap(); + let body = format!( + "---\ntarget_identity: {}\nslug: {}\n---\n# Legacy critique\n", + serde_json::to_string(&identity).unwrap(), + legacy_slug + ); + std::fs::write(jsp::join(&[&dir, &name]), body).unwrap(); + + let (code, out, _) = run_capture(&cwd, &["latest", target, "--json"]); + assert_eq!(code, 0); + assert!(out.contains(&name)); + + let (code, out, _) = run_capture(&cwd, &["trend", target, "5"]); + assert_eq!(code, 0); + assert!(out.contains(&legacy_slug)); + + let (code, _, _) = run_capture(&cwd, &["close", target, &name]); + assert_eq!(code, 0); + assert!(std::fs::read_to_string(jsp::join(&[&dir, &name])).unwrap().contains("closed: true")); + + let _ = std::fs::remove_dir_all(&cwd); + } + + #[test] + fn pre_hash_slug_collisions_require_matching_identity() { + let cwd = tmp(); + let dir = jsp::join(&[&cwd, ".impeccable", "critique"]); + std::fs::create_dir_all(&dir).unwrap(); + let target = "https://example.com/first-prefix-that-is-long-enough/a-very-long-shared-tail/component-name"; + let collision = "https://example.com/second-prefix-that-is-long-enough/a-very-long-shared-tail/component-name"; + let legacy_slug = legacy_slug_from_target(Some(target), &cwd).unwrap(); + assert_eq!(legacy_slug, legacy_slug_from_target(Some(collision), &cwd).unwrap()); + assert_ne!(slug_from_target(Some(target), &cwd), slug_from_target(Some(collision), &cwd)); + + let unidentified_name = format!("2026-05-12T18-30-00Z__{legacy_slug}.md"); + let unidentified_body = format!("---\nslug: {legacy_slug}\n---\n# Ambiguous legacy critique\n"); + std::fs::write(jsp::join(&[&dir, &unidentified_name]), unidentified_body).unwrap(); + + assert_eq!(run_capture(&cwd, &["latest", target]).0, 2); + assert_eq!(run_capture(&cwd, &["latest", collision]).0, 2); + assert_eq!(run_capture(&cwd, &["trend", target, "5"]).1.trim(), "[]"); + assert_eq!(run_capture(&cwd, &["close", target, &unidentified_name]).0, 2); + let (code, out, _) = run_capture(&cwd, &["latest", &legacy_slug]); + assert_eq!(code, 0); + assert!(out.contains("# Ambiguous legacy critique")); + assert!(run_capture(&cwd, &["trend", &legacy_slug, "5"]).1.contains(&legacy_slug)); + assert_eq!(run_capture(&cwd, &["close", &legacy_slug, &unidentified_name]).0, 0); + + let identified_name = format!("2026-05-12T18-31-00Z__{legacy_slug}.md"); + let identity = resolve_target_identity(target, &cwd).unwrap(); + let identified_body = format!( + "---\ntarget_identity: {}\nslug: {}\n---\n# Identified legacy critique\n", + serde_json::to_string(&identity).unwrap(), + legacy_slug + ); + std::fs::write(jsp::join(&[&dir, &identified_name]), identified_body).unwrap(); + + assert_eq!(run_capture(&cwd, &["latest", target]).0, 0); + assert_eq!(run_capture(&cwd, &["latest", collision]).0, 2); + assert!(run_capture(&cwd, &["trend", target, "5"]).1.contains("target_identity")); + assert_eq!(run_capture(&cwd, &["trend", collision, "5"]).1.trim(), "[]"); + assert_eq!(run_capture(&cwd, &["close", collision, &identified_name]).0, 2); + assert_eq!(run_capture(&cwd, &["close", target, &identified_name]).0, 0); + + let _ = std::fs::remove_dir_all(&cwd); + } } diff --git a/crates/context/src/surface_briefs.rs b/crates/context/src/surface_briefs.rs index 5b037f0ec..be2be1cd8 100644 --- a/crates/context/src/surface_briefs.rs +++ b/crates/context/src/surface_briefs.rs @@ -1,7 +1,7 @@ //! JS: lib/surface-briefs.mjs use crate::jsp; -use crate::target_slug::slug_from_target; +use crate::target_slug::{legacy_slug_from_target, slug_from_target}; use crate::url; use crate::util::{exists, js_trim, read_dir_names, safe_read}; use serde_json::{Map, Value}; @@ -87,6 +87,16 @@ pub fn surface_brief_path_for_target(target: Option<&str>, project_root: &str) - Some(jsp::join(&[&get_surface_brief_dir(project_root), &format!("{}.md", slug)])) } +fn legacy_surface_brief_path_for_target(target: Option<&str>, project_root: &str) -> Option { + let normalized = normalize_surface_target(target, project_root)?; + let slug_input = match normalized.strip_prefix("route:") { + Some(rest) => format!("route{}", rest), + None => normalized.clone(), + }; + let slug = legacy_slug_from_target(Some(&slug_input), project_root)?; + Some(jsp::join(&[&get_surface_brief_dir(project_root), &format!("{}.md", slug)])) +} + #[derive(Debug, Clone)] pub struct SurfaceBrief { pub path: Option, @@ -260,10 +270,17 @@ pub fn resolve_surface_brief(project_root: &str, target: Option<&str>) -> Surfac return SurfaceResolution { brief: None, candidates: briefs, reason: "invalid-target" }; }; let exact_path = surface_brief_path_for_target(Some(&normalized), project_root); - if let Some(exact) = briefs + let legacy_path = legacy_surface_brief_path_for_target(Some(&normalized), project_root); + let exact = briefs .iter() .find(|b| b.path == exact_path && (b.targets.is_empty() || b.targets.contains(&normalized))) - { + .or_else(|| { + // Pre-hash long slugs can collide because they contain only the + // target suffix. Require the legacy brief's metadata to prove it + // belongs to this target before accepting that compatibility path. + briefs.iter().find(|b| b.path == legacy_path && b.targets.contains(&normalized)) + }); + if let Some(exact) = exact { return SurfaceResolution { brief: Some(exact.clone()), candidates: briefs, reason: "slug" }; } let mapped: Vec = briefs.iter().filter(|b| b.targets.contains(&normalized)).cloned().collect(); @@ -309,3 +326,76 @@ pub fn write_surface_brief( std::fs::write(&file_path, content).map_err(|e| e.to_string())?; Ok(file_path) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TMP_SEQ: AtomicUsize = AtomicUsize::new(0); + + #[test] + fn resolves_pre_hash_long_slug_briefs() { + let root = std::env::temp_dir().join(format!( + "impeccable-surface-legacy-{}-{}", + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let root = root.to_string_lossy().into_owned(); + let target = "src/a-very-long-directory-structure-with-many-segments/component-name.tsx"; + let legacy = legacy_surface_brief_path_for_target(Some(target), &root).unwrap(); + let current = surface_brief_path_for_target(Some(target), &root).unwrap(); + assert_ne!(legacy, current); + + std::fs::create_dir_all(get_surface_brief_dir(&root)).unwrap(); + let normalized = normalize_surface_target(Some(target), &root).unwrap(); + let legacy_body = format!( + "---\nprimary_target: {}\n---\n# Legacy brief\n", + serde_json::to_string(&normalized).unwrap() + ); + std::fs::write(&legacy, legacy_body).unwrap(); + + let resolved = resolve_surface_brief(&root, Some(target)); + assert_eq!(resolved.reason, "slug"); + assert_eq!(resolved.brief.unwrap().path.as_deref(), Some(legacy.as_str())); + + std::fs::write(¤t, "# Current brief\n").unwrap(); + let resolved = resolve_surface_brief(&root, Some(target)); + assert_eq!(resolved.brief.unwrap().path.as_deref(), Some(current.as_str())); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rejects_unmapped_pre_hash_slug_collisions() { + let root = std::env::temp_dir().join(format!( + "impeccable-surface-legacy-collision-{}-{}", + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let root = root.to_string_lossy().into_owned(); + let target = "src/first-prefix-that-is-long-enough/a-very-long-shared-tail/component-name.tsx"; + let collision = "src/second-prefix-that-is-long-enough/a-very-long-shared-tail/component-name.tsx"; + let legacy = legacy_surface_brief_path_for_target(Some(target), &root).unwrap(); + assert_eq!(legacy, legacy_surface_brief_path_for_target(Some(collision), &root).unwrap()); + assert_ne!( + surface_brief_path_for_target(Some(target), &root), + surface_brief_path_for_target(Some(collision), &root) + ); + + std::fs::create_dir_all(get_surface_brief_dir(&root)).unwrap(); + std::fs::write(&legacy, "# Unmapped legacy brief\n").unwrap(); + assert_eq!(resolve_surface_brief(&root, Some(target)).reason, "not-found"); + + let normalized = normalize_surface_target(Some(target), &root).unwrap(); + let legacy_body = format!( + "---\nprimary_target: {}\n---\n# Mapped legacy brief\n", + serde_json::to_string(&normalized).unwrap() + ); + std::fs::write(&legacy, legacy_body).unwrap(); + assert_eq!(resolve_surface_brief(&root, Some(target)).reason, "slug"); + assert_eq!(resolve_surface_brief(&root, Some(collision)).reason, "not-found"); + + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/context/src/target_slug.rs b/crates/context/src/target_slug.rs index 77e056d64..0f5f9df55 100644 --- a/crates/context/src/target_slug.rs +++ b/crates/context/src/target_slug.rs @@ -2,11 +2,27 @@ use crate::jsp; use crate::util::js_trim; +use sha2::{Digest, Sha256}; const SLUG_MAX: usize = 50; +const SLUG_HASH_LEN: usize = 8; /// JS: slugFromTarget(resolved, { cwd }) pub fn slug_from_target(resolved: Option<&str>, cwd: &str) -> Option { + slug_from_target_using(resolved, cwd, kebab) +} + +/// Compatibility key used by releases that truncated normalized targets to +/// their last 50 characters without a hash suffix. +pub(crate) fn legacy_slug_from_target(resolved: Option<&str>, cwd: &str) -> Option { + slug_from_target_using(resolved, cwd, legacy_kebab) +} + +fn slug_from_target_using( + resolved: Option<&str>, + cwd: &str, + slugger: fn(&str) -> Option, +) -> Option { let resolved = resolved?; let trimmed = js_trim(resolved); if trimmed.is_empty() { @@ -15,7 +31,7 @@ pub fn slug_from_target(resolved: Option<&str>, cwd: &str) -> Option { let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") { let (host, pathname) = parse_url_host_path(trimmed)?; - return kebab(&format!("{}{}", host, pathname)); + return slugger(&format!("{}{}", host, pathname)); } let abs = if jsp::is_absolute(trimmed) { trimmed.to_string() } else { jsp::resolve(cwd, &[trimmed]) }; let mut rel = jsp::relative(cwd, cwd, &abs); @@ -25,7 +41,7 @@ pub fn slug_from_target(resolved: Option<&str>, cwd: &str) -> Option { if rel.is_empty() || rel == "." { return None; } - kebab(&rel) + slugger(&rel) } /// Minimal WHATWG URL parse for http(s): returns (hostname lowercased, pathname). @@ -37,6 +53,30 @@ pub fn parse_url_host_path(s: &str) -> Option<(String, String)> { /// JS: kebab(value) pub fn kebab(value: &str) -> Option { + let u = normalized_kebab(value)?; + if u.len() <= SLUG_MAX { + Some(u) + } else { + let digest = Sha256::digest(u.as_bytes()); + let hash = format!("{digest:x}"); + let tail_len = SLUG_MAX - SLUG_HASH_LEN - 1; + let tail = &u[u.len() - tail_len..]; + let tail = tail.strip_prefix('-').unwrap_or(tail); + Some(format!("{tail}-{}", &hash[..SLUG_HASH_LEN])) + } +} + +fn legacy_kebab(value: &str) -> Option { + let u = normalized_kebab(value)?; + if u.len() <= SLUG_MAX { + Some(u) + } else { + let tail = &u[u.len() - SLUG_MAX..]; + Some(tail.strip_prefix('-').unwrap_or(tail).to_string()) + } +} + +fn normalized_kebab(value: &str) -> Option { let lower = value.to_lowercase(); // replace runs of / \ . with '-' let mut s = String::with_capacity(lower.len()); @@ -81,13 +121,37 @@ pub fn kebab(value: &str) -> Option { // strip leading/trailing '-' (JS: /^-|-$/g -> one at each end; after collapse there is at most one) let u = u.strip_prefix('-').unwrap_or(&u).to_string(); let u = u.strip_suffix('-').unwrap_or(&u).to_string(); - if u.is_empty() { - return None; + (!u.is_empty()).then_some(u) +} + +#[cfg(test)] +mod tests { + use super::{kebab, legacy_kebab, SLUG_MAX}; + + #[test] + fn truncated_slugs_keep_distinct_full_inputs_distinct() { + let suffix = "a".repeat(SLUG_MAX); + let alpha = kebab(&format!("alpha-prefix-{suffix}")).unwrap(); + let beta = kebab(&format!("beta-prefix-{suffix}")).unwrap(); + + assert_ne!(alpha, beta); + assert!(alpha.len() <= SLUG_MAX); + assert!(beta.len() <= SLUG_MAX); + assert_eq!(alpha, kebab(&format!("alpha-prefix-{suffix}")).unwrap()); } - if u.len() <= SLUG_MAX { - Some(u) - } else { - let tail = &u[u.len() - SLUG_MAX..]; - Some(tail.strip_prefix('-').unwrap_or(tail).to_string()) + + #[test] + fn non_truncated_slugs_are_unchanged() { + assert_eq!(kebab("Button.Primary"), Some("button-primary".to_string())); + assert_eq!(kebab(&"a".repeat(SLUG_MAX)), Some("a".repeat(SLUG_MAX))); + } + + #[test] + fn legacy_kebab_preserves_the_previous_truncation_key() { + let value = "a-very-long-directory-structure-with-many-segments-component-name.tsx"; + assert_eq!( + legacy_kebab(value), + Some("ry-structure-with-many-segments-component-name-tsx".to_string()) + ); } } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 1dccafe71..7e8f9a74c 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -483,7 +483,7 @@ The build (`scripts/lib/utils.js` `replaceScriptProviderMarker`) rewrites exactl - non-string / empty after trim -> `null`. - URL (`/^https?:\/\//i`): `new URL(...)`; invalid -> `null`; slug = `kebab(hostname + pathname)` (port, query, hash dropped; hostname is lowercased by URL). - Else path: abs = absolute or `path.resolve(cwd, trimmed)`; rel = `path.relative(cwd, abs)`; if rel starts with `..` or is absolute -> rel = basename(abs); if rel is `''` or `'.'` -> `null`; slug = `kebab(rel)`. -`kebab(v)`: lowercase; replace runs of `/`, `\`, `.` (`/[/\\.]+/g`) with `-`; replace `/[^a-z0-9-]+/g` with `-`; collapse `/-+/g` -> `-`; strip leading/trailing `-`; empty -> `null`; if length > 50 keep the LAST 50 chars then strip one leading `-`. +`kebab(v)`: lowercase; replace runs of `/`, `\`, `.` (`/[/\\.]+/g`) with `-`; replace `/[^a-z0-9-]+/g` with `-`; collapse `/-+/g` -> `-`; strip leading/trailing `-`; empty -> `null`. Values up to 50 characters remain unchanged. Longer normalized values keep their last 41 characters (stripping one leading `-`) and append `-` plus the first 8 lowercase hex characters of the SHA-256 digest of the full normalized value. Examples: `site/pages/index.astro` -> `site-pages-index-astro`; `http://localhost:3000/pricing` -> `localhost-pricing`; `https://Impeccable.Style/docs/audit/` -> `impeccable-style-docs-audit`. #### `.impeccable/` path resolution (`lib/impeccable-paths.mjs`) @@ -710,7 +710,7 @@ Tier 2 (`staleness-deep.mjs`, doctor only): - **Invoked from**: `reference/new-work.md`: `node {{scripts_path}}/surface-brief.mjs read ` and `... write [related-target ...]`; `context.mjs` SURFACE_CONTEXT_AVAILABLE names `read `. `reference/live.md` says live must not shell out to it. - **CLI args**: positional ` [target] [bodyFile] [related...]`; commands `path`, `list`, `read`, `write`. projectRoot = `resolveProjectRoot(cwd, target ? {targetPath: target} : {})` (so the target itself steers monorepo resolution). -- **Outputs**: `path`: cwd-relative brief path + `\n` (error `surface brief path requires a concrete target` when unslugable). `list`: `JSON.stringify([{slug, path (projectRoot-relative posix), primaryTarget, relatedTargets}], null, 2)` + `\n`. `read`: on resolution prints the brief's full text verbatim (no added newline), exit 0; else if candidates exist prints their summaries JSON to **stderr**, and exits 2 either way. `write`: requires target and bodyFile (else error `usage: surface-brief.mjs write `); writes the brief (format above) and prints cwd-relative path + `\n`. Unknown command -> error `usage: surface-brief.mjs [target] [body-file] [related-target ...]`. All thrown errors -> stderr `\n`, exit 1. +- **Outputs**: `path`: cwd-relative brief path + `\n` (error `surface brief path requires a concrete target` when unslugable). `list`: `JSON.stringify([{slug, path (projectRoot-relative posix), primaryTarget, relatedTargets}], null, 2)` + `\n`. `read`: on resolution prints the brief's full text verbatim (no added newline), exit 0; direct target resolution probes both the current hashed long slug and the previous suffix-only long slug, but accepts the collision-prone legacy path only when the brief's target metadata matches the requested target; else if candidates exist prints their summaries JSON to **stderr**, and exits 2 either way. `write`: requires target and bodyFile (else error `usage: surface-brief.mjs write `); writes the brief (format above) and prints cwd-relative path + `\n`. Unknown command -> error `usage: surface-brief.mjs [target] [body-file] [related-target ...]`. All thrown errors -> stderr `\n`, exit 1. - **Tests**: `tests/surface-brief.test.mjs` (library level: slug path `.impeccable/surfaces/src-pages-index-astro.md`, related-target resolution, only-brief/ambiguous, overwrite semantics, route normalization, `route.md` root); `tests/context.test.mjs` brief loading via context. --- @@ -721,7 +721,7 @@ Tier 2 (`staleness-deep.mjs`, doctor only): - **CLI args**: `slug `; `write `; `latest `; `trend [limit=5]`. `coerceSlug`: value matching `/^[a-z0-9-]+$/` used as-is, else `slugFromTarget(value)` (cwd = process.cwd()). - **Env vars**: `IMPECCABLE_CRITIQUE_META` (JSON object for frontmatter on `write`; parse failure ignored). - **Storage**: dir `getCritiqueDir(cwd)` = `/.impeccable/critique/`. Filename `__.md`, stamp = ISO UTC with `:` and `.` -> `-` and the `-mmmZ` fraction removed: `2026-05-12T18-30-00Z`. File content: `---\n: \n...---\n\n`, keys = `{...meta, timestamp, slug}` (meta first, then computed override); null/undefined skipped; string values containing `:` or `#` are JSON-quoted. Snapshot filename regex `/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/`; sorted lexicographically (= chronologically). Frontmatter read: `"..."` values JSON-parsed, `/^-?\d+$/` -> Number, else string. -- **Outputs**: `slug`: slug + `\n` exit 0, or stderr `no stable slug for input\n` exit 1. `write`: missing args -> stderr `usage: write \n` exit 1; else absolute path written + `\n`. `latest`: none -> exit 2 (no output); else prints file body verbatim. `trend`: `JSON.stringify(rows, null, 2)` + `\n`, rows = frontmatter objects of the last N matching files oldest->newest (`[]` if none). Unknown -> stderr `usage: critique-storage.mjs [args]\n` exit 1. +- **Outputs**: `slug`: slug + `\n` exit 0, or stderr `no stable slug for input\n` exit 1. `write`: missing args -> stderr `usage: write \n` exit 1; else absolute path written + `\n`. `latest`: none -> exit 2 (no output); else prints file body verbatim. For explicit path/URL targets, `latest`, `trend`, and `close` probe both the current hashed long slug and the previous suffix-only long slug, but accept a legacy snapshot only when its recorded target identity matches. Identity-less pre-upgrade snapshots remain available by passing their exact legacy slug, which avoids guessing a path/URL owner for a collision-prone key. `trend`: `JSON.stringify(rows, null, 2)` + `\n`, rows = frontmatter objects of the last N matching files oldest->newest (`[]` if none). Unknown -> stderr `usage: critique-storage.mjs [args]\n` exit 1. - **Gotchas**: `latest`/`trend` match by suffix `__.md`; `readLatestSnapshotAcrossTargets` uses suffix `.md`. Main-module guard compares realpaths so symlinked invocation works. - **Tests**: `tests/critique-storage.test.mjs` (slug rules, stamp format, round-trip, newest selection, meta cannot override timestamp/slug, quoting `:`/`#`, CLI slug/exit codes/symlink/latest exit 2, trend ordering). diff --git a/tests/oracle/golden/critique-slug-long.json b/tests/oracle/golden/critique-slug-long.json index adb3cc709..a3cac3bf8 100644 --- a/tests/oracle/golden/critique-slug-long.json +++ b/tests/oracle/golden/critique-slug-long.json @@ -1,5 +1,5 @@ { - "stdout": "ry-structure-with-many-segments-component-name-tsx\n", + "stdout": "ure-with-many-segments-component-name-tsx-47c1e3a4\n", "stderr": "", "exit": 0, "signal": null,