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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/ask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
59 changes: 52 additions & 7 deletions src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}'];

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions tests/families.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion tests/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
2 changes: 1 addition & 1 deletion tests/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
Loading