mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
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.
This commit is contained in:
@@ -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<String> {
|
||||
slug_from_target(Some(v), cwd)
|
||||
}
|
||||
|
||||
fn slug_candidates(value: Option<&str>, cwd: &str) -> Vec<String> {
|
||||
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<Snapshot> {
|
||||
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<Snapshot> {
|
||||
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 <slug-or-target> [--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 <resolved-target> <snapshot-file>\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<String> = 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<Value> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>,
|
||||
@@ -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<SurfaceBrief> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
slug_from_target_using(resolved, cwd, legacy_kebab)
|
||||
}
|
||||
|
||||
fn slug_from_target_using(
|
||||
resolved: Option<&str>,
|
||||
cwd: &str,
|
||||
slugger: fn(&str) -> Option<String>,
|
||||
) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
// 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <primary-target>` and `... write <primary-target> <body-file> [related-target ...]`; `context.mjs` SURFACE_CONTEXT_AVAILABLE names `read <path>`. `reference/live.md` says live must not shell out to it.
|
||||
- **CLI args**: positional `<command> [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 <primary-target> <body-file>`); writes the brief (format above) and prints cwd-relative path + `\n`. Unknown command -> error `usage: surface-brief.mjs <path|list|read|write> [target] [body-file] [related-target ...]`. All thrown errors -> stderr `<message>\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 <primary-target> <body-file>`); writes the brief (format above) and prints cwd-relative path + `\n`. Unknown command -> error `usage: surface-brief.mjs <path|list|read|write> [target] [body-file] [related-target ...]`. All thrown errors -> stderr `<message>\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 <target>`; `write <slug-or-target> <body-file>`; `latest <slug-or-target>`; `trend <slug-or-target> [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)` = `<projectRoot>/.impeccable/critique/`. Filename `<stamp>__<slug>.md`, stamp = ISO UTC with `:` and `.` -> `-` and the `-mmmZ` fraction removed: `2026-05-12T18-30-00Z`. File content: `---\n<key>: <value>\n...---\n<body.trim()>\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 <slug-or-target> <body-file>\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 <slug|write|latest|trend> [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 <slug-or-target> <body-file>\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 <slug|write|latest|trend> [args]\n` exit 1.
|
||||
- **Gotchas**: `latest`/`trend` match by suffix `__<slug>.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).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user