diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 81b0c7124..f62212d0f 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -89,6 +89,7 @@ ack api-surface 61a7f958d4bf7e92 4 cid=8329d88eed614818 now=4 was=3 facet=contra ack api-surface 61e5df9e1e40ff70 5 cid=e2155dd6082b880a E2 (terminality round A, lane E): +1 defaulted out-param: the receipt's ONE next= is read off the fold it renders (callers 2, incompatible 0) ack api-surface 6302e2e27e23bcde 6 cid=7202bbc7db7cc1da C1 F-06/F-07/F-10 (the listing-paging round): three listing verbs learn to disclose and page their row listings, and every gating row is that one change. api-surface 14 = ONE trailing DEFAULTED parameter each (an int pageOffset, an McpPageArgs window, a SituPageArgs, or the next= invocation a header now carries) on the emitters that must be TOLD their window — writeFlags/writeGate, writeCappedRows/writeCappedList/writeFlip/writeFlipHeader/writeFlipLights, computeFlip, nearestGateNames (which gains its cap and its TOTAL, the disclosure itself), situShowingNote/writeSituation, and the three MCP twins flagsText/flipText/situationDiffJson; additive by construction, every pre-existing call site compiles unchanged, and the alternative — a second capped emitter per verb — is the drift this repo removes rather than adds, because two emitters that disagree about a window can drop the row that IS the answer. verbosity 3 = kDocDriftLegend +20 lines and writeDocDriftPage +10 are the in-band vocabulary a reader needs to read shown_failed=/failed_capped=/failed_total= where they meet it (the rationale and the next= scan were already hoisted OUT of the body into their own function and comment, which took the complexity row to zero and the LOC row from +48 to +10); dispatchMcpLine +12 is two pagedResult wrappers on a pre-existing 1376-line dispatcher this lane adds to rather than creates. complexity 1 = the same dispatcher, +9 on a base of 518. duplication 1 = flagsText | flipText at 110 tokens, down from 131 after the shared mcpRowCap fold; the residual is getIndex + compute + captureXml, the shape EVERY index-backed MCP twin in this file has, and merging two verbs that return different results behind one entry point would be worse code than the clone. short-horizon-churn 19 = this lane's own footprint across cli.h/docdrift.h/darkflags.h/flipimpact.h/situ.h/mcp*, plus cli.h symbols three other lanes touched the same day; none foreign, none thrash. ack api-surface 6483016e50837e70 3 cid=4c53bf4b5b7d4a0c degrade disclosure: each read seam and the ref pool now take the DISCLOSE sink whose field the document reads +ack api-surface 6511634f0768c591 5 cid=0f75a00dd52bc21d now=5 was=3 facet=contract-change p=src/graphlegend.h:921 impactRollupLegend carries the importer cut and the type rollup clauses. That signature is the one upstream already ships. Local main still has the three argument form. ack api-surface 654551bf984cc299 5 cid=913136f787574436 P9 the folded edit receipt: runEditVerb gains the postCheck opt-out parameter (defaulted true, so every existing call site is source-compatible) and the line-range + post-check splice; editplan's ensureStage and receipt each gain the root parameter they need to spell one identity (M12's root-relative rule, applied to the sibling it missed) and to run the per-op post-check. The +15 LOC in runEditVerb is the copy-out-before-the-index-rebuild discipline the fold requires — every reference into ing dangles once getIndex re-ingests, and that is stated in the code. ack api-surface 68301548c0560f5d 3 cid=07b87ac0d0ae631a interface-dispatch lane (2026-09-16, test/narrowcheck.sh arms 26-36): Narrower::rule2RecvVarType takes the two inputs class identity needs — the ClassIdentity index built once in buildGraph and the chaUp base-name graph C++ lookup walks for inherited nested types (1 -> 3 parameters). Single call site (graph.h buildGraph Rule 2), updated in the same commit. Every other quality-delta row this lane produced was fixed in code rather than acked: the identity logic moved out of Narrower into IdentityNarrower (Narrower verbosity 409 -> 800 undone), nine over-complex functions split, and five clone rows removed by reusing extent::inSet with kHeadRuleLangs/kExtentClassKinds, symbolsByFileInIdOrder, and a flat include index. ack api-surface 69cd4f735374521d 5 cid=27ae3ff0233a5863 now=5 was=4 facet=contract-change p=src/editcheck.h:755 train 30 fcall-forward nit N1 (d62c00e0): ONE trailing span parameter, the graph's keptForwards, so this call-site reader applies the one model.h forwardCounts rule (--uses' own) instead of counting a forward buildGraph dropped; every caller passes g.keptForwards in the same commit; red-first in test/fcallforwardcheck.sh S1-S3. diff --git a/CHANGELOG.md b/CHANGELOG.md index 082d83aab..95e1ba1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli ## [Unreleased] +### Added — markdown links bind the exact heading (#409 item 1, #414) + +A cross-file `[text](path/other.md#slug)` now edges to that file's heading — `--callees` / `--callers` on a doc section +list the sections it links to instead of one file-level edge. The extractor caches the link as written; `buildGraph` joins +the path against the linking file, so `../b/notes.md` never lands on a same-named file in the linker's own directory and a +link to a heading named like one of the linking file's own headings does not bind to the latter. Two headings of the same +slug give one edge (the first). A missing heading falls back to the target file's edge; percent-encoded slugs and GitHub's +`-1` duplicate suffix are not modelled and also fall back. A markdown link that no call shares is marked `via="link"` on +`--callers`, `--callees`, `--path`, `--connect`, and on a map or body callee row. `count=` and `cross_kind` stay the same. A crawl rooted at `.` stores `./docs/a.md`. The link index normalizes that path, so a root of `.` pins the same heading as an absolute root. `find_symbol` and `find_referencing_symbols` mark the link row, and the answer adds `via_link_note` when a row has it. `--json` callers and callees add that note too. `--safe-delete` and `--impact` mark a direct doc caller `via="link"`. `callers=` and `reaches=` still count it. An in-file anchor, a wikilink, and a root-relative link are `via="link"` too. The mark follows the reference, so an unpinned link does not clear a pinned one on the same pair. A link whose file is in the tree and whose slug names no heading carries `anchor="missing"`, unless the slug cannot be judged (non-ASCII, percent-encoded, a `-1` duplicate suffix of a heading that exists, a file that is one heading line): that link keeps the file edge with no mark. Columnar `` is `2` for a direct markdown link and `1` for a name-only edge. Gate: `test/mdsectioncheck.sh`. + ### Changed — `--for`'s first eight rows name up to eight distinct files before any file takes a second row (head round-robin) A routed (subtoken+body) `--for` answer's head was a list of the highest-scoring rows, and one file often took most of it: on @@ -271,7 +281,7 @@ it now names nothing.) Go's written `*os.File` no longer resolves to an in-tree type that happens to be called `File`. A Java/Kotlin class-name receiver binds an in-tree class only where the caller's file sees it — its package, a single-type import naming it, an on-demand import of its package (`import ext.Util; Util.open()`, `import ext.*; Util.open()`, `Util::open` no longer bind -an in-tree `Util` as proven). `kParserVer` and `kCacheVersion` move (198 and 36 in this release, see the versions note). +an in-tree `Util` as proven). `kParserVer` and `kCacheVersion` move (206 and 36 in this release, see the versions note). ### Fixed — `f.call( … )` and `f.apply( … )` on a proven function are calls of `f` `handleRequest.call( request.server, null, request, reply )` used to be recorded only as a member call named `call`, so @@ -281,7 +291,7 @@ the file declares as a function, or imports from a relative module, now stands f where the resolver proves a function or method: an imported object, const or class (`rpc.call()` on a required module object, `Klass.call( this )`) and a name bound by name alone add nothing. `f.bind( … )` is a value use of `f`, never a call. Floors, shared with the bare call `f()`: a function-local `var f` / `let f` declared after the site does not -shadow it, and a re-assignment `f = …` is not followed. `kParserVer` and `kCacheVersion` move (198 and 36 in this release, see the versions note). +shadow it, and a re-assignment `f = …` is not followed. `kParserVer` and `kCacheVersion` move (206 and 36 in this release, see the versions note). ### Fixed — a method a name-template lookup can name no longer reads `risk="none-found"` `getattr(obj, f"watch_{name}")`, `hasattr`, `cls.__dict__.get(f"on_{n}")` and JS/TS `this['compile' + node.typename]` @@ -295,7 +305,7 @@ each template's candidates (``, first 8, `next=` for the rest) `--safe-delete`, when nothing else was found, it still makes `risk="hedged"` and names its site as a `` row. A template with no literal side at all (`codes[sc + '']`, `f"{m}"`) carries no name and records nothing, like a bare `obj[k]`. Matched by name, never a proven call: `callers=`, `impact_reaches=`, `count=` and the `--dead-code` list are unchanged. The -`--json` and `--format=columnar` roots carry both counts but not the rows; the MCP transport carries neither yet. Cache format and parser version move (the reference role set grew; 36 and 198 in this release, see the versions +`--json` and `--format=columnar` roots carry both counts but not the rows; the MCP transport carries neither yet. Cache format and parser version move (the reference role set grew; 36 and 206 in this release, see the versions note). Gate: `test/safedeletecheck.sh` arms 9–18. ### Changed — `--for` tail rank rows name their file's top symbols @@ -323,7 +333,7 @@ scope only, a Rust `const` / `static` at module, `impl`, trait or `extern` level level; a call in a function-local initializer belongs to the enclosing function. Package-level grouped Go `var ( a = …; b = … )` declarations, which were missed (grouped `const` was already read), are now one symbol per name. On fzf, gotestsum and nfpm (Go), 574 function-local symbols leave the map, no package-level one is lost, and 87 grouped names are added. `kParserVer` moves -(198 in this release, see the versions note). Gate: `test/localdeclcheck.sh`. +(206 in this release, see the versions note). Gate: `test/localdeclcheck.sh`. ### Changed — `--path` hop rows name a member by its owner (`Owner.name`) @@ -587,15 +597,16 @@ server, when the cgroup's charge is the larger reading: restarting the server do ### Changed — the versions this release moves, stated once -`kParserVer` 167 → 198: the Clojure grammar (#349) took 171 on `main` and slice 4a (#400) 173 (its full-use file tag 174); the value-reference walk through +`kParserVer` 167 → 206: the Clojure grammar (#349) took 171 on `main` and slice 4a (#400) 173 (its full-use file tag 174); the value-reference walk through `??`/`||`/`&&`/`or`/`and` carried 173 on its branch, the Ruby/Python subscript references and Ruby's main-object mixins 174 (file tag 175), and the Elixir quote live holes (#380) 145 on theirs; they landed together at 176 (file tag 177). The minimum-arity field (`paramsMin`) carried 178 (file tag 179) on its branch and the Go receiver scope (#403) 182 (file tag 183); those landed at 184 (file tag 185). Call-result typing carried 180 (file tag 181), the `f.call` / `f.apply` forwarders 188 (189), the function-local Go / Rust / GDScript declarations 190 (191) and the name-template references 192 (193) on their branches; those landed at 196 (file tag 197). The qualified-annotation and `x op= v` rebinding change carried 194 (195) on -its branch. This release is 198, above every number a branch build has used, so no cache such a build wrote is read as this -release's; its full-use file tag is 199. +its branch and landed with train 31 at 198 (199); branches not in this release carried 202 (203) and 204 (205). The markdown section +links (#409) carried 185 (186) on their branch and were renumbered when it merged. This release is 206, above every number a branch +build has used, so no cache such a build wrote is read as this release's; its full-use file tag is 207. `kCacheVersion` 31 → 36: a reference record's role byte gained two values, the subscript role (9) and the inert role (10, numbered 9 on #380's branch, which predates the subscript role), and the binding kind byte one (Ruby's main-object mixin) (32); a definition record gained `paramsMin`, the fewest arguments a call can pass (33 on its branch, 34 in the train that landed diff --git a/src/columnar.h b/src/columnar.h index 9bcef0e66..ade0c81f4 100644 --- a/src/columnar.h +++ b/src/columnar.h @@ -168,9 +168,9 @@ inline void emitColumnarDepthColumn( std::FILE* out, const std::vector& rw::emitTo( out, "{}", column ); } -// FE-B: the optional dense `` column — 1 where the row's edge is name-only (via="name"), one value per row in row -// order. Present only when some row carries it, so an answer with no hedged row pays 0 bytes; columnarViaField is its -// fields= suffix. +// The optional dense `` column — 1 where the row's edge is name-only (via="name"), 2 where it is a direct markdown +// link (via="link"), one value per row in row order. Present only when some row carries it, so an answer with no such +// row pays 0 bytes; columnarViaField is its fields= suffix. inline bool columnarViaPresent( const std::vector* viaName ) noexcept { return viaName != nullptr && std::any_of( viaName->begin(), viaName->end(), []( char c ) { return c != 0; } ); @@ -179,6 +179,20 @@ inline std::string_view columnarViaField( const std::vector* viaName ) noe { return columnarViaPresent( viaName ) ? ",via" : ""; } +inline void emitColumnarViaColumn( std::FILE* out, const std::vector& via, std::size_t rowCount ) +{ + rw::emitTo( out, "" ); + for( std::size_t i = 0; i < rowCount; ++i ) + { + if( i ) + { + std::fputc( ',', out ); + } + const unsigned char v = ( i < via.size() ) ? static_cast( via[ i ] ) : 0; + std::fputc( v <= 9 ? char( '0' + v ) : '0', out ); + } + rw::emitTo( out, "" ); +} // CALLSITE-AT (2026-10-09): the optional `` column — each row's call sites (editcheck.h rowCallSites), its tokens joined // by a space, XML-escaped with a value comma written , (kColumnarLegend's rule), an empty value for a row with none — @@ -291,7 +305,7 @@ inline void emitColumnarOptionalColumns( std::FILE* out, const IngestResult& ing } if( columnarViaPresent( viaName ) ) { - emitColumnarFlagColumn( out, "via", rows.size(), [ & ]( std::size_t i ) { return i < viaName->size() && ( *viaName )[ i ] != 0; } ); + emitColumnarViaColumn( out, *viaName, rows.size() ); } if( extras.atTokens != nullptr && extras.atTotals != nullptr ) { diff --git a/src/compactlegend.h b/src/compactlegend.h index ef959548a..95a799b57 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -252,6 +252,8 @@ inline constexpr std::string_view kCompactProsePrefixes[] = ""; } +inline std::string viaLinkLegendComment() +{ + return std::string( ""; +} // …and the MAP's spelling, as its own comment: the same three facts as kViaNameLegend in the fewest bytes (a map prints the // most hedged rows of any answer, and its --max-tokens fit pays for every legend byte in rows), plus x=, the count a merged // row carries (serialize.h writeMapCalleeRows), and where each merged candidate is listed. No `--` inside an XML comment: // the flag is named callees=. inline constexpr std::string_view kMapViaNameLegend = ""; +inline constexpr std::string_view kMapViaLinkLegend = + ""; +inline constexpr std::string_view kMapAnchorMissLegend = + ""; +inline std::string anchorMissLegendComment() +{ + return std::string( ""; +} // …and MCP's spelling: the same sentence as the `via_note` key of an answer one of whose entries carries "via":"name". -inline std::string viaNameNoteJson() +inline std::string viaFactNoteJson( std::string text, std::string_view from, std::string_view jsonSpelling, std::string_view key ) { - std::string text = kViaNameLegend; - for( std::size_t at = text.find( "via=\"name\"" ); at != std::string::npos; at = text.find( "via=\"name\"", at ) ) + for( std::size_t at = text.find( from ); at != std::string::npos; at = text.find( from, at ) ) { - text.replace( at, 10, "\\\"via\\\":\\\"name\\\"" ); // via="name" -> \"via\":\"name\" inside the JSON string - at += 16; + text.replace( at, from.size(), jsonSpelling ); + at += jsonSpelling.size(); } if( const std::size_t at = text.find( "on a row" ); at != std::string::npos ) { @@ -1241,7 +1275,19 @@ inline std::string viaNameNoteJson() { text.pop_back(); } - return ",\"via_note\":\"" + text + "\""; + return ",\"" + std::string( key ) + "\":\"" + text + "\""; +} +inline std::string viaNameNoteJson() +{ + return viaFactNoteJson( kViaNameLegend, "via=\"name\"", "\\\"via\\\":\\\"name\\\"", "via_note" ); +} +inline std::string viaLinkNoteJson() +{ + return viaFactNoteJson( kViaLinkLegend, "via=\"link\"", "\\\"via\\\":\\\"link\\\"", "via_link_note" ); +} +inline std::string anchorMissNoteJson() +{ + return viaFactNoteJson( kAnchorMissLegend, "anchor=\"missing\"", "\\\"anchor\\\":\\\"missing\\\"", "anchor_missing_note" ); } // The same fact in --for's two dialects, which are prose strips joined by ';' and ':' rather than XML comment diff --git a/src/infra/mdslug.h b/src/infra/mdslug.h new file mode 100644 index 000000000..2d2086d36 --- /dev/null +++ b/src/infra/mdslug.h @@ -0,0 +1,40 @@ +#pragma once + +// mdslug.h — the ONE GitHub-style anchor slug of a markdown heading name. +// +// Two stages need it and neither can include the other: ingest_docs.h resolves an in-file [text](#slug) at +// extraction, and graph.h resolves a cross-file [text](other.md#slug) once every heading in the repo is a +// symbol. A leaf header keeps them on one definition (a second copy is the clone --quality-delta flags). + +#include +#include +#include + +namespace rw +{ + +// alnum lowered, spaces→'-', '-'/'_' kept, everything else dropped. First match wins on collision +// (GitHub's -1/-2 suffixes and percent-encoded slugs are not modelled; such a link falls back to the +// target file's own edge — disclosed here rather than guessed, and never marked anchor="missing": see +// mdSlugOutsideModel and graph.h mdSlugProvablyAbsent). +inline std::string mdSlugOf( std::string_view name ) +{ + std::string slug; + slug.reserve( name.size() ); + for( const char c : name ) + { + if( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) || c == '_' || c == '-' ) { slug += c; } + else if( c >= 'A' && c <= 'Z' ) { slug += char( c - 'A' + 'a' ); } + else if( c == ' ' || c == '\t' ) { slug += '-'; } + } + return slug; +} + +// A link slug this function cannot decide about: a byte >= 0x80 (it drops non-ASCII letters, GitHub keeps them) or a '%' +// (an encoded slug; it does not decode). A miss on such a slug proves nothing about the heading. +inline bool mdSlugOutsideModel( std::string_view slug ) noexcept +{ + return std::any_of( slug.begin(), slug.end(), []( char c ) { return c == '%' || static_cast( c ) >= 0x80; } ); +} + +} // namespace rw diff --git a/src/ingest.cpp b/src/ingest.cpp index bb300fe5f..cddb26107 100644 --- a/src/ingest.cpp +++ b/src/ingest.cpp @@ -192,6 +192,7 @@ extern "C" #include "handlershape.h" // --quality-delta's handler/placeholder shapes — AstWalk::HandlerShapes rides the shared file walk (reads nodeTextOf above) #include "ingest_relations.h" #include "ingest_jsimports.h" +#include "infra/mdslug.h" // mdSlugOf — shared with graph.h (the cross-file link bind) #include "ingest_docs.h" #include "ingest_names.h" #include "ingest_binds.h" diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 703b9dd9e..8b44965c6 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -345,11 +345,17 @@ constexpr std::uint32_t kCacheVersion = 36; // 36: train 30 — strict // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 198; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 206; // bump on any grammar/.scm/extraction change + // 206 = 2026-10-18 (#409, PR #414, renumbered again at merge with main 939dbb69): strictly above every + // live number — main 196 (rich 197), train 31 198 (199), lane rust-receivers 202 (203), lane ix1-slot + // 204 (205). Rich file tag 207. kCacheVersion stays 36 (no record format change). // 198 = 2026-10-18 (train 31): strictly above every number a live build used — main 196 (rich 197), // lane h1-annot 194 (195); body-fold, hrr, callers-band2, dangling-warnings and ix2-slice kept // their base value 184. Rich file tag 199. kCacheVersion stays 36: no lane changed a record format // (h1-annot appends two LocalBindKind values; no lane touched a cache serializer). + // 185 = 2026-10-18 (#409): a cross-file markdown [text](other.md#slug) keeps the link as + // written (RawRef::memberPath, unbound) so graph.h pins the exact file + heading. + // 185, not 184: 184 is train 29. Rich file tag 186. kCacheVersion stays 34. // 196 = 2026-10-18 (train 30): strictly above every number a live build used — main 184 (rich 185), // lane call-result-typing 180 (rich 181), lane fcall-forward 188 (189), lane go-local-var 190 (191), // lane name-template-dispatch 192 (193), lane h1-annot (train 31) 194 (195); tail-sym and diff --git a/src/ingest_docs.h b/src/ingest_docs.h index 6af4b4bc9..f0e88c4e9 100644 --- a/src/ingest_docs.h +++ b/src/ingest_docs.h @@ -40,7 +40,9 @@ namespace // (5) links → edges: [[slug]] (the agent-memory/Obsidian convention) and [text](other.md) / // [label]: other.md → a ref named by the target's stem (the resolve ladder's same-dir preference // lands it on the file node); [text](#anchor) → a ref named by the matching heading in THIS file -// (GitHub slugify; same-file preference wins) — a doc-section→doc-section edge. Dangling links drop. +// (GitHub slugify; same-file preference wins) — a doc-section→doc-section edge. A cross-file +// [text](other.md#anchor) keeps the stem on the ref and the link as written in memberPath; graph.h +// pins it to that exact file's heading (the file node when the heading is absent). Dangling links drop. // Determinism: the walk is a preorder over the AST (byte order); pending anchors resolve in byte order. // Names are XML-escaped downstream; CR bytes never enter a name, embedded newlines flatten to spaces. namespace mdtier @@ -79,21 +81,8 @@ inline std::string mdCleanHeadingText( std::string_view raw ) return name; } -// GitHub anchor slug of a heading name: alnum lowered, spaces→'-', '-'/'_' kept, everything else -// dropped. First match wins on collision (GitHub's -1/-2 suffixes are not modelled; a collided anchor -// resolves to the first heading — disclosed here rather than guessed). -inline std::string mdSlugOf( std::string_view name ) -{ - std::string slug; - slug.reserve( name.size() ); - for( const char c : name ) - { - if( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) || c == '_' || c == '-' ) { slug += c; } - else if( c >= 'A' && c <= 'Z' ) { slug += char( c - 'A' + 'a' ); } - else if( c == ' ' || c == '\t' ) { slug += '-'; } - } - return slug; -} +// GitHub anchor slug of a heading name: infra/mdslug.h (shared with graph.h's cross-file link bind). +using rw::mdSlugOf; inline void mdWalk( TSNode node, std::string_view src, bool inQuote, std::uint32_t depth, MdWalkOut& out ) { @@ -202,6 +191,23 @@ inline void mdWalk( TSNode node, std::string_view src, bool inQuote, std::uint32 } // namespace mdtier +inline void lowercaseMarkdownSlug( std::string& written ) +{ + const std::size_t frag = written.find( '#' ); + if( frag == std::string::npos ) + { + return; + } + for( std::size_t k = frag + 1; k < written.size(); ++k ) + { + char& c = written[ k ]; + if( c >= 'A' && c <= 'Z' ) + { + c = char( c - 'A' + 'a' ); + } + } +} + void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_view stem, TSNode root, std::vector& defs, std::vector& refs ) { @@ -284,8 +290,9 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi }; // link-target handling shared by inline links, reference definitions and (via slug) anchors. - // Cross-file: [x](other.md) / [x](../a/other.md#frag) → ref named by the target's STEM (the resolve - // ladder's same-dir preference lands it on other.md's file node, exactly like [[other]]). Anchors: + // Cross-file: [x](other.md) / [x](../a/other.md) → ref named by the target's STEM (the resolve + // ladder's same-dir preference lands it on other.md's file node, exactly like [[other]]). + // The ref also carries the link as written (memberPath) so buildGraph can pin the EXACT file and heading. Anchors: // [x](#slug) → pending, resolved against THIS file's headings once they are all known. std::vector> pendingAnchors; // (refByte, slug) const auto emitLinkTarget = [ & ]( std::string_view target, std::uint32_t refByte ) @@ -309,9 +316,10 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi pendingAnchors.emplace_back( refByte, std::move( slug ) ); return; } + const std::string_view writtenTarget = target; // path#slug as written, for the exact bind in buildGraph if( const std::size_t frag = target.find( '#' ); frag != std::string_view::npos ) { - target = target.substr( 0, frag ); // cross-file anchor part deferred (stem edge only, disclosed) + target = target.substr( 0, frag ); } const bool isMd = target.size() > 3 && ( target.ends_with( ".md" ) || target.ends_with( ".markdown" ) ); if( !isMd ) @@ -326,6 +334,11 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi RawRef r; r.fileId = fileId; r.startByte = refByte; r.lang = Lang::Markdown; r.isInherit = false; r.name.assign( base ); + // The link as written (path + optional #slug; the slug is lowered, the path is not). It is a fact of THIS file alone, so it is + // safe in the cache; the (file, slug) heading it names is resolved in buildGraph, where every file's + // headings exist (graph.h bindMarkdownLinks). buildGraph reads this path. A member call still reads it beside memberCall. + r.memberPath.assign( writtenTarget ); + lowercaseMarkdownSlug( r.memberPath ); refs.push_back( std::move( r ) ); } }; @@ -503,6 +516,7 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi } } } + } // namespace — ingest_docs.h section of ingest.cpp } // namespace rw diff --git a/src/langfacts.h b/src/langfacts.h index 160734514..d3961171c 100644 --- a/src/langfacts.h +++ b/src/langfacts.h @@ -77,6 +77,9 @@ struct LangFacts /// (verbs_quality.h; pinned by test/qddegradecheck.sh (E3)). bool defectShapeNeedsMacroSet = false; + /// Markdown: a link targets a section, so shared graph code treats this language's sections as link targets (graph.h). + bool linkSections = false; + /// C, C++, Objective-C: a function span the parser reports DIRECTLY inside a function's span is read as a misread (htop's /// `else if (…) {` inside a macro-heavy body read as a function named `if`), never a closure (src/closures.h; this fact /// decides only the closure relation, no symbol or edge). A lambda or a block is anonymous, and a C++ local class's @@ -93,7 +96,7 @@ inline constexpr std::array kLangFacts = { { { .lang = Lang::Rust }, { .lang = Lang::Swift }, { .lang = Lang::ObjC, .noNestedNamedFunctions = true }, - { .lang = Lang::Markdown }, + { .lang = Lang::Markdown, .linkSections = true }, { .lang = Lang::JavaScript }, { .lang = Lang::Bash }, { .lang = Lang::Java }, diff --git a/src/main.cpp b/src/main.cpp index b2c399d90..f80d1ee76 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2444,7 +2444,7 @@ int runDefaultMap( const MainDispatch& d ) { bodiesSection = rw::chargeSection( [ & ]( std::FILE* f ) { expandRecord = EmittedBodies{}; // a re-render starts from empty: the record is what THIS render emitted - packBodies( f, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, + packBodies( f, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/&expandRecord, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); }, // V1: octocode F2 sibs=/inc= rw::kBytesPerTokenBody ); @@ -2826,7 +2826,7 @@ int runDefaultMap( const MainDispatch& d ) // not as the literal source token, for that same reason (the count is a naive grep over this file). if( !expandNodes.empty() && noteAppliesToBundle ) // !serveWholeFile is this whole branch's precondition (see the if above) { - emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); bodiesEmittedEarly = true; @@ -2900,7 +2900,7 @@ int runDefaultMap( const MainDispatch& d ) } if( !expandNodes.empty() && !serveWholeFile && !bodiesEmittedEarly ) // #289: already served ahead of the map below { - emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, g.outNameOnly, cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); // L3: --expand bodies surface notes; V1: sibs=/inc= } diff --git a/src/mcpverbs.h b/src/mcpverbs.h index 87e8b6822..d4aa4bdea 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -723,6 +723,38 @@ inline std::string symbolQueryDefTableJson( const IngestResult& ing, const DefGr return out; } +inline std::string symbolQueryNextField( bool referencingOnly, const PageWindow& pwPrimary, std::size_t rowTotal, const McpPageArgs& page, + std::string_view name, std::string_view nextSelector ) +{ + // L8 + C2 rider: a capped page names the next page, the direction's own follow-up beside it (the CLI root's pair); any + // other answer names the follow-up alone — P3 (L7): the CLI root's next= (mcpattrparitycheck). + const std::string hintCall = nextFlag( referencingOnly ? "--uses=" : "--expand=", nextSelector ); + if( pwPrimary.end < rowTotal ) + { + return nextFieldJson( callHierarchyPageNext( referencingOnly, name, page.limit, pwPrimary.end ) ) + + nextFieldJson( hintCall, callHierarchyHintAttr( referencingOnly ) ); + } + return nextFieldJson( hintCall ); +} + +inline std::string symbolQueryNotes( bool anyVia, bool anyLink, bool anyMiss ) +{ + std::string out; + if( anyVia ) + { + out += viaNameNoteJson(); + } + if( anyLink ) + { + out += viaLinkNoteJson(); + } + if( anyMiss ) + { + out += anchorMissNoteJson(); + } + return out; +} + inline std::string symbolQueryJson( const std::string& root, const std::string& name, bool referencingOnly, McpPageArgs page = {} ) { @@ -754,10 +786,18 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& const HopTestedPartition chTested = computeHopTestedPartition( ing, g, referencingOnly ? calledBy : calls ); bool anyVia = false; // FE-B: an entry carried "via":"name" — the answer then carries via_note, its reading + bool anyLink = false; // an entry carried "via":"link" — the answer then carries via_link_note + bool anyMiss = false; // an entry carried "anchor":"missing" — the answer then carries anchor_missing_note + const std::span callerMatches( referencingOnly ? chRows.matches : chCallers.matches ); + const std::span calleeMatches( chRows.matches ); + NodeId linkRow = kNoNode; + bool linkToward = false; RowCallSites calledBySites{}; // CALLSITE-AT: the calledBy window's sites (set once the window is known, below) const auto symObj = [ & ]( NodeId id, bool via, const std::string& attrib = std::string() ) -> std::string { anyVia = anyVia || via; + const char* linkFrag = linkJsonFrag( g, callerMatches, calleeMatches, id, linkRow, linkToward, anyVia, anyLink ); + const char* missFrag = anchorMissJsonFrag( g, callerMatches, calleeMatches, id, linkRow, linkToward, anyMiss ); const Symbol& s = ing.symbols[id]; // T4: attach the stable content-handle so the agent can `fetch_body{handle}` instead of us re-sending // the body. Names/signatures by default (this verb); bodies by handle on request. @@ -771,6 +811,8 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& + ",\"handle\":\"" + mcpdetail::jsonEscape( handleFor( ix, id ) ) + "\"" + ( rw::isTestedByReach( ing, chTested.testReach, id ) ? ",\"tested\":true" : "" ) + ( via ? ",\"via\":\"name\"" : "" ) // FE-B: the CLI row's via="name", the same edge bit (defattrib.h, one pass) + + linkFrag + + missFrag + attrib // attrib-multidef: the CLI row's of_def= / prov="split" + "}"; }; @@ -786,6 +828,8 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { if( i != w.begin ) { a += ","; } anySplit = anySplit || at.splitArm( i ); + linkRow = ids[ i ]; + linkToward = callersSide; // CALLSITE-AT: a calledBy row carries its call sites (the CLI --callers row's sites_at=); a calls row never does. a += withRowSites( symObj( ids[i], at.nameOnly( i ), ofDefKeyJson( at.groups, at.tokens, i ) + splitArmKeyJson( at.splitArm( i ) ) ), calledBySites, i - w.begin, callersSide ); @@ -839,20 +883,6 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& // the UNION of all of their neighbours), count= = the un-windowed row total, hop_tested/hop_untested = // the partition over that full set. const auto [ chNextSelector, chNextIsBare ] = callHierarchyNextSelector( ing, chRows, name, referencingOnly ); - // L8 + C2 rider: a capped page names the next page, the direction's own follow-up beside it (the CLI root's pair); any - // other answer names the follow-up alone — P3 (L7): the CLI root's next= (mcpattrparitycheck). fix round 1 (B1): built - // here as its own statement, not as a ?: of string temporaries inside the root's one long concatenation below. - const std::string sqHintCall = nextFlag( referencingOnly ? "--uses=" : "--expand=", chNextSelector ); - std::string sqNextKeys; - if( pwPrimary.end < rowTotal ) - { - sqNextKeys = nextFieldJson( callHierarchyPageNext( referencingOnly, name, page.limit, pwPrimary.end ) ); - sqNextKeys += nextFieldJson( sqHintCall, callHierarchyHintAttr( referencingOnly ) ); - } - else - { - sqNextKeys = nextFieldJson( sqHintCall ); - } // 0.6.6 command sweep: find_symbol carries TWO arrays, and count/hop_tested/hop_untested describe `calls` // only — "count":0 sat beside "calledBy_total":67 on a leaf and read as a contradiction. The CLI key names // stay (mcpattrparitycheck), and count_of names the array they total, right beside them @@ -866,7 +896,7 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& + declinedSiteCutKeyJson( sqDcCut ) // the CLI root's dc_shown= / dc_next=, absent when nothing was cut + declinedIfaceKeyJson( chRows.declinedIface ) // the CLI root's declined_iface= (callers direction only) + stdMemberCallsSpelling( chRows.stdCalls, /*json=*/true ) // the CLI callees answer's line (callees direction only; absent when none) - + sqNextKeys; // next= (and on a capped page its uses_next / expand_next), built above + + symbolQueryNextField( referencingOnly, pwPrimary, rowTotal, page, name, chNextSelector ); // P3 (L7): the CLI root's next= (mcpattrparitycheck) if( !referencingOnly && chRows.bodylessDefs > 0 ) { out += ",\"bodyless_defs\":" + std::to_string( chRows.bodylessDefs ); @@ -899,10 +929,7 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { out += ",\"calls\":" + rowArray( calls, pwPrimary, false ); } - if( anyVia ) - { - out += rw::viaNameNoteJson(); // FE-B: the reading of "via":"name", only in an answer that carries it - } + out += symbolQueryNotes( anyVia, anyLink, anyMiss ); out += nextFieldJson( splitArmLegend( anySplit ), "split_note" ); // attrib-multidef Q1: likewise for "prov":"split" out += declinedSitesJson( sqDc, referencingOnly, /*mcp=*/true ); // lane declined-sites: the CLI rows, never in calls/calledBy or count out += nextFieldJson( declinedSitesLegend( declinedSitesLegendFlags( sqDc, referencingOnly, sqDcCut ), /*mcp=*/true ), "declined_note" ); // their reading, only when they ride @@ -2237,7 +2264,7 @@ inline McpForAssembly assembleMcpFor( const McpForAssemblyInputs& in, std::strin share = forSoftSectionsShare( budgetBytesForTokens( budgetTokens ), mcpHdrXml.size() + atCut + legoStr.size() + composeStr.size() + routeStr.size() ); } mcpSlot = renderForBodySlot( BodySlotInputs{ ing, in.g.outOff, in.g.outTargets, in.g.outNameOnly, lensRank, flRootArg, redact, notesPtr, - /*viaLegendInHead=*/false, /*compress=*/false, &in.g.outProv, /*splitLegendInHead=*/false }, + /*viaLegendInHead=*/false, /*compress=*/false, &in.g.outProv, /*splitLegendInHead=*/false, &in.g.outAnchorMiss }, in.slotOwner, share ); } mcpForSoftRefill( in, renderSigs, sigsStr, mcpSigsCut, &mcpSigsNext, @@ -3193,11 +3220,13 @@ inline std::optional impactText( const std::string& root, const std } const DefTableListing imListing = defTableListing( imAttr.groups, imNamed ); const bool imHasVia = std::any_of( show.begin() + ipw.begin, show.begin() + ipw.end, [ & ]( NodeId n ) { return impactRowNameOnly( proven, n ); } ); + const bool imHasLink = std::any_of( show.begin() + ipw.begin, show.begin() + ipw.end, [ & ]( NodeId n ) { return impactDirectLink( g, seeds, depth, n ); } ); rw::emitTo( mem, "{}{}. {}{}{}{}{}{}{}{}{}{}{}{}-->", kImpactLegendOpen, kPageRaiseCapClause, reach.empty() ? "" : kImpactDepthLegend, // 0.6.5: exactly when d=/by_depth= ride, as on the CLI ( std::string( kImpactImportTierLegend ) + importersNextLegend( !imports.next.empty() ) ).c_str(), // cut-fix E, as on the CLI ( impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ) // #220: exactly when the root carries them, as on the CLI - + viaNameLegend( imHasVia, true ) ).c_str(), // FE-B: exactly when a shown row is via="name", as on the CLI + + viaNameLegend( imHasVia, true ) + + viaLinkLegend( imHasLink ) + impactAnchorLegendFor( g, seeds, depth, show, ipw.begin, ipw.end ) ).c_str(), kTestedRowLegend, kImpactTestedPartitionLegend, // A6 kTestedLensBlindSpotLegend, // F-02: rides with the partition, byte-identical to the CLI twin unprovenDefsVerbLegend( UnprovenDefsVerb::Impact, unprovenDefs > 0 ).c_str(), // H1: exactly when the root carries unproven_defs=, as on the CLI @@ -3237,7 +3266,7 @@ inline std::optional impactText( const std::string& root, const std // A6: tested="1" only (never a literal 0) — see kTestedRowLegend. 0.6.5: d= as on the CLI row. rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, depthRunAttrXml( show, depth, i, ipw.begin ), isTestedByReach( ing, impTestReach, show[i] ) ? " tested=\"1\"" : "", - viaNameAttr( impactRowNameOnly( proven, show[i] ) ), imAttr.ofDefXml( show[i] ) ); } // FE-B (D3); attrib-multidef + impactLinkAttrs( g, proven, seeds, depth, show[ i ] ), imAttr.ofDefXml( show[i] ) ); } // the import tier's rows, after the symbol rows and under their own tag — a different unit, so a // different element (see the CLI arm and kImpactImportTierLegend for why they are never one number). emitImportRowsXml( mem, ing, std::span( imports.files ).first( imports.shown ), imRootPrefix, @@ -3615,6 +3644,13 @@ inline McpAffectedResult affectedText( const std::string& root, std::string_view return { mcpAnswerText( stream ), false, {}, false }; } +inline std::string pathKindLegend( const Graph& g, std::span path ) +{ + return std::string( viaNameLegend( pathHasNameOnlyHop( g, path ) ) ) + + viaLinkLegend( pathHasNameOnlyHop( g, path, kEdgeLink ) ) + + anchorMissLegend( pathHasNameOnlyHop( g, path, 0 ) ); +} + // `path` verb: the shortest directed CALL path from `from` to `to` (does A reach B, and how?). Reuses // resolveFocus + shortestPath, exactly as the CLI --path=A,B. Returns the … XML fragment (with // reachable="0" hops="0" and no children when B is NOT reachable from A — a valid answer, not an error), @@ -3670,14 +3706,14 @@ inline std::optional pathText( const std::string& root, const std:: const ToValueRefs ptToValueRefs = toValueRefs( ing, pth.empty(), dstDefs, &valueRefIndexOf( ix ) ); // PATH-GAP: the CLI --path's gap clause, by the same analysis and emitter (src/pathgaps.h). const PathSearchGaps ptGaps = pth.empty() ? pathSearchGaps( ing, g, srcDefs, valueRefIndexOf( ix ) ) : PathSearchGaps{}; - const PathNearSink ptNear = pth.empty() ? pathNearSink( ing, g, dstDefs, ptGaps.cone, valueRefIndexOf( ix ) ) : PathNearSink{}; // ix2-slice, as on the CLI - const PathBridge ptBridge = pth.empty() ? pathBridge( ing, g, srcDefs, dstDefs, valueRefIndexOf( ix ) ) : PathBridge{}; // ix2-slice, as on the CLI - const bool ptHasVia = pathHasNameOnlyHop( g, pth ); // FE-B: exactly when a hop of this path is via="name", as on the CLI - const PathEndpointBinding ptBound = pathEndpointBinding( g, endpointDefinitionCount( ing, srcDefs ), endpointDefinitionCount( ing, dstDefs ), pth ); // attrib-multidef, as on the CLI + const PathNearSink ptNear = pth.empty() ? pathNearSink( ing, g, dstDefs, ptGaps.cone, valueRefIndexOf( ix ) ) : PathNearSink{}; // ix2-slice, as on the CLI + const PathBridge ptBridge = pth.empty() ? pathBridge( ing, g, srcDefs, dstDefs, valueRefIndexOf( ix ) ) : PathBridge{}; // ix2-slice, as on the CLI + const std::string ptKindLegend = pathKindLegend( g, pth ); + const PathEndpointBinding ptBound = pathEndpointBinding( g, endpointDefinitionCount( ing, srcDefs ), endpointDefinitionCount( ing, dstDefs ), pth ); // attrib-multidef, as on the CLI const bool ptHasSplit = ptBound.splitHop; const std::vector ptOwners = pathHopOwners( ing, pth ); // PATH-OWNERS, as on the CLI rw::emitTo( mem, "{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + viaNameLegend( ptHasVia ) + "graph holds none. {}{}{}{}-->{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + ptKindLegend + splitArmLegend( ptHasSplit ) + pathUnboundLegend( !ptBound.from || !ptBound.to ) + pathOwnerLegend( anyPathHopOwner( ptOwners ) ) ).c_str(), ( std::string( toValueRefsLegend( ptToValueRefs.count > 0 ) ) + valueRefsDepthLegend( ptToValueRefs.depthCut.files > 0 ) ).c_str(), @@ -3963,7 +3999,17 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g { return grp.terminals.size() > 1 && std::any_of( grp.edges.begin(), grp.edges.end(), [ & ]( const ConnectEdge& e ) { return edgeNameOnly( g, e.from, e.to ); } ); } ); - const std::string connectViaLegend = connectHasVia ? "" : std::string(); + const bool connectHasLink = std::any_of( res.groups.begin(), res.groups.end(), [ & ]( const ConnectGroup& grp ) + { + return grp.terminals.size() > 1 && std::any_of( grp.edges.begin(), grp.edges.end(), [ & ]( const ConnectEdge& e ) { return edgeIsLink( g, e.from, e.to ); } ); + } ); + const bool connectHasMiss = std::any_of( res.groups.begin(), res.groups.end(), [ & ]( const ConnectGroup& grp ) + { + return grp.terminals.size() > 1 && std::any_of( grp.edges.begin(), grp.edges.end(), [ & ]( const ConnectEdge& e ) { return edgeAnchorMiss( g, e.from, e.to ); } ); + } ); + const std::string connectViaLegend = ( connectHasVia ? "" : std::string() ) + + ( connectHasLink ? "" : std::string() ) + + ( connectHasMiss ? "" : std::string() ); const std::size_t connectExtraBytes = connectRootAttr.size() + std::strlen( rootRelPathsLegend( !rootArg.empty() ) ) + connectUnindexedLegend.size() + connectUnprovenAttr.size() + connectUnprovenLegend.size() + connectAmbiguousAttr.size() + connectViaLegend.size(); @@ -4115,7 +4161,8 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g { payload.append( "" ); // FE-B: a name-only edge says so + .append( viaEdgeAttr( edgeNameOnly( g, e.from, e.to ), edgeIsLink( g, e.from, e.to ) ) ) + .append( anchorMissAttr( edgeAnchorMiss( g, e.from, e.to ) ) ).append( "/>" ); // FE-B, or via="link" } payload.append( "" ); } diff --git a/src/model.h b/src/model.h index f52b96dae..23f3ffb94 100644 --- a/src/model.h +++ b/src/model.h @@ -875,6 +875,8 @@ struct Reference // FE-B (test/receiverevidencecheck.sh): the rest of a member call's receiver chain, for the receiver-evidence rule // (graph.h ReceiverEvidence). memberPath is the member names BETWEEN memberRoot and the callee, '.'-joined — // "bucket" for `this.bucket.listSchemas()`, "req.raw.headers" for `ctx.req.raw.headers.get()`, "" for `root.m()`. + // A markdown link stores the path and slug as written here (the slug lowercased). memberCall stays false. + // buildGraph reads it (bindMarkdownLinks). // memberCtor is the class a CONSTRUCTED receiver names, final segment — "Reply" for `new Reply( r ).send()`, Go // "Merger" for `(&Merger{}).Len()` — with memberRoot "" (a construction has no root identifier). Both "" for every // other shape and every language that records no member-call shape. diff --git a/src/pathgaps.h b/src/pathgaps.h index 0153642cd..8a2bdd0a0 100644 --- a/src/pathgaps.h +++ b/src/pathgaps.h @@ -111,14 +111,14 @@ inline void countUnlistedNamesakes( const IngestResult& ing, const Graph& g, std }; const auto keyEq = []( const Key& a, const Key& b ) { return a.lang == b.lang && a.kind == b.kind && a.name == b.name; }; const auto keyOf = [ & ]( NodeId t ) { const Symbol& s = ing.symbols[t]; return Key{ s.lang, s.kind, s.name }; }; - const auto hasHedge = [ & ]( NodeId u ) { return std::any_of( g.outNameOnly.begin() + g.outOff[u], g.outNameOnly.begin() + g.outOff[u + 1], []( std::uint8_t b ) { return b != 0; } ); }; + const auto hasHedge = [ & ]( NodeId u ) { return std::any_of( g.outNameOnly.begin() + g.outOff[u], g.outNameOnly.begin() + g.outOff[u + 1], []( std::uint8_t b ) { return edgeKindByte( b ) == kEdgeName; } ); }; const auto isSym = [ & ]( std::uint32_t k ) { return g.outTargets[k] < ing.symbols.size(); }; std::vector names; for( const NodeId u : cone ) { for( std::uint32_t k = g.outOff[u]; k < g.outOff[u + 1]; ++k ) { - if( g.outNameOnly[k] != 0 && isSym( k ) ) + if( edgeKindByte( g.outNameOnly[k] ) == kEdgeName && isSym( k ) ) { names.push_back( ing.symbols[ g.outTargets[k] ].name ); } @@ -161,7 +161,7 @@ inline void countUnlistedNamesakes( const IngestResult& ing, const Graph& g, std std::vector hedged; // the targets u reached by name alone for( std::uint32_t k = g.outOff[u]; k < g.outOff[u + 1]; ++k ) { - if( isSym( k ) && g.outNameOnly[k] != 0 ) + if( isSym( k ) && edgeKindByte( g.outNameOnly[k] ) == kEdgeName ) { hedged.push_back( keyOf( g.outTargets[k] ) ); } diff --git a/src/quality.h b/src/quality.h index 6a1cfadb5..d2aa5f833 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2337,11 +2337,16 @@ inline std::string cacheRootKeyHex( const std::string& root ) // not include this header; it relies on ingest.cpp including quality.h (line 13) before ingest_cache.h, and a reorder // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 36; // MUST equal ingest.cpp's kCacheVersion (gated); 36 = train 30 (above call-result-typing's and fcall-forward's 33, name-template-dispatch's 35, main's 34), 34 = train 29 (above lane arity-min's and call-result-typing's 33), 33 = train 29 lane arity-min: def record paramsMin (the train renumbers), 33 = lane call-result-typing: RawBind resultIndex + unwrapDepth, 33 = lane fcall-forward: ref `forwarded` byte, 35 = lane name-template-dispatch: RefRole Pattern 11, 32 = train 28: RefRole Subscript 9 + Inert 10, 31 = train 27: lego-transitive's inherit-ref baseBind (the lane's 30), 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) -constexpr std::uint32_t kIngestParserVerMirror = 198; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 206; // MUST equal ingest.cpp's kParserVer (gated) + // 206 = 2026-10-18 (#409, PR #414, renumbered again at merge: above main 196/197, + // train 31 198/199, rust-receivers 202/203, ix1-slot 204/205; see the kParserVer note) // 198 = 2026-10-18 (train 31: above main 196/197 and h1-annot // 194/195; see the kParserVer note) // 194 = lane h1-annot (reservation 194/195; the train renumbers): // qualified-annotation importedName, AugAssign / OperatorResult binds + // 185 = 2026-10-18 (#409): cross-file markdown links carry their written path#slug + // (memberPath); graph.h pins the exact heading. 185, not 184: 184 is train 29. + // Rich file tag 186. kCacheVersion stays 34. // 196 = 2026-10-18 (train 30: above call-result-typing 180/181, fcall-forward // 188/189, go-local-var 190/191, name-template-dispatch 192/193, h1-annot // 194/195; see the kParserVer note) diff --git a/src/serialize.h b/src/serialize.h index 4d7c58421..3770d3881 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -2642,6 +2642,33 @@ inline std::uint32_t counterAt( const std::vector* v, NodeId id ) return ( v && id < v->size() ) ? ( *v )[ id ] : 0u; } +// The row's prov=, via=, x= and anchor="missing" tail. One writer so the row loop stays a scan. +inline void writeMapCalleeAttrs( XmlWriter& w, std::uint8_t prov, bool link, bool nameOnly, std::size_t repeats, bool miss ) +{ + if( prov != 0 ) + { + w.write( "\" prov=\"" ); + w.write( provLabel( prov ) ); + } + if( link ) + { + w.write( "\" via=\"link" ); + } + else if( nameOnly ) + { + w.write( "\" via=\"name" ); + } + if( repeats > 1 ) + { + w.write( "\" x=\"" ); + w.write( std::to_string( repeats ) ); + } + if( miss ) + { + w.write( "\" anchor=\"missing" ); + } +} + // FE-B: one map 's rows over out-edges [from, to). A via="name" row (matched by name alone) that the symbol would // print several times byte for byte — N same-named by-name candidates, one row each, carrying only n=/prov=/via= — is // written ONCE, at its first edge, with x="N": the count, and --callees on the lists each candidate with its file @@ -2652,7 +2679,9 @@ inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint std::vector& scratch ) { const auto provOf = [ & ]( std::uint32_t e ) -> std::uint8_t { return ( outProv && e < outProv->size() ) ? ( *outProv )[ e ] : 0; }; - const auto nameOnly = [ & ]( std::uint32_t e ) { return outNameOnly && e < outNameOnly->size() && ( *outNameOnly )[ e ] != 0; }; + const auto stored = [ & ]( std::uint32_t e ) -> std::uint8_t { return outNameOnly && e < outNameOnly->size() ? ( *outNameOnly )[ e ] : 0; }; + const auto nameOnly = [ & ]( std::uint32_t e ) { return edgeKindByte( stored( e ) ) == kEdgeName; }; + const auto link = [ & ]( std::uint32_t e ) { return edgeKindByte( stored( e ) ) == kEdgeLink; }; // the row's bytes among via="name" rows: name, prov= const auto rowLess = [ & ]( std::uint32_t x, std::uint32_t y ) { @@ -2690,30 +2719,16 @@ inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint // uniquely resolved (the common case → zero token cost). outProv parallels outTargets exactly. C1, and this is the // whole point of the marker: `amb="K"` on the enclosing says K of this symbol's CALLS were guesses and cannot // say WHICH edges; prov="split" names the arms, and the suspect set becomes the guessed edges and nothing else. - if( provOf( e ) != 0 ) - { - w.write( "\" prov=\"" ); - w.write( provLabel( provOf( e ) ) ); - } - if( nameOnly( e ) ) - { - w.write( "\" via=\"name" ); // FE-B: the same edge bit every other surface reads (viaNameLegendComment) - } - if( repeats > 1 ) - { - w.write( "\" x=\"" ); - w.write( std::to_string( repeats ) ); - } + writeMapCalleeAttrs( w, provOf( e ), link( e ), nameOnly( e ), repeats, ( stored( e ) & 0x80 ) != 0 ); w.write( "\"/>" ); } } -// FE-B: may a row under one of `nodes` carry via="name"? True when any of their out-edges is name-only — an -// over-approximation of what a section renders (a block's 16-per-symbol cap may cut that row; a merged overload -// row prints one representative's edges), asked BEFORE the rows are written by a surface whose first-screen legend must -// define every attribute its payload can carry (the map, --for, --exemplar). -inline bool namesOnlyOutAny( const std::vector& outOff, const std::vector& outNameOnly, - std::span nodes ) noexcept +// A row under one of `nodes` may carry the bit when any of their out-edges has it. Asked before the rows are written +// so the map's first-screen legend can define the attribute. outLink and outNameOnly share this scan. +// want 0: any nonzero byte. want 0x80: the anchor-missing high bit. Otherwise the kind byte equals want. +inline bool markedOutAny( const std::vector& outOff, const std::vector& bits, + std::span nodes, std::uint8_t want = 0 ) noexcept { for( const NodeId n : nodes ) { @@ -2721,9 +2736,10 @@ inline bool namesOnlyOutAny( const std::vector& outOff, const std { continue; } - for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < outNameOnly.size(); ++e ) + for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < bits.size(); ++e ) { - if( outNameOnly[ e ] != 0 ) + const bool hit = want == 0 ? bits[ e ] != 0 : ( want == 0x80 ? ( bits[ e ] & 0x80 ) != 0 : edgeKindByte( bits[ e ] ) == want ); + if( hit ) { return true; } @@ -2731,6 +2747,15 @@ inline bool namesOnlyOutAny( const std::vector& outOff, const std } return false; } +// FE-B: may a row under one of `nodes` carry via="name"? True when any of their out-edges is name-only — an +// over-approximation of what a section renders (a block's 16-per-symbol cap may cut that row; a merged overload +// row prints one representative's edges), asked BEFORE the rows are written by a surface whose first-screen legend must +// define every attribute its payload can carry (the map, --for, --exemplar). +inline bool namesOnlyOutAny( const std::vector& outOff, const std::vector& outNameOnly, + std::span nodes ) noexcept +{ + return markedOutAny( outOff, outNameOnly, nodes, kEdgeName ); +} // attrib-multidef Q1: may a row under one of `nodes` carry prov="split"? True when any of their out-edges is one arm of a // resolver split and not by name alone (a by-name row carries via="name" instead) — namesOnlyOutAny's twin question, over @@ -2743,7 +2768,7 @@ inline bool splitArmsOutAny( const std::vector& outOff, const std for( std::uint32_t e = ( std::size_t( n ) + 1 < outOff.size() ) ? outOff[ n ] : 0u; std::size_t( n ) + 1 < outOff.size() && e < outOff[ n + 1 ] && e < outProv.size(); ++e ) { - if( outProv[ e ] == 3u && !( e < outNameOnly.size() && outNameOnly[ e ] != 0 ) ) + if( outProv[ e ] == 3u && !( e < outNameOnly.size() && edgeKindByte( outNameOnly[ e ] ) == kEdgeName ) ) { return true; } @@ -2770,7 +2795,7 @@ inline bool hopSlotHasProvenEdge( const std::vector& outOff, cons } ASSUME( outOff[ id + 1 ] <= outNameOnly.size(), "the hedge bits are one per flattened edge (graph.h buildGraph)" ); return !std::all_of( outNameOnly.begin() + outOff[ id ], outNameOnly.begin() + outOff[ id + 1 ], - []( std::uint8_t bit ) { return bit != 0; } ); + []( std::uint8_t bit ) { return edgeKindByte( bit ) == kEdgeName; } ); } // D7 MAP-INVENTORY: whether THIS map carries the inventory tier (mapinventory.h), decided once for both dialects. The @@ -3155,6 +3180,16 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto // and whose element-qualified via/x rows restate it: the --max-tokens fit then prices what is delivered. legend += kMapViaNameLegend; } + if( outNameOnly && !stubbed && !ann.viaLegendStripped + && markedOutAny( outOff, *outNameOnly, std::span( order.data(), std::min( keep, order.size() ) ), kEdgeLink ) ) + { + legend += kMapViaLinkLegend; + } + if( outNameOnly && !stubbed && !ann.viaLegendStripped + && markedOutAny( outOff, *outNameOnly, std::span( order.data(), std::min( keep, order.size() ) ), 0x80 ) ) + { + legend += kMapAnchorMissLegend; + } // C1 DRIFT FIX (Round C lane B, found by re-reading this header's own output). `precise=` means "how many // out-edges a SCIP index PINNED", and the emitter's own comment below says it is "emitted ONLY under // --scip". Both were true when outProv held only {0, 1}. A4-R5 then added value 2 (an FFI binding edge) @@ -6673,6 +6708,8 @@ struct CalleeCallsSink // nullptr / empty ⇒ no row is marked. `splitLegendCharged`: as viaLegendCharged, for its comment (kForSplitClause). const std::vector* prov = nullptr; bool* splitLegendCharged = nullptr; + const std::vector* link = nullptr; // Graph::outLink: via="link" on a callee row. nullptr ⇒ none + const std::vector* anchor = nullptr; // Graph::outAnchorMiss: anchor="missing" on that row. nullptr ⇒ none }; // The wrapper, written in front of the rows it describes (they have to be walked before `shown` @@ -6818,17 +6855,30 @@ struct MergedCalleeNameRow std::vector lines; // every definition line of that name; joined ascending at append time bool via = false; // FE-B: these edges are name-only — merged only with rows of the same bit bool split = false; // attrib-multidef Q1: these edges are split arms — likewise + bool link = false; // a markdown link — merged only with other link rows of the same name + bool miss = false; // that link named no heading — merged only with other miss rows of the same name }; +struct CalleeRowMarks +{ + bool via = false; + bool split = false; + bool link = false; + bool miss = false; +}; inline void collectCalleeNameRow( std::vector& rows, const Symbol& cs, - std::size_t& used, const CalleeCallsSink& sink, bool via = false, bool split = false ) + std::size_t& used, const CalleeCallsSink& sink, CalleeRowMarks mark = {} ) { + const bool via = mark.via; + const bool split = mark.split; + const bool link = mark.link; + const bool miss = mark.miss; char lb[ 16 ]; rw::formatTo( lb, sizeof( lb ), "{}", cs.line ); bool merged = false; for( MergedCalleeNameRow& r : rows ) { - if( r.name == cs.name && r.via == via && r.split == split ) + if( r.name == cs.name && r.via == via && r.split == split && r.link == link && r.miss == miss ) { r.lines.push_back( cs.line ); merged = true; break; @@ -6836,11 +6886,11 @@ inline void collectCalleeNameRow( std::vector& rows, const } if( !merged ) { - rows.push_back( MergedCalleeNameRow { cs.name, { cs.line }, via, split } ); + rows.push_back( MergedCalleeNameRow { cs.name, { cs.line }, via, split, link, miss } ); } // the comma and the digits a merge appends, or the whole row it opens (FE-B: plus its 11-byte via="name"; Q1: its 13-byte - // prov="split") - used += merged ? std::strlen( lb ) + 1 : cs.name.size() + 16 + ( via ? 11u : 0u ) + ( split ? 13u : 0u ); + // prov="split"; a link row's via="link" is the same 11 bytes) + used += merged ? std::strlen( lb ) + 1 : cs.name.size() + 16 + ( ( via || link ) ? 11u : 0u ) + ( split ? 13u : 0u ) + ( miss ? 17u : 0u ); if( sink.recorded ) { sink.recorded->push_back( EmittedBodyCall { cs.name, cs.line, std::string() } ); // §H5: no sig to record @@ -6867,7 +6917,8 @@ inline void appendMergedCalleeNameRows( std::string& callsBody, std::vector& nameOnly; const std::vector* prov = nullptr; bool* splitLegendCharged = nullptr; + const std::vector* link = nullptr; + const std::vector* anchor = nullptr; CalleeEdgeBytes( const std::vector& nameOnlyBytes, const std::vector* provBytes = nullptr, // NOLINT: implicit bool* charged = nullptr ) noexcept : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ) {} }; +inline CalleeEdgeBytes withAnchorEdge( CalleeEdgeBytes edges, const std::vector* anchor ) noexcept +{ + edges.anchor = anchor; + return edges; +} // …and which of their readings the caller's own first-screen legend already carries (FE-B's via="name", Q1's prov="split"), so // the section neither repeats nor charges them. Built implicitly from the via bit alone, as every pre-Q1 caller passes it. struct CalleeLegendsInHead @@ -6915,6 +6973,13 @@ inline CalleeCallsSink withSplitArms( CalleeCallsSink sink, const std::vector* link, + const std::vector* anchor = nullptr ) noexcept +{ + sink.link = link; + sink.anchor = anchor; + return sink; +} // attrib-multidef Q1: is the row id → cid a split arm (and not by name alone: that row says via="name")? The first // such row of an answer charges the comment that defines it to `used`, before the row, when the head did not define it. @@ -6937,6 +7002,102 @@ inline std::string_view calleeRowSplitAttr( const std::vector& ou return split ? std::string_view( " prov=\"split\"" ) : std::string_view(); } +// A markdown link whose slug names no heading. Charges the legend once, marks a merged name row, or +// inserts the attribute into a signature row that already ends at `>`. +inline void applyCalleeAnchorMiss( const CalleeCallsSink& sink, const std::vector& outOff, + const std::vector& outTargets, NodeId id, NodeId cid, bool& legendCharged, + std::size_t& used, std::vector* nameRows, std::string_view name, + bool linkRow, std::string* callsBody ) +{ + const bool miss = sink.anchor != nullptr && edgeNameOnlyInCsr( outOff, outTargets, *sink.anchor, id, cid ); + if( !miss ) + { + return; + } + if( !legendCharged ) + { + used += anchorMissLegendComment().size(); + legendCharged = true; + } + if( nameRows != nullptr ) + { + for( MergedCalleeNameRow& nr : *nameRows ) + { + if( nr.name == name && nr.link == linkRow && !nr.miss ) + { + nr.miss = true; + used += 17; + return; + } + } + return; + } + if( callsBody == nullptr ) + { + return; + } + const std::size_t cut = callsBody->rfind( '>' ); + if( cut != std::string::npos ) + { + callsBody->insert( cut, " anchor=\"missing\"" ); + used += 17; + } +} + +// One of a body's callee block. True when the row counts toward shown=. +template +inline bool appendOneCalleeCall( std::string& callsBody, std::vector& nameRows, NodeId cid, NodeId id, + const std::vector& outOff, const std::vector& outTargets, + const IngestResult& ing, ContentOfFn&& contentOf, std::vector& esc, std::size_t& used, + const CalleeCallsSink& sink, bool& linkLegendCharged, bool& anchorLegendCharged ) +{ + if( cid >= ing.symbols.size() ) + { + return false; + } + const Symbol& cs = ing.symbols[cid]; + const bool linkRow = sink.nameOnly != nullptr && edgeNameOnlyInCsr( outOff, outTargets, *sink.nameOnly, id, cid, kEdgeLink ); + if( linkRow && !linkLegendCharged ) + { + used += viaLinkLegendComment().size(); + linkLegendCharged = true; + } + applyCalleeAnchorMiss( sink, outOff, outTargets, id, cid, anchorLegendCharged, used, nullptr, {}, false, nullptr ); + const bool nameRow = !linkRow && calleeRowVia( outOff, outTargets, id, cid, used, sink ); + const std::string_view viaAttr = viaEdgeAttr( nameRow, linkRow ); + const std::string_view splitAttr = calleeRowSplitAttr( outOff, outTargets, id, cid, !viaAttr.empty(), used, sink ); + if( sink.namesOnly ) + { + collectCalleeNameRow( nameRows, cs, used, sink, CalleeRowMarks{ nameRow, !splitAttr.empty(), linkRow, false } ); + applyCalleeAnchorMiss( sink, outOff, outTargets, id, cid, anchorLegendCharged, used, &nameRows, cs.name, linkRow, nullptr ); + return true; + } + const std::string& csrc = contentOf( cs.fileId ); + if( cs.sigStartByte >= cs.sigEndByte || cs.sigEndByte > csrc.size() ) + { + return false; + } + const std::string sig = cleanSig( csrc.data(), cs.sigStartByte, cs.sigEndByte, sink.redact ); + if( sig.empty() ) + { + return false; + } + char hb[ 64 ]; + rw::formatTo( hb, sizeof( hb ), "\" l=\"{}\"{}{}>", cs.line, viaAttr, splitAttr ); + callsBody += "push_back( EmittedBodyCall { cs.name, cs.line, sig } ); + } + return true; +} + // §P10.1: the disclosed block // for one body's 1-hop callee signatures — extracted out of packBodies so the disclosure logic doesn't // inflate packBodies' own complexity/LOC. `total` is outOff[id+1]-outOff[id] — outTargets is deduped-per- @@ -6950,7 +7111,6 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector ContentOfFn&& contentOf, std::vector& esc, std::size_t& used, std::size_t budgetBytes, const CalleeCallsSink& sink ) { - RedactCounts* const redact = sink.redact; if( id + 1 >= outOff.size() ) { return; @@ -6966,43 +7126,15 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector std::string callsBody; std::vector nameRows; // names-only rendering: collected, merged by name, written after the walk int shown = 0; + bool linkLegendCharged = false; + bool anchorLegendCharged = false; for( std::uint32_t k = outOff[id]; k < outOff[id + 1] && shown < int( sink.rowCap ) && used < budgetBytes; ++k ) { const NodeId cid = walk[ k - outOff[id] ]; - if( cid >= ing.symbols.size() ) - { - continue; - } - const Symbol& cs = ing.symbols[cid]; - const std::string_view viaAttr = viaNameAttr( calleeRowVia( outOff, outTargets, id, cid, used, sink ) ); // FE-B: via="name" or "" - const std::string_view splitAttr = calleeRowSplitAttr( outOff, outTargets, id, cid, !viaAttr.empty(), used, sink ); // Q1: prov="split" or "" - - // COMPACT: the names-only rendering — see collectCalleeNameRow above for what it does and does not do. - if( sink.namesOnly ) + if( appendOneCalleeCall( callsBody, nameRows, cid, id, outOff, outTargets, ing, contentOf, esc, used, sink, + linkLegendCharged, anchorLegendCharged ) ) { - collectCalleeNameRow( nameRows, cs, used, sink, !viaAttr.empty(), !splitAttr.empty() ); ++shown; - continue; - } - - const std::string& csrc = contentOf( cs.fileId ); - if( cs.sigStartByte >= cs.sigEndByte || cs.sigEndByte > csrc.size() ) - { - continue; - } - const std::string sig = cleanSig( csrc.data(), cs.sigStartByte, cs.sigEndByte, redact ); - if( sig.empty() ) - { - continue; - } - char hb[ 64 ]; rw::formatTo( hb, sizeof( hb ), "\" l=\"{}\"{}{}>", cs.line, viaAttr, splitAttr ); - callsBody += "push_back( EmittedBodyCall { cs.name, cs.line, sig } ); // §H5 } } appendMergedCalleeNameRows( callsBody, nameRows, esc ); // no-op on the signature rendering (nameRows stays empty) @@ -7680,8 +7812,8 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect // total=/shown=/capped= block — see emitCalleeCallsBlock above; `calleeRank` decides which // rows survive when it CUTS one, which is far from rare here (CalleeCallsSink::rank). emitCalleeCallsBlock( piece, id, outOff, outTargets, ing, contentOf, esc, used, budgetBytes, - withSplitArms( CalleeCallsSink{ redact, record ? &record->calls : nullptr, /*namesOnly=*/false, calleeRank, &outNameOnly, - &viaLegendCharged }, outEdges.prov, &splitLegendCharged ) ); + withLinkBits( withSplitArms( CalleeCallsSink{ redact, record ? &record->calls : nullptr, /*namesOnly=*/false, calleeRank, &outNameOnly, + &viaLegendCharged }, outEdges.prov, &splitLegendCharged ), outEdges.link, outEdges.anchor ) ); const std::string bodyNotes = renderNoteChildren( noteIndex, symbolNoteTarget( noteIndex, ing, s ), esc ); // L3/D5 piece += bodyNotes; used += bodyNotes.size(); // W3-N2: same charge-never-trim rule @@ -7757,6 +7889,14 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect { w.write( viaNameLegendComment() ); // FE-B: the same rule, for a row's via="name" } + if( !viaLegendInHead && children.find( " via=\"link\"" ) != std::string::npos ) + { + w.write( viaLinkLegendComment() ); + } + if( children.find( " anchor=\"missing\"" ) != std::string::npos ) + { + w.write( anchorMissLegendComment() ); + } if( !legendsInHead.split && children.find( " prov=\"split\"" ) != std::string::npos ) { w.write( splitArmCallsLegendComment() ); // attrib-multidef Q1: the same rule, for a row's prov="split" @@ -7855,8 +7995,8 @@ inline OwnerHopRows packOwnerHopRows( const IngestResult& ing, const std::vector } used += rowFixed; emitCalleeCallsBlock( row, id, outOff, outTargets, ing, contentOf, esc, used, budget, - withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &via, - kForOwnerHopNameGuard, next }, outEdges.prov, outEdges.splitLegendCharged ) ); + withLinkBits( withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &via, + kForOwnerHopNameGuard, next }, outEdges.prov, outEdges.splitLegendCharged ), outEdges.link, outEdges.anchor ) ); out.xml += row; out.xml += ""; ++out.shown; @@ -7980,7 +8120,7 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector // owner-hop: the leading owner rows first (THE QUESTION'S OWNER ROWS above; nothing when `owners` is null), then the // ranked rows exactly as before, from the node the owners end at, on the budget and via charge the owners leave them - const OwnerHopRows own = packOwnerHopRows( ing, nodes, owners, outOff, outTargets, CalleeEdgeBytes( outNameOnly, outEdges.prov, &splitLegendCharged ), + const OwnerHopRows own = packOwnerHopRows( ing, nodes, owners, outOff, outTargets, withAnchorEdge( CalleeEdgeBytes( outNameOnly, outEdges.prov, &splitLegendCharged ), outEdges.anchor ), redact, rank, rootArg, budgetBytes, viaLegendCharged, pathRel, contentOf, esc ); std::string children = own.xml; std::size_t requestedCount = own.shown; @@ -8035,9 +8175,9 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector // the 1-hop callee signatures — the identical block a body carries, charged against the same // running `used` so the row identity bytes and the edge bytes share one budget. emitCalleeCallsBlock( row, id, outOff, outTargets, ing, contentOf, esc, used, budgetBytes, - withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &viaLegendCharged, - CalleeCallsSink{}.rowCap, next }, - outEdges.prov, &splitLegendCharged ) ); + withLinkBits( withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &viaLegendCharged, + CalleeCallsSink{}.rowCap, next }, + outEdges.prov, &splitLegendCharged ), outEdges.link, outEdges.anchor ) ); row += ""; children += row; ++shownCount; @@ -8053,6 +8193,14 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector { w.write( viaNameLegendComment() ); // FE-B: exactly when a hop's row carries via="name" } + if( !viaLegendInHead && children.find( " via=\"link\"" ) != std::string::npos ) + { + w.write( viaLinkLegendComment() ); + } + if( children.find( " anchor=\"missing\"" ) != std::string::npos ) + { + w.write( anchorMissLegendComment() ); + } if( !legendsInHead.split && children.find( " prov=\"split\"" ) != std::string::npos ) { w.write( splitArmCallsLegendComment() ); // attrib-multidef Q1: exactly when a hop's row carries prov="split" diff --git a/src/verbs_for.h b/src/verbs_for.h index 10506b36e..652c00b11 100644 --- a/src/verbs_for.h +++ b/src/verbs_for.h @@ -2033,7 +2033,7 @@ ForAutoBodiesResult buildForAutoBodies( const rw::Config& cfg, const rw::IngestR // shell the "budget" branch below already gets for free, rather than a second hand-rolled tag. out.attr = " bundle=\"auto\" bodies=\"0\" reason=\"no_candidates\""; out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packBodies( f, ing, autoBodyIds, /*budgetBytes=*/1, g.outOff, g.outTargets, rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv ), cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, /*budgetBytes=*/1, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, nullptr, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2055,7 +2055,7 @@ ForAutoBodiesResult buildForAutoBodies( const rw::Config& cfg, const rw::IngestR const std::size_t autoBodyBudget = std::max( 1, std::min( leftBytes, cfg.packBudgetBytes ) ); rw::EmittedBodies autoEmitted; out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packBodies( f, ing, autoBodyIds, autoBodyBudget, g.outOff, g.outTargets, rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv ), cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, autoBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, &autoEmitted, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2461,7 +2461,7 @@ ForAutoBodiesResult buildForCompactHops( const rw::Config& cfg, const rw::Ingest const rw::HopOwners owners = forHopOwners( cfg, ing, ownerPlan, leftBytes, fcRootArg ); out.section = rw::chargeSection( [ & ]( std::FILE* f ) - { rw::packHops( f, ing, hopNodes, hopBudget, g.outOff, g.outTargets, rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv ), redactPtr, &lensRank, fcRootArg, + { rw::packHops( f, ing, hopNodes, hopBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr ), &g.outAnchorMiss ), redactPtr, &lensRank, fcRootArg, rw::CalleeLegendsInHead( viaLegendInHead, promised.splitLegendInHead ), &owners ); }, // g.outNameOnly also feeds the hop-slot rule (serialize.h noProvenEdge) // MARKUP rate, not the body rate — and this is an honesty choice, not a copy-paste slip. The body // rate (3.80 B/tok) prices SOURCE TEXT; the compact section contains none, only tags, identifiers @@ -3797,7 +3797,7 @@ std::optional runForLensPass( const MainDispatch& d, rw::LensRanking lr, co detailBodyBudget = std::min( detailBodyBudget, std::max( leftBytes, detailFloorBudget ) ); } detailSection = rw::chargeSection( [ & ]( std::FILE* f ) - { packBodies( f, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv ), cfg.compress, redactPtr, + { packBodies( f, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, flRootArg, &lensRank, rw::CalleeLegendsInHead( headerParts.viaPresent, headerParts.splitPresent ) ); }, // L3: --detail bodies surface notes too (part of the --for bundle) rw::kBytesPerTokenBody ); @@ -3856,7 +3856,7 @@ std::optional runForLensPass( const MainDispatch& d, rw::LensRanking lr, co { const std::size_t slotSpent = rowsSpentBytes + plan.attrReserve + enrich.section.xml.size(); // the rows at the cut + the hops slot = rw::renderForBodySlot( rw::BodySlotInputs{ ing, g.outOff, g.outTargets, g.outNameOnly, lensRank, flRootArg, redactPtr, notesPtr, - headerParts.viaPresent, cfg.compress, &g.outProv, headerParts.splitPresent }, + headerParts.viaPresent, cfg.compress, &g.outProv, headerParts.splitPresent, &g.outAnchorMiss }, slotOwner, forSoftTarget ? std::optional( rw::forSoftSectionsShare( bundleBudget, slotSpent ) ) : std::nullopt ); if( slot.served ) @@ -4098,7 +4098,7 @@ std::optional runForLensPass( const MainDispatch& d, rw::LensRanking lr, co // directly at the SAME budget — uncharged for that one run, with an alert, never a fabricated number. if( cfg.detail > 0 ) { - rw::emitChargedSection( stdout, detailSection, [ & ]{ packBodies( stdout, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv ), + rw::emitChargedSection( stdout, detailSection, [ & ]{ packBodies( stdout, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, flRootArg, &lensRank, rw::CalleeLegendsInHead( headerParts.viaPresent, headerParts.splitPresent ) ); } ); diff --git a/src/verbs_navigate.h b/src/verbs_navigate.h index 9dc37319b..59b86c6bf 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -77,6 +77,27 @@ inline std::string_view jsonRowKeysAt( const std::vector* rowKeys, { return rowKeys != nullptr && i < rowKeys->size() ? std::string_view( ( *rowKeys )[ i ] ) : std::string_view(); } +inline char hopViaCode( bool link, bool name ) noexcept +{ + if( link ) + { + return 2; + } + if( name ) + { + return 1; + } + return 0; +} + +inline const char* jsonViaField( const std::vector* via, std::size_t i ) noexcept +{ + if( via == nullptr || i >= via->size() || ( *via )[ i ] == 0 ) + { + return ""; + } + return ( *via )[ i ] == 2 ? ",\"via\":\"link\"" : ",\"via\":\"name\""; +} inline void printJsonSymbolRows( const rw::IngestResult& ing, const std::vector& ids, std::size_t begin, std::size_t end, std::string_view rootPrefix = {}, const std::vector* testReach = nullptr, @@ -92,7 +113,7 @@ inline void printJsonSymbolRows( const rw::IngestResult& ing, const std::vector< rw::symTag( s.kind ), rw::jsonStr( s.name ).c_str(), rw::jsonStr( p ).c_str(), s.line, depth ? ",\"d\":" + std::to_string( ( *depth )[ ids[i] ] ) : std::string(), ( testReach && rw::isTestedByReach( ing, *testReach, ids[i] ) ) ? ",\"tested\":true" : "", - ( viaName && i < viaName->size() && ( *viaName )[ i ] != 0 ) ? ",\"via\":\"name\"" : "", // FE-B: the row's hedge + jsonViaField( viaName, i ), jsonRowKeysAt( rowKeys, i ) ); } } @@ -137,6 +158,106 @@ inline int refuseNotFoundWithAnswer( const rw::IngestResult& ing, const rw::Conf return 1; } +inline int emitCallHierarchyAlt( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, const std::vector& result, + const std::vector& matches, const rw::PageWindow& pw, std::string_view sym, bool wantCallers, + std::size_t bodylessDefsCount, const std::string& chUnprovenAttr, const rw::CallHierarchyRows& chRows, + const rw::OneHopAttribution& chAttr, const rw::DefTableListing& chListing, const HopTestedPartition& chTested, + const std::string& chDeclinedAttr, const std::string& chValueRefsAttr, const std::string& chRootAttr, char* pab, + bool chDiscloseCap, const std::string& chNextAttr, const rw::RowCallSites& chSites, const std::string& chRootPrefix, + const char* tag, const std::string& chDcRows, bool chSingleRoot, const rw::VrRender& chVr, const std::string& chHintCall, + bool chNextIsPage, const rw::DeclinedSiteCut& chDcCut, const std::vector& chDc, + const std::vector& chClosures ) +{ + using namespace rw; + std::vector esc; + const auto ex = [ & ]( std::string_view s ) -> std::string { return std::string( escapeXml( s, esc ) ); }; + const std::string chVrNext = "--uses=" + std::string( sym ); + // lane ix2-slice (IX-2 P2): the callees form's closure blocks — root closures= and the blocks, absent with none + const rw::ClosureBlocksOut chIn = rw::closureBlocks( ing, g, chClosures, chSingleRoot, chRootPrefix, /*json=*/cfg.json && !cfg.columnar ); + if( cfg.columnar ) + { + std::vector page( result.begin() + pw.begin, result.begin() + pw.end ); + const std::string attr = "of=\"" + ex( sym ) + "\" defs=\"" + std::to_string( matches.size() ) + + "\" count=\"" + std::to_string( result.size() ) + "\"" + + ( !wantCallers && bodylessDefsCount > 0 ? " bodyless_defs=\"" + std::to_string( bodylessDefsCount ) + "\"" : "" ) + + chUnprovenAttr + + rw::crossKindAttrXml( chRows.crossKind ) + + rw::defsListedAttrXml( chAttr.groups, chListing ) + + chTested.xmlAttr + + chDeclinedAttr + + chValueRefsAttr + + chIn.head // ix2-slice: closures written in of='s body (rows: the blocks below) + + chRootAttr + + pageDisclosure( pab, kPageDisclosureCap, pw.end - pw.begin, result.size(), pw.end, + cfg.pageLimit, cfg.pageOffset, chDiscloseCap ) + + rw::graphCountFloorAttrXml( g ) + + chNextAttr; + std::vector pageVia( page.size(), 0 ); + std::vector pageSplit( page.size(), 0 ); + std::vector pageOfDef; + for( std::size_t i = 0; i < page.size(); ++i ) + { + pageVia[ i ] = hopViaCode( oneHopRowIsLink( g, matches, result[ pw.begin + i ], wantCallers ), chAttr.nameOnly( pw.begin + i ) ); + pageSplit[ i ] = chAttr.splitArm( pw.begin + i ) ? 1 : 0; + if( chAttr.groups.multi() ) + { + pageOfDef.push_back( rw::ofDefColumnValue( chAttr.groups, chAttr.tokens, pw.begin + i ) ); + } + } + const std::string chDefRows = rw::defTableXml( ing, chAttr.groups, chListing, chRootPrefix ); + const std::string chDcInRows = chDcRows + chIn.rows; + emitColumnarSymbolRows( stdout, ing, tag, attr, page, chRootPrefix, &chTested.testReach, nullptr, + rw::ColumnarRowExtras{ &pageVia, chSites.any ? &chSites.tokens : nullptr, &chSites.totals, + chAttr.groups.multi() ? &pageOfDef : nullptr, &chDefRows, &pageSplit, &chDcInRows } ); // + the and rows verbatim + return 0; + } + rw::emitTo( stdout, "{{\"of\":\"{}\",\"defs\":{},\"count\":{}", jsonStr( sym ).c_str(), matches.size(), result.size() ); + if( !wantCallers && bodylessDefsCount > 0 ) + { + rw::emitTo( stdout, ",\"bodyless_defs\":{}", bodylessDefsCount ); + } + rw::emitTo( stdout, "{}{}{}", rw::unprovenDefsKeyJson( chRows.unprovenDefs ).c_str(), + rw::crossKindKeyJson( chRows.crossKind ).c_str(), + rw::defTableJson( ing, chAttr.groups, chListing, chRootPrefix, /*mcp=*/false ) ); + if( chSingleRoot ) { rw::emitTo( stdout, ",\"root\":\"{}\"", jsonStr( cfg.roots[0] ).c_str() ); } + rw::emitTo( stdout, ",\"hop_tested\":{},\"hop_untested\":{}{}{}", chTested.tested, chTested.untested, + rw::declinedCallsKeyJson( chRows.declinedCalls ) + rw::declinedSiteCutKeyJson( chDcCut ) + + rw::declinedIfaceKeyJson( chRows.declinedIface ) + + rw::stdMemberCallsSpelling( chRows.stdCalls, /*json=*/true ), + ( rw::valueRefsCountKeyJson( chRows.valueRefs.rows.size() ) + rw::valueRefsDepthKeyJson( ing, chRows.valueRefs.depthCut, chVr ) + + rw::patternDispatchKeyJson( chRows.valueRefs.patternSites.size(), chRows.valueRefs.patternWideSites.size() ) ) ); // IX-3: the XML root's pattern counts + rw::emitTo( stdout, "{}{}{}", pageDisclosure( pab, kPageDisclosureCap, pw.end - pw.begin, result.size(), pw.end, + cfg.pageLimit, cfg.pageOffset, chDiscloseCap, kJsonPageSyntax ), + rw::graphCountFloorAttrJson( g ).c_str(), + chNextIsPage ? rw::nextFieldJson( rw::callHierarchyPageNext( wantCallers, sym, cfg.pageLimit, pw.end, false, true ) ) + + rw::nextFieldJson( chHintCall, rw::callHierarchyHintAttr( wantCallers ) ) + : std::string() ); + rw::emitTo( stdout, ",\"{}\":[", tag ); + std::vector chVia( result.size(), 0 ); + bool chHasLink = false; + std::vector chAtKeys( result.size() ); + for( std::size_t i = pw.begin; i < pw.end; ++i ) + { + chVia[ i ] = hopViaCode( oneHopRowIsLink( g, matches, result[ i ], wantCallers ), chAttr.nameOnly( i ) ); + chHasLink = chHasLink || ( chVia[ i ] == 2 ); + if( chSites.any ) + { + chAtKeys[ i ] = rw::callSitesKeyJson( chSites.tokens[ i - pw.begin ], chSites.totals[ i - pw.begin ] ); + } + chAtKeys[ i ] += rw::ofDefKeyJson( chAttr.groups, chAttr.tokens, i ) + rw::splitArmKeyJson( chAttr.splitArm( i ) ); + if( oneHopRowIsLink( g, matches, result[ i ], wantCallers, &g.outAnchorMiss ) ) + { + chAtKeys[ i ] += ",\"anchor\":\"missing\""; + } + } + printJsonSymbolRows( ing, result, pw.begin, pw.end, chRootPrefix, &chTested.testReach, nullptr, &chVia, &chAtKeys ); + rw::emitTo( stdout, "]{}{}{}{}}}", rw::declinedSitesJson( chDc, wantCallers, /*mcp=*/false ), + chIn.head + chIn.rows, // ix2-slice: the closure blocks, JSON spelling + rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ), + chHasLink ? rw::viaLinkNoteJson() : std::string() ); + return 0; +} + std::optional runCallHierarchy( const MainDispatch& d ) { using namespace rw; @@ -269,10 +390,13 @@ std::optional runCallHierarchy( const MainDispatch& d ) const bool chHasModScope = anyModuleScopeRow( ing, chPage ) || anyModuleScopeRow( ing, matches ); // FE-B: exactly when a row on THIS page is via="name" bool chHasVia = false; - for( std::size_t i = pw.begin; i < pw.end && !chHasVia; ++i ) + bool chHasLink = false; + for( std::size_t i = pw.begin; i < pw.end && ( !chHasVia || !chHasLink ); ++i ) { - chHasVia = chAttr.nameOnly( i ); // FE-B's via="name": every edge to the matches by name (defattrib.h, one pass) + chHasVia = chHasVia || chAttr.nameOnly( i ); // FE-B's via="name": every edge to the matches by name (defattrib.h, one pass) + chHasLink = chHasLink || oneHopRowIsLink( g, matches, result[ i ], wantCallers ); } + const bool chHasMiss = rw::pageHasAnchorMiss( g, matches, result, pw.begin, pw.end, wantCallers ); chHasVia = chHasVia || rw::closureBlocksAnyVia( ing, g, chClosures ); // ix2-slice: a closure block's row may carry it too rw::emitTo( stdout, "{}{}{}{}{}{}{}-->{}{}", rw::callHierarchyLegendOpen( wantCallers, chNextIsBare, cfg.columnar, chNextIsPage ).c_str(), rw::capLegendClause( rw::computePageDisclosure( pw.end - pw.begin, result.size(), pw.end, @@ -285,6 +409,7 @@ std::optional runCallHierarchy( const MainDispatch& d ) ( std::string( rw::unprovenDefsLegend( chRows.unprovenDefs > 0 ) ) // H1: likewise, exactly when unproven_defs= is there + rw::crossKindLegend( !chRows.crossKind.empty() ) // hono-07: likewise for cross_kind= + rw::viaNameLegend( chHasVia, false, cfg.columnar ) // FE-B: likewise for via="name" + + rw::viaLinkLegend( chHasLink ) + rw::defTableLegend( chAttr.groups.multi(), chListing.cut ) + rw::splitArmLegend( chHasSplit ) ).c_str(), // attrib-multidef: likewise ( std::string( rw::modScopeLegend( chHasModScope ) ) // #60: likewise, exactly when a t="modscope" row is + rw::callSitesAtLegend( chSites.any, chSites.anyCut, rw::kCallSitesPerRowCap ) ).c_str(), // CALLSITE-AT: likewise for sites_at=/sites_total= @@ -294,6 +419,14 @@ std::optional runCallHierarchy( const MainDispatch& d ) + std::string( rw::closureBlocksLegend( !chClosures.empty() ) ) ).c_str(), // IX-3: exactly when pattern_dispatch= rides; ix2-slice: exactly when an block rides rw::rootRelPathsLegend( chSingleRoot ), rw::multiRootTableLegend( ing.rootLabels.size() >= 2 ) ); + if( cfg.columnar && chHasLink ) + { + rw::emitTo( stdout, "", rw::kViaLinkColumnarClause ); + } + if( chHasMiss ) + { + rw::emitTo( stdout, "", rw::kAnchorMissLegend ); + } } // P3 (L7, nextverb.h): ONE follow-up, shared with MCP. Callers preserve SELECTOR unless a narrowed @@ -311,108 +444,18 @@ std::optional runCallHierarchy( const MainDispatch& d ) const rw::VrRender chVr { chSingleRoot, chRootPrefix }; const std::string chValueRefsAttr = ( rw::valueRefsCountAttrXml( chRows.valueRefs.rows.size() ) + rw::valueRefsDepthAttrXml( ing, chRows.valueRefs.depthCut, chVr ) // + the depth cut that makes it a floor + rw::patternDispatchAttrXml( chRows.valueRefs.patternSites.size(), chRows.valueRefs.patternWideSites.size() ) ); // IX-3: template lookups, never in count= - const std::string chVrNext = "--uses=" + std::string( sym ); + const std::string chVrNext = "--uses=" + std::string( sym ); + if( cfg.columnar || cfg.json ) + { + return emitCallHierarchyAlt( cfg, ing, g, result, matches, pw, sym, wantCallers, bodylessDefsCount, chUnprovenAttr, chRows, + chAttr, chListing, chTested, chDeclinedAttr, chValueRefsAttr, chRootAttr, pab, chDiscloseCap, chNextAttr, + chSites, chRootPrefix, tag, chDcRows, chSingleRoot, chVr, chHintCall, chNextIsPage, chDcCut, chDc, chClosures ); + } // lane ix2-slice (IX-2 P2): the callees form's closure blocks — root closures= and the blocks, absent with none const rw::ClosureBlocksOut chIn = rw::closureBlocks( ing, g, chClosures, chSingleRoot, chRootPrefix, /*json=*/false ); const std::string& chClosuresAttr = chIn.head; const std::string& chInRows = chIn.rows; - // --format=columnar (RESEARCH lever 1): the same page window, re-encoded as a path-table + parallel - // arrays (dedups the repeated per-row markup + paths). Default --format=xml is byte-identical below. - if( cfg.columnar ) - { - std::vector page( result.begin() + pw.begin, result.begin() + pw.end ); - // §B1.1: composed into a std::string, NEVER a fixed `char attrbuf[]`. `of=` echoes a - // caller-supplied symbol NAME, which is unbounded (a markdown SECTION heading routinely runs - // 200-600 chars), so the old 288-byte buffer truncated it mid-attribute at exit 0 — invalid - // XML past ~185 chars once paging flags widened the string, and at a boundary length a - // well-formed tag whose next_offset=/offset=/limit= had been silently amputated. Same - // composition runImpact already used; the columnar-capable family is exactly - // {callers, callees, uses, impact} and this is the last shared site of the first two. - const std::string attr = "of=\"" + ex( sym ) + "\" defs=\"" + std::to_string( matches.size() ) - + "\" count=\"" + std::to_string( result.size() ) + "\"" - + ( !wantCallers && bodylessDefsCount > 0 ? " bodyless_defs=\"" + std::to_string( bodylessDefsCount ) + "\"" : "" ) - + chUnprovenAttr // H1: the decl→def residue, on BOTH directions - + rw::crossKindAttrXml( chRows.crossKind ) // hono-07: defs of 2+ kinds, beside defs= - + rw::defsListedAttrXml( chAttr.groups, chListing ) // attrib-multidef: a cut definition table says so - + chTested.xmlAttr // A6: hop_tested=/hop_untested=, the same partition on every dialect - + chDeclinedAttr // the tier-3 declines, beside the count they are not in - + chValueRefsAttr // value uses, beside the count they are not in (rows: the XML form) - + chClosuresAttr // ix2-slice: closures written in of='s body (rows: the blocks below) - + chRootAttr // R-E: same root= the XML/JSON branches carry - + pageDisclosure( pab, sizeof( pab ), pw.end - pw.begin, result.size(), pw.end, - cfg.pageLimit, cfg.pageOffset, chDiscloseCap ) - + rw::graphCountFloorAttrXml( g ) // §H4 §3.4 — every dialect carries the marker - + chNextAttr; // P3 (L7): the same next= on the columnar root - std::vector pageVia( page.size(), 0 ); // FE-B: the rows' hedge, a dense column when any row carries it - std::vector pageSplit( page.size(), 0 ); // attrib-multidef Q1: likewise for a one-group answer's split arms - std::vector pageOfDef; // attrib-multidef: of_def per row, only on a multi-group answer - for( std::size_t i = 0; i < page.size(); ++i ) - { - pageVia[ i ] = chAttr.nameOnly( pw.begin + i ) ? 1 : 0; - pageSplit[ i ] = chAttr.splitArm( pw.begin + i ) ? 1 : 0; - if( chAttr.groups.multi() ) - { - pageOfDef.push_back( rw::ofDefColumnValue( chAttr.groups, chAttr.tokens, pw.begin + i ) ); - } - } - const std::string chDefRows = rw::defTableXml( ing, chAttr.groups, chListing, chRootPrefix ); - const std::string chDcInRows = chDcRows + chInRows; - emitColumnarSymbolRows( stdout, ing, tag, attr, page, chRootPrefix, &chTested.testReach, nullptr, - rw::ColumnarRowExtras{ &pageVia, chSites.any ? &chSites.tokens : nullptr, &chSites.totals, // CALLSITE-AT: sites_at only when a row has sites - chAttr.groups.multi() ? &pageOfDef : nullptr, &chDefRows, &pageSplit, &chDcInRows } ); // + the and rows verbatim - return 0; - } - - // L2: --json — same rows, keys mirror the XML attr names (of/count/offset/limit/t/n/p). - // §A4c: the page disclosure is pageDisclosure()'s JSON row of the syntax table now, not a hand-rolled - // `offset`+`limit` pair — the SAME seven fields the XML tag two lines below carries (discloseCap=false - // for the same reason: these two verbs have no display cap of their own, so un-paged discloses nothing). - if( cfg.json ) - { - rw::emitTo( stdout, "{{\"of\":\"{}\",\"defs\":{},\"count\":{}", jsonStr( sym ).c_str(), matches.size(), result.size() ); - if( !wantCallers && bodylessDefsCount > 0 ) - { - rw::emitTo( stdout, ",\"bodyless_defs\":{}", bodylessDefsCount ); - } - rw::emitTo( stdout, "{}{}{}", rw::unprovenDefsKeyJson( chRows.unprovenDefs ).c_str(), // H1: absent at zero, like its XML twin - rw::crossKindKeyJson( chRows.crossKind ).c_str(), // hono-07: absent unless defs span 2+ kinds - rw::defTableJson( ing, chAttr.groups, chListing, chRootPrefix, /*mcp=*/false ) ); // attrib-multidef: the table - // R-E: the JSON twin of the XML root= below — right after the leading identifying fields. - if( chSingleRoot ) { rw::emitTo( stdout, ",\"root\":\"{}\"", jsonStr( cfg.roots[0] ).c_str() ); } - rw::emitTo( stdout, ",\"hop_tested\":{},\"hop_untested\":{}{}{}", chTested.tested, chTested.untested, - rw::declinedCallsKeyJson( chRows.declinedCalls ) + rw::declinedSiteCutKeyJson( chDcCut ) - + rw::declinedIfaceKeyJson( chRows.declinedIface ) // A6; then the XML root's declined_calls=/dc_shown=/dc_next=/declined_iface= - + rw::stdMemberCallsSpelling( chRows.stdCalls, /*json=*/true ), // and the XML line - ( rw::valueRefsCountKeyJson( chRows.valueRefs.rows.size() ) + rw::valueRefsDepthKeyJson( ing, chRows.valueRefs.depthCut, chVr ) - + rw::patternDispatchKeyJson( chRows.valueRefs.patternSites.size(), chRows.valueRefs.patternWideSites.size() ) ) ); // IX-3: the XML root's pattern counts - rw::emitTo( stdout, "{}{}{}", pageDisclosure( pab, sizeof( pab ), pw.end - pw.begin, result.size(), pw.end, - cfg.pageLimit, cfg.pageOffset, chDiscloseCap, kJsonPageSyntax ), - rw::graphCountFloorAttrJson( g ).c_str(), // §H4 §3.4 — the JSON dialect's spelling of the same marker - // L8: a capped callers page names the next page and the uses verb, as the XML/columnar root and MCP - // find_referencing_symbols do; the page replays --json. Any other answer keeps its JSON bytes. - chNextIsPage ? rw::nextFieldJson( rw::callHierarchyPageNext( wantCallers, sym, cfg.pageLimit, pw.end, false, true ) ) - + rw::nextFieldJson( chHintCall, rw::callHierarchyHintAttr( wantCallers ) ) - : std::string() ); - rw::emitTo( stdout, ",\"{}\":[", tag ); - std::vector chVia( result.size(), 0 ); // FE-B: the same hedge bit the XML rows carry - std::vector chAtKeys( result.size() ); // CALLSITE-AT: the rows' sites_at/sites_total keys, absent when none - for( std::size_t i = pw.begin; i < pw.end; ++i ) - { - chVia[ i ] = chAttr.nameOnly( i ) ? 1 : 0; - if( chSites.any ) - { - chAtKeys[ i ] = rw::callSitesKeyJson( chSites.tokens[ i - pw.begin ], chSites.totals[ i - pw.begin ] ); - } - chAtKeys[ i ] += rw::ofDefKeyJson( chAttr.groups, chAttr.tokens, i ) + rw::splitArmKeyJson( chAttr.splitArm( i ) ); // attrib-multidef - } - printJsonSymbolRows( ing, result, pw.begin, pw.end, chRootPrefix, &chTested.testReach, nullptr, &chVia, &chAtKeys ); - rw::emitTo( stdout, "]{}{}{}}}", rw::declinedSitesJson( chDc, wantCallers, /*mcp=*/false ), // the XML rows, keys = its attribute names - [ & ] { const rw::ClosureBlocksOut j = rw::closureBlocks( ing, g, chClosures, chSingleRoot, chRootPrefix, /*json=*/true ); return j.head + j.rows; }(), - rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ) ); - return 0; - } - // §P10.6: defs= = resolved definitions this name matched (matches.size()) — the rows below UNION the // neighbors of every def, which --uses/--impact already disclose and these two verbs silently hid. rw::emitTo( stdout, "<{} of=\"{}\" defs=\"{}\" count=\"{}\"{}", tag, ex( sym ).c_str(), matches.size(), result.size(), chRootAttr.c_str() ); @@ -437,7 +480,8 @@ std::optional runCallHierarchy( const MainDispatch& d ) // already follows everywhere else (serialize.h/verbs_for.h), so an untested row costs 0 bytes. rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, macroRoleAttr( s.kind ), rw::isTestedByReach( ing, chTested.testReach, result[i] ) ? " tested=\"1\"" : "", - viaNameAttr( chAttr.nameOnly( i ) ), // FE-B: a name-only edge says so on its row + std::string( viaEdgeAttr( chAttr.nameOnly( i ), oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ) ) + + anchorMissAttr( oneHopRowIsLink( g, matches, result[ i ], wantCallers, &g.outAnchorMiss ) ), rw::splitArmAttrXml( chAttr.splitArm( i ) ), rw::ofDefAttrXml( chAttr.groups, chAttr.tokens, i ), // attrib-multidef chSites.any ? rw::callSitesAttrXml( chSites.tokens[ i - pw.begin ], chSites.totals[ i - pw.begin ] ) : std::string() ); // CALLSITE-AT } @@ -991,6 +1035,8 @@ struct SafeDeleteLegendFlags bool defTable = false; // attrib-multidef: the table + of_def= rows (a multi-group selector) bool defTableCut = false; // ... and the table lists only the groups this page's rows name (defs_listed=) bool splitArms = false; // a row is on this page (a one-group selector's split arm, Q1) + bool linkRows = false; // a row is on this page + bool anchorRows = false; // a row is on this page rw::DeclinedSitesLegendFlags declinedSites{}; // lane declined-sites: what the rows on this answer carry (declinedsites.h) }; @@ -1028,6 +1074,8 @@ inline std::string safeDeleteFloorLegend( const SafeDeleteLegendFlags& flags, st clause += "next= is that uses call. "; // runSafeDelete names it exactly then (sdNextAttr) } clause += rw::defTableLegend( flags.defTable, flags.defTableCut ) + rw::splitArmLegend( flags.splitArms ); // attrib-multidef + clause += rw::viaLinkLegend( flags.linkRows ); + clause += rw::anchorMissLegend( flags.anchorRows ); return clause; } @@ -1354,9 +1402,13 @@ std::optional runSafeDelete( const MainDispatch& d ) rw::defTableMarkNamed( sdNamed, sdAttr.groups, sdAttr.tokens, sdLw.begin, sdLw.end ); const rw::DefTableListing sdListing = rw::defTableListing( sdAttr.groups, sdNamed ); bool sdHasSplit = false; - for( std::size_t i = sdLw.begin; i < sdLw.end && !sdHasSplit; ++i ) + bool sdHasLink = false; + bool sdHasMiss = false; + for( std::size_t i = sdLw.begin; i < sdLw.end && ( !sdHasSplit || !sdHasLink || !sdHasMiss ); ++i ) { - sdHasSplit = sdAttr.splitArm( i ); + sdHasSplit = sdHasSplit || sdAttr.splitArm( i ); + sdHasLink = sdHasLink || rw::oneHopRowIsLink( g, defs, callerIds[ i ], true ); + sdHasMiss = sdHasMiss || rw::oneHopRowIsLink( g, defs, callerIds[ i ], true, &g.outAnchorMiss ); } const std::vector sdDc = rw::declinedSiteViews( ing, g, std::span( sdDeclinedSites ).first( sdDcCut.shown ), /*callersForm=*/true, defs, sdAttr.groups, @@ -1368,6 +1420,7 @@ std::optional runSafeDelete( const MainDispatch& d ) .valueRefs = sdValueRefs.rows.size(), .patternDispatch = !sdPatterns.empty() || !sdWide.empty(), .patternWideRows = sdWideRows, .callersFloor = sdFloor.isFloor, .usesFloor = usesFloor, .hasSitesAt = sdSites.any, .sitesCut = sdSites.anyCut, .valueRefDepthCut = sdValueRefs.depthCut.files > 0, .defTable = sdAttr.groups.multi(), .defTableCut = sdListing.cut, .splitArms = sdHasSplit, + .linkRows = sdHasLink, .anchorRows = sdHasMiss, .declinedSites = rw::declinedSitesLegendFlags( sdDc, /*callersForm=*/true, sdDcCut ) } ); const Symbol& lead = ing.symbols[ defs[0] ]; // resolveAllByNameQualified walks ascending id — defs[0] is the @@ -1410,7 +1463,9 @@ std::optional runSafeDelete( const MainDispatch& d ) { rw::emitTo( stdout, " amb=\"{}\"", g.ambOut[ callerId ] ); // M15: the same COUNT a map row's amb= prints — one meaning, one unit } - rw::emitTo( stdout, "{}{}/>", rw::splitArmAttrXml( sdAttr.splitArm( i ) ), rw::ofDefAttrXml( sdAttr.groups, sdAttr.tokens, i ) ); // attrib-multidef + rw::emitTo( stdout, "{}{}{}/>", rw::splitArmAttrXml( sdAttr.splitArm( i ) ), rw::ofDefAttrXml( sdAttr.groups, sdAttr.tokens, i ), + std::string( rw::viaEdgeAttr( false, rw::oneHopRowIsLink( g, defs, callerId, true ) ) ) + + rw::anchorMissAttr( rw::oneHopRowIsLink( g, defs, callerId, true, &g.outAnchorMiss ) ) ); // a direct markdown link } rw::emitTo( stdout, "{}{}", rw::declinedSitesXml( sdDc, /*callersForm=*/true ), // lane declined-sites: each declined possible caller, after the rows rw::valueRefsXml( ing, sdValueRefs, true, sdVr, "--uses=" + std::string( cfg.safeDeleteSym ) ) + rw::patternDispatchXml( ing, sdPatterns, sdVr ) @@ -2473,12 +2528,15 @@ std::optional runPath( const MainDispatch& d ) // ix2-slice (IX-2 P1): the hedged bridge — the fewest-crossings chain over declined / value / closure / namesake hops const rw::PathBridge pthBridge = path.empty() ? rw::pathBridge( ing, g, srcDefs, dstDefs, *pthValueIdx ) : rw::PathBridge{}; const bool pthHasVia = rw::pathHasNameOnlyHop( g, path ); // FE-B: exactly when a hop of THIS path is via="name" + const bool pthHasLink = rw::pathHasNameOnlyHop( g, path, rw::kEdgeLink ); + const bool pthHasMiss = rw::pathHasNameOnlyHop( g, path, 0 ); // attrib-multidef (defattrib.h): from_p=/to_p= only for an endpoint the path bound; prov="split" on a split-arm hop const rw::PathEndpointBinding pthBound = rw::pathEndpointBinding( g, rw::endpointDefinitionCount( ing, srcDefs ), rw::endpointDefinitionCount( ing, dstDefs ), path ); const bool pthHasSplit = pthBound.splitHop; const std::vector pthOwners = rw::pathHopOwners( ing, path ); // PATH-OWNERS: n="Owner.name" per hop rw::emitTo( stdout, "{}", ( rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Path, pthUnprovenDefs > 0 ) + rw::viaNameLegend( pthHasVia ) + + rw::viaLinkLegend( pthHasLink ) + rw::anchorMissLegend( pthHasMiss ) + rw::splitArmLegend( pthHasSplit ) + rw::pathUnboundLegend( !pthBound.from || !pthBound.to ) + rw::pathOwnerLegend( rw::anyPathHopOwner( pthOwners ) ) ).c_str(), ( std::string( rw::toValueRefsLegend( pthToValueRefs.count > 0 ) ) + rw::valueRefsDepthLegend( pthToValueRefs.depthCut.files > 0 ) ).c_str(), @@ -2503,7 +2561,7 @@ std::optional runPath( const MainDispatch& d ) : rw::PathGapsXml{}; rw::emitTo( stdout, "{}>{}{}{}", pthUnreached.rootAttrs, pthUnreached.rows, rw::pathBridgeXml( ing, g, pthBridge, pthSingleRoot, pthRootPrefix ), rw::pathNearSinkXml( ing, pthNear, pthSingleRoot, pthRootPrefix ) ); - // FE-B via="name", attrib-multidef prov="split", PATH-OWNERS n="Owner.name": one emitter, shared with MCP path_between + // FE-B via="name", #409 via="link"/anchor="missing", attrib-multidef prov="split", PATH-OWNERS n="Owner.name": one emitter, shared with MCP path_between rw::emitPathHopRows( stdout, ing, g, path, pthOwners, pthSingleRoot, pthRootPrefix ); rw::emitTo( stdout, "" ); return 0; @@ -2636,6 +2694,7 @@ struct ImpactView const rw::ReachAttribution& attrib; // attrib-multidef (defattrib.h): the definition groups + each reached row's ties const rw::DefTableListing& defListing; // ... and the groups this page's table lists const std::vector& typeOnly; // L7 (typeuse.h ImpactReach::typeOnly): per node, reached only through a type mention; empty when unarmed + std::span seeds; // the matched defs; a depth-1 row's link mark reads the edge caller → seed const rw::ImpactDeclinedRows& dc; // lane ix2-slice (IX-4, declinedsites.h): the rows declined_calls= counts, cut std::span patternSites {}; // IX-3: template lookups that may name SYM — never in reaches= (XML rows) }; @@ -2649,16 +2708,31 @@ inline std::vector impactOfDefKeys( const ImpactView& v ) { keys[ i ] = v.attrib.ofDefJson( v.show[ i ] ); } + for( std::size_t i = v.page.begin; i < v.page.end; ++i ) + { + if( rw::impactDirectLink( v.g, v.seeds, v.depth, v.show[ i ] ) + && rw::oneHopRowIsLink( v.g, v.seeds, v.show[ i ], true, &v.g.outAnchorMiss ) ) + { + keys[ i ] += ",\"anchor\":\"missing\""; + } + } return keys; } -// FE-B: the window's hedge bits in row order (impactRowNameOnly), for the dialects that carry them per entry or per column -inline std::vector impactViaColumn( const ImpactView& v, std::size_t begin, std::size_t end ) +// JSON rows: 2 = a direct markdown link, 1 = via="name". The columnar column uses the same codes. +inline std::vector impactJsonVia( const ImpactView& v ) { std::vector via( v.show.size(), 0 ); - for( std::size_t i = begin; i < end; ++i ) + for( std::size_t i = v.page.begin; i < v.page.end; ++i ) { - via[ i ] = rw::impactRowNameOnly( v.provenReach, v.show[ i ] ) ? 1 : 0; + if( rw::impactDirectLink( v.g, v.seeds, v.depth, v.show[ i ] ) ) + { + via[ i ] = 2; + } + else if( rw::impactRowNameOnly( v.provenReach, v.show[ i ] ) ) + { + via[ i ] = 1; + } } return via; } @@ -2710,7 +2784,7 @@ int emitImpactColumnar( const ImpactView& v ) : " lens=\"shown_importers,importers_capped,importers_next\"" ) + rw::renderDisclosure( v.prD, rw::DiscloseAs::XmlAttrs ) // W2-F + rw::nextAttrXml( rw::nextFlag( "--safe-delete=", v.sym ) ); // P3 (L7): the XML root's next=, same set - const std::vector via = impactViaColumn( v, v.page.begin, v.page.end ); + const std::vector via = impactJsonVia( v ); const std::vector rowVia( via.begin() + v.page.begin, via.begin() + v.page.end ); // FE-B: in `rows` order std::vector rowOfDef; // attrib-multidef: of_def per row, only on a multi-group selector for( std::size_t i = v.page.begin; i < v.page.end && v.attrib.groups.multi(); ++i ) @@ -2767,16 +2841,17 @@ int emitImpactJson( const ImpactView& v ) v.pageLimit, v.pageOffset, true, kJsonPageSyntax ), rw::graphCountFloorAttrJson( v.g ).c_str(), // §H4 §3.4 rw::renderDisclosure( v.prD, rw::DiscloseAs::JsonKeys ).c_str() ); // W2-F: ONE keyset - const std::vector via = impactViaColumn( v, v.page.begin, v.page.end ); // FE-B: the rows' hedge, per entry + const std::vector via = impactJsonVia( v ); // 2 = direct markdown link, 1 = via="name" const std::vector keys = impactOfDefKeys( v ); // attrib-multidef: each row's of_def printJsonSymbolRows( v.ing, v.show, v.page.begin, v.page.end, v.rootPrefix, v.testReach, &v.depth, &via, &keys ); rw::emitTo( stdout, "],\"import_reach\":[" ); rw::emitImportRowsJson( stdout, v.ing, v.importPage, v.rootPrefix, v.importLazyPage ); rw::emitRaw( stdout, "]" ); rw::emitImpactFileRollupJson( stdout, v.ing, v.fileRollup, v.rootPrefix ); // idea #6: present exactly when the XML element is - rw::emitTo( stdout, "{}{}}}", rw::declinedSitesJson( v.dc.views, /*callersForm=*/true, /*mcp=*/false ), // ix2-slice: the XML rows + const std::string imLinkNote = rw::impactViaLinkNote( via, v.page.begin, v.page.end ); + rw::emitTo( stdout, "{}{}{}}}", rw::declinedSitesJson( v.dc.views, /*callersForm=*/true, /*mcp=*/false ), // ix2-slice: the XML rows rw::valueRefsJson( v.ing, v.valueRefs, true, rw::VrRender{ v.singleRoot, v.rootPrefix }, "vrs", - "--uses=" + std::string( v.sym ) ) ); + "--uses=" + std::string( v.sym ) ), imLinkNote.c_str() ); return 0; } @@ -2811,10 +2886,12 @@ int emitImpactXml( const ImpactView& v ) const std::string_view rp = v.singleRoot ? rw::sarif::rootRelativeUri( v.ing.files[ s.fileId ], v.rootPrefix ) : std::string_view( v.ing.files[ s.fileId ] ); // A6: tested="1" only (never a literal 0) — see kTestedRowLegend. 0.6.5: d= the row's hop depth, run-length (graph.h depthRunAttrXml). + const bool imLink = rw::impactDirectLink( v.g, v.seeds, v.depth, v.show[ i ] ); rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, rw::depthRunAttrXml( v.show, v.depth, i, v.page.begin ), rw::isTestedByReach( v.ing, *v.testReach, v.show[i] ) ? " tested=\"1\"" : "", - viaNameAttr( rw::impactRowNameOnly( v.provenReach, v.show[i] ) ), // FE-B (D3): reached only through a name-only edge + std::string( viaEdgeAttr( rw::impactRowNameOnly( v.provenReach, v.show[i] ), imLink ) ) + + anchorMissAttr( imLink && rw::oneHopRowIsLink( v.g, v.seeds, v.show[ i ], true, &v.g.outAnchorMiss ) ), v.attrib.ofDefXml( v.show[i] ) ); // attrib-multidef } rw::emitImportRowsXml( stdout, v.ing, v.importPage, v.rootPrefix, v.importLazyPage ); @@ -2825,6 +2902,46 @@ int emitImpactXml( const ImpactView& v ) return 0; } +inline void emitImpactLegend( const rw::Config& cfg, const rw::IngestResult& ing, const rw::Graph& g, const std::vector& reach, + const std::vector& show, const rw::PageWindow& imPage, const rw::ImpactReach& imWalk, + const rw::ImportTier& imports, const std::vector& seeds, const std::vector& imDepth, + const std::vector& imProven, std::size_t imUnprovenDefs, std::size_t imDeclinedCalls, std::size_t imDeclinedIface, + const rw::ImpactFileRollup& imFiles, const rw::ReachAttribution& imAttr, const rw::DefTableListing& imListing, + const rw::ValueRefRows& imValueRefs, const rw::RankDisclosure& prD, const rw::ImpactDeclinedRows& imDc ) +{ + if( cfg.json ) + { + return; + } + const bool imHasModScope = anyModuleScopeRow( ing, std::span( show ).subspan( imPage.begin, imPage.end - imPage.begin ) ); + rw::emitTo( stdout, "{}{}. {}{}{}{}{}{}{}{}{}{}{}{}{}-->", rw::kImpactLegendOpen, rw::kPageRaiseCapClause, + reach.empty() ? std::string() + : std::string( rw::impactDepthLegend( cfg.columnar ) ) + + rw::impactByTypeColumnarLegend( cfg.columnar && std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, + [ & ]( rw::NodeId n ) { return n < imWalk.typeOnly.size() && imWalk.typeOnly[ n ] != 0; } ) ), + ( std::string( cfg.columnar ? rw::kImpactImportTierColumnarLegend : rw::kImpactImportTierLegend ) + + rw::importersNextLegend( !imports.next.empty() && !cfg.columnar ) ).c_str(), + ( rw::impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ) + + rw::viaNameLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, + [ & ]( rw::NodeId n ) { return rw::impactRowNameOnly( imProven, n ); } ), true, cfg.columnar ) + + rw::impactLinkLegendFor( g, seeds, imDepth, show, imPage.begin, imPage.end ) + + rw::impactAnchorLegendFor( g, seeds, imDepth, show, imPage.begin, imPage.end ) ).c_str(), + rw::testedLensLegend( cfg.columnar ), rw::kImpactTestedPartitionLegend, + rw::kTestedLensBlindSpotLegend, + rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Impact, imUnprovenDefs > 0 ).c_str(), + rw::declinedCallsLegendForRows( imDeclinedCalls > 0, g.gateDeclinedCalls > 0, g.stdMemberDeclinedCalls > 0, !imDc.views.empty() ) + + rw::declinedSitesLegend( rw::declinedSitesLegendFlags( imDc.views, /*callersForm=*/true, imDc.cut ) ), // ix2-slice: + the rows, one per counted call + rw::declinedIfaceLegend( imDeclinedIface > 0 ), + ( std::string( rw::modScopeLegend( imHasModScope ) ) + + rw::impactRollupLegend( !imFiles.rows.empty() && !cfg.columnar, imports.umbrella, rw::impactRollupHedged( imFiles ), + !imports.next.empty() && !cfg.columnar, rw::ImpactRollupTypeClauses{ imFiles.typed, imFiles.typeDefs, imFiles.typeCapped } ) + + rw::defTableLegend( imAttr.groups.multi(), imListing.cut ) ).c_str(), + ( std::string( rw::valueRefsReachLegend( !imValueRefs.rows.empty() ) ) + + rw::valueRefsDepthLegendFor( imValueRefs ) + + rw::patternDispatchLegend( !imValueRefs.patternSites.empty() || !imValueRefs.patternWideSites.empty(), true ) ).c_str(), // IX-3: exactly when pattern_dispatch=/pattern_too_wide= rides + rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str() ); +} + std::optional runImpact( const MainDispatch& d ) { using namespace rw; @@ -2916,42 +3033,8 @@ std::optional runImpact( const MainDispatch& d ) std::size_t( effectiveRowCap( cfg.pageLimit, rw::kCallHierarchyRowCap ) ), rw::VrRender{ imSingleRoot, imRootPrefix }, /*candidates=*/false ); - if( !cfg.json ) - { // L2: JSON has no comment-node analogue; the XML-only leading doc comment - // §B12.4 in-band (W3FIX): the paging clause comes from pageview.h::kPageRaiseCapClause, which - // carries the limit="0" definition too — rule 7 existed only in --help and that header before. - // §H4 §3.4: opener + the shared floor/counting-unit tail, both from src/graphlegend.h so the MCP - // twin cannot drift from this wording (the §B4 echo-site class). - // LB-H: the import-tier clause is the columnar variant under --format=columnar, because that - // form carries the count without the rows and a reader must be told which shape they hold. - // #60: exactly when a module-scope owner is one of the rows this answer prints — the PAGE, which - // is what `anyModuleScopeRow`'s own contract asks for ("a page of rows, never the corpus"). - const bool imHasModScope = anyModuleScopeRow( ing, std::span( show ).subspan( imPage.begin, imPage.end - imPage.begin ) ); - rw::emitTo( stdout, "{}{}. {}{}{}{}{}{}{}{}{}{}{}{}{}-->", rw::kImpactLegendOpen, rw::kPageRaiseCapClause, - reach.empty() ? std::string() - : std::string( rw::impactDepthLegend( cfg.columnar ) ) // 0.6.5: exactly when rows (d=) and by_depth= exist - + rw::impactByTypeColumnarLegend( cfg.columnar && std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, // L7: exactly when the column rides - [ & ]( NodeId n ) { return n < imWalk.typeOnly.size() && imWalk.typeOnly[ n ] != 0; } ) ), - ( std::string( cfg.columnar ? rw::kImpactImportTierColumnarLegend : rw::kImpactImportTierLegend ) - + rw::importersNextLegend( !imports.next.empty() && !cfg.columnar ) ).c_str(), // cut-fix E: exactly when the root carries it - ( rw::impactTsImportLegend( imports.importsUnresolved, imports.tsconfigUnread ) // #220: exactly when the root carries them - + rw::viaNameLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, // FE-B: exactly when a shown row is via="name" - [ & ]( NodeId n ) { return rw::impactRowNameOnly( imProven, n ); } ), true, cfg.columnar ) ).c_str(), - rw::testedLensLegend( cfg.columnar ), rw::kImpactTestedPartitionLegend, // A6: the columnar form reads its dense column - rw::kTestedLensBlindSpotLegend, // F-02: rides with the partition - rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Impact, imUnprovenDefs > 0 ).c_str(), // H1: exactly when the root carries unproven_defs= - rw::declinedCallsLegendForRows( imDeclinedCalls > 0, g.gateDeclinedCalls > 0, g.stdMemberDeclinedCalls > 0, !imDc.views.empty() ) - + rw::declinedSitesLegend( rw::declinedSitesLegendFlags( imDc.views, /*callersForm=*/true, imDc.cut ) ), // exactly when the root carries declined_calls=; ix2-slice: + the rows, one per counted call - rw::declinedIfaceLegend( imDeclinedIface > 0 ), // likewise, exactly when declined_iface= is there - ( std::string( rw::modScopeLegend( imHasModScope ) ) // #60: likewise, exactly when a t="modscope" row is - + rw::impactRollupLegend( !imFiles.rows.empty() && !cfg.columnar, imports.umbrella, rw::impactRollupHedged( imFiles ), - !imports.next.empty() && !cfg.columnar, rw::ImpactRollupTypeClauses{ imFiles.typed, imFiles.typeDefs, imFiles.typeCapped } ) // idea #6; fix27; FIX1 R1: the XML root's importers_next= (columnar names it in lens=); L7 - + rw::defTableLegend( imAttr.groups.multi(), imListing.cut ) ).c_str(), // attrib-multidef: exactly when the table rides - ( std::string( rw::valueRefsReachLegend( !imValueRefs.rows.empty() ) ) // exactly when the root carries value_refs= - + rw::valueRefsDepthLegendFor( imValueRefs ) // ... and the depth disclosure - + rw::patternDispatchLegend( !imPatterns.empty() || !imValueRefs.patternWideSites.empty(), true ) ).c_str(), // IX-3: exactly when pattern_dispatch=/pattern_too_wide= rides - rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str() ); - } + emitImpactLegend( cfg, ing, g, reach, show, imPage, imWalk, imports, seeds, imDepth, imProven, imUnprovenDefs, + imDeclinedCalls, imDeclinedIface, imFiles, imAttr, imListing, imValueRefs, prD, imDc ); // P2.1 + §P8 G1: the rank-ordered listing's 40 is a DEFAULT now, not a ceiling — see the §P10.3 note // above runImpact; pageDisclosure emits the ` shown= capped=` bytes this verb used to hand-roll // (src/pageview.h, THE TRUNCATION VOCABULARY, rules 1-3). LB-G: the same NAMED constant @@ -2961,7 +3044,7 @@ std::optional runImpact( const MainDispatch& d ) imports, importPage, importLazyPage, prD, imSingleRoot, imRootPrefix, imRootAttr, imSingleRoot ? cfg.roots[0] : std::string_view(), cfg.pageLimit, cfg.pageOffset, &imTestReach, imRadiusTested, imRadiusUntested, imDeclinedCalls, imDeclinedIface, g, imDepth, imByDepth, imValueRefs, - imProven, imFiles, imAttr, imListing, imWalk.typeOnly, imDc, imPatterns }; + imProven, imFiles, imAttr, imListing, imWalk.typeOnly, seeds, imDc, imPatterns }; if( cfg.columnar ) { return emitImpactColumnar( view ); } if( cfg.json ) { return emitImpactJson( view ); } diff --git a/test/compactlegendcheck.sh b/test/compactlegendcheck.sh index 616d08bfd..a4239af5d 100755 --- a/test/compactlegendcheck.sh +++ b/test/compactlegendcheck.sh @@ -124,8 +124,12 @@ elif op == "schema": elif op == "rootattrs": # sorted attribute NAMES of the root, `schema` excluded print( " ".join( sorted( set( re.findall( r'([\w:.-]+)="', m.group( 2 ) ) ) - { "schema" } ) ) if m else "" ) elif op == "payload": # the payload with the root's schema attribute removed — the byte-identity operand - if m: # est_tokens= on the root is the price of the EMITTED bytes — a smaller legend is a smaller price, not a row change - tag = m.group( 0 ); pay = pay.replace( tag, re.sub( r'\sest_tokens="[0-9]*"', "", re.sub( r'\sschema="[^"]*"', "", tag ) ), 1 ) + if m: # schema= is the compact dialect's own id + tag = m.group( 0 ); pay = pay.replace( tag, re.sub( r'\sschema="[^"]*"', "", tag ), 1 ) + # est_tokens= prices the emitted bytes. A legend the compact dialect strips moves that price on the + # document root and on an embedded map root (pack-signatures, pack-top-n). A smaller legend is a + # smaller price, not a row change. + pay = re.sub( r'\sest_tokens="[0-9]*"', "", pay ) sys.stdout.write( pay ) elif op == "legend": sys.stdout.write( "".join( leg ) ) elif op == "prose": # PROSE legend bytes: comments this document carries that the FULL document (argv[3]) does not carry @@ -350,6 +354,8 @@ echo "=== (A-PIN) --legend=full is BYTE-IDENTICAL to the pre-L1 default (pinned # RE-ANCHORED 2026-10-18 (train 31, merge of lane hrr over the merged body-fold pin), for.xml only: re-recorded from the # merged binary; a token-level 3-way merge (base = lane body-fold's pin, ours = the merged pin above, theirs = lane hrr's pin; # at= and est_tokens= masked) equals it exactly. 5527 -> 5688 B. The six other pins are unchanged. +# RE-ANCHORED BY HAND 2026-10-19 (markdown section links), map.xml only: the two fixture callees gain via="link", +# the full legend gains the via="link" comment, and est_tokens= prices them, 901 -> 929. PIN_DIR="$ROOT/test/compactlegendfix/pre_l1_full" # the one normalisation, in python on BOTH sides so no sed dialect decides it (BSD sed appends a final newline, GNU # sed does not): at="…" masked, trailing newlines dropped. @@ -630,12 +636,13 @@ probeFor() # reading (+~75 B) rides only an answer whose row names a site. Nothing else moved. Commit 2 (closure containment) adds # "|closure" to the ns d= reading: measured 852 B, inside the same 860 B pin. The OLD path keeps its pin: the # (U-twin-path) arm after the universe loop cuts exactly those readings out of the same probe and holds the rest at 550 B. +# RE-PINNED 2026-10-19 (markdown section links): the via=link reading and the deleted same-pair sentence. PIN_TABLE=' ripwire.edit-check/v1 650 636 ripwire.map/v1 1140 1129 -ripwire.map-diff/v1 900 885 -ripwire.pack-signatures/v1 770 759 -ripwire.metrics/v1 1040 1021 +ripwire.map-diff/v1 970 953 +ripwire.pack-signatures/v1 840 827 +ripwire.metrics/v1 1090 1080 ripwire.deps/v1 780 764 ripwire.hotspots/v1 530 518 ripwire.clones/v1 810 799 @@ -676,7 +683,7 @@ ripwire.mentions/v1 260 243 ripwire.affected/v1 840 826 ripwire.verify/v1 440 421 ripwire.help-task/v1 440 429 -ripwire.query/v1 720 707 +ripwire.query/v1 790 775 ripwire.grep/v1 1380 1368 ripwire.match/v1 370 355 ripwire.lego/v1 290 275 @@ -695,7 +702,7 @@ ripwire.whereis/v1 1050 1037 ripwire.community/v1 730 719 ripwire.layout/v1 1220 1203 ripwire.pack-task/v1 990 974 -ripwire.pack-top-n/v1 760 745 +ripwire.pack-top-n/v1 830 813 ripwire.expand/v1 280 265 ripwire.expand-file/v1 510 508 ripwire.for/v1 1100 1099 diff --git a/test/compactlegendfix/pre_l1_full/map.xml b/test/compactlegendfix/pre_l1_full/map.xml index 01d2de816..6e28ab00c 100644 --- a/test/compactlegendfix/pre_l1_full/map.xml +++ b/test/compactlegendfix/pre_l1_full/map.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test/fillordercheck.sh b/test/fillordercheck.sh index b22f252cb..6077b8610 100755 --- a/test/fillordercheck.sh +++ b/test/fillordercheck.sh @@ -132,7 +132,9 @@ OFIX="$( order_of test/fixture )" # RE-PIN 2026-10-04 (train 25): 863 -> 905, the document GREW by its legend and nothing else. The v1 header legend's # unresolved=/external= clauses now state the false-edge rule (+106 B); test/golden.xml was re-recorded beside it and differs # from its previous self by exactly those two clauses and est_tokens=. Order is unchanged (important-first). -{ [ "$EFIX" = "905" ] && [ "$OFIX" = "important-first" ]; } \ +# RE-PIN 2026-10-19 (markdown section links): 905 -> 933. The full map gained the via="link" comment and the two +# fixture edges ([[notes]] and [[related]]); order is unchanged. +{ [ "$EFIX" = "933" ] && [ "$OFIX" = "important-first" ]; } \ && ok "test/fixture (est_tokens=$EFIX) does NOT auto-flip — order=$OFIX (golden neutral)" \ || no "test/fixture unexpectedly changed order or est_tokens (est=$EFIX order=$OFIX)" diff --git a/test/golden.xml b/test/golden.xml index ea21e4313..93862cf78 100644 --- a/test/golden.xml +++ b/test/golden.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test/mdanchorfix/README.md b/test/mdanchorfix/README.md new file mode 100644 index 000000000..80c52b68a --- /dev/null +++ b/test/mdanchorfix/README.md @@ -0,0 +1,5 @@ +# Root Readme + +## Languages + +The root languages section. diff --git a/test/mdanchorfix/docs/README.md b/test/mdanchorfix/docs/README.md new file mode 100644 index 000000000..cecfaeee8 --- /dev/null +++ b/test/mdanchorfix/docs/README.md @@ -0,0 +1,5 @@ +# Docs Readme + +## Languages + +The docs-directory readme, not the root one. diff --git a/test/mdanchorfix/docs/a/README.md b/test/mdanchorfix/docs/a/README.md new file mode 100644 index 000000000..3e93c7af3 --- /dev/null +++ b/test/mdanchorfix/docs/a/README.md @@ -0,0 +1,3 @@ +# A readme + +Same filename as docs/b/README.md. A name-only guess from this directory lands here. diff --git a/test/mdanchorfix/docs/a/guide.md b/test/mdanchorfix/docs/a/guide.md new file mode 100644 index 000000000..e23235868 --- /dev/null +++ b/test/mdanchorfix/docs/a/guide.md @@ -0,0 +1,43 @@ +# Guide + +## Install + +The guide's own install section. + +## Self Collision + +See [partner install](../b/partner.md#install). + +## Path Choice + +See [setup](../b/notes.md#setup). + +## Root Readme Link + +See [languages](../../README.md#languages). + +## Dup Link + +See [notes](../b/dup.md#notes). + +## Func Name + +See [work](../b/partner.md#do_work). + +## Missing Anchor + +See [nothing](../b/partner.md#no-such-heading). + +## Fence Only + +```python +[fenced](../b/partner.md#install) +``` + +## Missed Readme + +See [gone](../b/README.md#no-such-heading). + +## Bare Readme + +See [the page](../b/README.md). diff --git a/test/mdanchorfix/docs/a/notes.md b/test/mdanchorfix/docs/a/notes.md new file mode 100644 index 000000000..ca62ba09e --- /dev/null +++ b/test/mdanchorfix/docs/a/notes.md @@ -0,0 +1,5 @@ +# Notes Alpha + +## Setup Alpha + +The notes beside the guide. diff --git a/test/mdanchorfix/docs/a/tools.py b/test/mdanchorfix/docs/a/tools.py new file mode 100644 index 000000000..8e33ea784 --- /dev/null +++ b/test/mdanchorfix/docs/a/tools.py @@ -0,0 +1,2 @@ +def do_work(): + return 1 diff --git a/test/mdanchorfix/docs/a/use_work.py b/test/mdanchorfix/docs/a/use_work.py new file mode 100644 index 000000000..9c5c09dce --- /dev/null +++ b/test/mdanchorfix/docs/a/use_work.py @@ -0,0 +1,2 @@ +def use_work(): + return do_work() diff --git a/test/mdanchorfix/docs/b/README.md b/test/mdanchorfix/docs/b/README.md new file mode 100644 index 000000000..4c7cbbd29 --- /dev/null +++ b/test/mdanchorfix/docs/b/README.md @@ -0,0 +1,3 @@ +# B readme + +The file a `../b/README.md` link names. It has no heading for the missing slug. diff --git a/test/mdanchorfix/docs/b/dup.md b/test/mdanchorfix/docs/b/dup.md new file mode 100644 index 000000000..5183d9cd9 --- /dev/null +++ b/test/mdanchorfix/docs/b/dup.md @@ -0,0 +1,9 @@ +# Dup + +## Notes + +First. + +## Notes + +Second. diff --git a/test/mdanchorfix/docs/b/notes.md b/test/mdanchorfix/docs/b/notes.md new file mode 100644 index 000000000..6e790643b --- /dev/null +++ b/test/mdanchorfix/docs/b/notes.md @@ -0,0 +1,5 @@ +# Notes Beta + +## Setup + +The notes in the other directory. diff --git a/test/mdanchorfix/docs/b/partner.md b/test/mdanchorfix/docs/b/partner.md new file mode 100644 index 000000000..5e7a3d4c0 --- /dev/null +++ b/test/mdanchorfix/docs/b/partner.md @@ -0,0 +1,9 @@ +# Partner + +## Install + +The partner install section. + +## do_work + +The partner heading named like a python function. diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index c662d070e..8a4f2412a 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -11,7 +11,8 @@ # canonical id is path::Parent::Child — identity stays PATH-QUALIFIED (the churn-keying # lesson: same heading text across files must never collide); # - markdown links are edges: [text](other.md) / [label]: other.md → doc→doc (file node, the -# wikilink machinery), [text](#anchor) → doc-section→doc-section IN-FILE; `backtick` +# wikilink machinery), [text](#anchor) → doc-section→doc-section IN-FILE, +# [text](other.md#slug) → that file's heading (stem if the heading is absent); `backtick` # mentions stay doc→code and now attribute to their ENCLOSING SECTION; # - fenced/tilde/indented code, html blocks, YAML front-matter and blockquoted headings are # NOT document structure: nothing inside them becomes a symbol, and fenced code is never @@ -76,6 +77,8 @@ guard "$G" '# zqhtmlphantom' 'the html-block decoy heading' guard "$G" '# zqindentphantom' 'the indented-code decoy heading' guard "$G" '> # Quoted Phantom' 'the blockquoted decoy heading' guard "$G" '(partner.md)' 'the inline link to partner.md' +guard "$G" '(partner.md#install-steps)' 'the cross-file heading link' +guard "$G" '(alt.markdown#no-such-heading)' 'the cross-file link whose heading is absent' guard "$G" '(#result-tables)' 'the in-file anchor link' guard "$G" '[[decoy]]' 'the wikilink to decoy' guard "$G" '[zqref]: decoy.md' 'the reference-style link definition' @@ -252,6 +255,8 @@ for m in re.finditer(r']*>(.*?)', xml, re.S): PYEOF edge(){ if grep -qF "$1 -> $2" "$TMP/edges.txt"; then ok "edge: $1 -> $2 ($3)"; else no "edge MISSING: $1 -> $2 ($3)"; fi; } edge "Cache Warm Path" "partner" 'inline [text](partner.md) link, attributed to its section' +edge "Cache Warm Path" "Install Steps" 'cross-file [text](partner.md#install-steps) — that file'\''s heading' +edge "Cache Warm Path" "alt" 'cross-file [text](alt.markdown#no-such-heading) falls back to the file stem' edge "Cache Warm Path" "Result Tables" 'in-file [text](#result-tables) anchor — doc-section→doc-section' edge "Result Tables" "decoy" '[[wikilink]], attributed to its section' edge "Deep Appendix" "decoy" 'reference-style [zqref]: decoy.md definition' @@ -351,6 +356,225 @@ $BIN "$FIX" --no-cache --mentions=codeIdentFn >"$TMP/men.xml" 2>/dev/null grep -qF 'guide.md' "$TMP/men.xml" && ok "--mentions=codeIdentFn names guide.md (backtick doc→code edge)" \ || no "--mentions=codeIdentFn lost the guide.md mention" +# ═══════════════════════════════════════════════════════════════════════════ +echo +echo "=== cross-file [text](path#slug) binds the EXACT (file, slug) heading, never a bare name ===" +# ═══════════════════════════════════════════════════════════════════════════ +# Each arm reads --callees of ONE linking section and compares the rows' n=@p= (file, no line). The +# fixture is built so a name-only resolver lands on the wrong target: the linking file has its own +# `## Install`; docs/a/notes.md and docs/b/notes.md both exist; docs/README.md shadows the root README. +AFIX="$ROOT/test/mdanchorfix" +[ -d "$AFIX" ] || { echo "no fixture at $AFIX"; exit 2; } +callees(){ # callees DIR SECTION → "n@file" per callee row, sorted, one per line + $BIN "$1" --no-cache --callees="$2" --legend=compact 2>/dev/null \ + | python3 -c ' +import sys, re +for m in re.finditer(r"/dev/null | grep -q 'n="partner"[^>]*anchor="missing"' \ + && ! $BIN "$AFIX" --no-cache --callees='Bare Readme' --legend=compact 2>/dev/null | grep -q 'anchor="missing"'; then + ok "a slug the file does not have is anchor=missing, and a bare link is not" +else + no "anchor=missing missed the slug fallback, or a bare link carried it" +fi +if $BIN "$AFIX" --no-cache --format=columnar --callees='Func Name' --legend=compact 2>/dev/null | grep -q '2'; then + ok "columnar via is 2 for a direct markdown link" +else + no "columnar via did not write 2 for the markdown link" +fi +want "$AFIX" "Fence Only" '' "a link inside a fenced block makes no edge" +# anchor="missing" is a CLAIM that the heading is absent, so it is made only when the slug is provably absent. Four shapes the +# slug function does not model reach the same file-node fallback as a truly missing heading (a non-ASCII or percent-encoded slug, +# GitHub's -N duplicate suffix, and a file that is only one heading line, whose heading IS the file node): each keeps its edge +# and carries NO mark. Each arm links from its own section, so the rows do not merge into one pair. +mkdir -p "$TMP/amiss/docs" +printf '%s\n' '# Doc' '' '## Überblick' '' 'x' '' '## 概要' '' 'y' '' '## Dup' '' 'a' '' '## Dup' '' 'b' '' '## Real Heading' '' 'z' > "$TMP/amiss/docs/t.md" +printf '%s\n' '# Title' > "$TMP/amiss/docs/one.md" +printf '%s\n' '# Links' '' \ + '## UberLink' '' '[a](t.md#überblick)' '' '## GaiLink' '' '[a](t.md#概要)' '' '## DupLink' '' '[a](t.md#dup-1)' '' \ + '## OneLink' '' '[a](one.md#title)' '' '## PctLink' '' '[a](t.md#real%20heading)' '' '## EncLink' '' '[a](t.md#%C3%BCberblick)' '' \ + '## MissLink' '' '[a](t.md#nonexistent)' '' '## MissNumLink' '' '[a](t.md#bar-2)' '' '## OneMissLink' '' '[a](one.md#nothere)' \ + > "$TMP/amiss/docs/links.md" +amiss_row(){ $BIN "$TMP/amiss" --no-cache --callees="$1" --legend=compact 2>/dev/null; } +amiss_unmarked(){ # amiss_unmarked SECTION TARGET WHY: the edge exists (via=link) and says nothing about a missing anchor + row="$( amiss_row "$1" )" + if printf '%s' "$row" | grep -q "n=\"$2\"[^>]*via=\"link\"" && ! printf '%s' "$row" | grep -q 'anchor="missing"'; then ok "$3"; else no "$3 — got [$( printf '%s' "$row" | grep -o ']*>' )]"; fi +} +amiss_marked(){ # amiss_marked SECTION TARGET WHY: the same fallback, marked, because the slug is absent + if amiss_row "$1" | grep -q "n=\"$2\"[^>]*via=\"link\"[^>]*anchor=\"missing\""; then ok "$3"; else no "$3"; fi +} +amiss_unmarked UberLink "t" "#überblick (a non-ASCII slug, heading present) keeps the file edge, no anchor=missing" +amiss_unmarked GaiLink "t" "#概要 (a CJK slug, heading present) keeps the file edge, no anchor=missing" +amiss_unmarked DupLink "t" "#dup-1 (GitHub's duplicate suffix, second ## Dup present) keeps the file edge, no anchor=missing" +amiss_unmarked OneLink "Title" "one.md#title (the file is just that heading) keeps the file edge, no anchor=missing" +amiss_unmarked PctLink "t" "#real%20heading (percent-encoded, heading present) keeps the file edge, no anchor=missing" +amiss_unmarked EncLink "t" "#%C3%BCberblick (percent-encoded UTF-8, heading present) keeps the file edge, no anchor=missing" +amiss_marked MissLink "t" "a plain ASCII slug no heading has is still anchor=missing" +amiss_marked MissNumLink "t" "#bar-2 with no ## Bar (the -N stem is absent) is still anchor=missing" +amiss_marked OneMissLink "Title" "one.md#nothere (a heading-only file, other slug) is still anchor=missing" +# --callers lists every matched def in (cross_kind when a heading and a function share the name). +# The edge is the row: it must name the section, never the .py function. +if $BIN "$AFIX" --no-cache --callers=do_work --legend=compact 2>/dev/null | python3 -c ' +import sys, re +xml = sys.stdin.read() +kind = { m.group(1): m.group(2) for m in re.finditer(r"]*)/?>", xml)) +ok = bool(rows) and "fn" in kind.values() and "sec" in kind.values() +saw_sec = False +saw_fn = False +for m in rows: + attrs = m.group(1) + p = re.search(r" p=\"([^\"]*)\"", attrs) + of = re.search(r"of_def=\"(\d+)", attrs) + via = re.search(r" via=\"([^\"]*)\"", attrs) + if p and "tools.py" in p.group(1): + ok = False + if of and kind.get(of.group(1)) == "fn": + saw_fn = True + if via and via.group(1) == "link": + ok = False + else: + if not via or via.group(1) != "link": + ok = False + else: + saw_sec = True +sys.exit(0 if ok and saw_sec and saw_fn else 1) +'; then ok "the section caller stays via=link and the Python caller of do_work is unmarked"; else no "the doc link leaked onto the .py function, or the section row lacks via=link"; fi +if ( cd "$AFIX" && $BIN . --no-cache --callers=do_work --legend=compact ) 2>/dev/null | grep -q 'n="Func Name"[^>]*via="link"'; then + ok "a dot crawl root still pins the markdown link (./docs/... matches docs/...)" +else + no "a dot crawl root dropped the markdown link pin" +fi +QUAL=$($BIN "$AFIX" --no-cache --callers='docs/a/tools.py:do_work' --legend=compact 2>/dev/null || true) +if printf '%s' "$QUAL" | grep -q 'count="[1-9]' && printf '%s' "$QUAL" | grep -q 'n="use_work"' && ! printf '%s' "$QUAL" | grep -q 'via="link"'; then + ok "the .py do_work has a real caller and that row is not via=link" +else + no "the qualified .py do_work callers were empty or carried via=link" +fi +if $BIN "$AFIX" --no-cache --callees='Self Collision' --legend=compact 2>/dev/null | grep -q 'n="Install"[^>]*via="link"'; then + ok "Self Collision's callee Install is via=link" +else + no "Self Collision's callee Install lacks via=link" +fi +if $BIN "$AFIX" --no-cache --json --callees='Self Collision' 2>/dev/null | grep -q '"via":"link"'; then + ok "the JSON callee row spells via=link" +else + no "the JSON callee row lacks via=link" +fi +if $BIN "$AFIX" --no-cache --json --callers=do_work 2>/dev/null | python3 -c ' +import json, sys +d = json.load(sys.stdin) +rows = d.get("callers", []) +sec = any(r.get("t") == "sec" and r.get("via") == "link" for r in rows) +fn = any(r.get("t") == "fn" and r.get("via") == "link" for r in rows) +sys.exit(0 if sec and d.get("via_link_note") and not fn else 1) +'; then ok "the JSON callers answer marks the section row and carries via_link_note"; else no "the JSON callers answer lacks via_link_note, or the function row is marked via=link"; fi +mcp_text(){ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize"}' "$1" \ + | "$BIN" --mcp 2>/dev/null | tail -1 | python3 -c ' +import sys, json +r = json.load(sys.stdin) +print("__ERROR__:" + r["error"].get("message", "") if "error" in r else r["result"]["content"][0]["text"]) +' +} +call(){ printf '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"%s","arguments":%s}}' "$1" "$2"; } +M="$( mcp_text "$( call find_referencing_symbols '{"path":"'"$AFIX"'","symbol":"do_work"}' )" )" +printf '%s' "$M" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +rows = d.get("calledBy", []) +sec = any(r.get("kind") == "sec" and r.get("via") == "link" for r in rows) +fn = any("tools.py" in r.get("file", "") and r.get("via") == "link" for r in rows) +sys.exit(0 if sec and d.get("via_link_note") and not fn else 1) +' && ok "find_referencing_symbols marks the section row via=link and carries via_link_note" \ + || no "find_referencing_symbols lacks via=link on the section row, or marks the .py caller" +M="$( mcp_text "$( call find_symbol '{"path":"'"$AFIX"'","symbol":"Self Collision"}' )" )" +printf '%s' "$M" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +rows = d.get("calls", []) +hit = any(r.get("name") == "Install" and r.get("via") == "link" for r in rows) +sys.exit(0 if hit and d.get("via_link_note") else 1) +' && ok "find_symbol marks Install via=link and carries via_link_note" \ + || no "find_symbol lacks via=link on Install or lacks via_link_note" + +# A direct doc caller is via="link" on --safe-delete and --impact. The counts still include it. +mkdir -p "$TMP/runfix/src" "$TMP/runfix/docs" +printf '%s\n' 'def run():' ' return 1' '' 'def main():' ' run()' > "$TMP/runfix/src/app.py" +printf '%s\n' '# API' '' '## run' > "$TMP/runfix/docs/api.md" +printf '%s\n' '# Guide' '' '## Run Notes' '' 'see [run](api.md#run)' > "$TMP/runfix/docs/guide.md" +SD="$( $BIN "$TMP/runfix" --no-cache --safe-delete=run --legend=compact 2>/dev/null )" +printf '%s' "$SD" | grep -q 'callers="2"' \ + && printf '%s' "$SD" | grep -q 'n="Run Notes"[^>]*via="link"' \ + && ! printf '%s' "$SD" | grep -q 'n="main"[^>]*via="link"' \ + && ok "safe-delete marks Run Notes via=link and still counts both callers" \ + || no "safe-delete dropped the link mark or the caller count" +IM="$( $BIN "$TMP/runfix" --no-cache --impact=run --legend=compact 2>/dev/null )" +printf '%s' "$IM" | grep -q 'reaches="2"' \ + && printf '%s' "$IM" | grep -q 'n="Run Notes"[^>]*via="link"' \ + && ! printf '%s' "$IM" | grep -q 'n="main"[^>]*via="link"' \ + && ok "impact marks Run Notes via=link and still counts both rows" \ + || no "impact dropped the link mark or the reach count" +IJ="$( $BIN "$TMP/runfix" --no-cache --impact=run --json 2>/dev/null )" +printf '%s' "$IJ" | grep -q 'via_link_note' \ + && ok "impact json carries via_link_note" \ + || no "impact json omitted via_link_note" + +# In-file anchors, wikilinks, and root-relative links are via="link". A code call on the same name stays unmarked. +mkdir -p "$TMP/kinds/src" "$TMP/kinds/docs" "$TMP/kinds/notes" +printf '%s\n' 'def api():' ' return 1' '' 'def main():' ' api()' > "$TMP/kinds/src/app.py" +printf '%s\n' '# api' > "$TMP/kinds/docs/api.md" +printf '%s\n' '# decoy' > "$TMP/kinds/notes/api.md" +printf '%s\n' '# Guide' '' '## Hub' '' '[down](#later)' '' '[a](api.md)' '' '[b](/docs/api.md)' '' '[[api]]' '' '## Later' '' 'notes' > "$TMP/kinds/docs/guide.md" +HUB="$( $BIN "$TMP/kinds" --no-cache --callees=Hub --legend=compact 2>/dev/null )" +printf '%s' "$HUB" | grep -q 'n="Later"[^>]*via="link"' \ + && printf '%s' "$HUB" | grep -q 'n="api"[^>]*via="link"' \ + && ok "in-file, wikilink, and root-relative links are via=link" \ + || no "an in-file, wikilink, or root-relative link lost via=link" +API="$( $BIN "$TMP/kinds" --no-cache --callers=api --legend=compact 2>/dev/null )" +printf '%s' "$API" | grep -q 'n="Hub"[^>]*via="link"' \ + && printf '%s' "$API" | grep -q 'n="main"' \ + && ! printf '%s' "$API" | grep -q 'n="main"[^>]*via="link"' \ + && ok "a code call to api stays unmarked while the doc link stays via=link" \ + || no "the code call was marked via=link, or the doc link lost the mark" +printf '%s' "$HUB" | grep -q 'docs/api.md:[0-9]*"[^>]*via="link"' \ + && ! printf '%s' "$HUB" | grep -q 'notes/api.md' \ + && ok "a leading-slash link joins the crawl root, not the other api.md" \ + || no "the leading-slash link missed docs/api.md or hit the decoy" +PTH="$( $BIN "$TMP/kinds" --no-cache --path=Hub,Later --legend=compact 2>/dev/null )" +printf '%s' "$PTH" | grep -q 'n="Later"[^>]*via="link"' \ + && ok "path marks the Later hop via=link" \ + || no "path lost via=link on the Later hop" +CON="$( $BIN "$TMP/kinds" --no-cache --connect=Hub,Later --legend=compact 2>/dev/null )" +printf '%s' "$CON" | grep -q 'via="link"' \ + && ok "connect marks the Hub to Later edge via=link" \ + || no "connect lost via=link on the Hub to Later edge" + +echo "--- warm == cold after editing ONLY the target file (the cached ref must stay unbound) ---" +cp -R "$AFIX" "$TMP/anchorcopy" +rm -f "$TMP/anchor.cache" +$BIN "$TMP/anchorcopy" --cache="$TMP/anchor.cache" --callees="Self Collision" --legend=compact >/dev/null 2>&1 +sed -i.bak 's/^## Install$/## Install Steps Renamed/' "$TMP/anchorcopy/docs/b/partner.md" +rm -f "$TMP/anchorcopy/docs/b/partner.md.bak" +grep -q '^## Install Steps Renamed$' "$TMP/anchorcopy/docs/b/partner.md" || no "fixture edit did not apply (sed)" +$BIN "$TMP/anchorcopy" --cache="$TMP/anchor.cache" --callees="Self Collision" --legend=compact >"$TMP/ac_warm" 2>/dev/null +$BIN "$TMP/anchorcopy" --no-cache --callees="Self Collision" --legend=compact >"$TMP/ac_cold" 2>/dev/null +if [ -s "$TMP/ac_cold" ] && diff -q "$TMP/ac_cold" "$TMP/ac_warm" >/dev/null; then ok "warm run equals cold run after only the target heading changed"; else no "warm run differs from cold after a target-only edit (a stale bound ref)"; fi +if grep -q 'n="partner"' "$TMP/ac_warm"; then ok "…and the dead anchor degraded to the partner file edge"; else no "the renamed-heading link lost its edge on the warm run"; fi + # ═══════════════════════════════════════════════════════════════════════════ echo echo "=== determinism ×3 and cache transparency on the md fixture ===" diff --git a/test/mdsectionfix/guide.md b/test/mdsectionfix/guide.md index 80a59b31b..0407514ad 100644 --- a/test/mdsectionfix/guide.md +++ b/test/mdsectionfix/guide.md @@ -13,7 +13,9 @@ The orientation body carries zqorientbody as its unique marker token. The warm path body carries zqcachewarmbody and mentions `codeIdentFn` in a backtick. It links to [the partner doc](partner.md) and jumps to -[result tables](#result-tables) further down. An archived copy +[result tables](#result-tables) further down. The install section is +[over there](partner.md#install-steps). A missing anchor stays on the file: +[nowhere](alt.markdown#no-such-heading). An archived copy [lives here][zqref]. ```cpp diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index 66cb651a3..455323b63 100644 --- a/test/printf_parity.manifest +++ b/test/printf_parity.manifest @@ -1,4 +1,4 @@ -flagless 0 22b42aea387b3ba54ac0921ce1caed39aea23a4947579eec2276b72ad77330e6 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +flagless 0 b0447db620f0c57726cc5be7851d6fb5dad0b3510eaa0318297ee7aaeee80904 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 lint 0 a3d29cee7631f2904dfeaa947f091d3e73181d325e6ddedd87a68580d5717ff5 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 lint_sarif 0 805e375b06b7755bcc6423607378e557ee82fef787cf09c34ba5c066639f4d7b e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 lint_select 0 740876ae2df8dbdced07c406c223f545e92a719d8bc3dee1cac56a85207f16c2 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 @@ -17,7 +17,7 @@ uses 0 5442767b638c8cac9eb0327cbfd5fde73f92fde5b85e2a995670a3982d903711 e3b0c442 path 0 976a050ece8954e7e0b245df5d61b693bb10dec1f3867918ec519c72f83f4aee e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 connect 0 6ceb99d6c5ecb9f57a1b7426da2ed2118705cb7cc6b3d0aa4456b84c0ff715f4 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 grep 0 ea5c66f3062d3ba805f271d18b146ee411f3b132f75d0c77062ecf2eda13f36e e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -pack_signatures 0 91446d0fc143c8d08605da1a3e7430834de105b15f0d2658f675c6bc3bf641a8 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +pack_signatures 0 66de39992132b15fdc91e088e5301976e2beb972cb95802441331350983fab25 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pack_task 0 f3de4fc3a28342cf515fedb8a02de0fea7f149ce8e948d39a7037c955a9eafd1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 arch 2 45b12ab02e3e9f2e32bf92fe0e1cef7b489e70711386e0749d50281743aeeec5 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 seams 0 069a723391d0a5687bdbe23d250a5dc901791fbcc676e5ac7435e9017cc30478 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index 706fd9d6e..2ccfe2fbb 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -b3bb57d4aa8bbcda9087d57babd7cf157b907c1912264e6de8fcda6ea02a21a4 +a667ffc55eff871ff6ea06c1068cc5c73c8fe06b5a582beb87dae733548f8a05 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index 4e0d341a9..1d38afd18 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,11 @@ SRC="$ROOT/src/quality.h" ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (2026-08-29 ingest.cpp section split); the hashed CONCAT label keeps its historical spelling so the pin holds PIN="$ROOT/test/qschemetrip.hash" # RE-PIN LOG (the pin is a bare hash, so its justification has to live here). +# 2026-10-19, PR #414 (#409 markdown section links) merged with main 90f6fae6 (train 31): RE-PINNED with UPDATE_GOLDEN=1 (hash +# a667ffc55e…8a05, unchanged from the PR's own earlier pin: main moved its hash with 198, the watched text here is 206 again). The only watched text that moved is the kParserVer line and quality.h's mirror: renumbered again at +# merge to 206 (rich file tag 207), above main 198 / 199 (train 31), 196 / 197 and the live branch builds 202 and 204. +# kCacheVersion stays 36 and kQSnapCacheScheme stays 22: the PR keeps a markdown link's written path in an existing field, +# no record format moved. The train 31 entry below is kept as written; its hash is superseded by this one. # 2026-10-18, train 31 (body-fold, hrr, h1-annot, callers-band2, dangling-warnings, ix2-slice merged onto main 939dbb69): # RE-DERIVED ONCE with UPDATE_GOLDEN=1 on the merged tree (hash b3bb57d4aa…a02a21a4). Watched text: kParserVer -> 198 (main # 196 / rich 197, lane h1-annot 194 / 195; the other five lanes kept their base 184; 198 / rich file tag 199 is above every diff --git a/test/regression.sh b/test/regression.sh index f9c74865f..4b9c55b8a 100755 --- a/test/regression.sh +++ b/test/regression.sh @@ -327,7 +327,8 @@ done # 4) golden snapshot — output matches the committed golden (catches ANY unintended output change). # --no-cache so the golden is the canonical cold parse, independent of any TMPDIR cache state. -"$BIN" "$CORPUS" --no-cache >"$TMP/cur" 2>/dev/null +# L1: the CLI default legend is compact; golden.xml is the full legend, so this compare asks for it. +"$BIN" "$CORPUS" --no-cache --legend=full >"$TMP/cur" 2>/dev/null if [ "${UPDATE_GOLDEN:-0}" = "1" ]; then cp "$TMP/cur" "$GOLD"; printf ' WROTE golden (%s B)\n' "$(wc -c <"$GOLD" | tr -d ' ')" elif [ -f "$GOLD" ]; then