diff --git a/README.md b/README.md index c61429ef..e2172c32 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,14 @@ zerograph ask cancellation policy # answer in a few lines ``` $ zerograph ask cancellation -sym:packages/db/src/schema/salon/scheduling.ts::CANCELLATION_CONSEQUENCES packages/db/src/schema/salon/scheduling.ts:110 CANCELLATION_CONSEQUENCES -BE-M10/T05 docs/.../BE-M10-payments-provider-stripe-connect-….md:80 Cancellation policy engine (`packages/domain/policy.ts`): inputs policy + appoin… - BE-M10 docs/.../BE-M10-payments-provider-stripe-connect-….md:1 BE-M10 Payments provider: Stripe Connect implementation… ← BE-M10/T05 +packages/db/src/schema/salon/scheduling.ts:110 CANCELLATION_CONSEQUENCES +docs/.../BE-M10-payments-provider-stripe-connect-….md:80 BE-M10/T05 Cancellation policy engine (`packages/domain/policy.ts`): inputs policy + appoin… + └ docs/.../BE-M10-payments-provider-stripe-connect-….md:1 BE-M10 BE-M10 Payments provider: Stripe Connect implementation… ``` -Each line is `ID path:line headline`. Top-level lines are the seeds the question matched; an indented -line is a neighbour one hop away, and `←` names the seed it came from. +Each line is `path:line [ID] headline`. A document id is printed; a code id is not, because the +location and the name already say it. Top-level lines are the seeds the question matched; a `└` line +is a neighbour one hop away, printed under the seed it came from. ```bash zerograph ask FR-PAY-22 # an exact id or symbol goes straight to the node diff --git a/docs/commands.md b/docs/commands.md index d1fd7cbe..8f06b6a5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,21 +70,22 @@ passed over and how many more there are. Answers are lines of the form: ``` -ID path:line headline - ID path:line headline ← seed-id +path:line [ID] headline + └ path:line [ID] headline ``` -Top-level lines are the seeds the query matched. An indented line is a neighbour reached by one hop -over the id graph, and the `←` names the seed it came from. A real run, against `beauty-crm`: +Top-level lines are the seeds the query matched, location first so a terminal can open it. A +document id (`BE-M10/T05`) is printed; a code, file or entity id is not, because the location and +the name already say it. A `└` line is a neighbour reached by one hop over the id graph, printed +under the seed it came from. A real run, against `beauty-crm`: ``` $ zerograph --repo beauty-crm ask cancellation -sym:packages/db/src/schema/salon/scheduling.ts::CANCELLATION_CONSEQUENCES packages/db/src/schema/salon/scheduling.ts:110 CANCELLATION_CONSEQUENCES -BE-M10/T05 docs/prd-2026-08-16/plans/milestones/backend/BE-M10-payments-provider-stripe-connect-implementation-deposits-car.md:80 Cancellation policy engine (`packages/domain/policy.ts`): inputs policy + appoin… -PLAT-M12/T09 docs/prd-2026-08-16/plans/milestones/platform/PLAT-M12-billing.md:48 Cancel semantics N-141: immediate stop of future charges, accrued usage as dated… -PLAT-M12/T04 docs/prd-2026-08-16/plans/milestones/platform/PLAT-M12-billing.md:43 Panel subscription page: plan, next invoice, cancel with end-of-access date, exp… -BE-M10/T07 docs/prd-2026-08-16/plans/milestones/backend/BE-M10-payments-provider-stripe-connect-implementation-deposits-car.md:82 EOD fee charger worker: `chargeOffSession` for due fees → intent(purpose=cancell… - BE-M10 docs/prd-2026-08-16/plans/milestones/backend/BE-M10-payments-provider-stripe-connect-implementation-deposits-car.md:1 BE-M10 Payments provider: Stripe Connect implementation, deposits, card-on-file … ← BE-M10/T05 +docs/prd-2026-08-16/plans/milestones/backend/BE-M05-slot-engine-scheduling-api-appointments-conflicts-requests-t.md:270 BE-M05/T15 `BookingRules` + `CancellationPolicy` CRUD, policy **snapshot** attached to the … +packages/db/src/schema/salon/scheduling.ts:110 CANCELLATION_CONSEQUENCES +docs/prd-2026-08-16/plans/milestones/backend/BE-M10-payments-provider-stripe-connect-implementation-deposits-car.md:80 BE-M10/T05 Cancellation policy engine (`packages/domain/policy.ts`): inputs policy + appoin… +packages/db/migrations/0008_first_slice.sql:808 salon_ctx/cancellation_policy cancellation_policy + └ packages/db/sql/0002_rls.sql:135 salon_ctx/cancellation_policy cancellation_policy ``` ### Answering a program instead of a person diff --git a/src/ask.rs b/src/ask.rs index 8da9c238..ada26195 100644 --- a/src/ask.rs +++ b/src/ask.rs @@ -610,7 +610,7 @@ mod tests { assert_eq!(first, second); assert!(first.contains("FR-PAY-1"), "{first}"); let exact = ctx.answer(&Request { words: vec!["FR-CAL-1".into()], ..req }).unwrap(); - assert!(exact.starts_with("FR-CAL-1"), "{exact}"); + assert!(exact.lines().next().unwrap().contains(" FR-CAL-1 "), "{exact}"); } /// The dense arm as a machine without the model has it, prepared once for the whole binary: diff --git a/src/query.rs b/src/query.rs index 0dfddfee..3bb6f6e2 100644 --- a/src/query.rs +++ b/src/query.rs @@ -309,7 +309,7 @@ pub fn render(answer: &Answer, graph: &Graph, opts: &Options) -> String { } let mut out = String::new(); for h in &answer.seeds { - out.push_str(&format!("{} {}:{} {}\n", h.id, h.file, h.line, headline(&h.label))); + out.push_str(&format!("{}\n", hit_line(h))); if opts.bodies { if let Some(n) = graph.nodes.get(&h.id) { if n.kind == crate::model::NodeKind::Text { @@ -324,13 +324,35 @@ pub fn render(answer: &Answer, graph: &Graph, opts: &Options) -> String { } } } + for n in answer.expanded.iter().filter(|n| n.via.as_deref() == Some(h.id.as_str())) { + out.push_str(&format!(" └ {}\n", hit_line(n))); + } } - for h in &answer.expanded { - out.push_str(&format!(" {} {}:{} {} ← {}\n", h.id, h.file, h.line, headline(&h.label), h.via.as_deref().unwrap_or(""))); + let orphans = answer.expanded.iter().filter(|n| !answer.seeds.iter().any(|s| n.via.as_deref() == Some(s.id.as_str()))); + for n in orphans { + match n.via.as_deref() { + Some(via) => out.push_str(&format!(" └ {} ← {via}\n", hit_line(n))), + None => out.push_str(&format!(" └ {}\n", hit_line(n))), + } } out } +/// Location first so a terminal can open it. A code, file or entity id only repeats the location +/// and the name beside it, so it is left out; a document id (`BE-M10/T05`) is what a reader quotes, so it stays. +fn hit_line(h: &Hit) -> String { + let at = format!("{}:{}", h.file, h.line); + let label = headline(&h.label); + if h.id == format!("file:{}", h.file) || h.id == format!("entity:{}", h.label) { + return format!("{at} {label}"); + } + match h.id.strip_prefix("sym:").and_then(|r| r.strip_prefix(h.file.as_str())).and_then(|r| r.strip_prefix("::")) { + Some(name) if name == h.label => format!("{at} {label}"), + Some(name) => format!("{at} {name} {label}"), + None => format!("{at} {} {label}", h.id), + } +} + /// What wraps a word in a sentence and is never part of an id: `FR-CAL-40,`, `(INV-07)`, `«…»`. const WRAPPING: &[char] = &[',', '.', ':', ';', '?', '!', '(', ')', '[', ']', '"', '\'', '«', '»', '\u{201c}', '\u{201d}', '\u{2018}', '\u{2019}']; @@ -947,16 +969,39 @@ mod tests { } #[test] - fn render_shape_is_id_path_line_headline() { + fn render_shape_is_path_line_id_headline_with_neighbours_under_their_seed() { let g = graph(); let a = ask(&g, &lex(&g), None, None, &["FR-PAY-22".to_string()], &opts()); let out = render(&a, &g, &opts()); - let first = out.lines().next().unwrap(); - assert!(first.starts_with("FR-PAY-22 docs/06.md:385 `CancellationPolicy`"), "{first}"); - assert!(out.lines().any(|l| l.starts_with(" N-151 docs/never.md:12 ") && l.ends_with("← FR-PAY-22"))); + let mut lines = out.lines(); + let first = lines.next().unwrap(); + assert!(first.starts_with("docs/06.md:385 FR-PAY-22 `CancellationPolicy`"), "{first}"); + assert!(lines.take_while(|l| l.starts_with(" └ ")).any(|l| l.starts_with(" └ docs/never.md:12 N-151 ")), "{out}"); + assert!(!out.contains('←'), "{out}"); assert!(out.len() / 4 < 130); } + #[test] + fn a_code_hit_prints_its_location_and_name_once() { + let h = Hit { id: "sym:a/b.ts::run".into(), file: "a/b.ts".into(), line: 3, label: "run".into(), score: 0.0, via: None }; + assert_eq!(hit_line(&h), "a/b.ts:3 run"); + let f = Hit { id: "file:a/b.ts".into(), label: "b.ts".into(), ..h.clone() }; + assert_eq!(hit_line(&f), "a/b.ts:3 b.ts"); + let e = Hit { id: "entity:Fee".into(), label: "Fee".into(), ..h.clone() }; + assert_eq!(hit_line(&e), "a/b.ts:3 Fee"); + let m = Hit { id: "sym:a/b.ts::Box.run".into(), label: "run(x)".into(), ..h }; + assert_eq!(hit_line(&m), "a/b.ts:3 Box.run run(x)"); + } + + #[test] + fn a_neighbour_whose_seed_is_not_shown_names_it() { + let g = graph(); + let seed = Hit { id: "FR-PAY-22".into(), file: "docs/06.md".into(), line: 385, label: "x".into(), score: 1.0, via: None }; + let stray = Hit { id: "N-151".into(), file: "docs/never.md".into(), line: 12, label: "y".into(), score: 0.0, via: Some("GONE-1".into()) }; + let out = render(&Answer { seeds: vec![seed], expanded: vec![stray] }, &g, &opts()); + assert_eq!(out, "docs/06.md:385 FR-PAY-22 x\n └ docs/never.md:12 N-151 y ← GONE-1\n"); + } + #[test] fn headline_truncates_by_character_not_byte() { let cyrillic: String = "ж".repeat(100); diff --git a/tests/families.rs b/tests/families.rs index 80cdbbc0..7fc97644 100644 --- a/tests/families.rs +++ b/tests/families.rs @@ -50,7 +50,7 @@ fn a_repository_gets_its_families_from_the_documents_and_says_when_they_move() { // The point of all of it: a family nobody configured answers exactly. let (ok, out, err) = zerograph(repo, &["ask", "REQ-7"]); assert!(ok, "{out}{err}"); - assert!(out.lines().next().unwrap().starts_with("REQ-7"), "{out}"); + assert!(out.lines().next().unwrap().starts_with("docs/req.md:3 REQ-7 "), "{out}"); let (ok, out, err) = zerograph(repo, &["families"]); assert!(ok, "{out}{err}"); @@ -87,7 +87,7 @@ fn a_repository_gets_its_families_from_the_documents_and_says_when_they_move() { assert!(ok && out.contains("held aside: 1 edges to ids in 1 prefixes no line defines ISO"), "{out}"); let (ok, out, err) = zerograph(repo, &["ask", "NEW-1"]); assert!(ok, "{out}{err}"); - assert!(out.lines().next().unwrap().starts_with("NEW-1"), "{out}"); + assert!(out.lines().next().unwrap().starts_with("docs/new.md:1 NEW-1 "), "{out}"); // And a file that still names the old keys: one line, and not one family different. std::fs::write(repo.join("zerograph.toml"), "id_families = [\"FR-PAY\"]\n").unwrap(); diff --git a/tests/layer.rs b/tests/layer.rs index 7a1fdced..703a4e42 100644 --- a/tests/layer.rs +++ b/tests/layer.rs @@ -130,7 +130,7 @@ fn a_text_file_is_found_by_what_it_says_and_named_when_it_changes() { with_text(repo, &["build"]); let (asked, _) = with_text(repo, &["ask", "saturday night window"]); let top: Vec<&str> = asked.lines().filter(|l| !l.starts_with(' ')).take(5).collect(); - assert!(top.iter().any(|l| l.starts_with("file:ops/deploy.yaml ")), "{asked}"); + assert!(top.iter().any(|l| l.starts_with("ops/deploy.yaml:")), "{asked}"); write(repo, "ops/deploy.yaml", "strategy: canary\nwindow: saturday-night\n"); let (changed, _) = with_text(repo, &["changes", "--base", "HEAD"]); assert!(changed.contains("ops/deploy.yaml"), "{changed}"); diff --git a/tests/serve.rs b/tests/serve.rs index 365af91f..5b388a67 100644 --- a/tests/serve.rs +++ b/tests/serve.rs @@ -259,7 +259,7 @@ fn a_family_a_document_grows_while_the_server_runs_reaches_the_store_it_writes() // Read from the store on disk, which is the server's own writing: a poll that extracted // the file under the old set would leave a `file:` node and nothing else. let (out, _) = ask(dir.path(), &["--no-serve", "--stale"], &["NEW-1"]); - if out.lines().next().is_some_and(|l| l.starts_with("NEW-1")) { break; } + if out.lines().next().is_some_and(|l| l.starts_with("docs/new.md:1 NEW-1 ")) { break; } assert!(start.elapsed() < Duration::from_secs(20), "the server never wrote NEW-1: {out}"); std::thread::sleep(Duration::from_millis(100)); }