From 3dac28b359a2ce8b47be2791b11fbc7c726b223e Mon Sep 17 00:00:00 2001 From: bompus Date: Sat, 31 Oct 2026 23:14:47 -0600 Subject: [PATCH 1/3] perf(markdown): skip path targets no indexed file can satisfy A Markdown link or path mention with a relative path created an import node and a reference even when no indexed file had that basename, so the reference could never resolve. On supabase those dead targets were 10,120 of 14,038 Markdown import nodes and 10,321 of 11,368 failed Markdown refs. The extractor now drops a slash-shaped target whose basename no indexed file has. Bare names stay: a root-absolute link (`/pricing`), a root-level mention (`Next.js`) or a parent reference can resolve to a route, module or table node by name, which the resolver reaches without a file node. The known basenames come from the scan plus existing DB rows, ride to each parse worker once per spawn on the grammar handshake, and also serve the in-process fallback. Single-file re-index paths keep their old permissive behaviour. Resolved Markdown edges are byte-identical on supabase, n8n, pretix and discourse. Supabase: 152,274 -> 142,154 nodes and 328,137 -> 318,017 edges (dead import nodes and their contains edges), failed Markdown refs 11,368 -> 1,047. Full index median 16.126 s -> 15.920 s (Hyperfine 2.0.0, 8 runs per build across two ABBA rounds, interquartile ranges not overlapping). The payroll-go golden dump loses only the four dead README refs. Full suite 958 files / 11,350 passed. --- CHANGELOG.md | 2 + __tests__/fixtures/golden/payroll-go.dump | 20 ++--- __tests__/markdown-path-refs.test.ts | 90 +++++++++++++++++++++++ src/extraction/index.ts | 17 ++++- src/extraction/markdown-extractor.ts | 50 +++++++++++-- src/extraction/parse-pool.ts | 18 ++++- src/extraction/parse-worker.ts | 17 ++++- src/extraction/tree-sitter.ts | 7 +- 8 files changed, 193 insertions(+), 28 deletions(-) create mode 100644 __tests__/markdown-path-refs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e26e3970c..2f80ff30e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- **A documentation link to a file that is not there no longer becomes a graph node.** A Markdown link or path mention with a relative path created an import node and a reference even when no indexed file had that basename, so the reference could never resolve. On supabase those dead targets were 10,120 of 14,038 Markdown import nodes and 10,321 of 11,368 failed Markdown references; extraction now skips them. Bare names stay, because a root-absolute link (`/pricing`), a root-level mention (`Next.js`) or a parent reference can resolve to a route, module or table node by name. Resolved Markdown edges are byte-identical on supabase, n8n, pretix and discourse, and a full supabase index is a median 0.206 s faster (16.126 s to 15.920 s, 8 Hyperfine runs per build across two ABBA rounds, interquartile ranges not overlapping) with 10,120 fewer nodes. Re-index Markdown-heavy projects after upgrading. + - **`codegraph sessions` and `codegraph_sessions` can search history before a date.** A search could only look back a number of days (`--since`, `sinceDays`), so its best 100 hits always came from the newest matching sessions and older context could not be reached. `--until ` and `until` keep only docs before a date (read as UTC) or a timestamp that names its zone; together with `--since` they bound a window. - A background sync after editing a widely imported file no longer leaves the MCP daemon holding about 19 GB (n8n, one new export in a barrel imported from 335 files). Re-checking the importers built a native resolver per binding and the process kept about 2.5 MB of each; they now share one. That check went from 38.7 s to 0.5 s, and idle memory after the edit from 19.4 GB to 1.4 GB. diff --git a/__tests__/fixtures/golden/payroll-go.dump b/__tests__/fixtures/golden/payroll-go.dump index 7012d043da..968bd0e742 100644 --- a/__tests__/fixtures/golden/payroll-go.dump +++ b/__tests__/fixtures/golden/payroll-go.dump @@ -1,4 +1,4 @@ -== nodes (313) +== nodes (310) {"id":"constant:03cf22f9cca66ee928171cceccbf44b1","kind":"constant","name":"LineEarning","qualified_name":"LineEarning","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":25,"end_line":25,"start_column":1,"end_column":35,"docstring":null,"signature":"= \"earning\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:49f451a6950b1d7e90eb6cfe2776192b","kind":"constant","name":"LineDeduction","qualified_name":"LineDeduction","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":26,"end_line":26,"start_column":1,"end_column":37,"docstring":null,"signature":"= \"deduction\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:56977e604248e3674f9296a2f68154bb","kind":"constant","name":"PayrollService_GetPayrollCycle_FullMethodName","qualified_name":"PayrollService_GetPayrollCycle_FullMethodName","file_path":"internal/gen/payrollpb/payroll_grpc.pb.go","language":"go","start_line":17,"end_line":17,"start_column":1,"end_column":93,"docstring":null,"signature":"= \"/payroll.v1.PayrollService/GetPayrollCycle\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -8,7 +8,7 @@ {"id":"constant:c62a1da458c5f390b2ac8d4662f81801","kind":"constant","name":"PayrollService_ListPayslips_FullMethodName","qualified_name":"PayrollService_ListPayslips_FullMethodName","file_path":"internal/gen/payrollpb/payroll_grpc.pb.go","language":"go","start_line":18,"end_line":18,"start_column":1,"end_column":90,"docstring":null,"signature":"= \"/payroll.v1.PayrollService/ListPayslips\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:ccc3400ce237825d2c42ef5fe177fe35","kind":"constant","name":"CycleOpen","qualified_name":"CycleOpen","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":33,"docstring":null,"signature":"= \"open\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:e8d10fecacdc9d2de71a3e3f7cd39d77","kind":"constant","name":"CycleClosed","qualified_name":"CycleClosed","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":35,"docstring":null,"signature":"= \"closed\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, explore-allocation-1500.test.ts, payslip_builder.go, probe-allocation.mjs, allocation-fixtures.js","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} +{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, payslip_builder.go","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:cmd/payrolld/main.go","kind":"file","name":"main.go","qualified_name":"cmd/payrolld/main.go","file_path":"cmd/payrolld/main.go","language":"go","start_line":1,"end_line":36,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/domain/payroll/payslip.go","kind":"file","name":"payslip.go","qualified_name":"internal/domain/payroll/payslip.go","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":1,"end_line":160,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/gen/fkit/employee/contract.go","kind":"file","name":"contract.go","qualified_name":"internal/gen/fkit/employee/contract.go","file_path":"internal/gen/fkit/employee/contract.go","language":"go","start_line":1,"end_line":85,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -116,7 +116,6 @@ {"id":"import:0bd6a21b585b4de298027fb9855ca26a","kind":"import","name":"github.com/example/payroll-svc/internal/domain/payroll","qualified_name":"github.com/example/payroll-svc/internal/domain/payroll","file_path":"internal/usecase/payroll/prorate.go","language":"go","start_line":6,"end_line":6,"start_column":1,"end_column":57,"docstring":null,"signature":"\"github.com/example/payroll-svc/internal/domain/payroll\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:1150c8c775775f9f8560aa873017d153","kind":"import","name":"sort","qualified_name":"sort","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":7,"end_line":7,"start_column":1,"end_column":7,"docstring":null,"signature":"\"sort\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:1ef8f8dc72d476dd57190a572c1f19e8","kind":"import","name":"time","qualified_name":"time","file_path":"internal/gen/fkit/timesheet/timesheet.go","language":"go","start_line":11,"end_line":11,"start_column":1,"end_column":7,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"import:20c50882bdafb89a0b54258fa936c5b2","kind":"import","name":"allocation-fixtures.js","qualified_name":"README.md::path:scripts/agent-eval/allocation-fixtures.js:79:49","file_path":"README.md","language":"markdown","start_line":79,"end_line":79,"start_column":49,"end_column":90,"docstring":null,"signature":"scripts/agent-eval/allocation-fixtures.js","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:2341305377f0bafe152c137e9466d659","kind":"import","name":"log","qualified_name":"log","file_path":"cmd/payrolld/main.go","language":"go","start_line":4,"end_line":4,"start_column":1,"end_column":6,"docstring":null,"signature":"\"log\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:2b0654627b2a83f4fa3e7b86866491c0","kind":"import","name":"context","qualified_name":"context","file_path":"internal/gen/fkit/employee/contract.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":10,"docstring":null,"signature":"\"context\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:3d7629cf02a4de5b41d0223adb5f3070","kind":"import","name":"time","qualified_name":"time","file_path":"internal/gen/fkit/employee/employee.go","language":"go","start_line":11,"end_line":11,"start_column":1,"end_column":7,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -139,7 +138,6 @@ {"id":"import:65db04d07bd3a1a85e6702c070edbcf7","kind":"import","name":"context","qualified_name":"context","file_path":"internal/gen/fkit/employee/employee.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":10,"docstring":null,"signature":"\"context\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:66bc065f81b0782b430037fc979102cc","kind":"import","name":"time","qualified_name":"time","file_path":"internal/gen/fkit/payroll/payslip.go","language":"go","start_line":11,"end_line":11,"start_column":1,"end_column":7,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:68f937efbcbc4ebd082c1c11e72df0ab","kind":"import","name":"cycle.go","qualified_name":"README.md::path:cycle.go:49:50","file_path":"README.md","language":"markdown","start_line":49,"end_line":49,"start_column":50,"end_column":58,"docstring":null,"signature":"cycle.go","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"import:6a7e6476a09ea4596d045f1ae552a8f2","kind":"import","name":"explore-allocation-1500.test.ts","qualified_name":"README.md::path:__tests__/explore-allocation-1500.test.ts:52:15","file_path":"README.md","language":"markdown","start_line":52,"end_line":52,"start_column":15,"end_column":56,"docstring":null,"signature":"__tests__/explore-allocation-1500.test.ts","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:7147cac4b1745ea38b0bfa164a3a5468","kind":"import","name":"time","qualified_name":"time","file_path":"internal/gen/fkit/payroll/dto.go","language":"go","start_line":8,"end_line":8,"start_column":7,"end_column":13,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:7222e16172e240000e603eceb98f94f9","kind":"import","name":"database/sql","qualified_name":"database/sql","file_path":"internal/gen/fkit/payroll/payslip.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":15,"docstring":null,"signature":"\"database/sql\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:76a964b6134242af279f95c049ac84c5","kind":"import","name":"store.go","qualified_name":"README.md::path:internal/gen/fkit/payroll/store.go:87:56","file_path":"README.md","language":"markdown","start_line":87,"end_line":87,"start_column":56,"end_column":90,"docstring":null,"signature":"internal/gen/fkit/payroll/store.go","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -171,7 +169,6 @@ {"id":"import:e715312ec518c465211ed82ddce82285","kind":"import","name":"fmt","qualified_name":"fmt","file_path":"internal/usecase/payroll/payslip_builder.go","language":"go","start_line":5,"end_line":5,"start_column":1,"end_column":6,"docstring":null,"signature":"\"fmt\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:e9cab8103939c5ea907234285e597639","kind":"import","name":"time","qualified_name":"time","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":8,"end_line":8,"start_column":1,"end_column":7,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:ee0a17b804d64444eaa8643521a20d48","kind":"import","name":"net/http","qualified_name":"net/http","file_path":"internal/transport/httpapi/router.go","language":"go","start_line":3,"end_line":3,"start_column":7,"end_column":17,"docstring":null,"signature":"\"net/http\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"import:f46fb6a4705f81701f5054f85a18d37f","kind":"import","name":"explore-budget-allocation.md","qualified_name":"README.md::path:docs/design/explore-budget-allocation.md:82:1","file_path":"README.md","language":"markdown","start_line":82,"end_line":82,"start_column":1,"end_column":41,"docstring":null,"signature":"docs/design/explore-budget-allocation.md","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:f779e8d1ccdf9fbef128be48f1cabe14","kind":"import","name":"errors","qualified_name":"errors","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":5,"end_line":5,"start_column":1,"end_column":9,"docstring":null,"signature":"\"errors\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:fa23f47e6bf27f154f8044ad15346d18","kind":"import","name":"store.go","qualified_name":"README.md::path:store.go:38:45","file_path":"README.md","language":"markdown","start_line":38,"end_line":38,"start_column":45,"end_column":53,"docstring":null,"signature":"store.go","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:fe70ccaba78866b57f5239f961d3ea11","kind":"import","name":"fmt","qualified_name":"fmt","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":6,"end_line":6,"start_column":1,"end_column":6,"docstring":null,"signature":"\"fmt\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -312,7 +309,7 @@ {"id":"variable:6b7bef84822056845bdabeb41a821f0a","kind":"variable","name":"ErrCycleClosed","qualified_name":"ErrCycleClosed","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":16,"end_line":16,"start_column":4,"end_column":58,"docstring":"ErrCycleClosed is returned when a cycle has already been finalized.","signature":"= errors.New(\"payroll cycle is closed\")","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:9c3a0c9681a69504be5d3af6ddb5378c","kind":"variable","name":"file_payroll_v1_payroll_proto_goTypes","qualified_name":"file_payroll_v1_payroll_proto_goTypes","file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","start_line":132,"end_line":137,"start_column":4,"end_column":1,"docstring":null,"signature":"= []any{\n\t(*RunPayrollCycleRequest)(nil),\n\t(*RunPayrollCycleResponse)(nil),\n\t(*Payslip)(nil),\n\t(*Payro...","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:baa4aee814b380ae99f1b1829407b106","kind":"variable","name":"ErrNoEmployees","qualified_name":"ErrNoEmployees","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":19,"end_line":19,"start_column":4,"end_column":72,"docstring":"ErrNoEmployees is returned when a cycle resolves to an empty roster.","signature":"= errors.New(\"payroll cycle has no active employees\")","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -== edges (657) +== edges (654) {"source":"file:69f63e0581b8138f256b63d2aac60e8a","target":"module:1be454da0953954aef45c525ee8f8b51","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"file:cmd/payrolld/main.go","target":"function:90eb51f51a3d9a397d4a53f26f2019aa","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:cmd/payrolld/main.go","target":"import:0a9ee69fa4c64208200c3bebcbbf0b75","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} @@ -847,7 +844,6 @@ {"source":"module:1be454da0953954aef45c525ee8f8b51","target":"module:f8d63ed69e573ade27e2d1d4fc0ae8b4","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:526c9118c077229729a7b9846c16882a","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:68f937efbcbc4ebd082c1c11e72df0ab","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} -{"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:6a7e6476a09ea4596d045f1ae552a8f2","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:8808ae056445d4f7fae22fa62685c9a4","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:fa23f47e6bf27f154f8044ad15346d18","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:900ad55709598429050c36805696291a","target":"import:44b0fa4765bb8c262fb8b290698b9c05","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} @@ -872,8 +868,6 @@ {"source":"module:a883d05f3bb8edb262fd18c8c2849417","target":"function:4827032314863eb2b99f791098f8668c","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:a883d05f3bb8edb262fd18c8c2849417","target":"function:8e554156c22d75e629b346ea4ed215d1","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:a883d05f3bb8edb262fd18c8c2849417","target":"function:c2b743a41c78c6d39eb292c4ecd1138d","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} -{"source":"module:a883d05f3bb8edb262fd18c8c2849417","target":"import:20c50882bdafb89a0b54258fa936c5b2","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} -{"source":"module:a883d05f3bb8edb262fd18c8c2849417","target":"import:f46fb6a4705f81701f5054f85a18d37f","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:f8d63ed69e573ade27e2d1d4fc0ae8b4","target":"import:76a964b6134242af279f95c049ac84c5","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"route:internal/transport/httpapi/router.go:10:GET:/v1/payroll/cycles/{cycleID}","target":"method:8baae5c5fb59db7a88c19bb295635d18","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"GetCycle\"}","line":10,"col":0,"provenance":null} {"source":"route:internal/transport/httpapi/router.go:11:GET:/v1/payroll/cycles/{cycleID}/payslips","target":"method:6e0fe8d0272ee955601347cf37b5a383","kind":"references","metadata":"{\"confidence\":0.7,\"resolvedBy\":\"exact-match\",\"refName\":\"ListPayslips\"}","line":11,"col":0,"provenance":null} @@ -970,7 +964,7 @@ {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:4325013490f7f7a77604146f5e38c292","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"RunPayrollCycleRequest\",\"refKind\":\"calls\"}","line":133,"col":1,"provenance":null} {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:d541b867b240edf388feab5ff2002afd","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Payslip\",\"refKind\":\"calls\"}","line":135,"col":1,"provenance":null} {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:e66f442bcf28c3bd0a3f42fbacb69b2a","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"RunPayrollCycleResponse\",\"refKind\":\"calls\"}","line":134,"col":1,"provenance":null} -== refs (599) +== refs (595) {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/platform/clock","reference_kind":"imports","line":9,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"clock"} {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/store/payslipstore","reference_kind":"imports","line":10,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"payslipstore"} {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/transport/httpapi","reference_kind":"imports","line":11,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"httpapi"} @@ -1090,7 +1084,6 @@ {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"Context","reference_kind":"references","line":58,"col":32,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"Context"} {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"DB","reference_kind":"references","line":58,"col":49,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"DB"} {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"db.ExecContext","reference_kind":"calls","line":60,"col":11,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"ExecContext","failure_reason":"unknown-receiver"} -{"from_node_id":"function:4827032314863eb2b99f791098f8668c","reference_name":"scripts/agent-eval/probe-allocation.mjs","reference_kind":"calls","line":75,"col":0,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"mjs"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"Request","reference_kind":"references","line":16,"col":43,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"Request"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"ResponseWriter","reference_kind":"references","line":16,"col":19,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"ResponseWriter"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"http.StatusOK","reference_kind":"function_ref","line":17,"col":20,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"StatusOK"} @@ -1500,14 +1493,11 @@ {"from_node_id":"method:fd95c33dd58bbddc5221ee4ced0bbcd3","reference_name":"len","reference_kind":"calls","line":69,"col":4,"candidates":null,"file_path":"internal/usecase/payroll/cycle.go","language":"go","status":"failed","name_tail":"len"} {"from_node_id":"method:ff7a7eea8969534c02484b666f0ef39b","reference_name":"protoimpl.X.MessageStringOf","reference_kind":"calls","line":125,"col":48,"candidates":null,"file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","status":"failed","name_tail":"MessageStringOf","failure_reason":"unknown-receiver"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":".pb.go","reference_kind":"references","line":41,"col":15,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} -{"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"__tests__/explore-allocation-1500.test.ts","reference_kind":"references","line":52,"col":15,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"explore-allocation-1500.test.ts"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"cycle.go","reference_kind":"references","line":49,"col":50,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"payslip.go","reference_kind":"references","line":38,"col":31,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"store.go","reference_kind":"references","line":38,"col":45,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:900ad55709598429050c36805696291a","reference_name":"cycle.go","reference_kind":"references","line":66,"col":1,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:900ad55709598429050c36805696291a","reference_name":"payslip_builder.go","reference_kind":"references","line":67,"col":54,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} -{"from_node_id":"module:a883d05f3bb8edb262fd18c8c2849417","reference_name":"docs/design/explore-budget-allocation.md","reference_kind":"references","line":82,"col":1,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"explore-budget-allocation.md"} -{"from_node_id":"module:a883d05f3bb8edb262fd18c8c2849417","reference_name":"scripts/agent-eval/allocation-fixtures.js","reference_kind":"references","line":79,"col":49,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"allocation-fixtures.js"} {"from_node_id":"module:f8d63ed69e573ade27e2d1d4fc0ae8b4","reference_name":"internal/gen/fkit/payroll/store.go","reference_kind":"references","line":87,"col":56,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"store.go"} {"from_node_id":"struct:09a019d5ab5eefb1f1bc3b25ad95d81c","reference_name":"Time","reference_kind":"references","line":38,"col":22,"candidates":null,"file_path":"internal/usecase/payroll/cycle.go","language":"go","status":"failed","name_tail":"Time"} {"from_node_id":"struct:13bfc2acbe1de6557e9a28c0be484ffa","reference_name":"DB","reference_kind":"references","line":15,"col":9,"candidates":null,"file_path":"internal/gen/fkit/payroll/store.go","language":"go","status":"failed","name_tail":"DB"} @@ -2282,7 +2272,7 @@ {"file_path":"internal/usecase/payroll/prorate.go","name":"to","kind":"param","node_id":null,"target_spec":null,"target_name":null,"exported_as":null,"export_form":null,"scope_start":34,"scope_end":39,"storage":null,"line":34} {"file_path":"internal/usecase/payroll/prorate.go","name":"window","kind":"local","node_id":null,"target_spec":null,"target_name":null,"exported_as":null,"export_form":null,"scope_start":11,"scope_end":24,"storage":null,"line":12} == files (21) -{"path":"README.md","language":"markdown","node_count":37} +{"path":"README.md","language":"markdown","node_count":34} {"path":"cmd/payrolld/main.go","language":"go","node_count":10} {"path":"internal/domain/payroll/payslip.go","language":"go","node_count":25} {"path":"internal/gen/fkit/employee/contract.go","language":"go","node_count":11} diff --git a/__tests__/markdown-path-refs.test.ts b/__tests__/markdown-path-refs.test.ts new file mode 100644 index 0000000000..fbf4d87036 --- /dev/null +++ b/__tests__/markdown-path-refs.test.ts @@ -0,0 +1,90 @@ +/** + * Markdown path mentions are only resolvable through the file-path arm, which + * collects candidates by basename. A target whose basename no indexed file has + * therefore cannot resolve, and creating an import node plus a reference for + * it only adds failed rows (and resolution work) to the graph. The extractor + * skips those targets when it is given the index's known file basenames, while + * keeping every target the resolver can still match: a live basename at a + * different path (the suffix/basename fallback), symbols inside a live file, + * and in-page anchors. + */ +import { describe, it, expect } from 'vitest'; +import { extractFromSource } from '../src/extraction/tree-sitter'; +import type { MarkdownExtractorContext } from '../src/extraction/markdown-extractor'; + +function known(...files: string[]): MarkdownExtractorContext { + return { + knownFileNames: new Set(files.map((file) => file.split('/').pop()!)), + }; +} + +const SOURCE = [ + '# Guide', + '', + 'Preamble about `createWidget`.', + '', + '## Links', + '', + '- [widget](../src/widget.ts)', + '- [missing](../src/missing.ts)', + '- [same name, other path](../apps/widget.ts#usage)', + '- [pricing](/pricing)', + '- [this section](#links)', + '', + 'Mention `scripts/run.sh`, `scripts/widget.ts` and `src/widget.ts::createWidget`.', + 'A missing symbol target: `src/missing.ts::createWidget`.', + '', + '```sh', + 'bash scripts/missing.sh', + 'bash scripts/exists.sh', + '```', +].join('\n'); + +function refs(context?: MarkdownExtractorContext): string[] { + return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], context) + .unresolvedReferences.map((ref) => ref.referenceName); +} + +function importTargets(context?: MarkdownExtractorContext): string[] { + return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], context) + .nodes.filter((node) => node.kind === 'import') + .map((node) => node.qualifiedName); +} + +describe('Markdown path-existence check', () => { + it('keeps every path target when no known-file set is supplied', () => { + const names = refs(); + expect(names).toContain('src/widget.ts'); + expect(names).toContain('src/missing.ts'); + expect(names).toContain('docs/scripts/run.sh'); + expect(importTargets().some((qn) => qn.includes('missing.ts'))).toBe(true); + }); + + it('drops targets no indexed file can satisfy', () => { + const context = known('docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); + const names = refs(context); + expect(names).not.toContain('src/missing.ts'); + expect(names).not.toContain('scripts/run.sh'); + expect(names).not.toContain('scripts/missing.sh'); + expect(names.some((name) => name.startsWith('src/missing.ts::'))).toBe(false); + expect(importTargets(context).some((qn) => qn.includes('missing.ts'))).toBe(false); + }); + + it('keeps targets the resolver reaches by basename, symbol, anchor or command', () => { + const context = known('docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); + const names = refs(context); + // A live basename at another path still resolves through the suffix and + // basename arms, so it must survive. + expect(names).toContain('apps/widget.ts#usage'); + expect(names).toContain('docs/scripts/widget.ts'); + // A bare name is not a file path: `/pricing` normalizes to `pricing`, + // which the resolver can match to a route or module node by name. + expect(names).toContain('pricing'); + // A symbol inside a live file. + expect(names).toContain('docs/src/widget.ts::createWidget'); + // A pure in-page anchor names this file. + expect(names.some((name) => name.startsWith('docs/guide.md#'))).toBe(true); + // A script the fence runs. + expect(names).toContain('docs/scripts/exists.sh'); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index ce05f8e208..c47a76af3a 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -2402,6 +2402,20 @@ export class ExtractionOrchestrator { }, skipStats); this.prepareLiquidThemeRoots(files); if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`); + + // Markdown link/path targets resolve through the file-path arm, which + // collects candidates by basename. Hand extraction that set so a path + // mention whose target file is absent creates no import node and no + // failed reference. Existing DB rows are included on a re-index so a ref + // whose target is still tracked (but gone from disk) resolves as before. + const knownFileNames = new Set(); + const rememberBasename = (file: string): void => { + const cut = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')); + knownFileNames.add(cut >= 0 ? file.slice(cut + 1) : file); + }; + for (const file of files) rememberBasename(file); + for (const file of this.queries.getAllFilePaths()) rememberBasename(file); + const extractionContext = { knownFileNames }; /** Only meaningful when nothing was indexable — see IndexResult (#1502). */ const skipSummary = (): Pick => { let total = 0; @@ -2498,6 +2512,7 @@ export class ExtractionOrchestrator { recycleInterval: WORKER_RECYCLE_INTERVAL, parseTimeoutMs: PARSE_TIMEOUT_MS, log, + sharedContext: extractionContext, }); log(`Parse worker pool: ${poolSize} worker(s)`); // Bulk index: every core will be needed — spawn the whole pool now so @@ -2538,7 +2553,7 @@ export class ExtractionOrchestrator { const parseFile = (filePath: string, content: string): Promise => { const language = detectLanguage(filePath, content, overrides, this.rootDir); const names = this.frameworksForFile(filePath, frameworkNames); - if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names)); + if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names, extractionContext)); return pool.requestParse({ filePath, content, language, frameworkNames: names }); }; diff --git a/src/extraction/markdown-extractor.ts b/src/extraction/markdown-extractor.ts index 4695ea4be4..18aeda02da 100644 --- a/src/extraction/markdown-extractor.ts +++ b/src/extraction/markdown-extractor.ts @@ -19,6 +19,19 @@ interface FenceState { lines: Array<{ text: string; line: number }>; } +/** Extraction inputs the Markdown extractor needs beyond the file itself. */ +export interface MarkdownExtractorContext { + /** + * Basenames of every file the index contains. Markdown path mentions and + * links resolve through the file-path arm, which looks candidates up by + * basename; a slash-shaped relative target whose basename is absent cannot + * resolve, so it creates no import node and no reference. Omitted by + * callers without an index (unit tests, single-file tooling), which keeps + * the old permissive behaviour. + */ + knownFileNames?: ReadonlySet; +} + /** * Lightweight Markdown extractor. * @@ -35,10 +48,12 @@ export class MarkdownExtractor { private errors: ExtractionError[] = []; private headings: HeadingInfo[] = []; private referenceKeys = new Set(); + private knownFileNames?: ReadonlySet; - constructor(filePath: string, source: string) { + constructor(filePath: string, source: string, context: MarkdownExtractorContext = {}) { this.filePath = normalizeRelativePath(filePath); this.lines = source.split('\n'); + this.knownFileNames = context.knownFileNames; } extract(): ExtractionResult { @@ -412,7 +427,7 @@ export class MarkdownExtractor { const scriptPath = extractScriptPath(command); if (scriptPath) { const normalizedTarget = this.normalizeReference(scriptPath); - if (normalizedTarget) { + if (normalizedTarget && this.isResolvableTarget(normalizedTarget)) { this.addReference(nodeId, normalizedTarget, 'calls', line, column); } } @@ -427,7 +442,7 @@ export class MarkdownExtractor { if (!isLocalReference(target)) continue; const normalizedTarget = this.normalizeReference(target); - if (!normalizedTarget) continue; + if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; const column = match.index; const label = stripInlineMarkdown(match[1] || target) || target; @@ -464,7 +479,7 @@ export class MarkdownExtractor { if (line[match.index - 1] === '(') continue; // inline markdown link target, already handled if (line.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); - if (!normalizedTarget) continue; + if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); const node: Node = { @@ -499,7 +514,7 @@ export class MarkdownExtractor { for (const symbol of symbols) { const normalizedTarget = this.normalizeReference(`${filePart}::${symbol}`); - if (!normalizedTarget) continue; + if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; if (createImportNode) { const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); @@ -554,7 +569,7 @@ export class MarkdownExtractor { const raw = match[0]!; if (text.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); - if (normalizedTarget) { + if (normalizedTarget && this.isResolvableTarget(normalizedTarget)) { this.addReference(owner.id, normalizedTarget, 'references', lineNumber, match.index); } } @@ -622,6 +637,29 @@ export class MarkdownExtractor { return `${normalized}${symbolPart ? `::${symbolPart}` : ''}${anchor}`; } + /** + * Whether a normalized reference target names a file the index can resolve. + * The resolver's file-path arm collects candidates by basename, so a + * relative path whose basename has no file node cannot resolve; symbol + * (`::`) and anchor (`#`) suffixes do not change that. A pure in-page anchor + * names this file, which is always present. + * + * Bare names stay: a root-absolute link (`/pricing`), a same-directory + * mention at the project root (`Next.js`, `config.toml`) or a parent + * reference can resolve to a route, module or table-row node by name, and + * the resolver's name arms do not need a file node for that. Only a + * slash-shaped relative path is unambiguously a file path. Without a + * known-file set the check is skipped. + */ + private isResolvableTarget(normalizedTarget: string): boolean { + if (!this.knownFileNames) return true; + const [pathAndSymbol] = splitAnchor(normalizedTarget); + const [pathPart] = splitFileSymbol(pathAndSymbol); + if (!pathPart || pathPart === this.filePath) return true; + if (!pathPart.includes('/')) return true; + return this.knownFileNames.has(path.posix.basename(pathPart)); + } + private buildDocstring(startLine: number, endLine: number): string | undefined { const text = this.lines .slice(Math.max(0, startLine - 1), Math.max(0, endLine)) diff --git a/src/extraction/parse-pool.ts b/src/extraction/parse-pool.ts index 383666ae58..61f592b7d3 100644 --- a/src/extraction/parse-pool.ts +++ b/src/extraction/parse-pool.ts @@ -22,6 +22,7 @@ import { Worker } from 'worker_threads'; import type { Language, ExtractionResult } from '../types'; +import type { MarkdownExtractorContext } from './markdown-extractor'; /** * Minimal worker surface the pool drives — satisfied by a real `worker_threads` @@ -210,6 +211,11 @@ export interface ParseWorkerPoolOptions { loadSettleMs?: number; /** Optional verbose logger (the orchestrator's `[worker] …` logger). */ log?: (msg: string) => void; + /** + * Extraction inputs every worker needs before its first parse (the + * index's known file basenames). Sent once per spawn, not per parse. + */ + sharedContext?: MarkdownExtractorContext; } export class ParseWorkerPool { @@ -236,6 +242,7 @@ export class ParseWorkerPool { private readonly createWorker: () => ParsePoolWorker; private readonly loadSettleMs: number; private readonly log: (msg: string) => void; + private readonly sharedContext?: MarkdownExtractorContext; constructor(opts: ParseWorkerPoolOptions) { this.languages = opts.languages; @@ -244,6 +251,7 @@ export class ParseWorkerPool { this.parseTimeoutMs = opts.parseTimeoutMs ?? DEFAULT_PARSE_TIMEOUT_MS; this.loadSettleMs = opts.loadSettleMs ?? GRAMMAR_LOAD_SETTLE_MS; this.log = opts.log ?? (() => {}); + this.sharedContext = opts.sharedContext; if (opts.createWorker) { this.createWorker = opts.createWorker; } else if (opts.workerScriptPath) { @@ -320,7 +328,15 @@ export class ParseWorkerPool { w.on('error', (e) => { this.loadSettled(w); this.onWorkerGone(w, `${WORKER_DIED}: ${e?.message ?? 'unknown error'}`); }); w.on('exit', (code) => { this.loadSettled(w); if (code !== 0) this.onWorkerGone(w, `${WORKER_DIED}: exited with code ${code}`); }); // Readiness handshake; the worker replies 'grammars-loaded' and only then is idle. - w.postMessage({ type: 'load-grammars', languages: this.languages }); + // The shared extraction context rides along, so a recycled worker gets it + // again without a per-parse clone. + w.postMessage({ + type: 'load-grammars', + languages: this.languages, + knownFileNames: this.sharedContext?.knownFileNames + ? [...this.sharedContext.knownFileNames] + : undefined, + }); } private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void { diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts index 4e30c35928..388093d100 100644 --- a/src/extraction/parse-worker.ts +++ b/src/extraction/parse-worker.ts @@ -22,11 +22,18 @@ import { applyFrameworkExtracts } from './framework-extracts'; import { mayDefineCppMacroMethods } from './languages/cpp-macro-methods'; import type { Language, ExtractionResult } from '../types'; -parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language }) => { +/** + * The index's known file basenames, for the Markdown extractor's path check. + * Sent once per worker spawn (the pool re-sends it to a recycled worker). + */ +let knownFileNames: Set | undefined; + +parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; knownFileNames?: string[] }) => { if (msg.type === 'load-grammars') { // Nothing to load since the wasm path was removed: grammars live in the // kernel binary. The handshake stays so the pool's readiness model // (spawned → ready → idle) is unchanged. + knownFileNames = msg.knownFileNames ? new Set(msg.knownFileNames) : undefined; parentPort!.postMessage({ type: 'grammars-loaded' }); } else if (msg.type === 'parse') { const { id, filePath, content, frameworkNames } = msg; @@ -67,7 +74,13 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st applyFrameworkExtracts(result, filePath!, content!, language, frameworkNames); } } - result ??= extractFromSource(filePath!, content!, language, frameworkNames); + result ??= extractFromSource( + filePath!, + content!, + language, + frameworkNames, + knownFileNames ? { knownFileNames } : undefined + ); parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 }); } catch (err) { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index e4c043e633..1924540b6f 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -39,7 +39,7 @@ import { DfmExtractor } from './dfm-extractor'; import { PASCAL_SYSTEM_NAMES } from './languages/pascal-system'; import { VueExtractor } from './vue-extractor'; import { MyBatisExtractor } from './mybatis-extractor'; -import { MarkdownExtractor } from './markdown-extractor'; +import { MarkdownExtractor, type MarkdownExtractorContext } from './markdown-extractor'; import { CfmlExtractor } from './cfml-extractor'; import { tryKernelExtract, takeDeferredPreParse, tryKernelBindings, attachBindingNodeIds } from './kernel'; import { captureLiterals } from './literal-capture'; @@ -9857,7 +9857,8 @@ export function extractFromSource( filePath: string, source: string, language?: Language, - frameworkNames?: string[] + frameworkNames?: string[], + context?: MarkdownExtractorContext ): ExtractionResult { const detectedLanguage = language || detectLanguage(filePath, source); const fileExtension = path.extname(filePath).toLowerCase(); @@ -9891,7 +9892,7 @@ export function extractFromSource( result = extractor.extract(); } else if (detectedLanguage === 'markdown') { // Use custom extractor for Markdown documentation structure and references - const extractor = new MarkdownExtractor(filePath, source); + const extractor = new MarkdownExtractor(filePath, source, context); result = extractor.extract(); } else if (detectedLanguage === 'cfml' || detectedLanguage === 'cfscript') { // Custom extractor for CFML (.cfc/.cfm) — dialect-switches between the From 90fa95cc744cd40204ff4e98e66fb6f2f749d66b Mon Sep 17 00:00:00 2001 From: bompus Date: Sat, 31 Oct 2026 23:41:53 -0600 Subject: [PATCH 2/3] fix(markdown): narrow the path pruning to the full-index file case Review fixes for the dead-target pruning: - Full index only. indexFile() passes no context, so incremental sync keeps every target and a file added later still resolves from the parked reference (#1240). - Keep root-absolute targets and extensionless paths: the resolver can match them to routes, modules or table rows by name, and a root-absolute link is not a relative project file. Only a relative, file-shaped target whose basename no indexed file has is dropped. - Keep every target in the file digest. digestReferences collects the same list unresolvedReferences had, including skipped targets, so a link to an unindexed asset still makes its file findable. Supabase: 152,274 -> 148,652 nodes and 328,137 -> 324,515 edges, 14,038 -> 10,416 Markdown import nodes, 11,368 -> 7,545 failed Markdown refs. Resolved Markdown edges and file digests are byte-identical on supabase, n8n, pretix and discourse. Index time shows no clear change (16.144 vs 16.032 s median, ranges overlap). Full suite 959 files / 11,352 passed. --- CHANGELOG.md | 2 +- __tests__/fixtures/golden/payroll-go.dump | 2 +- __tests__/markdown-path-refs.test.ts | 75 ++++++++++------ src/extraction/index.ts | 6 +- src/extraction/markdown-extractor.ts | 102 ++++++++++++++++------ src/extraction/parse-pool.ts | 1 + src/extraction/parse-worker.ts | 8 +- 7 files changed, 138 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f80ff30e0..500a64d3e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,7 +100,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- **A documentation link to a file that is not there no longer becomes a graph node.** A Markdown link or path mention with a relative path created an import node and a reference even when no indexed file had that basename, so the reference could never resolve. On supabase those dead targets were 10,120 of 14,038 Markdown import nodes and 10,321 of 11,368 failed Markdown references; extraction now skips them. Bare names stay, because a root-absolute link (`/pricing`), a root-level mention (`Next.js`) or a parent reference can resolve to a route, module or table node by name. Resolved Markdown edges are byte-identical on supabase, n8n, pretix and discourse, and a full supabase index is a median 0.206 s faster (16.126 s to 15.920 s, 8 Hyperfine runs per build across two ABBA rounds, interquartile ranges not overlapping) with 10,120 fewer nodes. Re-index Markdown-heavy projects after upgrading. +- **A documentation link to a file that is not there no longer becomes a graph node.** A Markdown link or path mention with a relative file path created an import node and a reference even when no indexed file had that basename, so the reference could never resolve. On supabase a full index now skips 3,622 of 14,038 Markdown import nodes and 3,823 of 11,368 failed Markdown references. Root-absolute links (`/api/users/`, `/pricing`), extensionless paths and bare names stay, because the resolver can match those to routes, modules or table rows by name; a root-absolute link is never a relative project file. Incremental sync keeps every target, so a file added later still resolves from the parked reference. A skipped target stays in the file's search digest, so a link to an unindexed asset still makes its file findable. Resolved Markdown edges and file digests are byte-identical on supabase, n8n, pretix and discourse; index time shows no clear change. Re-index Markdown-heavy projects after upgrading. - **`codegraph sessions` and `codegraph_sessions` can search history before a date.** A search could only look back a number of days (`--since`, `sinceDays`), so its best 100 hits always came from the newest matching sessions and older context could not be reached. `--until ` and `until` keep only docs before a date (read as UTC) or a timestamp that names its zone; together with `--since` they bound a window. diff --git a/__tests__/fixtures/golden/payroll-go.dump b/__tests__/fixtures/golden/payroll-go.dump index 968bd0e742..fb5b54073a 100644 --- a/__tests__/fixtures/golden/payroll-go.dump +++ b/__tests__/fixtures/golden/payroll-go.dump @@ -8,7 +8,7 @@ {"id":"constant:c62a1da458c5f390b2ac8d4662f81801","kind":"constant","name":"PayrollService_ListPayslips_FullMethodName","qualified_name":"PayrollService_ListPayslips_FullMethodName","file_path":"internal/gen/payrollpb/payroll_grpc.pb.go","language":"go","start_line":18,"end_line":18,"start_column":1,"end_column":90,"docstring":null,"signature":"= \"/payroll.v1.PayrollService/ListPayslips\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:ccc3400ce237825d2c42ef5fe177fe35","kind":"constant","name":"CycleOpen","qualified_name":"CycleOpen","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":33,"docstring":null,"signature":"= \"open\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:e8d10fecacdc9d2de71a3e3f7cd39d77","kind":"constant","name":"CycleClosed","qualified_name":"CycleClosed","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":35,"docstring":null,"signature":"= \"closed\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, payslip_builder.go","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} +{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, explore-allocation-1500.test.ts, payslip_builder.go, probe-allocation.mjs, allocation-fixtures.js","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:cmd/payrolld/main.go","kind":"file","name":"main.go","qualified_name":"cmd/payrolld/main.go","file_path":"cmd/payrolld/main.go","language":"go","start_line":1,"end_line":36,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/domain/payroll/payslip.go","kind":"file","name":"payslip.go","qualified_name":"internal/domain/payroll/payslip.go","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":1,"end_line":160,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/gen/fkit/employee/contract.go","kind":"file","name":"contract.go","qualified_name":"internal/gen/fkit/employee/contract.go","file_path":"internal/gen/fkit/employee/contract.go","language":"go","start_line":1,"end_line":85,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} diff --git a/__tests__/markdown-path-refs.test.ts b/__tests__/markdown-path-refs.test.ts index fbf4d87036..4c043cceb2 100644 --- a/__tests__/markdown-path-refs.test.ts +++ b/__tests__/markdown-path-refs.test.ts @@ -1,23 +1,27 @@ /** * Markdown path mentions are only resolvable through the file-path arm, which - * collects candidates by basename. A target whose basename no indexed file has - * therefore cannot resolve, and creating an import node plus a reference for - * it only adds failed rows (and resolution work) to the graph. The extractor - * skips those targets when it is given the index's known file basenames, while - * keeping every target the resolver can still match: a live basename at a - * different path (the suffix/basename fallback), symbols inside a live file, - * and in-page anchors. + * collects candidates by basename. A relative file-shaped target whose + * basename no indexed file has therefore cannot resolve. On a full index the + * extractor skips those targets, while keeping everything the resolver can + * still match: routes (root-absolute or extensionless), modules and table + * rows named without a file, symbols inside a live file, and in-page anchors. + * Incremental sync keeps every target so one added later can still resolve + * from the parked reference. */ import { describe, it, expect } from 'vitest'; import { extractFromSource } from '../src/extraction/tree-sitter'; import type { MarkdownExtractorContext } from '../src/extraction/markdown-extractor'; -function known(...files: string[]): MarkdownExtractorContext { +function context(prune: boolean, ...files: string[]): MarkdownExtractorContext { return { knownFileNames: new Set(files.map((file) => file.split('/').pop()!)), + pruneMissingTargets: prune, }; } +const FULL_INDEX = context(true, 'docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); +const SYNC = context(false, 'docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); + const SOURCE = [ '# Guide', '', @@ -29,6 +33,9 @@ const SOURCE = [ '- [missing](../src/missing.ts)', '- [same name, other path](../apps/widget.ts#usage)', '- [pricing](/pricing)', + '- [users route](/api/users/)', + '- [guide route](../guides/auth)', + '- [diagram](../assets/releaseblueprint.svg)', '- [this section](#links)', '', 'Mention `scripts/run.sh`, `scripts/widget.ts` and `src/widget.ts::createWidget`.', @@ -40,19 +47,22 @@ const SOURCE = [ '```', ].join('\n'); -function refs(context?: MarkdownExtractorContext): string[] { - return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], context) - .unresolvedReferences.map((ref) => ref.referenceName); +function extract(withContext?: MarkdownExtractorContext) { + return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], withContext); +} + +function refs(withContext?: MarkdownExtractorContext): string[] { + return extract(withContext).unresolvedReferences.map((ref) => ref.referenceName); } -function importTargets(context?: MarkdownExtractorContext): string[] { - return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], context) +function importTargets(withContext?: MarkdownExtractorContext): string[] { + return extract(withContext) .nodes.filter((node) => node.kind === 'import') .map((node) => node.qualifiedName); } describe('Markdown path-existence check', () => { - it('keeps every path target when no known-file set is supplied', () => { + it('keeps every path target when no context is supplied', () => { const names = refs(); expect(names).toContain('src/widget.ts'); expect(names).toContain('src/missing.ts'); @@ -60,31 +70,44 @@ describe('Markdown path-existence check', () => { expect(importTargets().some((qn) => qn.includes('missing.ts'))).toBe(true); }); - it('drops targets no indexed file can satisfy', () => { - const context = known('docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); - const names = refs(context); + it('drops relative file-shaped targets no indexed file can satisfy', () => { + const names = refs(FULL_INDEX); expect(names).not.toContain('src/missing.ts'); - expect(names).not.toContain('scripts/run.sh'); - expect(names).not.toContain('scripts/missing.sh'); + expect(names).not.toContain('docs/scripts/run.sh'); + expect(names).not.toContain('docs/scripts/missing.sh'); expect(names.some((name) => name.startsWith('src/missing.ts::'))).toBe(false); - expect(importTargets(context).some((qn) => qn.includes('missing.ts'))).toBe(false); + expect(importTargets(FULL_INDEX).some((qn) => qn.includes('missing.ts'))).toBe(false); }); - it('keeps targets the resolver reaches by basename, symbol, anchor or command', () => { - const context = known('docs/guide.md', 'src/widget.ts', 'scripts/exists.sh'); - const names = refs(context); + it('keeps sync references so a target added later can resolve', () => { + const names = refs(SYNC); + expect(names).toContain('src/missing.ts'); + expect(names).toContain('docs/scripts/run.sh'); + expect(importTargets(SYNC).some((qn) => qn.includes('missing.ts'))).toBe(true); + }); + + it('keeps routes and targets the resolver reaches by basename, symbol, anchor or command', () => { + const names = refs(FULL_INDEX); // A live basename at another path still resolves through the suffix and // basename arms, so it must survive. expect(names).toContain('apps/widget.ts#usage'); expect(names).toContain('docs/scripts/widget.ts'); - // A bare name is not a file path: `/pricing` normalizes to `pricing`, - // which the resolver can match to a route or module node by name. + // Root-absolute and extensionless links can name routes, modules or table + // rows; the file-path arm is not their only route to a match. expect(names).toContain('pricing'); + expect(names).toContain('api/users/'); + expect(names).toContain('guides/auth'); // A symbol inside a live file. expect(names).toContain('docs/src/widget.ts::createWidget'); // A pure in-page anchor names this file. - expect(names.some((name) => name.startsWith('docs/guide.md#'))).toBe(true); + expect(names).toContain('docs/guide.md#links'); // A script the fence runs. expect(names).toContain('docs/scripts/exists.sh'); }); + + it('keeps a pruned target in the file digest for search', () => { + const result = extract(FULL_INDEX); + const fileNode = result.nodes.find((node) => node.kind === 'file'); + expect(fileNode?.docstring).toContain('releaseblueprint.svg'); + }); }); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index c47a76af3a..640219bd6a 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -2415,7 +2415,11 @@ export class ExtractionOrchestrator { }; for (const file of files) rememberBasename(file); for (const file of this.queries.getAllFilePaths()) rememberBasename(file); - const extractionContext = { knownFileNames }; + // A full index has the whole project in hand, so a relative file target + // with no matching basename is dead. An incremental sync re-extracts one + // file at a time and must keep the reference for a target added later + // (#1240), so indexFile() passes no context and keeps every target. + const extractionContext = { knownFileNames, pruneMissingTargets: true }; /** Only meaningful when nothing was indexable — see IndexResult (#1502). */ const skipSummary = (): Pick => { let total = 0; diff --git a/src/extraction/markdown-extractor.ts b/src/extraction/markdown-extractor.ts index 18aeda02da..41077cc2dc 100644 --- a/src/extraction/markdown-extractor.ts +++ b/src/extraction/markdown-extractor.ts @@ -24,12 +24,21 @@ export interface MarkdownExtractorContext { /** * Basenames of every file the index contains. Markdown path mentions and * links resolve through the file-path arm, which looks candidates up by - * basename; a slash-shaped relative target whose basename is absent cannot + * basename; a relative file-shaped target whose basename is absent cannot * resolve, so it creates no import node and no reference. Omitted by * callers without an index (unit tests, single-file tooling), which keeps * the old permissive behaviour. */ knownFileNames?: ReadonlySet; + /** + * Drop a relative, file-shaped target whose basename no indexed file has. + * Set only for a full project index: an incremental sync re-extracts one + * file at a time, and a target added later must still resolve from the + * parked reference (#1240), so sync keeps every target. Root-absolute and + * extensionless targets stay too — they can name routes, modules or table + * rows, which the resolver matches by name without a file node. + */ + pruneMissingTargets?: boolean; } /** @@ -49,11 +58,20 @@ export class MarkdownExtractor { private headings: HeadingInfo[] = []; private referenceKeys = new Set(); private knownFileNames?: ReadonlySet; + private pruneMissingTargets: boolean; + /** + * Every normalized target in encounter order, including ones pruned from + * the graph. `collectKeyReferences` builds the file's searchable digest from + * this list, so a link to an unindexed asset still makes its file findable + * even though no import node or reference is created for it. + */ + private digestReferences: string[] = []; constructor(filePath: string, source: string, context: MarkdownExtractorContext = {}) { this.filePath = normalizeRelativePath(filePath); this.lines = source.split('\n'); this.knownFileNames = context.knownFileNames; + this.pruneMissingTargets = context.pruneMissingTargets === true; } extract(): ExtractionResult { @@ -427,8 +445,12 @@ export class MarkdownExtractor { const scriptPath = extractScriptPath(command); if (scriptPath) { const normalizedTarget = this.normalizeReference(scriptPath); - if (normalizedTarget && this.isResolvableTarget(normalizedTarget)) { - this.addReference(nodeId, normalizedTarget, 'calls', line, column); + if (normalizedTarget) { + if (this.isResolvableTarget(normalizedTarget, scriptPath)) { + this.addReference(nodeId, normalizedTarget, 'calls', line, column); + } else { + this.digestReferences.push(normalizedTarget); + } } } } @@ -442,7 +464,11 @@ export class MarkdownExtractor { if (!isLocalReference(target)) continue; const normalizedTarget = this.normalizeReference(target); - if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; + if (!normalizedTarget) continue; + if (!this.isResolvableTarget(normalizedTarget, target)) { + this.digestReferences.push(normalizedTarget); + continue; + } const column = match.index; const label = stripInlineMarkdown(match[1] || target) || target; @@ -479,7 +505,11 @@ export class MarkdownExtractor { if (line[match.index - 1] === '(') continue; // inline markdown link target, already handled if (line.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); - if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; + if (!normalizedTarget) continue; + if (!this.isResolvableTarget(normalizedTarget, raw)) { + this.digestReferences.push(normalizedTarget); + continue; + } const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); const node: Node = { @@ -514,7 +544,11 @@ export class MarkdownExtractor { for (const symbol of symbols) { const normalizedTarget = this.normalizeReference(`${filePart}::${symbol}`); - if (!normalizedTarget || !this.isResolvableTarget(normalizedTarget)) continue; + if (!normalizedTarget) continue; + if (!this.isResolvableTarget(normalizedTarget, filePart)) { + this.digestReferences.push(normalizedTarget); + continue; + } if (createImportNode) { const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); @@ -569,9 +603,12 @@ export class MarkdownExtractor { const raw = match[0]!; if (text.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); - if (normalizedTarget && this.isResolvableTarget(normalizedTarget)) { - this.addReference(owner.id, normalizedTarget, 'references', lineNumber, match.index); + if (!normalizedTarget) continue; + if (!this.isResolvableTarget(normalizedTarget, raw)) { + this.digestReferences.push(normalizedTarget); + continue; } + this.addReference(owner.id, normalizedTarget, 'references', lineNumber, match.index); } } @@ -605,6 +642,10 @@ export class MarkdownExtractor { const key = `${fromNodeId}:${referenceKind}:${referenceName}:${line}:${column}`; if (this.referenceKeys.has(key)) return; this.referenceKeys.add(key); + // The digest keeps the base list's order: accepted references arrive here + // exactly as they did before the pruning pass; skipped ones are pushed at + // their extraction site, just before this call would have run. + this.digestReferences.push(referenceName); this.unresolvedReferences.push({ fromNodeId, referenceName, @@ -638,26 +679,32 @@ export class MarkdownExtractor { } /** - * Whether a normalized reference target names a file the index can resolve. - * The resolver's file-path arm collects candidates by basename, so a - * relative path whose basename has no file node cannot resolve; symbol - * (`::`) and anchor (`#`) suffixes do not change that. A pure in-page anchor - * names this file, which is always present. + * Whether a normalized reference target can resolve, for the pruning pass a + * full index runs. The resolver's file-path arm collects candidates by + * basename, so a relative file-shaped path whose basename has no file node + * cannot resolve; symbol (`::`) and anchor (`#`) suffixes do not change + * that. A pure in-page anchor names this file, which is always present. * - * Bare names stay: a root-absolute link (`/pricing`), a same-directory - * mention at the project root (`Next.js`, `config.toml`) or a parent - * reference can resolve to a route, module or table-row node by name, and - * the resolver's name arms do not need a file node for that. Only a - * slash-shaped relative path is unambiguously a file path. Without a - * known-file set the check is skipped. + * Everything else stays. A root-absolute target (`/api/users/`, `/pricing`) + * can be a route; an extensionless path (`docs/guides/auth`) can be a route + * or directory; a bare name (`Next.js`, `config.toml`) can be a module or + * table row. The resolver's name arms match those without a file node. + * Without a known-file set, or outside a full index, the check is skipped. */ - private isResolvableTarget(normalizedTarget: string): boolean { - if (!this.knownFileNames) return true; + private isResolvableTarget(normalizedTarget: string, rawTarget: string): boolean { + if (!this.pruneMissingTargets || !this.knownFileNames) return true; + // Root-absolute links are routes or absolute file paths, never a relative + // project file the basename check can settle. + if (rawTarget.startsWith('/')) return true; const [pathAndSymbol] = splitAnchor(normalizedTarget); const [pathPart] = splitFileSymbol(pathAndSymbol); if (!pathPart || pathPart === this.filePath) return true; if (!pathPart.includes('/')) return true; - return this.knownFileNames.has(path.posix.basename(pathPart)); + const name = path.posix.basename(pathPart); + // No file extension: a route, a directory link, or a file the resolver's + // name arms may still match. + if (!/\.[A-Za-z0-9]+$/.test(name)) return true; + return this.knownFileNames.has(name); } private buildDocstring(startLine: number, endLine: number): string | undefined { @@ -736,18 +783,19 @@ export class MarkdownExtractor { /** * The documents/symbols this file points at, de-duplicated and compacted * (basename + `::symbol`/`#anchor`) in document order. Self-anchors and pure - * in-page links are dropped. Bounded so the whole digest stays short enough - * to display. + * in-page links are dropped. Includes targets pruned from the graph, so a + * link to an unindexed asset still makes the file findable. Bounded so the + * whole digest stays short enough to display. */ private collectKeyReferences(maxItems = 12, maxChars = 150): string[] { const seen = new Set(); const out: string[] = []; let budget = maxChars; - for (const ref of this.unresolvedReferences) { - const [pathSym] = splitAnchor(ref.referenceName); + for (const referenceName of this.digestReferences) { + const [pathSym] = splitAnchor(referenceName); const [pathPart] = splitFileSymbol(pathSym); if (!pathPart || pathPart === this.filePath) continue; // pure/self anchor - const display = compactRefDisplay(ref.referenceName); + const display = compactRefDisplay(referenceName); if (!display || seen.has(display)) continue; if (out.length >= maxItems) break; if (out.length > 0 && budget - display.length - 2 < 0) break; diff --git a/src/extraction/parse-pool.ts b/src/extraction/parse-pool.ts index 61f592b7d3..05b3ae8091 100644 --- a/src/extraction/parse-pool.ts +++ b/src/extraction/parse-pool.ts @@ -336,6 +336,7 @@ export class ParseWorkerPool { knownFileNames: this.sharedContext?.knownFileNames ? [...this.sharedContext.knownFileNames] : undefined, + pruneMissingTargets: this.sharedContext?.pruneMissingTargets, }); } diff --git a/src/extraction/parse-worker.ts b/src/extraction/parse-worker.ts index 388093d100..76bf578200 100644 --- a/src/extraction/parse-worker.ts +++ b/src/extraction/parse-worker.ts @@ -27,13 +27,15 @@ import type { Language, ExtractionResult } from '../types'; * Sent once per worker spawn (the pool re-sends it to a recycled worker). */ let knownFileNames: Set | undefined; +let pruneMissingTargets: boolean | undefined; -parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; knownFileNames?: string[] }) => { +parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; knownFileNames?: string[]; pruneMissingTargets?: boolean }) => { if (msg.type === 'load-grammars') { // Nothing to load since the wasm path was removed: grammars live in the // kernel binary. The handshake stays so the pool's readiness model // (spawned → ready → idle) is unchanged. knownFileNames = msg.knownFileNames ? new Set(msg.knownFileNames) : undefined; + pruneMissingTargets = msg.pruneMissingTargets; parentPort!.postMessage({ type: 'grammars-loaded' }); } else if (msg.type === 'parse') { const { id, filePath, content, frameworkNames } = msg; @@ -79,7 +81,9 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st content!, language, frameworkNames, - knownFileNames ? { knownFileNames } : undefined + knownFileNames || pruneMissingTargets + ? { knownFileNames, pruneMissingTargets } + : undefined ); parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 }); From 04a289350bcb354c46385e05180df0415c9e96d9 Mon Sep 17 00:00:00 2001 From: bompus Date: Sun, 1 Nov 2026 00:05:57 -0600 Subject: [PATCH 3/3] perf(markdown): skip link nodes for targets no file can supply Review round 2 changed the pruning from the reference to the decorative import node: - Every reference is kept. The resolver's route/module/table arms still see it, and sync can resolve a target added later from the parked failed reference. A sync integration test starts with a full index and adds the target afterwards. - Only the import node and its contains edge are skipped on a full index, when the target's basename no indexed file has. A relative route with a dotted final segment (`api/users.json`) still resolves from the owner. - Skipped targets go into a separate `links:` digest part (64 targets / 800 chars), so a link past the 12-item display digest stays searchable; the test uses a 13th link. Supabase: 152,274 -> 141,880 nodes and 328,137 -> 317,743 edges, import nodes 14,038 -> 3,644, failed Markdown refs unchanged at 11,368. Resolved Markdown edges are byte-identical on supabase, n8n, pretix and discourse. Full index median 16.176 -> 15.953 s (Hyperfine 2.0.0, 8 runs per build, two ABBA rounds, IQRs not overlapping). Full suite 960 files / 11,353 passed. --- CHANGELOG.md | 2 +- __tests__/fixtures/golden/payroll-go.dump | 16 +- __tests__/integration/full-pipeline.test.ts | 39 ++++ __tests__/markdown-path-refs.test.ts | 68 ++++--- src/extraction/markdown-extractor.ts | 193 ++++++++++---------- 5 files changed, 180 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500a64d3e4..548e1c9f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,7 +100,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes -- **A documentation link to a file that is not there no longer becomes a graph node.** A Markdown link or path mention with a relative file path created an import node and a reference even when no indexed file had that basename, so the reference could never resolve. On supabase a full index now skips 3,622 of 14,038 Markdown import nodes and 3,823 of 11,368 failed Markdown references. Root-absolute links (`/api/users/`, `/pricing`), extensionless paths and bare names stay, because the resolver can match those to routes, modules or table rows by name; a root-absolute link is never a relative project file. Incremental sync keeps every target, so a file added later still resolves from the parked reference. A skipped target stays in the file's search digest, so a link to an unindexed asset still makes its file findable. Resolved Markdown edges and file digests are byte-identical on supabase, n8n, pretix and discourse; index time shows no clear change. Re-index Markdown-heavy projects after upgrading. +- **A documentation link to a file that is not there no longer gets its own graph node.** A Markdown link or path mention whose target basename no indexed file has minted an import node and a `contains` edge, even though the resolver's file-path arm could not match it. On supabase a full index now skips 10,394 of 14,038 Markdown import nodes and their `contains` edges. The reference is always kept, so the resolver's route, module and table arms still match one (a relative `api/users.json` link included), `codegraph sync` can resolve a target added later from the parked failed reference, and a skipped target stays in a separate `links:` part of the file digest so it remains searchable past the 12-item display digest. Resolved Markdown edges are byte-identical on supabase (3,058), n8n, pretix and discourse, and the failed Markdown reference count is unchanged (11,368 on supabase). A full supabase index is a median 16.176 s to 15.953 s (Hyperfine 2.0.0, 8 runs per build, two ABBA rounds, 0.223 s/1.4%, interquartile ranges not overlapping). Re-index Markdown-heavy projects after upgrading. - **`codegraph sessions` and `codegraph_sessions` can search history before a date.** A search could only look back a number of days (`--since`, `sinceDays`), so its best 100 hits always came from the newest matching sessions and older context could not be reached. `--until ` and `until` keep only docs before a date (read as UTC) or a timestamp that names its zone; together with `--since` they bound a window. diff --git a/__tests__/fixtures/golden/payroll-go.dump b/__tests__/fixtures/golden/payroll-go.dump index fb5b54073a..cd7381ff6a 100644 --- a/__tests__/fixtures/golden/payroll-go.dump +++ b/__tests__/fixtures/golden/payroll-go.dump @@ -1,4 +1,4 @@ -== nodes (310) +== nodes (309) {"id":"constant:03cf22f9cca66ee928171cceccbf44b1","kind":"constant","name":"LineEarning","qualified_name":"LineEarning","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":25,"end_line":25,"start_column":1,"end_column":35,"docstring":null,"signature":"= \"earning\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:49f451a6950b1d7e90eb6cfe2776192b","kind":"constant","name":"LineDeduction","qualified_name":"LineDeduction","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":26,"end_line":26,"start_column":1,"end_column":37,"docstring":null,"signature":"= \"deduction\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:56977e604248e3674f9296a2f68154bb","kind":"constant","name":"PayrollService_GetPayrollCycle_FullMethodName","qualified_name":"PayrollService_GetPayrollCycle_FullMethodName","file_path":"internal/gen/payrollpb/payroll_grpc.pb.go","language":"go","start_line":17,"end_line":17,"start_column":1,"end_column":93,"docstring":null,"signature":"= \"/payroll.v1.PayrollService/GetPayrollCycle\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -8,7 +8,7 @@ {"id":"constant:c62a1da458c5f390b2ac8d4662f81801","kind":"constant","name":"PayrollService_ListPayslips_FullMethodName","qualified_name":"PayrollService_ListPayslips_FullMethodName","file_path":"internal/gen/payrollpb/payroll_grpc.pb.go","language":"go","start_line":18,"end_line":18,"start_column":1,"end_column":90,"docstring":null,"signature":"= \"/payroll.v1.PayrollService/ListPayslips\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:ccc3400ce237825d2c42ef5fe177fe35","kind":"constant","name":"CycleOpen","qualified_name":"CycleOpen","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":33,"docstring":null,"signature":"= \"open\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"constant:e8d10fecacdc9d2de71a3e3f7cd39d77","kind":"constant","name":"CycleClosed","qualified_name":"CycleClosed","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":35,"docstring":null,"signature":"= \"closed\"","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, explore-allocation-1500.test.ts, payslip_builder.go, probe-allocation.mjs, allocation-fixtures.js","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} +{"id":"file:69f63e0581b8138f256b63d2aac60e8a","kind":"file","name":"README.md","qualified_name":"README.md","file_path":"README.md","language":"markdown","start_line":1,"end_line":96,"start_column":0,"end_column":0,"docstring":"A synthetic Go service reproducing the repo shape from issue #1500: · refs: payslip.go, store.go, .pb.go, cycle.go, explore-allocation-1500.test.ts, payslip_builder.go, probe-allocation.mjs, allocation-fixtures.js · links: .pb.go, explore-allocation-1500.test.ts, allocation-fixtures.js, explore-budget-allocation.md","signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:cmd/payrolld/main.go","kind":"file","name":"main.go","qualified_name":"cmd/payrolld/main.go","file_path":"cmd/payrolld/main.go","language":"go","start_line":1,"end_line":36,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/domain/payroll/payslip.go","kind":"file","name":"payslip.go","qualified_name":"internal/domain/payroll/payslip.go","file_path":"internal/domain/payroll/payslip.go","language":"go","start_line":1,"end_line":160,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"file:internal/gen/fkit/employee/contract.go","kind":"file","name":"contract.go","qualified_name":"internal/gen/fkit/employee/contract.go","file_path":"internal/gen/fkit/employee/contract.go","language":"go","start_line":1,"end_line":85,"start_column":0,"end_column":0,"docstring":null,"signature":null,"visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -143,7 +143,6 @@ {"id":"import:76a964b6134242af279f95c049ac84c5","kind":"import","name":"store.go","qualified_name":"README.md::path:internal/gen/fkit/payroll/store.go:87:56","file_path":"README.md","language":"markdown","start_line":87,"end_line":87,"start_column":56,"end_column":90,"docstring":null,"signature":"internal/gen/fkit/payroll/store.go","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:7afb422fcf48196342c2d44ba9df65b4","kind":"import","name":"context","qualified_name":"context","file_path":"internal/gen/fkit/payroll/store.go","language":"go","start_line":9,"end_line":9,"start_column":1,"end_column":10,"docstring":null,"signature":"\"context\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:85d6602214f6381a034f6034da409655","kind":"import","name":"database/sql","qualified_name":"database/sql","file_path":"internal/gen/fkit/employee/contract.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":15,"docstring":null,"signature":"\"database/sql\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -{"id":"import:8808ae056445d4f7fae22fa62685c9a4","kind":"import","name":".pb.go","qualified_name":"README.md::path:.pb.go:41:15","file_path":"README.md","language":"markdown","start_line":41,"end_line":41,"start_column":15,"end_column":21,"docstring":null,"signature":".pb.go","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:8a55768bc75a7d6e26bd621aa388263c","kind":"import","name":"time","qualified_name":"time","file_path":"internal/gen/fkit/payroll/payroll_cycle.go","language":"go","start_line":11,"end_line":11,"start_column":1,"end_column":7,"docstring":null,"signature":"\"time\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:8ca292ed8417acda5c41ff30467e3188","kind":"import","name":"google.golang.org/protobuf/runtime/protoimpl","qualified_name":"google.golang.org/protobuf/runtime/protoimpl","file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","start_line":11,"end_line":11,"start_column":1,"end_column":57,"docstring":null,"signature":"protoimpl \"google.golang.org/protobuf/runtime/protoimpl\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"import:94d6b9da5b862536535fdb747dd8ef41","kind":"import","name":"google.golang.org/protobuf/reflect/protoreflect","qualified_name":"google.golang.org/protobuf/reflect/protoreflect","file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","start_line":10,"end_line":10,"start_column":1,"end_column":63,"docstring":null,"signature":"protoreflect \"google.golang.org/protobuf/reflect/protoreflect\"","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} @@ -309,7 +308,7 @@ {"id":"variable:6b7bef84822056845bdabeb41a821f0a","kind":"variable","name":"ErrCycleClosed","qualified_name":"ErrCycleClosed","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":16,"end_line":16,"start_column":4,"end_column":58,"docstring":"ErrCycleClosed is returned when a cycle has already been finalized.","signature":"= errors.New(\"payroll cycle is closed\")","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:9c3a0c9681a69504be5d3af6ddb5378c","kind":"variable","name":"file_payroll_v1_payroll_proto_goTypes","qualified_name":"file_payroll_v1_payroll_proto_goTypes","file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","start_line":132,"end_line":137,"start_column":4,"end_column":1,"docstring":null,"signature":"= []any{\n\t(*RunPayrollCycleRequest)(nil),\n\t(*RunPayrollCycleResponse)(nil),\n\t(*Payslip)(nil),\n\t(*Payro...","visibility":null,"is_exported":0,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} {"id":"variable:baa4aee814b380ae99f1b1829407b106","kind":"variable","name":"ErrNoEmployees","qualified_name":"ErrNoEmployees","file_path":"internal/usecase/payroll/cycle.go","language":"go","start_line":19,"end_line":19,"start_column":4,"end_column":72,"docstring":"ErrNoEmployees is returned when a cycle resolves to an empty roster.","signature":"= errors.New(\"payroll cycle has no active employees\")","visibility":null,"is_exported":1,"is_async":0,"is_static":0,"is_abstract":0,"decorators":null,"type_parameters":null,"return_type":null} -== edges (654) +== edges (653) {"source":"file:69f63e0581b8138f256b63d2aac60e8a","target":"module:1be454da0953954aef45c525ee8f8b51","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"file:cmd/payrolld/main.go","target":"function:90eb51f51a3d9a397d4a53f26f2019aa","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} {"source":"file:cmd/payrolld/main.go","target":"import:0a9ee69fa4c64208200c3bebcbbf0b75","kind":"contains","metadata":null,"line":null,"col":null,"provenance":null} @@ -844,7 +843,6 @@ {"source":"module:1be454da0953954aef45c525ee8f8b51","target":"module:f8d63ed69e573ade27e2d1d4fc0ae8b4","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:526c9118c077229729a7b9846c16882a","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:68f937efbcbc4ebd082c1c11e72df0ab","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} -{"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:8808ae056445d4f7fae22fa62685c9a4","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:39730d6601ee76ddcd5e78d50abfe5a2","target":"import:fa23f47e6bf27f154f8044ad15346d18","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:900ad55709598429050c36805696291a","target":"import:44b0fa4765bb8c262fb8b290698b9c05","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} {"source":"module:900ad55709598429050c36805696291a","target":"import:e2b363aa11b1fe2c65cb016ea68a0948","kind":"contains","metadata":null,"line":null,"col":null,"provenance":"heuristic"} @@ -964,7 +962,7 @@ {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:4325013490f7f7a77604146f5e38c292","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"RunPayrollCycleRequest\",\"refKind\":\"calls\"}","line":133,"col":1,"provenance":null} {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:d541b867b240edf388feab5ff2002afd","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"Payslip\",\"refKind\":\"calls\"}","line":135,"col":1,"provenance":null} {"source":"variable:9c3a0c9681a69504be5d3af6ddb5378c","target":"struct:e66f442bcf28c3bd0a3f42fbacb69b2a","kind":"references","metadata":"{\"confidence\":0.9,\"resolvedBy\":\"exact-match\",\"refName\":\"RunPayrollCycleResponse\",\"refKind\":\"calls\"}","line":134,"col":1,"provenance":null} -== refs (595) +== refs (599) {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/platform/clock","reference_kind":"imports","line":9,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"clock"} {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/store/payslipstore","reference_kind":"imports","line":10,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"payslipstore"} {"from_node_id":"file:cmd/payrolld/main.go","reference_name":"github.com/example/payroll-svc/internal/transport/httpapi","reference_kind":"imports","line":11,"col":1,"candidates":null,"file_path":"cmd/payrolld/main.go","language":"go","status":"failed","name_tail":"httpapi"} @@ -1084,6 +1082,7 @@ {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"Context","reference_kind":"references","line":58,"col":32,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"Context"} {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"DB","reference_kind":"references","line":58,"col":49,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"DB"} {"from_node_id":"function:3de0ed87c97e45d857a0ea66c1b2e97d","reference_name":"db.ExecContext","reference_kind":"calls","line":60,"col":11,"candidates":null,"file_path":"internal/gen/fkit/employee/employee.go","language":"go","status":"failed","name_tail":"ExecContext","failure_reason":"unknown-receiver"} +{"from_node_id":"function:4827032314863eb2b99f791098f8668c","reference_name":"scripts/agent-eval/probe-allocation.mjs","reference_kind":"calls","line":75,"col":0,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"mjs"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"Request","reference_kind":"references","line":16,"col":43,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"Request"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"ResponseWriter","reference_kind":"references","line":16,"col":19,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"ResponseWriter"} {"from_node_id":"function:4bfd3234b60c7cb018e09fa29aea24df","reference_name":"http.StatusOK","reference_kind":"function_ref","line":17,"col":20,"candidates":null,"file_path":"internal/transport/httpapi/router.go","language":"go","status":"failed","name_tail":"StatusOK"} @@ -1493,11 +1492,14 @@ {"from_node_id":"method:fd95c33dd58bbddc5221ee4ced0bbcd3","reference_name":"len","reference_kind":"calls","line":69,"col":4,"candidates":null,"file_path":"internal/usecase/payroll/cycle.go","language":"go","status":"failed","name_tail":"len"} {"from_node_id":"method:ff7a7eea8969534c02484b666f0ef39b","reference_name":"protoimpl.X.MessageStringOf","reference_kind":"calls","line":125,"col":48,"candidates":null,"file_path":"internal/gen/payrollpb/payroll.pb.go","language":"go","status":"failed","name_tail":"MessageStringOf","failure_reason":"unknown-receiver"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":".pb.go","reference_kind":"references","line":41,"col":15,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} +{"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"__tests__/explore-allocation-1500.test.ts","reference_kind":"references","line":52,"col":15,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"explore-allocation-1500.test.ts"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"cycle.go","reference_kind":"references","line":49,"col":50,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"payslip.go","reference_kind":"references","line":38,"col":31,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:39730d6601ee76ddcd5e78d50abfe5a2","reference_name":"store.go","reference_kind":"references","line":38,"col":45,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:900ad55709598429050c36805696291a","reference_name":"cycle.go","reference_kind":"references","line":66,"col":1,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} {"from_node_id":"module:900ad55709598429050c36805696291a","reference_name":"payslip_builder.go","reference_kind":"references","line":67,"col":54,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"go"} +{"from_node_id":"module:a883d05f3bb8edb262fd18c8c2849417","reference_name":"docs/design/explore-budget-allocation.md","reference_kind":"references","line":82,"col":1,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"explore-budget-allocation.md"} +{"from_node_id":"module:a883d05f3bb8edb262fd18c8c2849417","reference_name":"scripts/agent-eval/allocation-fixtures.js","reference_kind":"references","line":79,"col":49,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"allocation-fixtures.js"} {"from_node_id":"module:f8d63ed69e573ade27e2d1d4fc0ae8b4","reference_name":"internal/gen/fkit/payroll/store.go","reference_kind":"references","line":87,"col":56,"candidates":null,"file_path":"README.md","language":"markdown","status":"failed","name_tail":"store.go"} {"from_node_id":"struct:09a019d5ab5eefb1f1bc3b25ad95d81c","reference_name":"Time","reference_kind":"references","line":38,"col":22,"candidates":null,"file_path":"internal/usecase/payroll/cycle.go","language":"go","status":"failed","name_tail":"Time"} {"from_node_id":"struct:13bfc2acbe1de6557e9a28c0be484ffa","reference_name":"DB","reference_kind":"references","line":15,"col":9,"candidates":null,"file_path":"internal/gen/fkit/payroll/store.go","language":"go","status":"failed","name_tail":"DB"} @@ -2272,7 +2274,7 @@ {"file_path":"internal/usecase/payroll/prorate.go","name":"to","kind":"param","node_id":null,"target_spec":null,"target_name":null,"exported_as":null,"export_form":null,"scope_start":34,"scope_end":39,"storage":null,"line":34} {"file_path":"internal/usecase/payroll/prorate.go","name":"window","kind":"local","node_id":null,"target_spec":null,"target_name":null,"exported_as":null,"export_form":null,"scope_start":11,"scope_end":24,"storage":null,"line":12} == files (21) -{"path":"README.md","language":"markdown","node_count":34} +{"path":"README.md","language":"markdown","node_count":33} {"path":"cmd/payrolld/main.go","language":"go","node_count":10} {"path":"internal/domain/payroll/payslip.go","language":"go","node_count":25} {"path":"internal/gen/fkit/employee/contract.go","language":"go","node_count":11} diff --git a/__tests__/integration/full-pipeline.test.ts b/__tests__/integration/full-pipeline.test.ts index 6a2fca222f..6d32099019 100644 --- a/__tests__/integration/full-pipeline.test.ts +++ b/__tests__/integration/full-pipeline.test.ts @@ -131,6 +131,45 @@ node scripts/release.mjs } }); + it('resolves a full-index Markdown link after sync adds its target', async () => { + fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'README.md'), + `# Guide + +## Links + +See [Setup](docs/setup.md#install). +` + ); + + const cg = await CodeGraph.init(tempDir); + try { + await cg.indexAll(); + // The target is absent: the reference is kept (parked as failed), but no + // edge exists yet. + expect(cg.getNodesInFile('docs/setup.md')).toEqual([]); + const linksHeading = cg + .getNodesInFile('README.md') + .find((n) => n.kind === 'module' && n.name === 'Links')!; + expect( + cg.getOutgoingEdges(linksHeading.id) + .filter((e) => e.kind === 'imports') + .map((e) => cg.getNode(e.target)?.qualifiedName) + ).not.toContain('docs/setup.md#install'); + + fs.writeFileSync(path.join(tempDir, 'docs', 'setup.md'), '# Install\n\nInstall it.\n'); + await cg.sync(); + + const install = cg + .getNodesInFile('docs/setup.md') + .find((n) => n.kind === 'module' && n.name === 'Install')!; + expect(cg.getIncomingEdges(install.id, ['imports']).some((e) => e.source === linksHeading.id)).toBe(true); + } finally { + cg.destroy(); + } + }); + it('indexes Markdown template tables and resolves file-symbol references to implementation functions', async () => { fs.mkdirSync(path.join(tempDir, 'phases'), { recursive: true }); fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true }); diff --git a/__tests__/markdown-path-refs.test.ts b/__tests__/markdown-path-refs.test.ts index 4c043cceb2..3a65d179cd 100644 --- a/__tests__/markdown-path-refs.test.ts +++ b/__tests__/markdown-path-refs.test.ts @@ -1,12 +1,11 @@ /** - * Markdown path mentions are only resolvable through the file-path arm, which - * collects candidates by basename. A relative file-shaped target whose - * basename no indexed file has therefore cannot resolve. On a full index the - * extractor skips those targets, while keeping everything the resolver can - * still match: routes (root-absolute or extensionless), modules and table - * rows named without a file, symbols inside a live file, and in-page anchors. - * Incremental sync keeps every target so one added later can still resolve - * from the parked reference. + * A Markdown path target whose basename no indexed file has cannot resolve to + * a file through the resolver's file-path arm. Its reference is still kept — + * the resolver's route/module/symbol arms may match it, and sync can retry it + * when a file appears later (#1240) — but its own import node only added a + * search child and a `contains` edge. A full index skips that node; sync keeps + * every node. Pruned targets stay in a separate, larger `links:` part of the + * file digest so a link beyond the 12-item display digest is still searchable. */ import { describe, it, expect } from 'vitest'; import { extractFromSource } from '../src/extraction/tree-sitter'; @@ -35,6 +34,19 @@ const SOURCE = [ '- [pricing](/pricing)', '- [users route](/api/users/)', '- [guide route](../guides/auth)', + '- [dotted route](api/users.json)', + '- [one](../src/one.ts)', + '- [two](../src/two.ts)', + '- [three](../src/three.ts)', + '- [four](../src/four.ts)', + '- [five](../src/five.ts)', + '- [six](../src/six.ts)', + '- [seven](../src/seven.ts)', + '- [eight](../src/eight.ts)', + '- [nine](../src/nine.ts)', + '- [ten](../src/ten.ts)', + '- [eleven](../src/eleven.ts)', + '- [twelve](../src/twelve.ts)', '- [diagram](../assets/releaseblueprint.svg)', '- [this section](#links)', '', @@ -61,8 +73,8 @@ function importTargets(withContext?: MarkdownExtractorContext): string[] { .map((node) => node.qualifiedName); } -describe('Markdown path-existence check', () => { - it('keeps every path target when no context is supplied', () => { +describe('Markdown link-node pruning', () => { + it('keeps every reference and link node without a context', () => { const names = refs(); expect(names).toContain('src/widget.ts'); expect(names).toContain('src/missing.ts'); @@ -70,44 +82,40 @@ describe('Markdown path-existence check', () => { expect(importTargets().some((qn) => qn.includes('missing.ts'))).toBe(true); }); - it('drops relative file-shaped targets no indexed file can satisfy', () => { + it('keeps the reference but skips the link node on a full index', () => { const names = refs(FULL_INDEX); - expect(names).not.toContain('src/missing.ts'); - expect(names).not.toContain('docs/scripts/run.sh'); - expect(names).not.toContain('docs/scripts/missing.sh'); - expect(names.some((name) => name.startsWith('src/missing.ts::'))).toBe(false); + // The reference survives so routes, modules and later syncs can match it. + expect(names).toContain('src/missing.ts'); + expect(names).toContain('docs/scripts/run.sh'); + expect(names).toContain('docs/scripts/missing.sh'); + // The dead target's import node does not exist. expect(importTargets(FULL_INDEX).some((qn) => qn.includes('missing.ts'))).toBe(false); + // A target whose basename is known keeps its node. + expect(importTargets(FULL_INDEX).some((qn) => qn.includes('src/widget.ts'))).toBe(true); }); - it('keeps sync references so a target added later can resolve', () => { - const names = refs(SYNC); - expect(names).toContain('src/missing.ts'); - expect(names).toContain('docs/scripts/run.sh'); + it('keeps every link node on an incremental sync', () => { expect(importTargets(SYNC).some((qn) => qn.includes('missing.ts'))).toBe(true); }); - it('keeps routes and targets the resolver reaches by basename, symbol, anchor or command', () => { + it('keeps routes and targets the resolver reaches by name', () => { const names = refs(FULL_INDEX); - // A live basename at another path still resolves through the suffix and - // basename arms, so it must survive. expect(names).toContain('apps/widget.ts#usage'); expect(names).toContain('docs/scripts/widget.ts'); - // Root-absolute and extensionless links can name routes, modules or table - // rows; the file-path arm is not their only route to a match. expect(names).toContain('pricing'); expect(names).toContain('api/users/'); expect(names).toContain('guides/auth'); - // A symbol inside a live file. + // A relative route with a dotted final segment is kept for name matching. + expect(names).toContain('docs/api/users.json'); expect(names).toContain('docs/src/widget.ts::createWidget'); - // A pure in-page anchor names this file. expect(names).toContain('docs/guide.md#links'); - // A script the fence runs. expect(names).toContain('docs/scripts/exists.sh'); }); - it('keeps a pruned target in the file digest for search', () => { - const result = extract(FULL_INDEX); - const fileNode = result.nodes.find((node) => node.kind === 'file'); + it('keeps a pruned target searchable beyond the display digest budget', () => { + const fileNode = extract(FULL_INDEX).nodes.find((node) => node.kind === 'file'); + // The target is the 13th link, past the 12-item `refs:` budget. + expect(fileNode?.docstring).toContain('links:'); expect(fileNode?.docstring).toContain('releaseblueprint.svg'); }); }); diff --git a/src/extraction/markdown-extractor.ts b/src/extraction/markdown-extractor.ts index 41077cc2dc..7037e14ec8 100644 --- a/src/extraction/markdown-extractor.ts +++ b/src/extraction/markdown-extractor.ts @@ -24,19 +24,18 @@ export interface MarkdownExtractorContext { /** * Basenames of every file the index contains. Markdown path mentions and * links resolve through the file-path arm, which looks candidates up by - * basename; a relative file-shaped target whose basename is absent cannot - * resolve, so it creates no import node and no reference. Omitted by - * callers without an index (unit tests, single-file tooling), which keeps - * the old permissive behaviour. + * basename; a target whose basename is absent cannot resolve to a file. + * Omitted by callers without an index (unit tests, single-file tooling), + * which keeps the old permissive behaviour. */ knownFileNames?: ReadonlySet; /** - * Drop a relative, file-shaped target whose basename no indexed file has. - * Set only for a full project index: an incremental sync re-extracts one - * file at a time, and a target added later must still resolve from the - * parked reference (#1240), so sync keeps every target. Root-absolute and - * extensionless targets stay too — they can name routes, modules or table - * rows, which the resolver matches by name without a file node. + * Skip the per-link import node for a target whose basename no indexed file + * has. Set only for a full project index. The reference is always kept: the + * resolver still tries the target's route/module/symbol arms and sync can + * retry a failed reference when a file appears later (#1240). The link node + * is decoration (a search child and a `contains` edge), so dropping it + * loses no edge. Without this flag every target gets its node, as before. */ pruneMissingTargets?: boolean; } @@ -60,12 +59,11 @@ export class MarkdownExtractor { private knownFileNames?: ReadonlySet; private pruneMissingTargets: boolean; /** - * Every normalized target in encounter order, including ones pruned from - * the graph. `collectKeyReferences` builds the file's searchable digest from - * this list, so a link to an unindexed asset still makes its file findable - * even though no import node or reference is created for it. + * Targets whose import node was skipped. Their references are still stored; + * this list feeds a separate, larger `links:` part of the file digest so a + * link beyond the display digest's 12-item budget still stays searchable. */ - private digestReferences: string[] = []; + private prunedTargets: string[] = []; constructor(filePath: string, source: string, context: MarkdownExtractorContext = {}) { this.filePath = normalizeRelativePath(filePath); @@ -446,11 +444,7 @@ export class MarkdownExtractor { if (scriptPath) { const normalizedTarget = this.normalizeReference(scriptPath); if (normalizedTarget) { - if (this.isResolvableTarget(normalizedTarget, scriptPath)) { - this.addReference(nodeId, normalizedTarget, 'calls', line, column); - } else { - this.digestReferences.push(normalizedTarget); - } + this.addReference(nodeId, normalizedTarget, 'calls', line, column); } } } @@ -465,33 +459,33 @@ export class MarkdownExtractor { const normalizedTarget = this.normalizeReference(target); if (!normalizedTarget) continue; - if (!this.isResolvableTarget(normalizedTarget, target)) { - this.digestReferences.push(normalizedTarget); - continue; - } const column = match.index; - const label = stripInlineMarkdown(match[1] || target) || target; - const displayName = path.posix.basename(stripAnchor(normalizedTarget)) || label; - const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${column}`, lineNumber); - const node: Node = { - id: nodeId, - kind: 'import', - name: displayName, - qualifiedName: `${this.filePath}::link:${normalizedTarget}:${lineNumber}:${column}`, - filePath: this.filePath, - language: 'markdown', - signature: match[0], - docstring: label, - startLine: lineNumber, - endLine: lineNumber, - startColumn: column, - endColumn: column + match[0].length, - updatedAt: Date.now(), - }; + if (this.shouldCreateLinkNode(normalizedTarget)) { + const label = stripInlineMarkdown(match[1] || target) || target; + const displayName = path.posix.basename(stripAnchor(normalizedTarget)) || label; + const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${column}`, lineNumber); + const node: Node = { + id: nodeId, + kind: 'import', + name: displayName, + qualifiedName: `${this.filePath}::link:${normalizedTarget}:${lineNumber}:${column}`, + filePath: this.filePath, + language: 'markdown', + signature: match[0], + docstring: label, + startLine: lineNumber, + endLine: lineNumber, + startColumn: column, + endColumn: column + match[0].length, + updatedAt: Date.now(), + }; - this.nodes.push(node); - this.edges.push({ source: owner.id, target: nodeId, kind: 'contains', provenance: 'heuristic' }); + this.nodes.push(node); + this.edges.push({ source: owner.id, target: nodeId, kind: 'contains', provenance: 'heuristic' }); + } else { + this.prunedTargets.push(normalizedTarget); + } this.addReference(owner.id, normalizedTarget, 'imports', lineNumber, column); } } @@ -506,29 +500,29 @@ export class MarkdownExtractor { if (line.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); if (!normalizedTarget) continue; - if (!this.isResolvableTarget(normalizedTarget, raw)) { - this.digestReferences.push(normalizedTarget); - continue; - } - const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); - const node: Node = { - id: nodeId, - kind: 'import', - name: path.posix.basename(stripAnchor(normalizedTarget)) || normalizedTarget, - qualifiedName: `${this.filePath}::path:${normalizedTarget}:${lineNumber}:${match.index}`, - filePath: this.filePath, - language: 'markdown', - signature: raw, - startLine: lineNumber, - endLine: lineNumber, - startColumn: match.index, - endColumn: match.index + raw.length, - updatedAt: Date.now(), - }; + if (this.shouldCreateLinkNode(normalizedTarget)) { + const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); + const node: Node = { + id: nodeId, + kind: 'import', + name: path.posix.basename(stripAnchor(normalizedTarget)) || normalizedTarget, + qualifiedName: `${this.filePath}::path:${normalizedTarget}:${lineNumber}:${match.index}`, + filePath: this.filePath, + language: 'markdown', + signature: raw, + startLine: lineNumber, + endLine: lineNumber, + startColumn: match.index, + endColumn: match.index + raw.length, + updatedAt: Date.now(), + }; - this.nodes.push(node); - this.edges.push({ source: owner.id, target: nodeId, kind: 'contains', provenance: 'heuristic' }); + this.nodes.push(node); + this.edges.push({ source: owner.id, target: nodeId, kind: 'contains', provenance: 'heuristic' }); + } else { + this.prunedTargets.push(normalizedTarget); + } this.addReference(owner.id, normalizedTarget, 'references', lineNumber, match.index); } } @@ -545,12 +539,10 @@ export class MarkdownExtractor { for (const symbol of symbols) { const normalizedTarget = this.normalizeReference(`${filePart}::${symbol}`); if (!normalizedTarget) continue; - if (!this.isResolvableTarget(normalizedTarget, filePart)) { - this.digestReferences.push(normalizedTarget); - continue; - } + const createNode = createImportNode && this.shouldCreateLinkNode(normalizedTarget); + if (createImportNode && !createNode) this.prunedTargets.push(normalizedTarget); - if (createImportNode) { + if (createNode) { const nodeId = generateNodeId(this.filePath, 'import', `${normalizedTarget}:${lineNumber}:${match.index}`, lineNumber); const node: Node = { id: nodeId, @@ -604,10 +596,6 @@ export class MarkdownExtractor { if (text.slice(match.index + raw.length, match.index + raw.length + 2) === '::') continue; const normalizedTarget = this.normalizeReference(raw); if (!normalizedTarget) continue; - if (!this.isResolvableTarget(normalizedTarget, raw)) { - this.digestReferences.push(normalizedTarget); - continue; - } this.addReference(owner.id, normalizedTarget, 'references', lineNumber, match.index); } } @@ -642,10 +630,6 @@ export class MarkdownExtractor { const key = `${fromNodeId}:${referenceKind}:${referenceName}:${line}:${column}`; if (this.referenceKeys.has(key)) return; this.referenceKeys.add(key); - // The digest keeps the base list's order: accepted references arrive here - // exactly as they did before the pruning pass; skipped ones are pushed at - // their extraction site, just before this call would have run. - this.digestReferences.push(referenceName); this.unresolvedReferences.push({ fromNodeId, referenceName, @@ -679,32 +663,34 @@ export class MarkdownExtractor { } /** - * Whether a normalized reference target can resolve, for the pruning pass a - * full index runs. The resolver's file-path arm collects candidates by - * basename, so a relative file-shaped path whose basename has no file node - * cannot resolve; symbol (`::`) and anchor (`#`) suffixes do not change - * that. A pure in-page anchor names this file, which is always present. - * - * Everything else stays. A root-absolute target (`/api/users/`, `/pricing`) - * can be a route; an extensionless path (`docs/guides/auth`) can be a route - * or directory; a bare name (`Next.js`, `config.toml`) can be a module or - * table row. The resolver's name arms match those without a file node. - * Without a known-file set, or outside a full index, the check is skipped. + * Whether a target should get its own import node on a full index. The + * resolver's file-path arm collects candidates by basename, so a target + * whose basename no file node has cannot resolve to a file. Its reference + * is still recorded (the resolver's route/module/symbol arms may match it, + * and sync can retry it when a file appears), but the link node only added a + * search child and a `contains` edge. Without a known-file set, or outside a + * full index, every target keeps its node. */ - private isResolvableTarget(normalizedTarget: string, rawTarget: string): boolean { + private shouldCreateLinkNode(normalizedTarget: string): boolean { if (!this.pruneMissingTargets || !this.knownFileNames) return true; - // Root-absolute links are routes or absolute file paths, never a relative - // project file the basename check can settle. - if (rawTarget.startsWith('/')) return true; const [pathAndSymbol] = splitAnchor(normalizedTarget); const [pathPart] = splitFileSymbol(pathAndSymbol); if (!pathPart || pathPart === this.filePath) return true; - if (!pathPart.includes('/')) return true; - const name = path.posix.basename(pathPart); - // No file extension: a route, a directory link, or a file the resolver's - // name arms may still match. - if (!/\.[A-Za-z0-9]+$/.test(name)) return true; - return this.knownFileNames.has(name); + return this.knownFileNames.has(path.posix.basename(pathPart)); + } + + /** Pruned link targets for the file digest's separate `links:` part. */ + private summarizePrunedTargets(): string[] { + const seen = new Set(); + const out: string[] = []; + for (const target of this.prunedTargets) { + const display = compactRefDisplay(target); + if (!display || seen.has(display)) continue; + seen.add(display); + out.push(display); + if (out.length >= 64) break; + } + return out; } private buildDocstring(startLine: number, endLine: number): string | undefined { @@ -744,9 +730,15 @@ export class MarkdownExtractor { private finalizeFileDigest(fileNode: Node): void { const intro = this.buildIntro(); const refs = this.collectKeyReferences(); + const links = this.summarizePrunedTargets(); const parts: string[] = []; if (intro) parts.push(intro); if (refs.length > 0) parts.push(`refs: ${refs.join(', ')}`); + if (links.length > 0) { + let text = links.join(', '); + if (text.length > 800) text = `${text.slice(0, 797).trimEnd()}...`; + parts.push(`links: ${text}`); + } const digest = parts.join(' · ').trim(); fileNode.docstring = digest || this.buildDocstring(1, Math.min(this.lines.length, 40)); } @@ -791,7 +783,8 @@ export class MarkdownExtractor { const seen = new Set(); const out: string[] = []; let budget = maxChars; - for (const referenceName of this.digestReferences) { + for (const ref of this.unresolvedReferences) { + const referenceName = ref.referenceName; const [pathSym] = splitAnchor(referenceName); const [pathPart] = splitFileSymbol(pathSym); if (!pathPart || pathPart === this.filePath) continue; // pure/self anchor