From 305a95571791b4079345be6db84e3ff63e6d05c1 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Fri, 16 Oct 2026 15:19:24 +0300 Subject: [PATCH 01/18] WIP: caller/callee in markdown --- src/ingest_cache.h | 5 +- src/ingest_docs.h | 109 +++++++++++++++++++++++++++++++++++-- src/ingest_parsepool.h | 1 + src/quality.h | 4 +- test/mdsectioncheck.sh | 7 ++- test/mdsectionfix/guide.md | 4 +- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/ingest_cache.h b/src/ingest_cache.h index ba885c6c7..1faaabc16 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -330,7 +330,10 @@ constexpr std::uint32_t kCacheVersion = 34; // 34: train 29 — lane a // (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 = 184; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 185; // bump on any grammar/.scm/extraction change + // 185 = 2026-10-16 (#409): a cross-file markdown [text](other.md#slug) names the + // target file's heading instead of dropping the fragment. 185, not 184: 184 is + // train 29. Rich file tag 186. kCacheVersion stays 34. // 184 = 2026-10-17 (train 29): strictly above every number a train-29 binary used — lane arity-min 178 // (rich 179), lane call-result-typing 180 (rich 181; carried to train 30), lane go-scope 182 (rich 183); // the other train-29 lanes kept 176. Rich file tag 185. kCacheVersion 34 (see its note). diff --git a/src/ingest_docs.h b/src/ingest_docs.h index 6af4b4bc9..870d96959 100644 --- a/src/ingest_docs.h +++ b/src/ingest_docs.h @@ -40,7 +40,10 @@ 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 parks the slug in qualifier until +// bindMarkdownCrossFileAnchors renames it to that file's heading, or leaves the stem 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 @@ -284,8 +287,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]]). + // [x](other.md#slug) parks the slug in qualifier for bindMarkdownCrossFileAnchors. 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 +313,11 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi pendingAnchors.emplace_back( refByte, std::move( slug ) ); return; } + std::string_view anchorSlug; 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) + anchorSlug = target.substr( frag + 1 ); + target = target.substr( 0, frag ); } const bool isMd = target.size() > 3 && ( target.ends_with( ".md" ) || target.ends_with( ".markdown" ) ); if( !isMd ) @@ -326,6 +332,14 @@ 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 ); + if( !anchorSlug.empty() ) + { + r.qualifier.reserve( anchorSlug.size() ); + for( const char c : anchorSlug ) + { + r.qualifier += ( c >= 'A' && c <= 'Z' ) ? char( c - 'A' + 'a' ) : c; + } + } refs.push_back( std::move( r ) ); } }; @@ -503,6 +517,93 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi } } } + +// Cross-file [text](other.md#slug): emitLinkTarget left the stem in name and the slug in qualifier, +// because the target file's headings are not known while one file is extracted. After the parse pool +// merges every file, rename the ref to that file's heading (first slug match, the same rule as an +// in-file anchor). A missing file or heading leaves the stem edge and clears qualifier, so the +// resolver never sees a markdown qualifier. Same-directory stem wins; otherwise the lowest fileId. +inline void bindMarkdownCrossFileAnchors( const std::vector& files, const std::vector& defs, std::vector& refs ) +{ + const auto parentDir = []( std::string_view path ) noexcept + { + const std::size_t slash = path.find_last_of( '/' ); + return slash == std::string_view::npos ? std::string_view{} : path.substr( 0, slash ); + }; + + const auto appendId = []( std::string& out, std::uint32_t id ) + { + char digits[ 10 ]; + int digitCount = 0; + do { digits[ digitCount++ ] = char( '0' + id % 10u ); id /= 10u; } while( id != 0u ); + while( digitCount > 0 ) { out.push_back( digits[ --digitCount ] ); } + }; + + HashMap> stems; + HashMap headingBySlug; + std::string key; + for( const RawDef& d : defs ) + { + if( d.lang != Lang::Markdown || d.kind != SymKind::Section ) + { + continue; + } + if( d.startByte == 0 && d.bodyByte == 0 ) + { + stems[ d.name ].push_back( d.fileId ); + continue; + } + key.clear(); + appendId( key, d.fileId ); + key.push_back( '#' ); + key.append( mdtier::mdSlugOf( d.name ) ); + headingBySlug.try_emplace( key, d.name ); + } + + for( RawRef& r : refs ) + { + if( r.lang != Lang::Markdown || r.isDocLink || r.qualifier.empty() ) + { + continue; + } + const std::string slug = std::move( r.qualifier ); + r.qualifier.clear(); + const auto stemIt = stems.find( r.name ); + if( stemIt == stems.end() || r.fileId >= files.size() ) + { + continue; + } + const std::string_view refDir = parentDir( files[ r.fileId ] ); + std::uint32_t best = stemIt->second.front(); + bool inDir = false; + for( const std::uint32_t fid : stemIt->second ) + { + if( fid >= files.size() ) + { + continue; + } + const bool sameDir = parentDir( files[ fid ] ) == refDir; + if( sameDir && ( !inDir || fid < best ) ) + { + best = fid; + inDir = true; + } + else if( !inDir && !sameDir && fid < best ) + { + best = fid; + } + } + key.clear(); + appendId( key, best ); + key.push_back( '#' ); + key.append( slug ); + if( const auto hit = headingBySlug.find( key ); hit != headingBySlug.end() ) + { + r.name = hit->second; + } + } +} + } // namespace — ingest_docs.h section of ingest.cpp } // namespace rw diff --git a/src/ingest_parsepool.h b/src/ingest_parsepool.h index f583c78a0..3abf6846f 100644 --- a/src/ingest_parsepool.h +++ b/src/ingest_parsepool.h @@ -914,6 +914,7 @@ inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::st } raw = mergeThreadFacts( tFacts ); + bindMarkdownCrossFileAnchors( result.files, raw.defs, raw.refs ); const bool parseCut = applyMemoryParseCut( result, memWatch, nextFile.load( std::memory_order_relaxed ), nfiles ); // #350 diff --git a/src/quality.h b/src/quality.h index ff3ffd5ba..dfef0ff53 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2336,7 +2336,9 @@ 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 = 34; // MUST equal ingest.cpp's kCacheVersion (gated); 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), 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 = 184; // MUST equal ingest.cpp's kParserVer (gated) +constexpr std::uint32_t kIngestParserVerMirror = 185; // MUST equal ingest.cpp's kParserVer (gated) + // 185 = 2026-10-16 (#409): cross-file markdown #slug binds to that file's + // heading. 185, not 184: 184 is train 29. Rich file tag 186. // 184 = 2026-10-17 (train 29: above arity-min 178/179, call-result-typing // 180/181, go-scope 182/183; see the kParserVer note) // 182 = 2026-10-16 (train 29 L5 go-scope reservation, #403: Go method / diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index c662d070e..a15256a11 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' 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 From 9ee11bffdae341cba8c003d9651b7016f25bec9a Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sat, 17 Oct 2026 23:17:35 +0300 Subject: [PATCH 02/18] make CodeRabbit's happy by fixing cache --- src/ingest_parsepool.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ingest_parsepool.h b/src/ingest_parsepool.h index 3abf6846f..04e47d121 100644 --- a/src/ingest_parsepool.h +++ b/src/ingest_parsepool.h @@ -914,7 +914,6 @@ inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::st } raw = mergeThreadFacts( tFacts ); - bindMarkdownCrossFileAnchors( result.files, raw.defs, raw.refs ); const bool parseCut = applyMemoryParseCut( result, memWatch, nextFile.load( std::memory_order_relaxed ), nfiles ); // #350 @@ -956,6 +955,7 @@ inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::st saveCache( std::string( cacheFile ), rootDir, result.files, scan.hash, scan.statSize, scan.statMtime, scan.statCtime, scan.health, raw.defs, raw.refs, raw.incs, raw.binds, raw.ffis, raw.routeDefs, raw.routeUses, raw.constOpens, captureValueUses ); } } + bindMarkdownCrossFileAnchors( result.files, raw.defs, raw.refs ); return raw; } From deff7dfe09294f3119d1a8dfb83c393b55d31b06 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 11:41:58 +0300 Subject: [PATCH 03/18] mdslug [text](path#slug), update CHANGELOG.md --- CHANGELOG.md | 9 ++ src/graph.h | 115 ++++++++++++++++++++++++++ src/infra/mdslug.h | 31 +++++++ src/ingest.cpp | 1 + src/ingest_cache.h | 6 +- src/ingest_docs.h | 127 ++++------------------------- src/ingest_parsepool.h | 1 - src/quality.h | 5 +- test/mdanchorfix/README.md | 5 ++ test/mdanchorfix/docs/README.md | 5 ++ test/mdanchorfix/docs/a/guide.md | 35 ++++++++ test/mdanchorfix/docs/a/notes.md | 5 ++ test/mdanchorfix/docs/a/tools.py | 2 + test/mdanchorfix/docs/b/dup.md | 9 ++ test/mdanchorfix/docs/b/notes.md | 5 ++ test/mdanchorfix/docs/b/partner.md | 9 ++ test/mdsectioncheck.sh | 40 +++++++++ 17 files changed, 292 insertions(+), 118 deletions(-) create mode 100644 src/infra/mdslug.h create mode 100644 test/mdanchorfix/README.md create mode 100644 test/mdanchorfix/docs/README.md create mode 100644 test/mdanchorfix/docs/a/guide.md create mode 100644 test/mdanchorfix/docs/a/notes.md create mode 100644 test/mdanchorfix/docs/a/tools.py create mode 100644 test/mdanchorfix/docs/b/dup.md create mode 100644 test/mdanchorfix/docs/b/notes.md create mode 100644 test/mdanchorfix/docs/b/partner.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 965c7ee73..64e9e8d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ 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. Gate: `test/mdsectioncheck.sh`. + ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) Go methods now carry their receiver type as scope (`sc=`), with `*` and type arguments stripped (`*Stack[T]` → `Stack`), diff --git a/src/graph.h b/src/graph.h index 4c49f1424..6fc83bd12 100644 --- a/src/graph.h +++ b/src/graph.h @@ -26,6 +26,7 @@ #include "externalnames.h" // Phase 5: the committed builtin/stdlib tables behind the external-name veto #include "infra/sortutil.h" // radix edge sorting for large integer-key graph edge lists #include "clones.h" // scanCodeTokens / CodeScanOptions — BuiltinMethodGate::namedBeyondDefinition's code-token count +#include "infra/mdslug.h" // mdSlugOf — bindMarkdownLinks matches a link slug to a heading #include "infra/namesplit.h" // isIdentChar — identifierRunCount, the identifier runs inside a string annotation #include "docparse.h" // detail::readWholeFile — resolveAtSeed reads the seed line's byte range off disk #include "infra/profileScope.h" // PROFILE_SCOPE self-profiling — gated by PROFILE_ENABLED (off unless -DRIPWIRE_PROFILE=ON) @@ -6281,6 +6282,106 @@ inline void fillImplementorBound( Graph& g, const std::vector fileByPath; // every .md/.markdown file, by root-relative path + std::vector headings; // sorted (fileId, slug, startByte, id): the first of a slug is its heading + std::vector wholeFile; // per file: the file-level Section node, kNoNode when none +}; + +inline bool isMarkdownLinkRef( const Reference& r ) noexcept +{ + return r.lang == Lang::Markdown && !r.isDocLink && !r.memberCall && !r.memberPath.empty(); +} + +inline MdLinkIndex buildMdLinkIndex( const IngestResult& ing ) +{ + MdLinkIndex ix; + for( std::uint32_t f = 0; f < ing.files.size(); ++f ) + { + if( ing.files[ f ].ends_with( ".md" ) || ing.files[ f ].ends_with( ".markdown" ) ) + { + ix.fileByPath.try_emplace( ing.files[ f ], f ); + } + } + ix.wholeFile.assign( ing.files.size(), kNoNode ); + for( const Symbol& sym : ing.symbols ) + { + if( sym.lang != Lang::Markdown || sym.kind != SymKind::Section || sym.fileId >= ing.files.size() ) + { + continue; + } + if( sym.sigStartByte == 0 && sym.sigEndByte == 0 ) + { + ix.wholeFile[ sym.fileId ] = sym.id; + } + else + { + ix.headings.push_back( { sym.fileId, sym.sigStartByte, sym.id, mdSlugOf( sym.name ) } ); + } + } + std::sort( ix.headings.begin(), ix.headings.end(), []( const MdLinkIndex::Heading& a, const MdLinkIndex::Heading& b ) + { + return std::tie( a.fileId, a.slug, a.startByte, a.id ) < std::tie( b.fileId, b.slug, b.startByte, b.id ); + } ); + return ix; +} + +// The Section one link names, or kNoNode when its path is not in the tree. +inline NodeId bindOneMarkdownLink( const MdLinkIndex& ix, std::string_view linkerPath, std::string_view written ) +{ + const std::size_t frag = written.find( '#' ); + const std::string_view link = written.substr( 0, frag ); + const std::string_view slug = frag == std::string_view::npos ? std::string_view{} : written.substr( frag + 1 ); + const std::string_view dir = includerDir( linkerPath ); + std::string path( dir ); + if( !dir.empty() ) + { + path.push_back( '/' ); + } + path.append( link ); + path = lexicalNormalize( path ); // empty on an escape above the root → stem ladder + const auto file = path.empty() ? ix.fileByPath.end() : ix.fileByPath.find( path ); + if( file == ix.fileByPath.end() ) + { + return kNoNode; + } + const std::uint32_t target = file->second; + using Key = std::pair; + const auto hit = slug.empty() ? ix.headings.end() + : std::lower_bound( ix.headings.begin(), ix.headings.end(), Key{ target, slug }, + []( const MdLinkIndex::Heading& h, const Key& key ) { return Key{ h.fileId, h.slug } < key; } ); + return ( hit != ix.headings.end() && hit->fileId == target && hit->slug == slug ) ? hit->id : ix.wholeFile[ target ]; +} + +inline std::vector bindMarkdownLinks( const IngestResult& ing ) +{ + std::vector pin; + if( std::none_of( ing.references.begin(), ing.references.end(), isMarkdownLinkRef ) ) + { + return pin; + } + pin.assign( ing.references.size(), kNoNode ); + const MdLinkIndex ix = buildMdLinkIndex( ing ); + for( std::size_t i = 0; i < ing.references.size(); ++i ) + { + const Reference& r = ing.references[ i ]; + if( isMarkdownLinkRef( r ) && r.fileId < ing.files.size() ) + { + pin[ i ] = bindOneMarkdownLink( ix, ing.files[ r.fileId ], r.memberPath ); + } + } + return pin; +} + // B2.2 MINIMUM ARITY ACROSS DECLARATIONS: per symbol, the fewest arguments a call can pass to it — its own paramsMin // (ingest_metrics.h cc_paramShape) lowered to the lowest paramsMin of any DECLARATION with the same name and the same // parameter count. C++ may write a default on a separate prototype only (`int f( int x, int y = 5 );` in a header, @@ -7434,6 +7535,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null }; { PROFILE_SCOPE_DESCRIBE( "buildGraph/3: resolve loop (per reference)" ); + const std::vector mdLinkPin = bindMarkdownLinks( ing ); // empty unless the tree holds a cross-file markdown link for( siteIndex = 0; siteIndex < ing.references.size(); ++siteIndex ) { const Reference& site = ing.references[ siteIndex ]; @@ -7517,6 +7619,19 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } } + // A cross-file markdown link whose file is in the tree: pinned to the exact heading (or file) bindMarkdownLinks + // found, like a SCIP site — the name ladder (same-dir stem, same-file heading) must not re-choose it. + if( !mdLinkPin.empty() && mdLinkPin[ siteIndex ] != kNoNode ) + { + const NodeId to = mdLinkPin[ siteIndex ]; + tier.clear(); + if( to != r.fromSymbol ) + { + tier.push_back( to ); + } + scipPinned = true; + } + // Java issue #74: the grammar labels both `Widget::makeFn` and `widget::makeFn` // with an identifier receiver. A method-reference capture is therefore admitted only when // repository evidence proves the receiver is a type at this site. Every other receiver is diff --git a/src/infra/mdslug.h b/src/infra/mdslug.h new file mode 100644 index 000000000..de0c8da97 --- /dev/null +++ b/src/infra/mdslug.h @@ -0,0 +1,31 @@ +#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 + +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). +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; +} + +} // 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 1faaabc16..f4a6387bb 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -331,9 +331,9 @@ constexpr std::uint32_t kCacheVersion = 34; // 34: train 29 — lane a // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field constexpr std::uint32_t kParserVer = 185; // bump on any grammar/.scm/extraction change - // 185 = 2026-10-16 (#409): a cross-file markdown [text](other.md#slug) names the - // target file's heading instead of dropping the fragment. 185, not 184: 184 is - // train 29. Rich file tag 186. kCacheVersion stays 34. + // 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. // 184 = 2026-10-17 (train 29): strictly above every number a train-29 binary used — lane arity-min 178 // (rich 179), lane call-result-typing 180 (rich 181; carried to train 30), lane go-scope 182 (rich 183); // the other train-29 lanes kept 176. Rich file tag 185. kCacheVersion 34 (see its note). diff --git a/src/ingest_docs.h b/src/ingest_docs.h index 870d96959..b0eea75d7 100644 --- a/src/ingest_docs.h +++ b/src/ingest_docs.h @@ -41,9 +41,8 @@ namespace // [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. A cross-file -// [text](other.md#anchor) keeps the stem on the ref and parks the slug in qualifier until -// bindMarkdownCrossFileAnchors renames it to that file's heading, or leaves the stem when the -// heading is absent. Dangling links drop. +// [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 @@ -82,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 ) { @@ -289,7 +275,7 @@ 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) → 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]]). - // [x](other.md#slug) parks the slug in qualifier for bindMarkdownCrossFileAnchors. Anchors: + // 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 ) @@ -313,11 +299,10 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi pendingAnchors.emplace_back( refByte, std::move( slug ) ); return; } - std::string_view anchorSlug; + 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 ) { - anchorSlug = target.substr( frag + 1 ); - target = target.substr( 0, frag ); + target = target.substr( 0, frag ); } const bool isMd = target.size() > 3 && ( target.ends_with( ".md" ) || target.ends_with( ".markdown" ) ); if( !isMd ) @@ -332,12 +317,16 @@ 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 ); - if( !anchorSlug.empty() ) + // 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). memberPath is read beside memberCall / compose only. + r.memberPath.assign( writtenTarget ); + if( const std::size_t frag = r.memberPath.find( '#' ); frag != std::string::npos ) { - r.qualifier.reserve( anchorSlug.size() ); - for( const char c : anchorSlug ) + for( std::size_t k = frag + 1; k < r.memberPath.size(); ++k ) { - r.qualifier += ( c >= 'A' && c <= 'Z' ) ? char( c - 'A' + 'a' ) : c; + char& c = r.memberPath[ k ]; + c = ( c >= 'A' && c <= 'Z' ) ? char( c - 'A' + 'a' ) : c; } } refs.push_back( std::move( r ) ); @@ -518,92 +507,6 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi } } -// Cross-file [text](other.md#slug): emitLinkTarget left the stem in name and the slug in qualifier, -// because the target file's headings are not known while one file is extracted. After the parse pool -// merges every file, rename the ref to that file's heading (first slug match, the same rule as an -// in-file anchor). A missing file or heading leaves the stem edge and clears qualifier, so the -// resolver never sees a markdown qualifier. Same-directory stem wins; otherwise the lowest fileId. -inline void bindMarkdownCrossFileAnchors( const std::vector& files, const std::vector& defs, std::vector& refs ) -{ - const auto parentDir = []( std::string_view path ) noexcept - { - const std::size_t slash = path.find_last_of( '/' ); - return slash == std::string_view::npos ? std::string_view{} : path.substr( 0, slash ); - }; - - const auto appendId = []( std::string& out, std::uint32_t id ) - { - char digits[ 10 ]; - int digitCount = 0; - do { digits[ digitCount++ ] = char( '0' + id % 10u ); id /= 10u; } while( id != 0u ); - while( digitCount > 0 ) { out.push_back( digits[ --digitCount ] ); } - }; - - HashMap> stems; - HashMap headingBySlug; - std::string key; - for( const RawDef& d : defs ) - { - if( d.lang != Lang::Markdown || d.kind != SymKind::Section ) - { - continue; - } - if( d.startByte == 0 && d.bodyByte == 0 ) - { - stems[ d.name ].push_back( d.fileId ); - continue; - } - key.clear(); - appendId( key, d.fileId ); - key.push_back( '#' ); - key.append( mdtier::mdSlugOf( d.name ) ); - headingBySlug.try_emplace( key, d.name ); - } - - for( RawRef& r : refs ) - { - if( r.lang != Lang::Markdown || r.isDocLink || r.qualifier.empty() ) - { - continue; - } - const std::string slug = std::move( r.qualifier ); - r.qualifier.clear(); - const auto stemIt = stems.find( r.name ); - if( stemIt == stems.end() || r.fileId >= files.size() ) - { - continue; - } - const std::string_view refDir = parentDir( files[ r.fileId ] ); - std::uint32_t best = stemIt->second.front(); - bool inDir = false; - for( const std::uint32_t fid : stemIt->second ) - { - if( fid >= files.size() ) - { - continue; - } - const bool sameDir = parentDir( files[ fid ] ) == refDir; - if( sameDir && ( !inDir || fid < best ) ) - { - best = fid; - inDir = true; - } - else if( !inDir && !sameDir && fid < best ) - { - best = fid; - } - } - key.clear(); - appendId( key, best ); - key.push_back( '#' ); - key.append( slug ); - if( const auto hit = headingBySlug.find( key ); hit != headingBySlug.end() ) - { - r.name = hit->second; - } - } -} - } // namespace — ingest_docs.h section of ingest.cpp } // namespace rw diff --git a/src/ingest_parsepool.h b/src/ingest_parsepool.h index 04e47d121..f583c78a0 100644 --- a/src/ingest_parsepool.h +++ b/src/ingest_parsepool.h @@ -955,7 +955,6 @@ inline RawFacts runParsePool( IngestResult& result, const char* rootDir, std::st saveCache( std::string( cacheFile ), rootDir, result.files, scan.hash, scan.statSize, scan.statMtime, scan.statCtime, scan.health, raw.defs, raw.refs, raw.incs, raw.binds, raw.ffis, raw.routeDefs, raw.routeUses, raw.constOpens, captureValueUses ); } } - bindMarkdownCrossFileAnchors( result.files, raw.defs, raw.refs ); return raw; } diff --git a/src/quality.h b/src/quality.h index dfef0ff53..5565adeb0 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2337,8 +2337,9 @@ inline std::string cacheRootKeyHex( const std::string& root ) // that broke that fails the build on the undeclared name rather than passing. constexpr std::uint32_t kIngestCacheVersionMirror = 34; // MUST equal ingest.cpp's kCacheVersion (gated); 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), 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 = 185; // MUST equal ingest.cpp's kParserVer (gated) - // 185 = 2026-10-16 (#409): cross-file markdown #slug binds to that file's - // heading. 185, not 184: 184 is train 29. Rich file tag 186. + // 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. // 184 = 2026-10-17 (train 29: above arity-min 178/179, call-result-typing // 180/181, go-scope 182/183; see the kParserVer note) // 182 = 2026-10-16 (train 29 L5 go-scope reservation, #403: Go method / 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/guide.md b/test/mdanchorfix/docs/a/guide.md new file mode 100644 index 000000000..33d4fe0f3 --- /dev/null +++ b/test/mdanchorfix/docs/a/guide.md @@ -0,0 +1,35 @@ +# 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) +``` 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/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 a15256a11..426653c24 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -356,6 +356,46 @@ $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 -c 'p="docs/a/tools.py' )" +[ "$n" = "0" ] && ok "the .py do_work has no caller edge from the doc link" || no "the doc link leaked onto the .py function (rows naming tools.py: $n)" + +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 's/^## Install$/## Install Steps Renamed/' "$TMP/anchorcopy/docs/b/partner.md" +$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 +grep -q 'n="partner"' "$TMP/ac_warm" && ok "…and the dead anchor degraded to the partner file edge" || no "the renamed-heading link lost its edge on the warm run" + # ═══════════════════════════════════════════════════════════════════════════ echo echo "=== determinism ×3 and cache transparency on the md fixture ===" From 194a84e798baae95211d5a9cdb2cc6472a3f4bd2 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 12:04:56 +0300 Subject: [PATCH 04/18] update unit test in test/mdsectioncheck --- test/mdsectioncheck.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index 426653c24..83c4acaca 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -383,8 +383,24 @@ want "$AFIX" "Dup Link" 'Notes@docs/b/dup.md' "#notes over t want "$AFIX" "Func Name" 'do_work@docs/b/partner.md' "a heading named like a .py function binds the section, never the function" want "$AFIX" "Missing Anchor" 'partner@docs/b/partner.md' "#no-such-heading falls back to the file (stem) edge" want "$AFIX" "Fence Only" '' "a link inside a fenced block makes no edge" -n="$( $BIN "$AFIX" --no-cache --callers=do_work --legend=compact 2>/dev/null | grep -c 'p="docs/a/tools.py' )" -[ "$n" = "0" ] && ok "the .py do_work has no caller edge from the doc link" || no "the doc link leaked onto the .py function (rows naming tools.py: $n)" +# --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() +for m in rows: + attrs = m.group(1) + p = re.search(r" p=\"([^\"]*)\"", attrs) + of = re.search(r"of_def=\"(\d+)", attrs) + if p and "tools.py" in p.group(1): + ok = False + if of and kind.get(of.group(1)) == "fn": + ok = False +sys.exit(0 if ok else 1) +'; then ok "the .py do_work is a matched def only — the caller edge belongs to the section"; else no "the doc link leaked onto the .py function"; fi echo "--- warm == cold after editing ONLY the target file (the cached ref must stay unbound) ---" cp -R "$AFIX" "$TMP/anchorcopy" From 9464c61d6264838ee12514b458dca7d0a041540c Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 14:33:30 +0300 Subject: [PATCH 05/18] handle missing heading in markdown link. --- src/graph.h | 52 ++++++++++++++++++++++++------- test/mdanchorfix/docs/a/README.md | 3 ++ test/mdanchorfix/docs/a/guide.md | 8 +++++ test/mdanchorfix/docs/b/README.md | 3 ++ test/mdsectioncheck.sh | 4 ++- 5 files changed, 58 insertions(+), 12 deletions(-) create mode 100644 test/mdanchorfix/docs/a/README.md create mode 100644 test/mdanchorfix/docs/b/README.md diff --git a/src/graph.h b/src/graph.h index 6fc83bd12..af00b3565 100644 --- a/src/graph.h +++ b/src/graph.h @@ -6302,36 +6302,66 @@ inline bool isMarkdownLinkRef( const Reference& r ) noexcept return r.lang == Lang::Markdown && !r.isDocLink && !r.memberCall && !r.memberPath.empty(); } -inline MdLinkIndex buildMdLinkIndex( const IngestResult& ing ) +// The file node is the Section extractMarkdown builds with bodyByte 0 over [0, size). assignSymbols then +// leaves sigStart 0 and sigEnd == endByte == the file span (widest is that span, measured over the file's +// own sections). A 0,0 span is a ModuleScope, never this node. Same predicate recall.h uses. A heading +// matches it only when the whole file is that one heading line. +inline bool isMarkdownFileNode( const Symbol& sym, std::uint32_t widestEnd ) noexcept { - MdLinkIndex ix; - for( std::uint32_t f = 0; f < ing.files.size(); ++f ) + return sym.sigStartByte == 0 && sym.sigEndByte >= sym.endByte && sym.endByte >= widestEnd; +} + +inline std::vector markdownWidestEnd( const IngestResult& ing ) +{ + std::vector widest( ing.files.size(), 0 ); + for( const Symbol& sym : ing.symbols ) { - if( ing.files[ f ].ends_with( ".md" ) || ing.files[ f ].ends_with( ".markdown" ) ) + if( sym.lang == Lang::Markdown && sym.kind == SymKind::Section && sym.fileId < ing.files.size() ) { - ix.fileByPath.try_emplace( ing.files[ f ], f ); + widest[ sym.fileId ] = std::max( widest[ sym.fileId ], sym.endByte ); } } + return widest; +} + +// Headings, and each file's own node (the first matching symbol in id order). The file node is not a heading. +inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) +{ ix.wholeFile.assign( ing.files.size(), kNoNode ); + const std::vector widest = markdownWidestEnd( ing ); for( const Symbol& sym : ing.symbols ) { if( sym.lang != Lang::Markdown || sym.kind != SymKind::Section || sym.fileId >= ing.files.size() ) { continue; } - if( sym.sigStartByte == 0 && sym.sigEndByte == 0 ) - { - ix.wholeFile[ sym.fileId ] = sym.id; - } - else + if( isMarkdownFileNode( sym, widest[ sym.fileId ] ) ) { - ix.headings.push_back( { sym.fileId, sym.sigStartByte, sym.id, mdSlugOf( sym.name ) } ); + if( ix.wholeFile[ sym.fileId ] == kNoNode ) + { + ix.wholeFile[ sym.fileId ] = sym.id; + } + continue; } + ix.headings.push_back( { sym.fileId, sym.sigStartByte, sym.id, mdSlugOf( sym.name ) } ); } std::sort( ix.headings.begin(), ix.headings.end(), []( const MdLinkIndex::Heading& a, const MdLinkIndex::Heading& b ) { return std::tie( a.fileId, a.slug, a.startByte, a.id ) < std::tie( b.fileId, b.slug, b.startByte, b.id ); } ); +} + +inline MdLinkIndex buildMdLinkIndex( const IngestResult& ing ) +{ + MdLinkIndex ix; + for( std::uint32_t f = 0; f < ing.files.size(); ++f ) + { + if( ing.files[ f ].ends_with( ".md" ) || ing.files[ f ].ends_with( ".markdown" ) ) + { + ix.fileByPath.try_emplace( ing.files[ f ], f ); + } + } + indexMarkdownSections( ix, ing ); return ix; } 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 index 33d4fe0f3..e23235868 100644 --- a/test/mdanchorfix/docs/a/guide.md +++ b/test/mdanchorfix/docs/a/guide.md @@ -33,3 +33,11 @@ See [nothing](../b/partner.md#no-such-heading). ```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/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/mdsectioncheck.sh b/test/mdsectioncheck.sh index 83c4acaca..23ab1632b 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -381,7 +381,9 @@ want "$AFIX" "Path Choice" 'Setup@docs/b/notes.md' "../b/notes.md want "$AFIX" "Root Readme Link" 'Languages@README.md' "../../README.md#languages binds the ROOT README, not docs/README.md" want "$AFIX" "Dup Link" 'Notes@docs/b/dup.md' "#notes over two ## Notes gives ONE edge (first heading wins)" want "$AFIX" "Func Name" 'do_work@docs/b/partner.md' "a heading named like a .py function binds the section, never the function" -want "$AFIX" "Missing Anchor" 'partner@docs/b/partner.md' "#no-such-heading falls back to the file (stem) edge" +want "$AFIX" "Missing Anchor" 'partner@docs/b/partner.md' "#no-such-heading falls back to that file's own node" +want "$AFIX" "Missed Readme" 'README@docs/b/README.md' "../b/README.md#no-such-heading pins docs/b/README.md, not the same-directory README" +want "$AFIX" "Bare Readme" 'README@docs/b/README.md' "a bare ../b/README.md pins that file, not the same-directory README" want "$AFIX" "Fence Only" '' "a link inside a fenced block makes no edge" # --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. From 4d8dd119bded39edb0a2a54edf1055c1dfe96632 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 15:28:30 +0300 Subject: [PATCH 06/18] Mark a markdown link with via="link" when no call shares that edge. A heading that shares a function's name stays in the callers union, and the row says the edge is a link. mdsectioncheck passes. legendcoverage hit the 300-second runner timeout, and quality-delta has not been --- CHANGELOG.md | 4 +- src/compactlegend.h | 4 ++ src/forowner.h | 3 +- src/graph.h | 103 +++++++++++++++++++++++++++++++++++++++ src/graphlegend.h | 12 +++++ src/main.cpp | 12 ++--- src/mcpverbs.h | 19 +++++--- src/serialize.h | 107 ++++++++++++++++++++++++++++++++--------- src/verbs_for.h | 12 ++--- src/verbs_navigate.h | 20 +++++--- test/mdsectioncheck.sh | 22 ++++++++- 11 files changed, 265 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e9e8d5c..8696ecded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,9 @@ list the sections it links to instead of one file-level edge. The extractor cach 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. Gate: `test/mdsectioncheck.sh`. +`-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 +call on the same pair leaves the row unmarked. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/src/compactlegend.h b/src/compactlegend.h index 44a050727..075aace41 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -252,6 +252,7 @@ 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 = + ""; // …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() { diff --git a/src/main.cpp b/src/main.cpp index b2c399d90..03a0148ab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2041,7 +2041,7 @@ int runDefaultMap( const MainDispatch& d ) { return {}; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); const rw::MemoryStreamBytes measured = probe.finish(); return measured.isWhole ? std::string( measured.bytes ) : std::string(); }; @@ -2054,7 +2054,7 @@ int runDefaultMap( const MainDispatch& d ) DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens fit probe — the map is emitted unshaped and its ceiling unverified" ); return 0; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { @@ -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::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), 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::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); bodiesEmittedEarly = true; @@ -2854,7 +2854,7 @@ int runDefaultMap( const MainDispatch& d ) } else { - serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); + serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); } } else @@ -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::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), 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 4e8395f26..f3f62ad01 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -454,7 +454,7 @@ inline std::string analyzeToString( const std::string& root, int topK, bool stab // §B4 family, and mcpclidiffcheck is the gate that keeps the two surfaces one. /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure, .codeFirstRows = ix.isCleanWorkingSet, .inventoryTier = ix.isCleanWorkingSet }, /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ) ); } ); } // `rank_by` verb (lane/t10-mcp-coverage): the MCP twin of --rank-by=pagerank|authority|hub|rrf — the SAME @@ -522,7 +522,7 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i /*extraPayloadTokens=*/0, /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure, .codeFirstRows = rankByLabel == nullptr, .inventoryTier = rankByLabel == nullptr }, // pagerank: the map scope's code-first pick /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ) ); } ); } // ─── the cross-branch + dark-content MCP twins (`whereis`, `stray_content`, `flags`) ─── @@ -2213,7 +2213,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, linkOutPtr( in.g ) }, in.slotOwner, share ); } mcpForSoftRefill( in, renderSigs, sigsStr, mcpSigsCut, &mcpSigsNext, @@ -3637,10 +3637,12 @@ inline std::optional pathText( const std::string& root, const std:: // 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 bool ptHasVia = pathHasNameOnlyHop( g, pth ); // FE-B: exactly when a hop of this path is via="name", as on the CLI + const bool ptHasLink = pathHasLinkHop( 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; rw::emitTo( mem, "{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + viaNameLegend( ptHasVia ) + + viaLinkLegend( ptHasLink ) + splitArmLegend( ptHasSplit ) + pathUnboundLegend( !ptBound.from || !ptBound.to ) ).c_str(), ( std::string( toValueRefsLegend( ptToValueRefs.count > 0 ) ) + valueRefsDepthLegend( ptToValueRefs.depthCut.files > 0 ) ).c_str(), pathGapsLegend( ptGaps.any() ), @@ -3661,7 +3663,7 @@ inline std::optional pathText( const std::string& root, const std:: { const Symbol& s = ing.symbols[ pth[i] ]; const std::string_view rp = ptSingleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], ptRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - viaNameAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ) ), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); } // FE-B; attrib-multidef: as on the CLI + viaEdgeAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ), i > 0 && edgeIsLink( g, pth[ i - 1 ], pth[ i ] ) ), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); } // FE-B; a link hop says via="link" rw::emitRaw( mem, "" ); return mcpAnswerText( stream ); // nullopt = the buffer lost bytes (dispatch answers -32603), "" stays not-found } @@ -3925,7 +3927,12 @@ 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 std::string connectViaLegend = ( connectHasVia ? "" : std::string() ) + + ( connectHasLink ? "" : std::string() ); const std::size_t connectExtraBytes = connectRootAttr.size() + std::strlen( rootRelPathsLegend( !rootArg.empty() ) ) + connectUnindexedLegend.size() + connectUnprovenAttr.size() + connectUnprovenLegend.size() + connectAmbiguousAttr.size() + connectViaLegend.size(); @@ -4077,7 +4084,7 @@ 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( "/>" ); // FE-B, or via="link" } payload.append( "" ); } diff --git a/src/serialize.h b/src/serialize.h index 761ebe8ad..e70d13d0e 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -2642,10 +2642,11 @@ inline std::uint32_t counterAt( const std::vector* v, NodeId id ) // before. `scratch` is reused across symbols. inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint32_t from, std::uint32_t to, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, std::vector& esc, - std::vector& scratch ) + std::vector& scratch, const std::vector* outLink = nullptr ) { 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 link = [ & ]( std::uint32_t e ) { return outLink && e < outLink->size() && ( *outLink )[ e ] != 0; }; // the row's bytes among via="name" rows: name, prov= const auto rowLess = [ & ]( std::uint32_t x, std::uint32_t y ) { @@ -2688,7 +2689,11 @@ inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint w.write( "\" prov=\"" ); w.write( provLabel( provOf( e ) ) ); } - if( nameOnly( e ) ) + if( link( e ) ) + { + w.write( "\" via=\"link" ); // a pinned markdown link, and no real call on this pair + } + else if( nameOnly( e ) ) { w.write( "\" via=\"name" ); // FE-B: the same edge bit every other surface reads (viaNameLegendComment) } @@ -2701,6 +2706,27 @@ inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint } } +// A row under one of `nodes` may carry via="link" when any of their out-edges is a markdown link. Same over-approximation +// as namesOnlyOutAny, asked before the rows are written so the map's first-screen legend can define the attribute. +inline bool linksOutAny( const std::vector& outOff, const std::vector& outLink, + std::span nodes ) noexcept +{ + for( const NodeId n : nodes ) + { + if( std::size_t( n ) + 1 >= outOff.size() ) + { + continue; + } + for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < outLink.size(); ++e ) + { + if( outLink[ e ] != 0 ) + { + return true; + } + } + } + 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 @@ -2782,7 +2808,8 @@ template inline void writeMapInventoryXml( XmlWriter& w, const IngestResult& ing, const MapInventory& inv, const PathRel& pathRel, const std::vector& outOff, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, - std::vector& esc, std::vector& scratch ) + std::vector& esc, std::vector& scratch, + const std::vector* outLink = nullptr ) { EXPECTS( inv.active, "the caller emits the tier only when it has a cut to inventory" ); w.write( "" ); - writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch ); + writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch, outLink ); w.write( "" ); } for( const MapInventoryDir& d : inv.dirs ) @@ -2893,7 +2920,10 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto std::size_t stdMemberDeclinedCalls = 0, // of declined=, the Elixir call candidates inside inert quote AST (g.inertDeclinedCalls, #380) → // the kInertDeclinedMapLegend clause, absent when zero. - std::size_t inertDeclinedCalls = 0 ) + std::size_t inertDeclinedCalls = 0, + // Markdown link edges (Graph::outLink) → via="link" on a row and kMapViaLinkLegend. nullptr/empty + // ⇒ the map has no link edge and stays byte-identical. + const std::vector* outLink = nullptr ) { const std::size_t* changedCount = ann.changedCount; const std::string* mapAtStamp = ann.atStamp; @@ -3148,6 +3178,11 @@ 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( outLink && !stubbed && !ann.viaLegendStripped + && linksOutAny( outOff, *outLink, std::span( order.data(), std::min( keep, order.size() ) ) ) ) + { + legend += kMapViaLinkLegend; + } // 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) @@ -3442,7 +3477,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { if( inventory.active ) { - writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch ); + writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink ); } }; const bool inventoryFirst = !stable && effImportantLast; // --stable's path order wins, as it does for the rows @@ -3663,7 +3698,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto } w.write( ">" ); - writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch ); + writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink ); w.write( "" ); } w.write( "" ); @@ -6544,6 +6579,7 @@ 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 }; // The wrapper, written in front of the rows it describes (they have to be walked before `shown` @@ -6689,17 +6725,18 @@ 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 }; 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, bool via = false, bool split = false, bool link = false ) { 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.lines.push_back( cs.line ); merged = true; break; @@ -6707,11 +6744,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 } ); } // 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 ); if( sink.recorded ) { sink.recorded->push_back( EmittedBodyCall { cs.name, cs.line, std::string() } ); // §H5: no sig to record @@ -6738,7 +6775,7 @@ inline void appendMergedCalleeNameRows( std::string& callsBody, std::vector& nameOnly; const std::vector* prov = nullptr; bool* splitLegendCharged = nullptr; + const std::vector* link = nullptr; CalleeEdgeBytes( const std::vector& nameOnlyBytes, const std::vector* provBytes = nullptr, // NOLINT: implicit - bool* charged = nullptr ) noexcept - : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ) {} + bool* charged = nullptr, const std::vector* linkBytes = nullptr ) noexcept + : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ), link( linkBytes ) {} }; // …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. @@ -6786,6 +6824,11 @@ inline CalleeCallsSink withSplitArms( CalleeCallsSink sink, const std::vector* link ) noexcept +{ + sink.link = link; + 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. @@ -6837,6 +6880,7 @@ 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; 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] ]; @@ -6845,13 +6889,20 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector 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 bool linkRow = sink.link != nullptr && edgeNameOnlyInCsr( outOff, outTargets, *sink.link, id, cid ); + if( linkRow && !linkLegendCharged ) + { + used += viaLinkLegendComment().size(); + linkLegendCharged = true; + } + 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 ); // Q1: prov="split" or "" // COMPACT: the names-only rendering — see collectCalleeNameRow above for what it does and does not do. if( sink.namesOnly ) { - collectCalleeNameRow( nameRows, cs, used, sink, !viaAttr.empty(), !splitAttr.empty() ); + collectCalleeNameRow( nameRows, cs, used, sink, nameRow, !splitAttr.empty(), linkRow ); ++shown; continue; } @@ -7551,8 +7602,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 ) ); 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 @@ -7628,6 +7679,10 @@ 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( children.find( " via=\"link\"" ) != std::string::npos ) + { + w.write( viaLinkLegendComment() ); + } 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" @@ -7726,8 +7781,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 ) ); out.xml += row; out.xml += ""; ++out.shown; @@ -7851,7 +7906,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, CalleeEdgeBytes( outNameOnly, outEdges.prov, &splitLegendCharged, outEdges.link ), redact, rank, rootArg, budgetBytes, viaLegendCharged, pathRel, contentOf, esc ); std::string children = own.xml; std::size_t requestedCount = own.shown; @@ -7903,8 +7958,8 @@ 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 }, - outEdges.prov, &splitLegendCharged ) ); + withLinkBits( withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &viaLegendCharged }, + outEdges.prov, &splitLegendCharged ), outEdges.link ) ); row += ""; children += row; ++shownCount; @@ -7920,6 +7975,10 @@ 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( children.find( " via=\"link\"" ) != std::string::npos ) + { + w.write( viaLinkLegendComment() ); + } 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 5957b815d..5acd0db66 100644 --- a/src/verbs_for.h +++ b/src/verbs_for.h @@ -1981,7 +1981,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::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, nullptr, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2003,7 +2003,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::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, &autoEmitted, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2409,7 +2409,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::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), 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 @@ -3725,7 +3725,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::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), 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 ); @@ -3779,7 +3779,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, rw::linkOutPtr( g ) }, slotOwner, forSoftTarget ? std::optional( rw::forSoftSectionsShare( bundleBudget, slotSpent ) ) : std::nullopt ); if( slot.served ) @@ -3999,7 +3999,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::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), 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 dbe2a6366..31073e2bf 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -92,7 +92,8 @@ 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 + ( viaName && i < viaName->size() && ( *viaName )[ i ] == 2 ) ? ",\"via\":\"link\"" + : ( ( viaName && i < viaName->size() && ( *viaName )[ i ] == 1 ) ? ",\"via\":\"name\"" : "" ), // 2 = markdown link, 1 = name hedge jsonRowKeysAt( rowKeys, i ) ); } } @@ -262,9 +263,11 @@ 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 ); } rw::emitTo( stdout, "{}{}{}{}{}{}{}-->{}{}", rw::callHierarchyLegendOpen( wantCallers, chNextIsBare, cfg.columnar, chNextIsPage ).c_str(), rw::capLegendClause( rw::computePageDisclosure( pw.end - pw.begin, result.size(), pw.end, @@ -277,6 +280,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= @@ -381,7 +385,7 @@ std::optional runCallHierarchy( const MainDispatch& d ) 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; + chVia[ i ] = oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ? 2 : ( chAttr.nameOnly( i ) ? 1 : 0 ); if( chSites.any ) { chAtKeys[ i ] = rw::callSitesKeyJson( chSites.tokens[ i - pw.begin ], chSites.totals[ i - pw.begin ] ); @@ -418,7 +422,7 @@ 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 + viaEdgeAttr( chAttr.nameOnly( i ), oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ), 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 } @@ -2413,11 +2417,13 @@ std::optional runPath( const MainDispatch& d ) const rw::ToValueRefs pthToValueRefs = path.empty() ? rw::toValueRefs( ing, true, dstDefs, &*pthValueIdx ) : rw::ToValueRefs{}; const rw::PathSearchGaps pthGaps = path.empty() ? rw::pathSearchGaps( ing, g, srcDefs, *pthValueIdx ) : rw::PathSearchGaps{}; const bool pthHasVia = rw::pathHasNameOnlyHop( g, path ); // FE-B: exactly when a hop of THIS path is via="name" + const bool pthHasLink = rw::pathHasLinkHop( g, path ); // 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; rw::emitTo( stdout, "{}", ( rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Path, pthUnprovenDefs > 0 ) + rw::viaNameLegend( pthHasVia ) + + rw::viaLinkLegend( pthHasLink ) + rw::splitArmLegend( pthHasSplit ) + rw::pathUnboundLegend( !pthBound.from || !pthBound.to ) ).c_str(), ( std::string( rw::toValueRefsLegend( pthToValueRefs.count > 0 ) ) + rw::valueRefsDepthLegend( pthToValueRefs.depthCut.files > 0 ) ).c_str(), rw::pathGapsLegend( pthGaps.any() ), @@ -2445,7 +2451,7 @@ std::optional runPath( const MainDispatch& d ) const std::string_view rp = pthSingleRoot ? rw::sarif::rootRelativeUri( ing.files[ s.fileId ], pthRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); // FE-B: a hop whose edge INTO it is name-only says so on its row (the first row is no hop) rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - viaNameAttr( i > 0 && edgeNameOnly( g, path[ i - 1 ], path[ i ] ) ), rw::splitArmAttrXml( rw::pathHopSplitArm( g, path, i ) ) ); + viaEdgeAttr( i > 0 && edgeNameOnly( g, path[ i - 1 ], path[ i ] ), i > 0 && edgeIsLink( g, path[ i - 1 ], path[ i ] ) ), rw::splitArmAttrXml( rw::pathHopSplitArm( g, path, i ) ) ); } rw::emitTo( stdout, "" ); return 0; @@ -3061,7 +3067,7 @@ std::optional runAround( const MainDispatch& d ) aroundAnn.seed = { ing.symbols[ focus ].name, cfg.aroundDepth, cfg.aroundFanout, definitionCountOfName( ing, focus ), eg.fanoutCut, eg.depthTruncated }; aroundAnn.seed.unprovenDefs = arUnprovenDefs; // H1: unproven_defs= beside defs= on the root, its clause in the map legend - serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls ); + serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); if( !g.composeEdges.empty() ) { diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index 23ab1632b..8d1e99629 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -391,7 +391,7 @@ if $BIN "$AFIX" --no-cache --callers=do_work --legend=compact 2>/dev/null | pyth import sys, re xml = sys.stdin.read() kind = { m.group(1): m.group(2) for m in re.finditer(r"]*)/?>", xml)) +rows = list(re.finditer(r"]*)/?>", xml)) ok = bool(rows) and "fn" in kind.values() and "sec" in kind.values() for m in rows: attrs = m.group(1) @@ -401,8 +401,26 @@ for m in rows: ok = False if of and kind.get(of.group(1)) == "fn": ok = False + via = re.search(r" via=\"([^\"]*)\"", attrs) + if not via or via.group(1) != "link": + ok = False sys.exit(0 if ok else 1) -'; then ok "the .py do_work is a matched def only — the caller edge belongs to the section"; else no "the doc link leaked onto the .py function"; fi +'; then ok "the .py do_work is a matched def only — the caller edge belongs to the section and is via=link"; else no "the doc link leaked onto the .py function, or the section row lacks via=link"; fi +if $BIN "$AFIX" --no-cache --callers='docs/a/tools.py:do_work' --legend=compact 2>/dev/null | grep -q 'via="link"'; then + no "a real function's callers carry via=link" +else + ok "the .py do_work callers are not marked 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 echo "--- warm == cold after editing ONLY the target file (the cached ref must stay unbound) ---" cp -R "$AFIX" "$TMP/anchorcopy" From 403bee476b472320b5b4c00f9f2ad93b362de8ce Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 17:14:58 +0300 Subject: [PATCH 07/18] Mark agent rows with via="link", and pin the link when the crawl root is a dot. find_symbol and find_referencing_symbols omitted the mark. A root of `.` stored `./docs/a.md`, so the lookup missed the file. --- CHANGELOG.md | 3 ++- src/graph.h | 44 ++++++++++++++++++++++++++++++++++++++++-- src/graphlegend.h | 19 ++++++++++++------ src/mcpverbs.h | 13 +++++++++++++ src/verbs_navigate.h | 7 +++++-- test/mdsectioncheck.sh | 41 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8696ecded..2dc2119e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ link to a heading named like one of the linking file's own headings does not bin 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 -call on the same pair leaves the row unmarked. Gate: `test/mdsectioncheck.sh`. +call on the same pair leaves the row unmarked. A crawl rooted at `.` stores `./docs/a.md`; the link index normalizes that path, so the pin matches an absolute root. `find_symbol` and `find_referencing_symbols` carry the same mark, and a +`via_link_note` sentence when a row has it. `--json` callers and callees carry that note too. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/src/graph.h b/src/graph.h index 63b8e0313..3e2a56b47 100644 --- a/src/graph.h +++ b/src/graph.h @@ -494,6 +494,46 @@ inline const char* viaEdgeAttr( bool nameOnly, bool link ) noexcept return link ? " via=\"link\"" : viaNameAttr( nameOnly ); } +// The JSON twin of viaEdgeAttr. Link wins. Empty when the row is a plain call. +inline const char* viaJsonAttr( bool nameOnly, bool link ) noexcept +{ + return link ? ",\"via\":\"link\"" : ( nameOnly ? ",\"via\":\"name\"" : "" ); +} + +// oneHopRowIsLink plus the name hedge, and the two answer-level bits the notes read. The branch stays here so +// symbolQueryJson does not grow one. +inline const char* viaJsonForRow( const Graph& g, std::span matches, NodeId row, bool towardMatches, bool nameOnly, + bool& anyName, bool& anyLink ) noexcept +{ + const bool link = oneHopRowIsLink( g, matches, row, towardMatches ); + if( link ) + { + anyLink = true; + } + else if( nameOnly ) + { + anyName = true; + } + return viaJsonAttr( nameOnly, link ); +} + +inline const char* viaJsonForSide( const Graph& g, std::span callerMatches, std::span calleeMatches, NodeId row, + bool callersSide, bool nameOnly, bool& anyName, bool& anyLink ) noexcept +{ + return viaJsonForRow( g, callersSide ? callerMatches : calleeMatches, row, callersSide, nameOnly, anyName, anyLink ); +} + +// Empty unless id is the row the caller just selected. nameOnly stays false: the name fragment is the caller's own bit. +inline const char* linkJsonFrag( const Graph& g, std::span callerMatches, std::span calleeMatches, NodeId id, + NodeId linkRow, bool callersSide, bool& anyName, bool& anyLink ) noexcept +{ + if( id != linkRow ) + { + return ""; + } + return viaJsonForSide( g, callerMatches, calleeMatches, id, callersSide, false, anyName, anyLink ); +} + // The prov= VOCABULARY, spelled once. serialize's XML and serializeJson's JSON must emit identical // words for identical edges — test/mcpclidiffcheck.sh is the gate that says so — and two ternary // chains over the same integers is precisely how two dialects drift apart. Returns "" for 0 (a @@ -6348,7 +6388,7 @@ inline void fillImplementorBound( Graph& g, const std::vector fileByPath; // every .md/.markdown file, by root-relative path + HashMap fileByPath; // every .md/.markdown file, by lexicalNormalize(ing.files). A `.`-rooted crawl stores `./docs/a.md`; the lookup key does not. An absolute root stores the absolute path, and the lookup joins to that same absolute path. std::vector headings; // sorted (fileId, slug, startByte, id): the first of a slug is its heading std::vector wholeFile; // per file: the file-level Section node, kNoNode when none }; @@ -6449,7 +6489,7 @@ inline MdLinkIndex buildMdLinkIndex( const IngestResult& ing ) { if( ing.files[ f ].ends_with( ".md" ) || ing.files[ f ].ends_with( ".markdown" ) ) { - ix.fileByPath.try_emplace( ing.files[ f ], f ); + ix.fileByPath.try_emplace( lexicalNormalize( ing.files[ f ] ), f ); } } indexMarkdownSections( ix, ing ); diff --git a/src/graphlegend.h b/src/graphlegend.h index 88331e93f..8ed52c3f7 100644 --- a/src/graphlegend.h +++ b/src/graphlegend.h @@ -1230,13 +1230,12 @@ inline constexpr std::string_view kMapViaNameLegend = inline constexpr std::string_view kMapViaLinkLegend = ""; // …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 ) { @@ -1246,7 +1245,15 @@ 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" ); } // The same fact in --for's two dialects, which are prose strips joined by ';' and ':' rather than XML comment diff --git a/src/mcpverbs.h b/src/mcpverbs.h index f3f62ad01..f3d5887b3 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -754,10 +754,16 @@ 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 + 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 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 +777,7 @@ 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 + attrib // attrib-multidef: the CLI row's of_def= / prov="split" + "}"; }; @@ -786,6 +793,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 ); @@ -879,6 +888,10 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { out += rw::viaNameNoteJson(); // FE-B: the reading of "via":"name", only in an answer that carries it } + if( anyLink ) + { + out += rw::viaLinkNoteJson(); + } 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 diff --git a/src/verbs_navigate.h b/src/verbs_navigate.h index 31073e2bf..efa192b14 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -382,10 +382,12 @@ std::optional runCallHierarchy( const MainDispatch& d ) : std::string() ); rw::emitTo( stdout, ",\"{}\":[", tag ); std::vector chVia( result.size(), 0 ); // FE-B: the same hedge bit the XML rows carry + bool chHasLink = false; 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 ] = oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ? 2 : ( chAttr.nameOnly( i ) ? 1 : 0 ); + chHasLink = chHasLink || ( chVia[ i ] == 2 ); if( chSites.any ) { chAtKeys[ i ] = rw::callSitesKeyJson( chSites.tokens[ i - pw.begin ], chSites.totals[ i - pw.begin ] ); @@ -393,8 +395,9 @@ std::optional runCallHierarchy( const MainDispatch& d ) 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 - rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ) ); + rw::emitTo( stdout, "]{}{}{}}}", rw::declinedSitesJson( chDc, wantCallers, /*mcp=*/false ), // the XML rows, keys = its attribute names + rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ), + chHasLink ? rw::viaLinkNoteJson() : std::string() ); return 0; } diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index 8d1e99629..a379bd087 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -406,6 +406,11 @@ for m in rows: ok = False sys.exit(0 if ok else 1) '; then ok "the .py do_work is a matched def only — the caller edge belongs to the section and is via=link"; 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 if $BIN "$AFIX" --no-cache --callers='docs/a/tools.py:do_work' --legend=compact 2>/dev/null | grep -q 'via="link"'; then no "a real function's callers carry via=link" else @@ -421,6 +426,42 @@ if $BIN "$AFIX" --no-cache --json --callees='Self Collision' 2>/dev/null | grep 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" echo "--- warm == cold after editing ONLY the target file (the cached ref must stay unbound) ---" cp -R "$AFIX" "$TMP/anchorcopy" From dae894baa371667ff1a1dfafe7a8aa9703f0d4f8 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 17:22:51 +0300 Subject: [PATCH 08/18] fix sed on macOS behavior --- CHANGELOG.md | 3 +-- test/mdsectioncheck.sh | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc2119e2..1644d0ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,7 @@ link to a heading named like one of the linking file's own headings does not bin 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 -call on the same pair leaves the row unmarked. A crawl rooted at `.` stores `./docs/a.md`; the link index normalizes that path, so the pin matches an absolute root. `find_symbol` and `find_referencing_symbols` carry the same mark, and a -`via_link_note` sentence when a row has it. `--json` callers and callees carry that note too. Gate: `test/mdsectioncheck.sh`. +call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index a379bd087..f2f46c056 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -467,7 +467,9 @@ echo "--- warm == cold after editing ONLY the target file (the cached ref must s 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 's/^## Install$/## Install Steps Renamed/' "$TMP/anchorcopy/docs/b/partner.md" +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 From b6bdcc8155234f3c838c996acc2e578250dcb1dd Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 18:38:25 +0300 Subject: [PATCH 09/18] Mark a direct doc caller with via="link" on --safe-delete and --impact. callers= and reaches= still count that row. A real call on the same pair stays unmarked. --- CHANGELOG.md | 2 +- src/graph.h | 6 ++++++ src/mcpverbs.h | 6 ++++-- src/verbs_navigate.h | 42 +++++++++++++++++++++++++++++++++++------- test/mdsectioncheck.sh | 18 ++++++++++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1644d0ac5..c80c69475 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ link to a heading named like one of the linking file's own headings does not bin 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 -call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. +call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/src/graph.h b/src/graph.h index 3e2a56b47..3f7b600c0 100644 --- a/src/graph.h +++ b/src/graph.h @@ -12185,6 +12185,12 @@ inline bool impactRowNameOnly( const std::vector& proven, NodeId n ) noexc return !proven.empty() && n < proven.size() && proven[n] == 0; } +// A depth-1 impact row whose edge to every matched def is a markdown link. The edge runs caller → seed. +inline bool impactDirectLink( const Graph& g, std::span seeds, const std::vector& depth, NodeId n ) noexcept +{ + return n < depth.size() && depth[ n ] == 1 && oneHopRowIsLink( g, seeds, n, true ); +} + struct ImpactFileRow { std::uint32_t fileId = 0; diff --git a/src/mcpverbs.h b/src/mcpverbs.h index f3d5887b3..880d02c8c 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -3174,11 +3174,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 ) ).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 @@ -3219,7 +3221,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 + viaEdgeAttr( impactRowNameOnly( proven, show[i] ), impactDirectLink( g, 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, diff --git a/src/verbs_navigate.h b/src/verbs_navigate.h index efa192b14..b09d04e7d 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -972,6 +972,7 @@ 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 rw::DeclinedSitesLegendFlags declinedSites{}; // lane declined-sites: what the rows on this answer carry (declinedsites.h) }; @@ -1009,6 +1010,7 @@ 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 ); return clause; } @@ -1312,9 +1314,11 @@ 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; + for( std::size_t i = sdLw.begin; i < sdLw.end && ( !sdHasSplit || !sdHasLink ); ++i ) { - sdHasSplit = sdAttr.splitArm( i ); + sdHasSplit = sdHasSplit || sdAttr.splitArm( i ); + sdHasLink = sdHasLink || rw::oneHopRowIsLink( g, defs, callerIds[ i ], true ); } const std::vector sdDc = rw::declinedSiteViews( ing, g, std::span( sdDeclinedSites ).first( sdDcCut.shown ), /*callersForm=*/true, defs, sdAttr.groups, @@ -1326,6 +1330,7 @@ std::optional runSafeDelete( const MainDispatch& d ) .valueRefs = sdValueRefs.rows.size(), .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, .declinedSites = rw::declinedSitesLegendFlags( sdDc, /*callersForm=*/true, sdDcCut ) } ); const Symbol& lead = ing.symbols[ defs[0] ]; // resolveAllByNameQualified walks ascending id — defs[0] is the @@ -1367,7 +1372,8 @@ 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 ), + rw::viaEdgeAttr( false, rw::oneHopRowIsLink( g, defs, callerId, true ) ) ); // 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 ) ) ); @@ -2587,6 +2593,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 }; @@ -2612,6 +2619,24 @@ inline std::vector impactViaColumn( const ImpactView& v, std::size_t begin return via; } +// JSON rows: 2 = a direct markdown link, 1 = via="name". The columnar column stays impactViaColumn (1 means name). +inline std::vector impactJsonVia( const ImpactView& v ) +{ + std::vector via( v.show.size(), 0 ); + 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 ] ) ) + { + via[ i ] = 2; + } + else if( rw::impactRowNameOnly( v.provenReach, v.show[ i ] ) ) + { + via[ i ] = 1; + } + } + return via; +} + // --format=columnar (RESEARCH lever 1): same page window, path-table + parallel arrays. // V1-1: a fixed 160-byte buffer truncated mid-attribute on long escaped symbol names (invalid XML, the // F6 class), and this branch hand-rolled shown=/capped= without the paging half — the one @@ -2712,7 +2737,7 @@ 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\":[" ); @@ -2757,7 +2782,8 @@ int emitImpactXml( const ImpactView& v ) 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 + viaEdgeAttr( rw::impactRowNameOnly( v.provenReach, v.show[i] ), + rw::impactDirectLink( v.g, v.seeds, v.depth, v.show[i] ) ), v.attrib.ofDefXml( v.show[i] ) ); // attrib-multidef } rw::emitImportRowsXml( stdout, v.ing, v.importPage, v.rootPrefix, v.importLazyPage ); @@ -2871,7 +2897,9 @@ std::optional runImpact( const MainDispatch& d ) + 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(), + [ & ]( NodeId n ) { return rw::impactRowNameOnly( imProven, n ); } ), true, cfg.columnar ) + + rw::viaLinkLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, + [ & ]( NodeId n ) { return rw::impactDirectLink( g, seeds, imDepth, n ); } ) ) ).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= @@ -2894,7 +2922,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 }; + imProven, imFiles, imAttr, imListing, imWalk.typeOnly, seeds }; if( cfg.columnar ) { return emitImpactColumnar( view ); } if( cfg.json ) { return emitImpactJson( view ); } diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index f2f46c056..d5239d971 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -463,6 +463,24 @@ 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" + 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" From 31e0fc4002c90e8d62e0371a38ba5c5554c94fdf Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 21:28:11 +0300 Subject: [PATCH 10/18] Mark in-file anchors, wikilinks, and root-relative links with via="link". The mark follows the reference. A code call on the same pair stays unmarked, and an unpinned link no longer clears a pinned one. --- CHANGELOG.md | 2 +- src/graph.h | 31 +++++++++++++++++++++---------- test/mdsectioncheck.sh | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c80c69475..c5db32646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ link to a heading named like one of the linking file's own headings does not bin 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 -call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. +call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/src/graph.h b/src/graph.h index 3f7b600c0..b96d9dfde 100644 --- a/src/graph.h +++ b/src/graph.h @@ -6398,17 +6398,16 @@ inline bool isMarkdownLinkRef( const Reference& r ) noexcept return r.lang == Lang::Markdown && !r.isDocLink && !r.memberCall && !r.memberPath.empty(); } -// A pinned markdown link records the pair as a link. Any other site that binds the same pair records a real call, -// and the real call wins when the edge is stamped. -inline void noteMarkdownLinkSite( HashMap& linkEdges, HashMap& realCalls, std::uint64_t ekey, - const std::vector& mdLinkPin, std::size_t siteIndex, const Reference& r ) +// Link-ness is where the reference came from: lang Markdown and not a doc mention. isMarkdownLinkRef also requires a +// path, which an in-file anchor and a wikilink do not have, and a root-relative link can miss the pin. A non-markdown +// site on the same pair is a real call and wins at stamp time. A markdown doc mention is neither. +inline void noteMarkdownLinkSite( HashMap& linkEdges, HashMap& realCalls, std::uint64_t ekey, const Reference& r ) { - const bool linkSite = !mdLinkPin.empty() && siteIndex < mdLinkPin.size() && mdLinkPin[ siteIndex ] != kNoNode && isMarkdownLinkRef( r ); - if( linkSite ) + if( r.lang == Lang::Markdown && !r.isDocLink ) { linkEdges[ ekey ] = 1; } - else + else if( r.lang != Lang::Markdown ) { realCalls[ ekey ] = 1; } @@ -6977,9 +6976,9 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // so its bucket layout cannot reach output, and it is filled inside a loop // whose per-reference tier work dominates the growth cascade by orders of // magnitude. A guessed reserve would be a made-up number in a hot struct. - HashMap linkEdges; // (from<<32|to) a pinned markdown link bound. A real call on the same pair + HashMap linkEdges; // (from<<32|to) a markdown link bound. A non-markdown call on the same pair // (realCallEdges) drops the mark: the merged edge stays a call. - HashMap realCallEdges; // (from<<32|to) bound by a site that was not a pinned markdown link. + HashMap realCallEdges; // (from<<32|to) bound by a non-markdown site. Empty on a tree with no markdown link. std::vector rule3Out; // reused Rule-3 output buffer (candidates from the single included file) Rule3FileMemo rule3Memo; // Rule 3's answer per (caller file, byName entry): one walk per name per file, not per call std::string qkey; // reused "qualifier::name" buffer for the E#4 canonical lookup (no per-ref alloc) @@ -7700,6 +7699,15 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null { PROFILE_SCOPE_DESCRIBE( "buildGraph/3: resolve loop (per reference)" ); const std::vector mdLinkPin = bindMarkdownLinks( ing ); // empty unless the tree holds a cross-file markdown link + bool anyMarkdownLink = false; + for( const Reference& r : ing.references ) + { + if( r.lang == Lang::Markdown && !r.isDocLink ) + { + anyMarkdownLink = true; + break; + } + } for( siteIndex = 0; siteIndex < ing.references.size(); ++siteIndex ) { const Reference& site = ing.references[ siteIndex ]; @@ -9164,7 +9172,10 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null finalSegmentEdges[ekey] = 1; // remember (from,to) for prov="final-segment" } ( nameOnly ? nameOnlyEdges : provenEdges )[ ekey ] = 1; // FE-B: a proven site wins the merged row (Graph::outNameOnly) - noteMarkdownLinkSite( linkEdges, realCallEdges, ekey, mdLinkPin, siteIndex, r ); + if( anyMarkdownLink ) + { + noteMarkdownLinkSite( linkEdges, realCallEdges, ekey, r ); + } } disposition = CallDisposition::Bound; } diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index d5239d971..ab539890c 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -481,6 +481,23 @@ printf '%s' "$IM" | grep -q 'reaches="2"' \ && ok "impact marks Run Notes via=link and still counts both rows" \ || no "impact dropped the link mark or the reach count" +# 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" +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' '# 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" + 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" From 6f3ffe4438dd3608eed9fd573dc13e6284a0cffe Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Sun, 18 Oct 2026 23:16:12 +0300 Subject: [PATCH 11/18] Stamp anchor="missing" when a markdown slug names no heading. Columnar is 2 for a direct link, and the do_work callers test has a real Python caller. --- CHANGELOG.md | 2 +- src/columnar.h | 22 +++- src/compactlegend.h | 6 +- src/forowner.h | 3 +- src/graph.h | 152 +++++++++++++++++++++++----- src/graphlegend.h | 22 ++++ src/ingest_docs.h | 2 +- src/main.cpp | 12 +-- src/mcpverbs.h | 34 +++++-- src/model.h | 2 + src/serialize.h | 146 +++++++++++++++++++------- src/verbs_for.h | 12 +-- src/verbs_navigate.h | 79 +++++++++++---- test/mdanchorfix/docs/a/use_work.py | 2 + test/mdsectioncheck.sh | 37 +++++-- 15 files changed, 411 insertions(+), 122 deletions(-) create mode 100644 test/mdanchorfix/docs/a/use_work.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5db32646..9d51fcbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ link to a heading named like one of the linking file's own headings does not bin 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 -call on the same pair leaves the row unmarked. 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. Gate: `test/mdsectioncheck.sh`. +call on the same pair leaves the row unmarked. 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"`. Columnar `` is `2` for a direct markdown link and `1` for a name-only edge. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) 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 075aace41..6f6d6c9ce 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -253,6 +253,7 @@ inline constexpr std::string_view kCompactProsePrefixes[] = ""; 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 viaFactNoteJson( std::string text, std::string_view from, std::string_view jsonSpelling, std::string_view key ) { @@ -1255,6 +1273,10 @@ 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 // clauses — one constant each so the wording cannot drift from kModScopeLegend above. Present-only, on that diff --git a/src/ingest_docs.h b/src/ingest_docs.h index b0eea75d7..d62f0c86f 100644 --- a/src/ingest_docs.h +++ b/src/ingest_docs.h @@ -319,7 +319,7 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi 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). memberPath is read beside memberCall / compose only. + // headings exist (graph.h bindMarkdownLinks). buildGraph reads this path. A member call still reads it beside memberCall. r.memberPath.assign( writtenTarget ); if( const std::size_t frag = r.memberPath.find( '#' ); frag != std::string::npos ) { diff --git a/src/main.cpp b/src/main.cpp index 03a0148ab..493b34c6a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2041,7 +2041,7 @@ int runDefaultMap( const MainDispatch& d ) { return {}; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); const rw::MemoryStreamBytes measured = probe.finish(); return measured.isWhole ? std::string( measured.bytes ) : std::string(); }; @@ -2054,7 +2054,7 @@ int runDefaultMap( const MainDispatch& d ) DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens fit probe — the map is emitted unshaped and its ceiling unverified" ); return 0; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { @@ -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, rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + packBodies( f, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &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, rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), cfg.compress, redactPtr, expandRanges.empty() ? nullptr : &expandRanges, d.notesPtr, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/true, mapRootArg, &calleeOrder ); } ); bodiesEmittedEarly = true; @@ -2854,7 +2854,7 @@ int runDefaultMap( const MainDispatch& d ) } else { - serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); + serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); } } else @@ -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, rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + emitSection( bodiesSection, [ & ]{ packBodies( out, ing, expandNodes, cfg.packBudgetBytes, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &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 880d02c8c..5c4612e56 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -454,7 +454,7 @@ inline std::string analyzeToString( const std::string& root, int topK, bool stab // §B4 family, and mcpclidiffcheck is the gate that keeps the two surfaces one. /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure, .codeFirstRows = ix.isCleanWorkingSet, .inventoryTier = ix.isCleanWorkingSet }, /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ) ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ), &ix.g.outAnchorMiss ); } ); } // `rank_by` verb (lane/t10-mcp-coverage): the MCP twin of --rank-by=pagerank|authority|hub|rrf — the SAME @@ -522,7 +522,7 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i /*extraPayloadTokens=*/0, /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure, .codeFirstRows = rankByLabel == nullptr, .inventoryTier = rankByLabel == nullptr }, // pagerank: the map scope's code-first pick /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ) ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ), &ix.g.outAnchorMiss ); } ); } // ─── the cross-branch + dark-content MCP twins (`whereis`, `stray_content`, `flags`) ─── @@ -755,6 +755,7 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& 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; @@ -764,6 +765,7 @@ inline std::string symbolQueryJson( const std::string& root, const 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. @@ -778,6 +780,7 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& + ( 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" + "}"; }; @@ -892,6 +895,10 @@ inline std::string symbolQueryJson( const std::string& root, const std::string& { out += rw::viaLinkNoteJson(); } + if( anyMiss ) + { + out += rw::anchorMissNoteJson(); + } 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 @@ -2226,7 +2233,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, linkOutPtr( in.g ) }, + /*viaLegendInHead=*/false, /*compress=*/false, &in.g.outProv, /*splitLegendInHead=*/false, linkOutPtr( in.g ), &in.g.outAnchorMiss }, in.slotOwner, share ); } mcpForSoftRefill( in, renderSigs, sigsStr, mcpSigsCut, &mcpSigsNext, @@ -3180,7 +3187,7 @@ inline std::optional impactText( const std::string& root, const std ( 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 ) - + viaLinkLegend( imHasLink ) ).c_str(), + + 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 @@ -3221,7 +3228,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\"" : "", - viaEdgeAttr( impactRowNameOnly( proven, show[i] ), impactDirectLink( g, seeds, depth, show[i] ) ), imAttr.ofDefXml( show[i] ) ); } + 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, @@ -3653,11 +3660,13 @@ inline std::optional pathText( const std::string& root, const std:: const PathSearchGaps ptGaps = pth.empty() ? pathSearchGaps( ing, g, srcDefs, valueRefIndexOf( ix ) ) : PathSearchGaps{}; const bool ptHasVia = pathHasNameOnlyHop( g, pth ); // FE-B: exactly when a hop of this path is via="name", as on the CLI const bool ptHasLink = pathHasLinkHop( g, pth ); + const bool ptHasMiss = std::adjacent_find( pth.begin(), pth.end(), + [ & ]( NodeId from, NodeId to ) { return edgeAnchorMiss( g, from, to ); } ) != pth.end(); const PathEndpointBinding ptBound = pathEndpointBinding( g, endpointDefinitionCount( ing, srcDefs ), endpointDefinitionCount( ing, dstDefs ), pth ); // attrib-multidef, as on the CLI const bool ptHasSplit = ptBound.splitHop; rw::emitTo( mem, "{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + viaNameLegend( ptHasVia ) - + viaLinkLegend( ptHasLink ) + + viaLinkLegend( ptHasLink ) + anchorMissLegend( ptHasMiss ) + splitArmLegend( ptHasSplit ) + pathUnboundLegend( !ptBound.from || !ptBound.to ) ).c_str(), ( std::string( toValueRefsLegend( ptToValueRefs.count > 0 ) ) + valueRefsDepthLegend( ptToValueRefs.depthCut.files > 0 ) ).c_str(), pathGapsLegend( ptGaps.any() ), @@ -3678,7 +3687,8 @@ inline std::optional pathText( const std::string& root, const std:: { const Symbol& s = ing.symbols[ pth[i] ]; const std::string_view rp = ptSingleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], ptRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - viaEdgeAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ), i > 0 && edgeIsLink( g, pth[ i - 1 ], pth[ i ] ) ), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); } // FE-B; a link hop says via="link" + std::string( viaEdgeAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ), i > 0 && edgeIsLink( g, pth[ i - 1 ], pth[ i ] ) ) ) + + anchorMissAttr( i > 0 && edgeAnchorMiss( g, pth[ i - 1 ], pth[ i ] ) ), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); } // FE-B; a link hop says via="link" rw::emitRaw( mem, "" ); return mcpAnswerText( stream ); // nullopt = the buffer lost bytes (dispatch answers -32603), "" stays not-found } @@ -3946,8 +3956,13 @@ 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 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() ); + + ( 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(); @@ -4099,7 +4114,8 @@ inline void packConnect( std::FILE* out, const IngestResult& ing, const Graph& g { payload.append( "" ); // FE-B, or via="link" + .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 b861763a9..f2deaa3e3 100644 --- a/src/model.h +++ b/src/model.h @@ -822,6 +822,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/serialize.h b/src/serialize.h index e70d13d0e..2c22a9a2b 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -2640,9 +2640,16 @@ inline std::uint32_t counterAt( const std::vector* v, NodeId id ) // written ONCE, at its first edge, with x="N": the count, and --callees on the lists each candidate with its file // (graphlegend.h kMapViaNameLegend / compactlegend.h's x row). Every other row, prov="split" arms included, is written as // before. `scratch` is reused across symbols. +inline void writeMapAnchorAttr( XmlWriter& w, const std::vector* bits, std::uint32_t e ) +{ + if( bits != nullptr && e < bits->size() && ( *bits )[ e ] != 0 ) + { + w.write( "\" anchor=\"missing" ); + } +} inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint32_t from, std::uint32_t to, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, std::vector& esc, - std::vector& scratch, const std::vector* outLink = nullptr ) + std::vector& scratch, const std::vector* outLink = nullptr, const std::vector* outAnchor = nullptr ) { 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; }; @@ -2702,14 +2709,15 @@ inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint w.write( "\" x=\"" ); w.write( std::to_string( repeats ) ); } + writeMapAnchorAttr( w, outAnchor, e ); w.write( "\"/>" ); } } -// A row under one of `nodes` may carry via="link" when any of their out-edges is a markdown link. Same over-approximation -// as namesOnlyOutAny, asked before the rows are written so the map's first-screen legend can define the attribute. -inline bool linksOutAny( const std::vector& outOff, const std::vector& outLink, - 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. +inline bool markedOutAny( const std::vector& outOff, const std::vector& bits, + std::span nodes ) noexcept { for( const NodeId n : nodes ) { @@ -2717,9 +2725,9 @@ inline bool linksOutAny( const std::vector& outOff, const std::ve { continue; } - for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < outLink.size(); ++e ) + for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < bits.size(); ++e ) { - if( outLink[ e ] != 0 ) + if( bits[ e ] != 0 ) { return true; } @@ -2727,6 +2735,11 @@ inline bool linksOutAny( const std::vector& outOff, const std::ve } return false; } +inline bool linksOutAny( const std::vector& outOff, const std::vector& outLink, + std::span nodes ) noexcept +{ + return markedOutAny( outOff, outLink, nodes ); +} // 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 @@ -2734,21 +2747,7 @@ inline bool linksOutAny( const std::vector& outOff, const std::ve inline bool namesOnlyOutAny( const std::vector& outOff, const std::vector& outNameOnly, std::span nodes ) noexcept { - for( const NodeId n : nodes ) - { - if( std::size_t( n ) + 1 >= outOff.size() ) - { - continue; - } - for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < outNameOnly.size(); ++e ) - { - if( outNameOnly[ e ] != 0 ) - { - return true; - } - } - } - return false; + return markedOutAny( outOff, outNameOnly, nodes ); } // 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 @@ -2809,7 +2808,8 @@ inline void writeMapInventoryXml( XmlWriter& w, const IngestResult& ing, const M const std::vector& outOff, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, std::vector& esc, std::vector& scratch, - const std::vector* outLink = nullptr ) + const std::vector* outLink = nullptr, + const std::vector* outAnchor = nullptr ) { EXPECTS( inv.active, "the caller emits the tier only when it has a cut to inventory" ); w.write( "" ); - writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch, outLink ); + writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch, outLink, outAnchor ); w.write( "" ); } for( const MapInventoryDir& d : inv.dirs ) @@ -2923,7 +2923,8 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto std::size_t inertDeclinedCalls = 0, // Markdown link edges (Graph::outLink) → via="link" on a row and kMapViaLinkLegend. nullptr/empty // ⇒ the map has no link edge and stays byte-identical. - const std::vector* outLink = nullptr ) + const std::vector* outLink = nullptr, + const std::vector* outAnchor = nullptr ) { const std::size_t* changedCount = ann.changedCount; const std::string* mapAtStamp = ann.atStamp; @@ -3183,6 +3184,11 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { legend += kMapViaLinkLegend; } + if( outAnchor && !stubbed && !ann.viaLegendStripped + && markedOutAny( outOff, *outAnchor, std::span( order.data(), std::min( keep, order.size() ) ) ) ) + { + 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) @@ -3477,7 +3483,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { if( inventory.active ) { - writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink ); + writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink, outAnchor ); } }; const bool inventoryFirst = !stable && effImportantLast; // --stable's path order wins, as it does for the rows @@ -3698,7 +3704,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto } w.write( ">" ); - writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink ); + writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink, outAnchor ); w.write( "" ); } w.write( "" ); @@ -6580,6 +6586,7 @@ struct CalleeCallsSink 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` @@ -6726,17 +6733,19 @@ struct MergedCalleeNameRow 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 }; inline void collectCalleeNameRow( std::vector& rows, const Symbol& cs, - std::size_t& used, const CalleeCallsSink& sink, bool via = false, bool split = false, bool link = false ) + std::size_t& used, const CalleeCallsSink& sink, bool via = false, bool split = false, bool link = false, + bool miss = false ) { 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 && r.link == link ) + 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; @@ -6744,11 +6753,11 @@ inline void collectCalleeNameRow( std::vector& rows, const } if( !merged ) { - rows.push_back( MergedCalleeNameRow { cs.name, { cs.line }, via, split, link } ); + 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"; 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 ); + 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 @@ -6776,6 +6785,7 @@ inline void appendMergedCalleeNameRows( std::string& callsBody, 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, const std::vector* linkBytes = nullptr ) noexcept : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ), link( linkBytes ) {} }; +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 @@ -6824,9 +6840,11 @@ inline CalleeCallsSink withSplitArms( CalleeCallsSink sink, const std::vector* link ) noexcept +inline CalleeCallsSink withLinkBits( CalleeCallsSink sink, const std::vector* link, + const std::vector* anchor = nullptr ) noexcept { - sink.link = link; + sink.link = link; + sink.anchor = anchor; return sink; } @@ -6851,6 +6869,48 @@ 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; + } +} + // §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- @@ -6881,6 +6941,7 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector 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] ]; @@ -6895,6 +6956,7 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector 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 ); // Q1: prov="split" or "" @@ -6903,6 +6965,7 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector if( sink.namesOnly ) { collectCalleeNameRow( nameRows, cs, used, sink, nameRow, !splitAttr.empty(), linkRow ); + applyCalleeAnchorMiss( sink, outOff, outTargets, id, cid, anchorLegendCharged, used, &nameRows, cs.name, linkRow, nullptr ); ++shown; continue; } @@ -6919,6 +6982,7 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector } char hb[ 64 ]; rw::formatTo( hb, sizeof( hb ), "\" l=\"{}\"{}{}>", cs.line, viaAttr, splitAttr ); callsBody += "calls : nullptr, /*namesOnly=*/false, calleeRank, &outNameOnly, - &viaLegendCharged }, outEdges.prov, &splitLegendCharged ), outEdges.link ) ); + &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 @@ -7683,6 +7747,10 @@ inline void packBodies( std::FILE* out, const IngestResult& ing, const std::vect { 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" @@ -7782,7 +7850,7 @@ inline OwnerHopRows packOwnerHopRows( const IngestResult& ing, const std::vector used += rowFixed; emitCalleeCallsBlock( row, id, outOff, outTargets, ing, contentOf, esc, used, budget, withLinkBits( withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &via, - kForOwnerHopNameGuard, next }, outEdges.prov, outEdges.splitLegendCharged ), outEdges.link ) ); + kForOwnerHopNameGuard, next }, outEdges.prov, outEdges.splitLegendCharged ), outEdges.link, outEdges.anchor ) ); out.xml += row; out.xml += ""; ++out.shown; @@ -7906,7 +7974,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, outEdges.link ), + const OwnerHopRows own = packOwnerHopRows( ing, nodes, owners, outOff, outTargets, withAnchorEdge( CalleeEdgeBytes( outNameOnly, outEdges.prov, &splitLegendCharged, outEdges.link ), outEdges.anchor ), redact, rank, rootArg, budgetBytes, viaLegendCharged, pathRel, contentOf, esc ); std::string children = own.xml; std::size_t requestedCount = own.shown; @@ -7959,7 +8027,7 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector // running `used` so the row identity bytes and the edge bytes share one budget. emitCalleeCallsBlock( row, id, outOff, outTargets, ing, contentOf, esc, used, budgetBytes, withLinkBits( withSplitArms( CalleeCallsSink{ redact, /*recorded=*/nullptr, /*namesOnly=*/true, rank, &outNameOnly, &viaLegendCharged }, - outEdges.prov, &splitLegendCharged ), outEdges.link ) ); + outEdges.prov, &splitLegendCharged ), outEdges.link, outEdges.anchor ) ); row += ""; children += row; ++shownCount; @@ -7979,6 +8047,10 @@ inline void packHops( std::FILE* out, const IngestResult& ing, const std::vector { 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 5acd0db66..cea67a1ee 100644 --- a/src/verbs_for.h +++ b/src/verbs_for.h @@ -1981,7 +1981,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, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, /*budgetBytes=*/1, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, nullptr, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2003,7 +2003,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, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + { rw::packBodies( f, ing, autoBodyIds, autoBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), cfg.compress, redactPtr, /*ranges=*/nullptr, /*noteIndex=*/nullptr, &autoEmitted, /*truncateOversizedFirst=*/false, /*withFileContext=*/false, fabRootArg, &lensRank, rw::CalleeLegendsInHead( viaLegendInHead, splitLegendInHead ) ); }, rw::kBytesPerTokenBody ); @@ -2409,7 +2409,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, nullptr, rw::linkOutPtr( g ) ), redactPtr, &lensRank, fcRootArg, + { rw::packHops( f, ing, hopNodes, hopBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &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 @@ -3725,7 +3725,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, nullptr, rw::linkOutPtr( g ) ), cfg.compress, redactPtr, + { packBodies( f, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &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 ); @@ -3779,7 +3779,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, rw::linkOutPtr( g ) }, + headerParts.viaPresent, cfg.compress, &g.outProv, headerParts.splitPresent, rw::linkOutPtr( g ), &g.outAnchorMiss }, slotOwner, forSoftTarget ? std::optional( rw::forSoftSectionsShare( bundleBudget, slotSpent ) ) : std::nullopt ); if( slot.served ) @@ -3999,7 +3999,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, nullptr, rw::linkOutPtr( g ) ), + rw::emitChargedSection( stdout, detailSection, [ & ]{ packBodies( stdout, ing, detailIds, detailBodyBudget, g.outOff, g.outTargets, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &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 b09d04e7d..06df64eef 100644 --- a/src/verbs_navigate.h +++ b/src/verbs_navigate.h @@ -269,6 +269,11 @@ std::optional runCallHierarchy( const MainDispatch& d ) 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 ); } + bool chHasMiss = false; + for( std::size_t i = pw.begin; i < pw.end && !chHasMiss; ++i ) + { + chHasMiss = oneHopRowIsLink( g, matches, result[ i ], wantCallers, &g.outAnchorMiss ); + } rw::emitTo( stdout, "{}{}{}{}{}{}{}-->{}{}", rw::callHierarchyLegendOpen( wantCallers, chNextIsBare, cfg.columnar, chNextIsPage ).c_str(), rw::capLegendClause( rw::computePageDisclosure( pw.end - pw.begin, result.size(), pw.end, cfg.pageLimit, cfg.pageOffset, chDiscloseCap ).active ), @@ -288,6 +293,14 @@ std::optional runCallHierarchy( const MainDispatch& d ) + rw::valueRefsDepthLegendFor( chRows.valueRefs ) ).c_str(), // exactly when the root carries the depth disclosure 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 @@ -344,6 +357,13 @@ std::optional runCallHierarchy( const MainDispatch& d ) pageOfDef.push_back( rw::ofDefColumnValue( chAttr.groups, chAttr.tokens, pw.begin + i ) ); } } + for( std::size_t i = 0; i < page.size(); ++i ) + { + if( oneHopRowIsLink( g, matches, result[ pw.begin + i ], wantCallers ) ) + { + pageVia[ i ] = 2; + } + } const std::string chDefRows = rw::defTableXml( ing, chAttr.groups, chListing, chRootPrefix ); 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 @@ -393,6 +413,10 @@ std::optional runCallHierarchy( const MainDispatch& d ) 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 + 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 ), // the XML rows, keys = its attribute names @@ -425,7 +449,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\"" : "", - viaEdgeAttr( chAttr.nameOnly( i ), oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ), + 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 } @@ -973,6 +998,7 @@ struct SafeDeleteLegendFlags 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) }; @@ -1011,6 +1037,7 @@ inline std::string safeDeleteFloorLegend( const SafeDeleteLegendFlags& flags, st } 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; } @@ -1315,10 +1342,12 @@ std::optional runSafeDelete( const MainDispatch& d ) const rw::DefTableListing sdListing = rw::defTableListing( sdAttr.groups, sdNamed ); bool sdHasSplit = false; bool sdHasLink = false; - for( std::size_t i = sdLw.begin; i < sdLw.end && ( !sdHasSplit || !sdHasLink ); ++i ) + bool sdHasMiss = false; + for( std::size_t i = sdLw.begin; i < sdLw.end && ( !sdHasSplit || !sdHasLink || !sdHasMiss ); ++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, @@ -1330,7 +1359,7 @@ std::optional runSafeDelete( const MainDispatch& d ) .valueRefs = sdValueRefs.rows.size(), .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, + .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 @@ -1373,7 +1402,8 @@ 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 ), - rw::viaEdgeAttr( false, rw::oneHopRowIsLink( g, defs, callerId, true ) ) ); // a direct markdown link + 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 ) ) ); @@ -2427,12 +2457,14 @@ std::optional runPath( const MainDispatch& d ) const rw::PathSearchGaps pthGaps = path.empty() ? rw::pathSearchGaps( ing, g, srcDefs, *pthValueIdx ) : rw::PathSearchGaps{}; const bool pthHasVia = rw::pathHasNameOnlyHop( g, path ); // FE-B: exactly when a hop of THIS path is via="name" const bool pthHasLink = rw::pathHasLinkHop( g, path ); + const bool pthHasMiss = std::adjacent_find( path.begin(), path.end(), + [ & ]( rw::NodeId from, rw::NodeId to ) { return rw::edgeAnchorMiss( g, from, to ); } ) != path.end(); // 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; rw::emitTo( stdout, "{}", ( rw::unprovenDefsVerbLegend( rw::UnprovenDefsVerb::Path, pthUnprovenDefs > 0 ) + rw::viaNameLegend( pthHasVia ) - + rw::viaLinkLegend( pthHasLink ) + + rw::viaLinkLegend( pthHasLink ) + rw::anchorMissLegend( pthHasMiss ) + rw::splitArmLegend( pthHasSplit ) + rw::pathUnboundLegend( !pthBound.from || !pthBound.to ) ).c_str(), ( std::string( rw::toValueRefsLegend( pthToValueRefs.count > 0 ) ) + rw::valueRefsDepthLegend( pthToValueRefs.depthCut.files > 0 ) ).c_str(), rw::pathGapsLegend( pthGaps.any() ), @@ -2460,7 +2492,8 @@ std::optional runPath( const MainDispatch& d ) const std::string_view rp = pthSingleRoot ? rw::sarif::rootRelativeUri( ing.files[ s.fileId ], pthRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); // FE-B: a hop whose edge INTO it is name-only says so on its row (the first row is no hop) rw::emitTo( stdout, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - viaEdgeAttr( i > 0 && edgeNameOnly( g, path[ i - 1 ], path[ i ] ), i > 0 && edgeIsLink( g, path[ i - 1 ], path[ i ] ) ), rw::splitArmAttrXml( rw::pathHopSplitArm( g, path, i ) ) ); + std::string( viaEdgeAttr( i > 0 && edgeNameOnly( g, path[ i - 1 ], path[ i ] ), i > 0 && edgeIsLink( g, path[ i - 1 ], path[ i ] ) ) ) + + anchorMissAttr( i > 0 && edgeAnchorMiss( g, path[ i - 1 ], path[ i ] ) ), rw::splitArmAttrXml( rw::pathHopSplitArm( g, path, i ) ) ); } rw::emitTo( stdout, "" ); return 0; @@ -2605,21 +2638,18 @@ inline std::vector impactOfDefKeys( const ImpactView& v ) { keys[ i ] = v.attrib.ofDefJson( v.show[ i ] ); } - 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 ) -{ - 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 ] ) + && rw::oneHopRowIsLink( v.g, v.seeds, v.show[ i ], true, &v.g.outAnchorMiss ) ) + { + keys[ i ] += ",\"anchor\":\"missing\""; + } } - return via; + return keys; } -// JSON rows: 2 = a direct markdown link, 1 = via="name". The columnar column stays impactViaColumn (1 means name). +// 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 ); @@ -2682,7 +2712,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 ) @@ -2779,11 +2809,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\"" : "", - viaEdgeAttr( rw::impactRowNameOnly( v.provenReach, v.show[i] ), - rw::impactDirectLink( v.g, v.seeds, v.depth, v.show[i] ) ), + 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 ); @@ -2899,7 +2930,11 @@ std::optional runImpact( const MainDispatch& d ) + 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 ) + rw::viaLinkLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, - [ & ]( NodeId n ) { return rw::impactDirectLink( g, seeds, imDepth, n ); } ) ) ).c_str(), + [ & ]( NodeId n ) { return rw::impactDirectLink( g, seeds, imDepth, n ); } ) ) + + rw::anchorMissLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, + [ & ]( NodeId n ) + { return rw::impactDirectLink( g, seeds, imDepth, n ) + && rw::oneHopRowIsLink( g, seeds, n, true, &g.outAnchorMiss ); } ) ) ).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= @@ -3098,7 +3133,7 @@ std::optional runAround( const MainDispatch& d ) aroundAnn.seed = { ing.symbols[ focus ].name, cfg.aroundDepth, cfg.aroundFanout, definitionCountOfName( ing, focus ), eg.fanoutCut, eg.depthTruncated }; aroundAnn.seed.unprovenDefs = arUnprovenDefs; // H1: unproven_defs= beside defs= on the root, its clause in the map legend - serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls, rw::linkOutPtr( g ) ); + serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); if( !g.composeEdges.empty() ) { 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/mdsectioncheck.sh b/test/mdsectioncheck.sh index ab539890c..70980a32e 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -384,6 +384,17 @@ want "$AFIX" "Func Name" 'do_work@docs/b/partner.md' "a heading nam want "$AFIX" "Missing Anchor" 'partner@docs/b/partner.md' "#no-such-heading falls back to that file's own node" want "$AFIX" "Missed Readme" 'README@docs/b/README.md' "../b/README.md#no-such-heading pins docs/b/README.md, not the same-directory README" want "$AFIX" "Bare Readme" 'README@docs/b/README.md' "a bare ../b/README.md pins that file, not the same-directory README" +if $BIN "$AFIX" --no-cache --callees='Missing Anchor' --legend=compact 2>/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" # --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. @@ -393,28 +404,36 @@ 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": - ok = False - via = re.search(r" via=\"([^\"]*)\"", attrs) - if not via or via.group(1) != "link": - ok = False -sys.exit(0 if ok else 1) -'; then ok "the .py do_work is a matched def only — the caller edge belongs to the section and is via=link"; else no "the doc link leaked onto the .py function, or the section row lacks via=link"; fi + 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 -if $BIN "$AFIX" --no-cache --callers='docs/a/tools.py:do_work' --legend=compact 2>/dev/null | grep -q 'via="link"'; then - no "a real function's callers carry via=link" +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 - ok "the .py do_work callers are not marked via=link" + 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" From d91536245c8b7be640f280104d58cb21a1526328 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Mon, 19 Oct 2026 01:56:17 +0300 Subject: [PATCH 12/18] Fold call, name, and link into one byte per edge. Path, connect, and impact JSON mark a direct markdown link, and a leading-slash link joins the crawl root. --- .ripwire_quality_acks | 1 + CHANGELOG.md | 3 +- src/compactlegend.h | 2 +- src/defattrib.h | 2 +- src/forowner.h | 3 +- src/graph.h | 188 ++++++++++++++++-------- src/graphlegend.h | 11 +- src/ingest_docs.h | 26 +++- src/main.cpp | 12 +- src/mcpverbs.h | 95 ++++++++---- src/pathgaps.h | 6 +- src/serialize.h | 221 +++++++++++++++------------- src/verbs_for.h | 12 +- src/verbs_navigate.h | 324 +++++++++++++++++++++-------------------- test/mdsectioncheck.sh | 19 ++- 15 files changed, 542 insertions(+), 383 deletions(-) diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index 5bd8199e7..6845830a1 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -88,6 +88,7 @@ ack api-surface 610cc771fd6f10c1 5 cid=86df6969a0d891fb now=5 was=4 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 6e58b0a307757079 24 cid=cb5c8aaa7451a632 by=src/* Phase 5 (docs/EVALS.md): the external-name veto (@external, externalnames.h tables, ExternalVeto predicate, import-name bindings at ingest) and the receiver MRO walk (rule1BaseWalk, SuperObj); every gating row is this one change — the four deliberate contract changes (serialize/serializeJson gain externalCalls, captureIncludes gains binds, methodOnTypeOrBases gains skipSelf/unionOnMulti), the ladder's two new steps in buildGraph, the census's tenth mechanism, kParserVer 77, and the two lexical clone false-positives (a one-line binary_search vs a counts total; a key-buffer probe vs a JSON string writer) | prior: Phase 4 lane (lpin= disclosure + localityKey tie-break, 2026-09-03): serialize/serializeJson each gain ONE trailing defaulted locPinOut param (the identical shape every honesty counter took — ambOut/unresolvedOut/bind); classifyPin churn=self is the one-line reroute of its Locality outcome through isLocalityPin so the shipped marker and the census label are the same predicate; runAround churn=self is the one-argument extension at its serialize call, the same edit every serialize caller took (main.cpp x4, mcpverbs analyze). Six duplicated sum/at chains folded into counterTotal/counterAt in the same change; astropy map + census byte-identical before and after that fold. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d51fcbe8..c7abb20c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,7 @@ the path against the linking file, so `../b/notes.md` never lands on a same-name 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 -call on the same pair leaves the row unmarked. 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"`. Columnar `` is `2` for a direct markdown link and `1` for a name-only edge. Gate: `test/mdsectioncheck.sh`. +`--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"`. Columnar `` is `2` for a direct markdown link and `1` for a name-only edge. Gate: `test/mdsectioncheck.sh`. ### Changed — Go methods carry their receiver type as scope (#403, slice 9a) diff --git a/src/compactlegend.h b/src/compactlegend.h index 6f6d6c9ce..6f08c3ca5 100644 --- a/src/compactlegend.h +++ b/src/compactlegend.h @@ -583,7 +583,7 @@ inline constexpr CompactCompletenessTerm kCompactCompletenessTerms[] = // and VALUE-qualified (// whose via= lists name), so impact's importer rows never pull it in; the // columnar form names it in fields= like the tested column. { "via", "via=name: the target was matched by name alone (receiver unproven); every by-name candidate in reach is listed; NOT a claim the edge is false", true, "s", MapHeaderRead::No, "name" }, - { "via", "via=link: this edge is a markdown link, not a call. A real call on the same pair stays unmarked", true, "s", MapHeaderRead::No, "link" }, + { "via", "via=link: this edge is a markdown link, not a call", true, "s", MapHeaderRead::No, "link" }, { "anchor", "anchor=missing: the markdown link named a heading this file does not have, so the edge is the file", true, "s", MapHeaderRead::No, "missing" }, { "via", ": that callee matched by name alone (receiver unproven); it does NOT mean the edge is false", true, "c", MapHeaderRead::No, "name" }, { "via", ": that callee is a markdown link, not a call", true, "c", MapHeaderRead::No, "link" }, diff --git a/src/defattrib.h b/src/defattrib.h index 9587c4ce9..8f88923f8 100644 --- a/src/defattrib.h +++ b/src/defattrib.h @@ -377,7 +377,7 @@ struct RowDefTokens // One out-edge's tie, off its two parallel per-edge bytes (either may be unallocated: a graph with no such edge). inline DefMark defMarkOfEdge( const Graph& g, std::size_t e ) noexcept { - if( e < g.outNameOnly.size() && g.outNameOnly[e] != 0 ) + if( e < g.outNameOnly.size() && edgeKindByte( g.outNameOnly[e] ) == kEdgeName ) { return DefMark::Name; } diff --git a/src/forowner.h b/src/forowner.h index 60b097465..b075b3ae5 100644 --- a/src/forowner.h +++ b/src/forowner.h @@ -391,7 +391,6 @@ struct BodySlotInputs // a split arm reads as one here too; the split reading rides beside the rows unless the header already defines it const std::vector* outProv = nullptr; // Graph::outProv (nullptr: no split labels) bool splitLegendInHead = false; // the header already defines prov="split" - const std::vector* outLink = nullptr; // Graph::outLink (nullptr: no via="link") const std::vector* outAnchor = nullptr; // Graph::outAnchorMiss (nullptr: no anchor="missing") }; inline BodySlotRender renderForBodySlot( const BodySlotInputs& in, ForBodySlotOwner owner, std::optional share ) @@ -400,7 +399,7 @@ inline BodySlotRender renderForBodySlot( const BodySlotInputs& in, ForBodySlotOw BodySlotRender out; const std::optional before = in.redact != nullptr ? std::optional( *in.redact ) : std::nullopt; ChargedSection sec = chargeSection( [ & ]( std::FILE* f ) - { packBodies( f, in.ing, { owner.id }, kForBodySlotMaxBytes, in.outOff, in.outTargets, withAnchorEdge( CalleeEdgeBytes( in.outNameOnly, in.outProv, nullptr, in.outLink ), in.outAnchor ), + { packBodies( f, in.ing, { owner.id }, kForBodySlotMaxBytes, in.outOff, in.outTargets, withAnchorEdge( CalleeEdgeBytes( in.outNameOnly, in.outProv, nullptr ), in.outAnchor ), in.compress, in.redact, /*ranges=*/nullptr, in.notes, /*outEmitted=*/nullptr, /*truncateOversizedFirst=*/true, /*withFileContext=*/false, in.rootArg, &in.lensRank, CalleeLegendsInHead( in.viaLegendInHead, in.splitLegendInHead ) ); }, kBytesPerTokenBody ); diff --git a/src/graph.h b/src/graph.h index eda4935d0..73a5f0eb9 100644 --- a/src/graph.h +++ b/src/graph.h @@ -80,11 +80,9 @@ struct Graph // the export (JS/TS); read, not chosen by name // Empty ⇒ no overlay, no FFI/import edge and nothing split: every edge // uniquely resolved, so the whole attribute is absent (omit-at-confident). - std::vector outNameOnly; // FE-B: per-out-edge HEDGE bit, parallel to outTargets — 1 = every call site that bound this - // edge did so by NAME ALONE (no receiver evidence: receiverevidence.h), rendered via="name" - // on every surface; 0 = at least one site proved it. Empty ⇒ no edge is name-only. - std::vector outLink; // per-out-edge markdown-link bit, parallel to outTargets — 1 = every site that bound this - // edge was a pinned [text](path) link and no real call bound the same pair. Empty ⇒ no link edge. + std::vector outNameOnly; // per-out-edge kind, parallel to outTargets. 0 = a call. 1 = every site bound this edge by + // NAME ALONE (receiverevidence.h), rendered via="name". 2 = a markdown link, rendered via="link". + // The high bit on a 2 marks anchor="missing" for the map writer. Empty ⇒ every edge is a plain call. std::vector outAnchorMiss; // parallel to outTargets — 1 = that link edge named a heading the file does not have, so the // target is the file node. Empty ⇒ no such edge. A link with no slug stays 0. std::size_t scipDocsSeen = 0; // # SCIP documents consumed (0 unless --scip); honesty summary @@ -390,21 +388,30 @@ inline LegoClosure legoImplementorClosure( const std::vector return out; } -// FE-B: is the call edge from → to NAME-ONLY, over the raw out-CSR and its parallel hedge bits — bound by name alone at -// every site that bound it, so every surface renders it via="name". false for an edge that does not exist and for empty -// hedge bits (a graph with no name-only edge at all). edgeNameOnly( Graph ) below and serialize.h's block share it. +// Graph::outNameOnly kind byte. The high bit is the map writer's anchor="missing" mark on a link. +inline constexpr std::uint8_t kEdgeName = 1; +inline constexpr std::uint8_t kEdgeLink = 2; +inline constexpr std::uint8_t kEdgeKindMask = 0x7f; + +inline std::uint8_t edgeKindByte( std::uint8_t stored ) noexcept +{ + return std::uint8_t( stored & kEdgeKindMask ); +} + +// FE-B: is the call edge from → to the asked kind, over the raw out-CSR. want is kEdgeName or kEdgeLink. +// false for an edge that does not exist and for an empty kind vector. inline bool edgeNameOnlyInCsr( const std::vector& outOff, const std::vector& outTargets, - const std::vector& nameOnly, NodeId from, NodeId to ) noexcept + const std::vector& nameOnly, NodeId from, NodeId to, std::uint8_t want = kEdgeName ) noexcept { if( nameOnly.empty() || std::size_t( from ) + 1 >= outOff.size() ) { return false; } - EXPECTS( nameOnly.size() == outTargets.size(), "the hedge bit is parallel to outTargets (buildGraph allocates it per edge)" ); + EXPECTS( nameOnly.size() == outTargets.size(), "the kind byte is parallel to outTargets (buildGraph allocates it per edge)" ); const auto b = outTargets.begin() + outOff[ from ]; const auto e = outTargets.begin() + outOff[ std::size_t( from ) + 1 ]; const auto it = std::lower_bound( b, e, to ); // ascending within a source (buildGraph sorts by (from, to)) - return it != e && *it == to && nameOnly[ std::size_t( it - outTargets.begin() ) ] != 0; + return it != e && *it == to && edgeKindByte( nameOnly[ std::size_t( it - outTargets.begin() ) ] ) == want; } inline bool edgeNameOnly( const Graph& g, NodeId from, NodeId to ) noexcept { @@ -426,7 +433,7 @@ inline std::size_t edgeIndex( const Graph& g, NodeId from, NodeId to ) noexcept return ( it != e && *it == to ) ? std::size_t( it - g.outTargets.begin() ) : SIZE_MAX; } -// One per-edge bit vector (outLink, outAnchorMiss). false when that vector was not allocated, or the edge is absent. +// One per-edge bit vector (outAnchorMiss). false when that vector was not allocated, or the edge is absent. inline bool edgeBitSet( const Graph& g, const std::vector& bits, NodeId from, NodeId to ) noexcept { if( bits.empty() ) @@ -440,7 +447,7 @@ inline bool edgeBitSet( const Graph& g, const std::vector& bits, N inline bool edgeIsLink( const Graph& g, NodeId from, NodeId to ) noexcept { - return edgeBitSet( g, g.outLink, from, to ); + return edgeNameOnlyInCsr( g.outOff, g.outTargets, g.outNameOnly, from, to, kEdgeLink ); } inline bool edgeAnchorMiss( const Graph& g, NodeId from, NodeId to ) noexcept @@ -449,12 +456,13 @@ inline bool edgeAnchorMiss( const Graph& g, NodeId from, NodeId to ) noexcept } // Callers (towardMatches): every edge from the row to a matched def is a link. Callees: every edge from a matched def -// to the row is. A row with no such edge is not a link. One real call among them drops the mark. -// bits, when passed, reads that vector instead of outLink (outAnchorMiss: the slug named no heading). +// to the row is. A row with no such edge is not a link. +// bits, when passed, reads that vector (outAnchorMiss: the slug named no heading). Otherwise the kind byte is read. inline bool oneHopRowIsLink( const Graph& g, std::span matches, NodeId row, bool towardMatches, const std::vector* bits = nullptr ) noexcept { - const std::vector& edgeBits = bits != nullptr ? *bits : g.outLink; + const bool onKind = bits == nullptr; + const std::vector& edgeBits = onKind ? g.outNameOnly : *bits; if( edgeBits.empty() ) { return false; @@ -470,7 +478,8 @@ inline bool oneHopRowIsLink( const Graph& g, std::span matches, No continue; } any = true; - if( e >= edgeBits.size() || edgeBits[ e ] == 0 ) + const bool set = e < edgeBits.size() && ( onKind ? edgeKindByte( edgeBits[ e ] ) == kEdgeLink : edgeBits[ e ] != 0 ); + if( !set ) { return false; } @@ -478,16 +487,27 @@ inline bool oneHopRowIsLink( const Graph& g, std::span matches, No return any; } -// FE-B: does any hop of a directed call path (path[i-1] → path[i]) run through a name-only edge? --path and MCP -// path_between ask it once to decide their via="name" legend sentence (one spelling for both transports). -inline bool pathHasNameOnlyHop( const Graph& g, std::span path ) noexcept +inline bool pageHasAnchorMiss( const Graph& g, std::span matches, const std::vector& rows, + std::size_t begin, std::size_t end, bool towardMatches ) noexcept { - return std::adjacent_find( path.begin(), path.end(), [ & ]( NodeId from, NodeId to ) { return edgeNameOnly( g, from, to ); } ) != path.end(); + for( std::size_t i = begin; i < end && i < rows.size(); ++i ) + { + if( oneHopRowIsLink( g, matches, rows[ i ], towardMatches, &g.outAnchorMiss ) ) + { + return true; + } + } + return false; } -inline bool pathHasLinkHop( const Graph& g, std::span path ) noexcept +// Does any hop of a directed path carry this kind byte? kind 1 is via="name", kind 2 is via="link". +// --path and MCP path_between ask it once for the legend sentence. +inline bool pathHasNameOnlyHop( const Graph& g, std::span path, std::uint8_t kind = kEdgeName ) noexcept { - return std::adjacent_find( path.begin(), path.end(), [ & ]( NodeId from, NodeId to ) { return edgeIsLink( g, from, to ); } ) != path.end(); + return std::adjacent_find( path.begin(), path.end(), [ & ]( NodeId from, NodeId to ) + { + return kind == 0 ? edgeAnchorMiss( g, from, to ) : edgeNameOnlyInCsr( g.outOff, g.outTargets, g.outNameOnly, from, to, kind ); + } ) != path.end(); } // FE-B's per-row via="name" (EVERY edge joining a row to the selector's definitions is name-only) is defattrib.h's @@ -501,7 +521,7 @@ inline const char* viaNameAttr( bool nameOnly ) noexcept } inline const std::vector* linkOutPtr( const Graph& g ) noexcept { - return g.outLink.empty() ? nullptr : &g.outLink; + return g.outNameOnly.empty() ? nullptr : &g.outNameOnly; } inline const char* viaEdgeAttr( bool nameOnly, bool link ) noexcept @@ -6435,31 +6455,30 @@ struct MdLinkIndex inline bool isMarkdownLinkRef( const Reference& r ) noexcept { - return r.lang == Lang::Markdown && !r.isDocLink && !r.memberCall && !r.memberPath.empty(); + if( r.lang != Lang::Markdown || r.isDocLink || r.memberCall ) + { + return false; + } + return !r.memberPath.empty(); } // Link-ness is where the reference came from: lang Markdown and not a doc mention. isMarkdownLinkRef also requires a -// path, which an in-file anchor and a wikilink do not have, and a root-relative link can miss the pin. A non-markdown -// site on the same pair is a real call and wins at stamp time. A markdown doc mention is neither. -inline void noteMarkdownLinkSite( HashMap& linkEdges, HashMap& realCalls, std::uint64_t ekey, const Reference& r ) +// path, which an in-file anchor and a wikilink do not have. A markdown doc mention is neither. +inline void noteMarkdownLinkSite( HashMap& linkEdges, std::uint64_t ekey, const Reference& r ) { if( r.lang == Lang::Markdown && !r.isDocLink ) { linkEdges[ ekey ] = 1; } - else if( r.lang != Lang::Markdown ) - { - realCalls[ ekey ] = 1; - } } template -inline bool graphHasMarkdownLink( const std::vector& edges, const HashMap& linkEdges, const HashMap& realCalls ) +inline bool graphHasMarkdownLink( const std::vector& edges, const HashMap& linkEdges ) { for( const Edge& e : edges ) { const std::uint64_t key = ( std::uint64_t( e.from ) << 32 ) | e.to; - if( linkEdges.find( key ) != linkEdges.end() && realCalls.find( key ) == realCalls.end() ) + if( linkEdges.find( key ) != linkEdges.end() ) { return true; } @@ -6467,9 +6486,9 @@ inline bool graphHasMarkdownLink( const std::vector& edges, const HashMap< return false; } -inline std::uint8_t markdownLinkBit( const HashMap& linkEdges, const HashMap& realCalls, std::uint64_t key ) noexcept +inline std::uint8_t markdownLinkBit( const HashMap& linkEdges, std::uint64_t key ) noexcept { - return ( linkEdges.find( key ) != linkEdges.end() && realCalls.find( key ) == realCalls.end() ) ? 1u : 0u; + return linkEdges.find( key ) != linkEdges.end() ? 1u : 0u; } // The file node is the Section extractMarkdown builds with bodyByte 0 over [0, size). assignSymbols then @@ -6543,18 +6562,41 @@ struct MarkdownLinkTarget bool slugMiss = false; }; -inline MarkdownLinkTarget bindOneMarkdownLink( const MdLinkIndex& ix, std::string_view linkerPath, std::string_view written ) +inline MarkdownLinkTarget bindOneMarkdownLink( const MdLinkIndex& ix, std::string_view linkerPath, std::string_view written, + std::string_view crawlRoot ) { const std::size_t frag = written.find( '#' ); const std::string_view link = written.substr( 0, frag ); const std::string_view slug = frag == std::string_view::npos ? std::string_view{} : written.substr( frag + 1 ); - const std::string_view dir = includerDir( linkerPath ); - std::string path( dir ); - if( !dir.empty() ) + std::string path; + if( !link.empty() && link.front() == '/' ) + { + // A leading slash is the crawl root. A multi-root crawl leaves crawlRoot empty, and the stem ladder answers. + if( crawlRoot.empty() ) + { + return {}; + } + path.assign( crawlRoot ); + if( path.empty() || path.back() != '/' ) + { + path.push_back( '/' ); + } + const std::size_t body = link.find_first_not_of( '/' ); + if( body != std::string_view::npos ) + { + path.append( link.substr( body ) ); + } + } + else { - path.push_back( '/' ); + const std::string_view dir = includerDir( linkerPath ); + path.assign( dir ); + if( !dir.empty() ) + { + path.push_back( '/' ); + } + path.append( link ); } - path.append( link ); path = lexicalNormalize( path ); // empty on an escape above the root → stem ladder const auto file = path.empty() ? ix.fileByPath.end() : ix.fileByPath.find( path ); if( file == ix.fileByPath.end() ) @@ -6595,7 +6637,7 @@ inline MdLinkPins bindMarkdownLinks( const IngestResult& ing ) const Reference& r = ing.references[ i ]; if( isMarkdownLinkRef( r ) && r.fileId < ing.files.size() ) { - const MarkdownLinkTarget t = bindOneMarkdownLink( ix, ing.files[ r.fileId ], r.memberPath ); + const MarkdownLinkTarget t = bindOneMarkdownLink( ix, ing.files[ r.fileId ], r.memberPath, ing.crawlRoot ); out.pin[ i ] = t.id; out.slugMiss[ i ] = t.slugMiss ? 1 : 0; } @@ -7037,9 +7079,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // so its bucket layout cannot reach output, and it is filled inside a loop // whose per-reference tier work dominates the growth cascade by orders of // magnitude. A guessed reserve would be a made-up number in a hot struct. - HashMap linkEdges; // (from<<32|to) a markdown link bound. A non-markdown call on the same pair - // (realCallEdges) drops the mark: the merged edge stays a call. - HashMap realCallEdges; // (from<<32|to) bound by a non-markdown site. Empty on a tree with no markdown link. + HashMap linkEdges; // (from<<32|to) a markdown link bound. The section that holds the link is the edge's from. HashMap anchorMissEdges; // (from<<32|to) a link whose slug named no heading, so the target is the file node. std::vector rule3Out; // reused Rule-3 output buffer (candidates from the single included file) Rule3FileMemo rule3Memo; // Rule 3's answer per (caller file, byName entry): one walk per name per file, not per call @@ -9143,7 +9183,8 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null } } - // drop self-loops BEFORE the split so the confidence mass is conserved over real targets + // drop self-loops BEFORE the split so the confidence mass is conserved over real targets. + // A markdown link whose target is the section that contains the link is dropped here on purpose. std::size_t nReal = 0; for( NodeId c : tier ) { @@ -9238,7 +9279,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null ( nameOnly ? nameOnlyEdges : provenEdges )[ ekey ] = 1; // FE-B: a proven site wins the merged row (Graph::outNameOnly) if( anyMarkdownLink ) { - noteMarkdownLinkSite( linkEdges, realCallEdges, ekey, r ); + noteMarkdownLinkSite( linkEdges, ekey, r ); if( siteIndex < mdSlugMiss.size() && mdSlugMiss[ siteIndex ] != 0 && siteIndex < mdLinkPin.size() && to == mdLinkPin[ siteIndex ] ) { anchorMissEdges[ ekey ] = 1; @@ -9303,22 +9344,23 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null { g.outProv.assign( edges.size(), 0u ); } - if( graphHasMarkdownLink( edges, linkEdges, realCallEdges ) ) - { - g.outLink.assign( edges.size(), 0u ); - } const auto anchorMissed = [ & ]( std::uint64_t k ) { - return markdownLinkBit( linkEdges, realCallEdges, k ) != 0 && anchorMissEdges.contains( k ); + return markdownLinkBit( linkEdges, k ) != 0 && anchorMissEdges.contains( k ); }; if( std::any_of( edges.begin(), edges.end(), [ & ]( const E& e ) { return anchorMissed( ( std::uint64_t( e.from ) << 32 ) | e.to ); } ) ) { g.outAnchorMiss.assign( edges.size(), 0u ); } const EdgeProvenanceSets provSets{ bindingEdges, importEdges, splitEdges, finalSegmentEdges }; - // FE-B: the hedge bit, allocated only when some edge is name-only (empty ⇒ every edge proven, nothing marked anywhere) + // Kind byte, allocated when some edge is name-only or a markdown link. Empty ⇒ every edge is a plain call. const auto hedged = [ & ]( std::uint64_t k ) { return nameOnlyEdges.contains( k ) && !provenEdges.contains( k ); }; - if( std::any_of( edges.begin(), edges.end(), [ & ]( const E& e ) { return hedged( ( std::uint64_t( e.from ) << 32 ) | e.to ); } ) ) + const auto marked = [ & ]( const E& e ) + { + const std::uint64_t k = ( std::uint64_t( e.from ) << 32 ) | e.to; + return hedged( k ) || markdownLinkBit( linkEdges, k ) != 0; + }; + if( std::any_of( edges.begin(), edges.end(), marked ) ) { g.outNameOnly.assign( edges.size(), 0u ); } @@ -9341,11 +9383,17 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null if( !g.outNameOnly.empty() ) { ASSUME( pos < g.outNameOnly.size(), "outNameOnly is allocated one slot per flattened edge" ); - g.outNameOnly[ pos ] = hedged( ( std::uint64_t( e.from ) << 32 ) | e.to ) ? 1u : 0u; - } - if( !g.outLink.empty() ) - { - g.outLink[ pos ] = markdownLinkBit( linkEdges, realCallEdges, ( std::uint64_t( e.from ) << 32 ) | e.to ); + const std::uint64_t key = ( std::uint64_t( e.from ) << 32 ) | e.to; + std::uint8_t kind = 0; + if( markdownLinkBit( linkEdges, key ) != 0 ) + { + kind = anchorMissed( key ) ? std::uint8_t( kEdgeLink | 0x80 ) : kEdgeLink; + } + else if( hedged( key ) ) + { + kind = kEdgeName; + } + g.outNameOnly[ pos ] = kind; } if( !g.outAnchorMiss.empty() ) { @@ -12281,6 +12329,17 @@ inline bool impactDirectLink( const Graph& g, std::span seeds, con { return n < depth.size() && depth[ n ] == 1 && oneHopRowIsLink( g, seeds, n, true ); } +inline std::string impactViaLinkNote( const std::vector& via, std::size_t begin, std::size_t end ) +{ + for( std::size_t i = begin; i < end && i < via.size(); ++i ) + { + if( via[ i ] == 2 ) + { + return viaLinkNoteJson(); + } + } + return {}; +} // via="link" plus anchor="missing" when that direct edge named no heading. The && stays here so the impact emitters do not grow a branch. inline std::string impactLinkAttrs( const Graph& g, const std::vector& proven, std::span seeds, @@ -12294,6 +12353,15 @@ inline std::string impactLinkAttrs( const Graph& g, const std::vector& pro } return attrs; } +inline std::string impactLinkLegendFor( const Graph& g, std::span seeds, const std::vector& depth, + const std::vector& show, std::size_t begin, std::size_t end ) +{ + const bool any = std::any_of( show.begin() + std::ptrdiff_t( begin ), show.begin() + std::ptrdiff_t( end ), [ & ]( NodeId n ) + { + return impactDirectLink( g, seeds, depth, n ); + } ); + return viaLinkLegend( any ); +} inline std::string impactAnchorLegendFor( const Graph& g, std::span seeds, const std::vector& depth, const std::vector& show, std::size_t begin, std::size_t end ) { diff --git a/src/graphlegend.h b/src/graphlegend.h index a5a7a4e46..5a7642be4 100644 --- a/src/graphlegend.h +++ b/src/graphlegend.h @@ -1163,13 +1163,18 @@ inline constexpr const char* kViaNameImpactClause = "An impact row reached only through such an edge carries it too; one that some all-proven path reaches stays plain. "; inline constexpr const char* kViaNameColumnarClause = "format=columnar carries it as the column (1 = via=\"name\"). "; inline constexpr const char* kViaLinkLegend = - "via=\"link\" on a row: the edge is a markdown link, not a call. A real call on the same pair keeps the row unmarked. "; + "via=\"link\" on a row: the edge is a markdown link, not a call. "; inline constexpr const char* kViaLinkColumnarClause = "format=columnar carries it as the column (2 = via=\"link\"). "; inline constexpr const char* kAnchorMissLegend = "anchor=\"missing\" on a row: the markdown link named a heading this file does not have, so the edge is the file. "; inline std::string viaLinkLegend( bool present ) { - return present ? std::string( kViaLinkLegend ) : std::string(); + std::string sentence; + if( present ) + { + sentence.assign( kViaLinkLegend ); + } + return sentence; } inline std::string anchorMissLegend( bool present ) { @@ -1240,7 +1245,7 @@ inline std::string viaLinkLegendComment() inline constexpr std::string_view kMapViaNameLegend = ""; inline constexpr std::string_view kMapViaLinkLegend = - ""; + ""; inline constexpr std::string_view kMapAnchorMissLegend = ""; inline std::string anchorMissLegendComment() diff --git a/src/ingest_docs.h b/src/ingest_docs.h index d62f0c86f..f0e88c4e9 100644 --- a/src/ingest_docs.h +++ b/src/ingest_docs.h @@ -191,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 ) { @@ -321,14 +338,7 @@ void extractMarkdown( std::uint32_t fileId, std::string_view src, std::string_vi // 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 ); - if( const std::size_t frag = r.memberPath.find( '#' ); frag != std::string::npos ) - { - for( std::size_t k = frag + 1; k < r.memberPath.size(); ++k ) - { - char& c = r.memberPath[ k ]; - c = ( c >= 'A' && c <= 'Z' ) ? char( c - 'A' + 'a' ) : c; - } - } + lowercaseMarkdownSlug( r.memberPath ); refs.push_back( std::move( r ) ); } }; diff --git a/src/main.cpp b/src/main.cpp index 493b34c6a..f80d1ee76 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2041,7 +2041,7 @@ int runDefaultMap( const MainDispatch& d ) { return {}; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); const rw::MemoryStreamBytes measured = probe.finish(); return measured.isWhole ? std::string( measured.bytes ) : std::string(); }; @@ -2054,7 +2054,7 @@ int runDefaultMap( const MainDispatch& d ) DISCLOSE( maxTokensFit, rw::MapAnnotations::MaxTokensFit::DisclosureWhy::ProbeUnmeasured, "runDefaultMap: open_memstream failed for the --max-tokens fit probe — the map is emitted unshaped and its ceiling unverified" ); return 0; } - serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); + serialize( m, ing, rank, g.outOff, g.outTargets, k, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, /*outEstTokens=*/nullptr, extraPayloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); const rw::MemoryStreamBytes measured = probe.finish(); if( !measured.isWhole ) { @@ -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, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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; @@ -2854,7 +2854,7 @@ int runDefaultMap( const MainDispatch& d ) } else { - serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); + serialize( out, ing, rank, g.outOff, g.outTargets, mapTopK, cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, cfg.stable, mapProvPtr, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, mapAutoOrder, &mapEstTokens, payloadTokens, mapAnn, /*statsFirstScreen=*/false, mapRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, g.gateDeclinedCalls, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, g.stdMemberDeclinedCalls, g.inertDeclinedCalls ); } } else @@ -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, rw::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, nullptr, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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 5c4612e56..1fd54230c 100644 --- a/src/mcpverbs.h +++ b/src/mcpverbs.h @@ -454,7 +454,7 @@ inline std::string analyzeToString( const std::string& root, int topK, bool stab // §B4 family, and mcpclidiffcheck is the gate that keeps the two surfaces one. /*ann=*/rw::MapAnnotations{ .prDisclosure = ix.prDisclosure, .codeFirstRows = ix.isCleanWorkingSet, .inventoryTier = ix.isCleanWorkingSet }, /*statsFirstScreen=*/true, anRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ), &ix.g.outAnchorMiss ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls ); } ); } // `rank_by` verb (lane/t10-mcp-coverage): the MCP twin of --rank-by=pagerank|authority|hub|rrf — the SAME @@ -522,7 +522,7 @@ inline std::string rankByText( const std::string& root, std::string_view mode, i /*extraPayloadTokens=*/0, /*ann=*/rw::MapAnnotations{ .rankByLabel = rankByLabel, .prDisclosure = disclosure, .codeFirstRows = rankByLabel == nullptr, .inventoryTier = rankByLabel == nullptr }, // pagerank: the map scope's code-first pick /*statsFirstScreen=*/true, rbRootArg, &ix.g.locPinOut, ix.g.externalCalls, &ix.g.declinedOut, ix.g.gateDeclinedCalls, - ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls, rw::linkOutPtr( ix.g ), &ix.g.outAnchorMiss ); } ); + ix.g.outNameOnly.empty() ? nullptr : &ix.g.outNameOnly, ix.g.stdMemberDeclinedCalls, ix.g.inertDeclinedCalls ); } ); } // ─── the cross-branch + dark-content MCP twins (`whereis`, `stray_content`, `flags`) ─── @@ -723,6 +723,35 @@ 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 ) +{ + if( referencingOnly && pwPrimary.end < rowTotal ) + { + return nextFieldJson( callersPageNext( name, page.limit, pwPrimary.end ) ) + + nextFieldJson( nextFlag( "--uses=", nextSelector ), "uses_next" ); + } + return nextFieldJson( nextFlag( referencingOnly ? "--uses=" : "--expand=", nextSelector ) ); +} + +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 = {} ) { @@ -852,9 +881,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) - + ( referencingOnly && pwPrimary.end < rowTotal // L8: a capped callers page names the next page, the uses verb as uses_next (the CLI root's pair) - ? nextFieldJson( callersPageNext( name, page.limit, pwPrimary.end ) ) + nextFieldJson( nextFlag( "--uses=", chNextSelector ), "uses_next" ) - : nextFieldJson( nextFlag( referencingOnly ? "--uses=" : "--expand=", chNextSelector ) ) ); // P3 (L7): the CLI root's next= (mcpattrparitycheck) + + 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 ); @@ -887,18 +914,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 - } - if( anyLink ) - { - out += rw::viaLinkNoteJson(); - } - if( anyMiss ) - { - out += rw::anchorMissNoteJson(); - } + 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 @@ -2233,7 +2249,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, linkOutPtr( in.g ), &in.g.outAnchorMiss }, + /*viaLegendInHead=*/false, /*compress=*/false, &in.g.outProv, /*splitLegendInHead=*/false, &in.g.outAnchorMiss }, in.slotOwner, share ); } mcpForSoftRefill( in, renderSigs, sigsStr, mcpSigsCut, &mcpSigsNext, @@ -3605,6 +3621,32 @@ 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 ) ); +} + +inline void emitPathHopRows( std::FILE* mem, const IngestResult& ing, const Graph& g, std::span pth, + bool singleRoot, std::string_view rootPrefix ) +{ + std::vector esc; + for( std::size_t i = 0; i < pth.size(); ++i ) + { + const Symbol& s = ing.symbols[ pth[ i ] ]; + const std::string_view rp = singleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], rootPrefix ) + : std::string_view( ing.files[ s.fileId ] ); + const bool name = i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ); + const bool link = i > 0 && edgeIsLink( g, pth[ i - 1 ], pth[ i ] ); + const std::string mark = std::string( viaEdgeAttr( name, link ) ) + + anchorMissAttr( i > 0 && edgeAnchorMiss( g, pth[ i - 1 ], pth[ i ] ) ); + rw::emitTo( mem, "", symTag( s.kind ), + std::string( escapeXml( s.name, esc ) ).c_str(), std::string( escapeXml( rp, esc ) ).c_str(), s.line, + mark.c_str(), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); + } +} + // `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), @@ -3658,15 +3700,11 @@ 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 bool ptHasVia = pathHasNameOnlyHop( g, pth ); // FE-B: exactly when a hop of this path is via="name", as on the CLI - const bool ptHasLink = pathHasLinkHop( g, pth ); - const bool ptHasMiss = std::adjacent_find( pth.begin(), pth.end(), - [ & ]( NodeId from, NodeId to ) { return edgeAnchorMiss( g, from, to ); } ) != pth.end(); - const PathEndpointBinding ptBound = pathEndpointBinding( g, endpointDefinitionCount( ing, srcDefs ), endpointDefinitionCount( ing, dstDefs ), pth ); // attrib-multidef, 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; rw::emitTo( mem, "{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + viaNameLegend( ptHasVia ) - + viaLinkLegend( ptHasLink ) + anchorMissLegend( ptHasMiss ) + "graph holds none. {}{}{}{}-->{}", ( unprovenDefsVerbLegend( UnprovenDefsVerb::Path, unprovenDefs > 0 ) + ptKindLegend + splitArmLegend( ptHasSplit ) + pathUnboundLegend( !ptBound.from || !ptBound.to ) ).c_str(), ( std::string( toValueRefsLegend( ptToValueRefs.count > 0 ) ) + valueRefsDepthLegend( ptToValueRefs.depthCut.files > 0 ) ).c_str(), pathGapsLegend( ptGaps.any() ), @@ -3683,12 +3721,7 @@ inline std::optional pathText( const std::string& root, const std:: ? pathUnreachedXml( ing, ptGaps, ptSingleRoot, ptRootPrefix, { "the connect verb on " + from + "," + to, "uses/impact", "" } ) : PathGapsXml{}; rw::emitTo( mem, "{}>{}", ptUnreached.rootAttrs, ptUnreached.rows ); - for( std::size_t i = 0; i < pth.size(); ++i ) - { const Symbol& s = ing.symbols[ pth[i] ]; - const std::string_view rp = ptSingleRoot ? sarif::rootRelativeUri( ing.files[ s.fileId ], ptRootPrefix ) : std::string_view( ing.files[ s.fileId ] ); - rw::emitTo( mem, "", symTag( s.kind ), ex( s.name ).c_str(), ex( rp ).c_str(), s.line, - std::string( viaEdgeAttr( i > 0 && edgeNameOnly( g, pth[ i - 1 ], pth[ i ] ), i > 0 && edgeIsLink( g, pth[ i - 1 ], pth[ i ] ) ) ) - + anchorMissAttr( i > 0 && edgeAnchorMiss( g, pth[ i - 1 ], pth[ i ] ) ), splitArmAttrXml( pathHopSplitArm( g, pth, i ) ) ); } // FE-B; a link hop says via="link" + emitPathHopRows( mem, ing, g, pth, ptSingleRoot, ptRootPrefix ); rw::emitRaw( mem, "" ); return mcpAnswerText( stream ); // nullopt = the buffer lost bytes (dispatch answers -32603), "" stays not-found } diff --git a/src/pathgaps.h b/src/pathgaps.h index 5c682abb9..4d002303e 100644 --- a/src/pathgaps.h +++ b/src/pathgaps.h @@ -106,14 +106,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 ); } @@ -156,7 +156,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/serialize.h b/src/serialize.h index 2c22a9a2b..2bb22ddb9 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -2635,25 +2635,46 @@ inline std::uint32_t counterAt( const std::vector* v, NodeId id ) return ( v && id < v->size() ) ? ( *v )[ id ] : 0u; } -// 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 -// (graphlegend.h kMapViaNameLegend / compactlegend.h's x row). Every other row, prov="split" arms included, is written as -// before. `scratch` is reused across symbols. -inline void writeMapAnchorAttr( XmlWriter& w, const std::vector* bits, std::uint32_t e ) +// 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( bits != nullptr && e < bits->size() && ( *bits )[ e ] != 0 ) + 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 +// (graphlegend.h kMapViaNameLegend / compactlegend.h's x row). Every other row, prov="split" arms included, is written as +// before. `scratch` is reused across symbols. inline void writeMapCalleeRows( XmlWriter& w, const IngestResult& ing, std::uint32_t from, std::uint32_t to, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, std::vector& esc, - std::vector& scratch, const std::vector* outLink = nullptr, const std::vector* outAnchor = nullptr ) + 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 link = [ & ]( std::uint32_t e ) { return outLink && e < outLink->size() && ( *outLink )[ 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 ) { @@ -2691,33 +2712,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( link( e ) ) - { - w.write( "\" via=\"link" ); // a pinned markdown link, and no real call on this pair - } - else 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 ) ); - } - writeMapAnchorAttr( w, outAnchor, e ); + writeMapCalleeAttrs( w, provOf( e ), link( e ), nameOnly( e ), repeats, ( stored( e ) & 0x80 ) != 0 ); w.write( "\"/>" ); } } // 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 ) noexcept + std::span nodes, std::uint8_t want = 0 ) noexcept { for( const NodeId n : nodes ) { @@ -2727,7 +2731,8 @@ inline bool markedOutAny( const std::vector& outOff, const std::v } for( std::uint32_t e = outOff[ n ]; e < outOff[ n + 1 ] && e < bits.size(); ++e ) { - if( bits[ e ] != 0 ) + const bool hit = want == 0 ? bits[ e ] != 0 : ( want == 0x80 ? ( bits[ e ] & 0x80 ) != 0 : edgeKindByte( bits[ e ] ) == want ); + if( hit ) { return true; } @@ -2735,11 +2740,6 @@ inline bool markedOutAny( const std::vector& outOff, const std::v } return false; } -inline bool linksOutAny( const std::vector& outOff, const std::vector& outLink, - std::span nodes ) noexcept -{ - return markedOutAny( outOff, outLink, nodes ); -} // 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 @@ -2747,7 +2747,7 @@ inline bool linksOutAny( const std::vector& outOff, const std::ve inline bool namesOnlyOutAny( const std::vector& outOff, const std::vector& outNameOnly, std::span nodes ) noexcept { - return markedOutAny( outOff, outNameOnly, nodes ); + 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 @@ -2761,7 +2761,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; } @@ -2788,7 +2788,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 @@ -2807,9 +2807,7 @@ template inline void writeMapInventoryXml( XmlWriter& w, const IngestResult& ing, const MapInventory& inv, const PathRel& pathRel, const std::vector& outOff, const std::vector& outTargets, const std::vector* outProv, const std::vector* outNameOnly, - std::vector& esc, std::vector& scratch, - const std::vector* outLink = nullptr, - const std::vector* outAnchor = nullptr ) + std::vector& esc, std::vector& scratch ) { EXPECTS( inv.active, "the caller emits the tier only when it has a cut to inventory" ); w.write( "" ); - writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch, outLink, outAnchor ); + writeMapCalleeRows( w, ing, outOff[ id ], outOff[ id + 1 ], outTargets, outProv, outNameOnly, esc, scratch ); w.write( "" ); } for( const MapInventoryDir& d : inv.dirs ) @@ -2920,11 +2918,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto std::size_t stdMemberDeclinedCalls = 0, // of declined=, the Elixir call candidates inside inert quote AST (g.inertDeclinedCalls, #380) → // the kInertDeclinedMapLegend clause, absent when zero. - std::size_t inertDeclinedCalls = 0, - // Markdown link edges (Graph::outLink) → via="link" on a row and kMapViaLinkLegend. nullptr/empty - // ⇒ the map has no link edge and stays byte-identical. - const std::vector* outLink = nullptr, - const std::vector* outAnchor = nullptr ) + std::size_t inertDeclinedCalls = 0 ) { const std::size_t* changedCount = ann.changedCount; const std::string* mapAtStamp = ann.atStamp; @@ -3179,13 +3173,13 @@ 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( outLink && !stubbed && !ann.viaLegendStripped - && linksOutAny( outOff, *outLink, std::span( order.data(), std::min( keep, order.size() ) ) ) ) + if( outNameOnly && !stubbed && !ann.viaLegendStripped + && markedOutAny( outOff, *outNameOnly, std::span( order.data(), std::min( keep, order.size() ) ), kEdgeLink ) ) { legend += kMapViaLinkLegend; } - if( outAnchor && !stubbed && !ann.viaLegendStripped - && markedOutAny( outOff, *outAnchor, std::span( order.data(), std::min( keep, order.size() ) ) ) ) + if( outNameOnly && !stubbed && !ann.viaLegendStripped + && markedOutAny( outOff, *outNameOnly, std::span( order.data(), std::min( keep, order.size() ) ), 0x80 ) ) { legend += kMapAnchorMissLegend; } @@ -3483,7 +3477,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto { if( inventory.active ) { - writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink, outAnchor ); + writeMapInventoryXml( w, ing, inventory, pathRel, outOff, outTargets, outProv, outNameOnly, esc, hedgedScratch ); } }; const bool inventoryFirst = !stable && effImportantLast; // --stable's path order wins, as it does for the rows @@ -3704,7 +3698,7 @@ inline void serialize( std::FILE* out, const IngestResult& ing, const std::vecto } w.write( ">" ); - writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch, outLink, outAnchor ); + writeMapCalleeRows( w, ing, outOff[id], outOff[id + 1], outTargets, outProv, outNameOnly, esc, hedgedScratch ); w.write( "" ); } w.write( "" ); @@ -6736,10 +6730,20 @@ struct MergedCalleeNameRow 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, bool link = false, - bool miss = 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; @@ -6816,8 +6820,8 @@ struct CalleeEdgeBytes 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, const std::vector* linkBytes = nullptr ) noexcept - : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ), link( linkBytes ) {} + bool* charged = nullptr ) noexcept + : nameOnly( nameOnlyBytes ), prov( provBytes ), splitLegendCharged( charged ) {} }; inline CalleeEdgeBytes withAnchorEdge( CalleeEdgeBytes edges, const std::vector* anchor ) noexcept { @@ -6911,6 +6915,60 @@ inline void applyCalleeAnchorMiss( const CalleeCallsSink& sink, const std::vecto } } +// 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- @@ -6924,7 +6982,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; @@ -6945,50 +7002,10 @@ inline void emitCalleeCallsBlock( std::string& out, NodeId id, const std::vector 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() ) + if( appendOneCalleeCall( callsBody, nameRows, cid, id, outOff, outTargets, ing, contentOf, esc, used, sink, + linkLegendCharged, anchorLegendCharged ) ) { - continue; - } - const Symbol& cs = ing.symbols[cid]; - const bool linkRow = sink.link != nullptr && edgeNameOnlyInCsr( outOff, outTargets, *sink.link, id, cid ); - 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 ); // Q1: prov="split" or "" - - // COMPACT: the names-only rendering — see collectCalleeNameRow above for what it does and does not do. - if( sink.namesOnly ) - { - collectCalleeNameRow( nameRows, cs, used, sink, nameRow, !splitAttr.empty(), linkRow ); - applyCalleeAnchorMiss( sink, outOff, outTargets, id, cid, anchorLegendCharged, used, &nameRows, cs.name, linkRow, nullptr ); ++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) @@ -7974,7 +7991,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, withAnchorEdge( CalleeEdgeBytes( outNameOnly, outEdges.prov, &splitLegendCharged, outEdges.link ), outEdges.anchor ), + 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; diff --git a/src/verbs_for.h b/src/verbs_for.h index cea67a1ee..75eb11e69 100644 --- a/src/verbs_for.h +++ b/src/verbs_for.h @@ -1981,7 +1981,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::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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 ); @@ -2003,7 +2003,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::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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 ); @@ -2409,7 +2409,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::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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 @@ -3725,7 +3725,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::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), 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 ); @@ -3779,7 +3779,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, rw::linkOutPtr( g ), &g.outAnchorMiss }, + headerParts.viaPresent, cfg.compress, &g.outProv, headerParts.splitPresent, &g.outAnchorMiss }, slotOwner, forSoftTarget ? std::optional( rw::forSoftSectionsShare( bundleBudget, slotSpent ) ) : std::nullopt ); if( slot.served ) @@ -3999,7 +3999,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::withAnchorEdge( rw::CalleeEdgeBytes( g.outNameOnly, &g.outProv, nullptr, rw::linkOutPtr( g ) ), &g.outAnchorMiss ), + 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 06df64eef..eb1d08304 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,8 +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 ] == 2 ) ? ",\"via\":\"link\"" - : ( ( viaName && i < viaName->size() && ( *viaName )[ i ] == 1 ) ? ",\"via\":\"name\"" : "" ), // 2 = markdown link, 1 = name hedge + jsonViaField( viaName, i ), jsonRowKeysAt( rowKeys, i ) ); } } @@ -138,6 +158,99 @@ 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 ) +{ + 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 ); + 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 + + 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 ); + 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, &chDcRows } ); + 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::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::callersPageNext( sym, cfg.pageLimit, pw.end, false, true ) ) + + rw::nextFieldJson( chHintCall, "uses_next" ) + : 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 ), + 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,11 +382,7 @@ std::optional runCallHierarchy( const MainDispatch& d ) 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 ); } - bool chHasMiss = false; - for( std::size_t i = pw.begin; i < pw.end && !chHasMiss; ++i ) - { - chHasMiss = oneHopRowIsLink( g, matches, result[ i ], wantCallers, &g.outAnchorMiss ); - } + const bool chHasMiss = rw::pageHasAnchorMiss( g, matches, result, pw.begin, pw.end, wantCallers ); rw::emitTo( stdout, "{}{}{}{}{}{}{}-->{}{}", rw::callHierarchyLegendOpen( wantCallers, chNextIsBare, cfg.columnar, chNextIsPage ).c_str(), rw::capLegendClause( rw::computePageDisclosure( pw.end - pw.begin, result.size(), pw.end, cfg.pageLimit, cfg.pageOffset, chDiscloseCap ).active ), @@ -317,113 +426,14 @@ std::optional runCallHierarchy( const MainDispatch& d ) // Reference-as-value round: value_refs= beside count= (never in it), absent at zero; the window after the rows. 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 - const std::string chVrNext = "--uses=" + std::string( sym ); - - // --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 ) + const std::string chVrNext = "--uses=" + std::string( sym ); + if( cfg.columnar || cfg.json ) { - 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) - + 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 ) ); - } - } - for( std::size_t i = 0; i < page.size(); ++i ) - { - if( oneHopRowIsLink( g, matches, result[ pw.begin + i ], wantCallers ) ) - { - pageVia[ i ] = 2; - } - } - const std::string chDefRows = rw::defTableXml( ing, chAttr.groups, chListing, chRootPrefix ); - 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, &chDcRows } ); // + the rows verbatim - return 0; + 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 ); } - // 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::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::callersPageNext( sym, cfg.pageLimit, pw.end, false, true ) ) - + rw::nextFieldJson( chHintCall, "uses_next" ) - : std::string() ); - rw::emitTo( stdout, ",\"{}\":[", tag ); - std::vector chVia( result.size(), 0 ); // FE-B: the same hedge bit the XML rows carry - bool chHasLink = false; - 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 ] = oneHopRowIsLink( g, matches, result[ i ], wantCallers ) ? 2 : ( chAttr.nameOnly( i ) ? 1 : 0 ); - 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 ) ); // attrib-multidef - 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 ), // the XML rows, keys = its attribute names - rw::valueRefsJson( ing, chRows.valueRefs, wantCallers, chVr, "vrs", chVrNext ), - chHasLink ? rw::viaLinkNoteJson() : std::string() ); - 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. @@ -2456,9 +2466,8 @@ std::optional runPath( const MainDispatch& d ) const rw::ToValueRefs pthToValueRefs = path.empty() ? rw::toValueRefs( ing, true, dstDefs, &*pthValueIdx ) : rw::ToValueRefs{}; const rw::PathSearchGaps pthGaps = path.empty() ? rw::pathSearchGaps( ing, g, srcDefs, *pthValueIdx ) : rw::PathSearchGaps{}; const bool pthHasVia = rw::pathHasNameOnlyHop( g, path ); // FE-B: exactly when a hop of THIS path is via="name" - const bool pthHasLink = rw::pathHasLinkHop( g, path ); - const bool pthHasMiss = std::adjacent_find( path.begin(), path.end(), - [ & ]( rw::NodeId from, rw::NodeId to ) { return rw::edgeAnchorMiss( g, from, to ); } ) != path.end(); + 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; @@ -2774,8 +2783,9 @@ int emitImpactJson( const ImpactView& v ) 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::valueRefsJson( v.ing, v.valueRefs, true, rw::VrRender{ v.singleRoot, v.rootPrefix }, "vrs", - "--uses=" + std::string( v.sym ) ) ); + const std::string imLinkNote = rw::impactViaLinkNote( via, v.page.begin, v.page.end ); + rw::emitTo( stdout, "{}{}}}", imLinkNote.c_str(), rw::valueRefsJson( v.ing, v.valueRefs, true, rw::VrRender{ v.singleRoot, v.rootPrefix }, "vrs", + "--uses=" + std::string( v.sym ) ).c_str() ); return 0; } @@ -2823,6 +2833,44 @@ 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 ) +{ + 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::declinedCallsLegendWithGates( imDeclinedCalls > 0, g.gateDeclinedCalls > 0, g.stdMemberDeclinedCalls > 0 ) + rw::declinedSitesImpactPointer( imDeclinedCalls > 0 ), + 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 ) ).c_str(), + rw::graphCountDisclosure( rw::graphGaugeClauses( g ) ).c_str(), rw::renderDisclosure( prD, rw::DiscloseAs::LegendClause ).c_str() ); +} + std::optional runImpact( const MainDispatch& d ) { using namespace rw; @@ -2908,46 +2956,8 @@ std::optional runImpact( const MainDispatch& d ) const rw::ValueRefIndex imVri( ing ); const rw::ValueRefRows imValueRefs = rw::valueRefCallerRows( ing, imVri, seeds ); - 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 ) - + rw::viaLinkLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, - [ & ]( NodeId n ) { return rw::impactDirectLink( g, seeds, imDepth, n ); } ) ) - + rw::anchorMissLegend( std::any_of( show.begin() + imPage.begin, show.begin() + imPage.end, - [ & ]( NodeId n ) - { return rw::impactDirectLink( g, seeds, imDepth, n ) - && rw::oneHopRowIsLink( g, seeds, n, true, &g.outAnchorMiss ); } ) ) ).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::declinedCallsLegendWithGates( imDeclinedCalls > 0, g.gateDeclinedCalls > 0, g.stdMemberDeclinedCalls > 0 ) + rw::declinedSitesImpactPointer( imDeclinedCalls > 0 ), // exactly when the root carries declined_calls= - 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 ) ).c_str(), // ... and the depth disclosure - 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 ); // 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 @@ -3133,7 +3143,7 @@ std::optional runAround( const MainDispatch& d ) aroundAnn.seed = { ing.symbols[ focus ].name, cfg.aroundDepth, cfg.aroundFanout, definitionCountOfName( ing, focus ), eg.fanoutCut, eg.depthTruncated }; aroundAnn.seed.unprovenDefs = arUnprovenDefs; // H1: unproven_defs= beside defs= on the root, its clause in the map legend - serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls, rw::linkOutPtr( g ), &g.outAnchorMiss ); + serialize( stdout, ing, rank, g.outOff, g.outTargets, int( eg.nodes.size() ), cfg.mostImportantLast, cfg.metrics, fanInPtr, &g.ambOut, false, g.outProv.empty() ? nullptr : &g.outProv, cboPtr, testedPtr, lcom4Ptr, ampPtr, &g.unresolvedOut, g.bindLabel.empty() ? nullptr : &g.bindLabel, /*autoOrder=*/false, /*outEstTokens=*/nullptr, aroundCompose.tokens + aroundRoutes.tokens + wrap.tokens, aroundAnn, /*statsFirstScreen=*/false, aroundRootArg, &g.locPinOut, g.externalCalls, &g.declinedOut, /*gateDeclinedCalls=*/0, g.outNameOnly.empty() ? nullptr : &g.outNameOnly, /*stdMemberDeclinedCalls=*/0, g.inertDeclinedCalls ); if( !g.composeEdges.empty() ) { diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index 70980a32e..522bf5963 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -499,11 +499,16 @@ printf '%s' "$IM" | grep -q 'reaches="2"' \ && ! 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" +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"' \ @@ -516,6 +521,18 @@ printf '%s' "$API" | grep -q 'n="Hub"[^>]*via="link"' \ && ! 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" From 4438031c9324a9d761e726d697cb2bfbe59b3b7d Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Mon, 19 Oct 2026 05:41:32 +0300 Subject: [PATCH 13/18] Read a markdown section link from a language fact, so the shared Lang:: count stays under its limit. Re-pin the golden, the printf hashes, and the compact legend sizes for the via="link" rows. --- src/graph.h | 12 ++++++------ src/langfacts.h | 5 ++++- src/serialize.h | 4 ++-- test/compactlegendcheck.sh | 21 ++++++++++++++------- test/compactlegendfix/pre_l1_full/map.xml | 2 +- test/fillordercheck.sh | 4 +++- test/golden.xml | 2 +- test/mdsectioncheck.sh | 2 +- test/printf_parity.manifest | 4 ++-- test/regression.sh | 3 ++- 10 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/graph.h b/src/graph.h index 73a5f0eb9..8091c89c1 100644 --- a/src/graph.h +++ b/src/graph.h @@ -6455,7 +6455,7 @@ struct MdLinkIndex inline bool isMarkdownLinkRef( const Reference& r ) noexcept { - if( r.lang != Lang::Markdown || r.isDocLink || r.memberCall ) + if( !langFacts( r.lang ).linkSections || r.isDocLink || r.memberCall ) { return false; } @@ -6466,7 +6466,7 @@ inline bool isMarkdownLinkRef( const Reference& r ) noexcept // path, which an in-file anchor and a wikilink do not have. A markdown doc mention is neither. inline void noteMarkdownLinkSite( HashMap& linkEdges, std::uint64_t ekey, const Reference& r ) { - if( r.lang == Lang::Markdown && !r.isDocLink ) + if( langFacts( r.lang ).linkSections && !r.isDocLink ) { linkEdges[ ekey ] = 1; } @@ -6505,7 +6505,7 @@ inline std::vector markdownWidestEnd( const IngestResult& ing ) std::vector widest( ing.files.size(), 0 ); for( const Symbol& sym : ing.symbols ) { - if( sym.lang == Lang::Markdown && sym.kind == SymKind::Section && sym.fileId < ing.files.size() ) + if( langFacts( sym.lang ).linkSections && sym.kind == SymKind::Section && sym.fileId < ing.files.size() ) { widest[ sym.fileId ] = std::max( widest[ sym.fileId ], sym.endByte ); } @@ -6520,7 +6520,7 @@ inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) const std::vector widest = markdownWidestEnd( ing ); for( const Symbol& sym : ing.symbols ) { - if( sym.lang != Lang::Markdown || sym.kind != SymKind::Section || sym.fileId >= ing.files.size() ) + if( !langFacts( sym.lang ).linkSections || sym.kind != SymKind::Section || sym.fileId >= ing.files.size() ) { continue; } @@ -7806,7 +7806,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null bool anyMarkdownLink = false; for( const Reference& r : ing.references ) { - if( r.lang == Lang::Markdown && !r.isDocLink ) + if( langFacts( r.lang ).linkSections && !r.isDocLink ) { anyMarkdownLink = true; break; @@ -11331,7 +11331,7 @@ inline FieldUseAnswer collectFieldUseSites( const IngestResult& ing, FieldId fie for( std::uint32_t refIndex = 0; refIndex < ing.references.size(); ++refIndex ) { const Reference& r = ing.references[ refIndex ]; - if( ( r.role != RefRole::Read && r.role != RefRole::Write ) || r.isCompose || r.isDocLink || r.lang == Lang::Markdown ) + if( ( r.role != RefRole::Read && r.role != RefRole::Write ) || r.isCompose || r.isDocLink || langFacts( r.lang ).linkSections ) { continue; } diff --git a/src/langfacts.h b/src/langfacts.h index 7d0105cc2..38b432dfb 100644 --- a/src/langfacts.h +++ b/src/langfacts.h @@ -76,6 +76,9 @@ struct LangFacts /// judged against the base's whole set. A changed C or Objective-C file does not need it, by today's behaviour /// (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; }; /// The rows, one per Lang in enum order. A language says only the facts it has. @@ -87,7 +90,7 @@ inline constexpr std::array kLangFacts = { { { .lang = Lang::Rust }, { .lang = Lang::Swift }, { .lang = Lang::ObjC }, - { .lang = Lang::Markdown }, + { .lang = Lang::Markdown, .linkSections = true }, { .lang = Lang::JavaScript }, { .lang = Lang::Bash }, { .lang = Lang::Java }, diff --git a/src/serialize.h b/src/serialize.h index 2bb22ddb9..f05bcf96d 100644 --- a/src/serialize.h +++ b/src/serialize.h @@ -7760,7 +7760,7 @@ 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( children.find( " via=\"link\"" ) != std::string::npos ) + if( !viaLegendInHead && children.find( " via=\"link\"" ) != std::string::npos ) { w.write( viaLinkLegendComment() ); } @@ -8060,7 +8060,7 @@ 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( children.find( " via=\"link\"" ) != std::string::npos ) + if( !viaLegendInHead && children.find( " via=\"link\"" ) != std::string::npos ) { w.write( viaLinkLegendComment() ); } diff --git a/test/compactlegendcheck.sh b/test/compactlegendcheck.sh index 030edca98..345204da4 100755 --- a/test/compactlegendcheck.sh +++ b/test/compactlegendcheck.sh @@ -119,8 +119,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 @@ -318,6 +322,8 @@ echo "=== (A-PIN) --legend=full is BYTE-IDENTICAL to the pre-L1 default (pinned # --for file page (rows with sym=), its full legend clause defines them, and est_tokens= prices them, 4111 -> 4500 B. Checked # mechanically: with the element, the tail clause and est_tokens= masked, the new answer equals the previous pin byte for # byte (at= masked). 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. @@ -586,12 +592,13 @@ probeFor() # probe; 595 on main 0852bc0f). The probe's contract is unchanged with nothing flagged, so the answer is now the verdict # line (no rows), and the compact legend reads its two new facts: rows_next= (the --limit call listing the callers) and # callers= as the c rows' total (a floor). Nothing else moved. +# 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 @@ -632,7 +639,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 @@ -651,7 +658,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 820 802 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/mdsectioncheck.sh b/test/mdsectioncheck.sh index 522bf5963..bf57e12a1 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -544,7 +544,7 @@ grep -q '^## Install Steps Renamed$' "$TMP/anchorcopy/docs/b/partner.md" || no " $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 -grep -q 'n="partner"' "$TMP/ac_warm" && ok "…and the dead anchor degraded to the partner file edge" || no "the renamed-heading link lost its edge on the warm run" +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 diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index c1821a140..c5cfba42e 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 fe032ed241174b3892bd9369cbc3d3b904d9e6f681cc307c27c476b6a28ea59e 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/regression.sh b/test/regression.sh index c8ff2d3c4..61d2399b0 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 From 3a3f0a6156e49c1a1f7e33bf845b318c99029c97 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Mon, 19 Oct 2026 01:26:39 -0400 Subject: [PATCH 14/18] chore(versions): kParserVer 206 after the merge with main; re-pin qschemetrip The merge renumbered kParserVer (and quality.h's mirror) to 206, rich file tag 207, above every live number: main 196, train 31 198, and the branch reservations 202 and 204. The PR's own 185 is below main's 196, so it had to move. qschemetripcheck watches those lines; the only watched text that moved is the version, so the hash is re-pinned with a log line. kCacheVersion stays 36. The CHANGELOG versions note says 206 where it said 196. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++------- test/qschemetrip.hash | 2 +- test/qschemetripcheck.sh | 4 ++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433b46d04..a97a39456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,7 +153,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 (196 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 @@ -163,7 +163,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 (196 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]` @@ -177,7 +177,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 196 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 @@ -205,7 +205,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 -(196 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`) @@ -452,14 +452,15 @@ server, when the cgroup's charge is the larger reading: restarting the server do ### Changed — the versions this release moves, stated once -`kParserVer` 167 → 196: 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, and a branch not in this release 194 (195). This release is 196, 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 197. +branches, and branches not in this release 194 (195), 198 (199), 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/test/qschemetrip.hash b/test/qschemetrip.hash index 7abac5d07..2ccfe2fbb 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -5af7514356283527ea8833c20b3f63578e930bc6e7d7438e6f7b4d4d4f3726b4 +a667ffc55eff871ff6ea06c1068cc5c73c8fe06b5a582beb87dae733548f8a05 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index ed58efa73..7dfa58851 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -34,6 +34,10 @@ 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 939dbb69: RE-PINNED with UPDATE_GOLDEN=1 (hash +# a667ffc55e…8a05). 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 196 / 197 and the live branch builds 198, 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. # 2026-10-18, train 30 (call-result-typing, go-local-var, fcall-forward, name-template-dispatch, tail-sym, path-owners merged # onto main b59b0824): RE-DERIVED ONCE with UPDATE_GOLDEN=1 on the merged tree (hash 5af7514356…3726b4). Watched text: # kParserVer -> 196 (lanes call-result-typing 180 / rich 181, fcall-forward 188 / 189, go-local-var 190 / 191, From 2254f8e68ff32e4cc942fcda2fe8597aa6742a20 Mon Sep 17 00:00:00 2001 From: Muayyad Alsadi Date: Mon, 19 Oct 2026 11:30:46 +0300 Subject: [PATCH 15/18] Keep the farthest section end inside the section index, so quality-delta no longer pairs that walk with the document-file mask. --- src/graph.h | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/graph.h b/src/graph.h index 72798b132..5bef59f30 100644 --- a/src/graph.h +++ b/src/graph.h @@ -6505,8 +6505,10 @@ inline bool isMarkdownFileNode( const Symbol& sym, std::uint32_t widestEnd ) noe return sym.sigStartByte == 0 && sym.sigEndByte >= sym.endByte && sym.endByte >= widestEnd; } -inline std::vector markdownWidestEnd( const IngestResult& ing ) +// Headings, and each file's own node (the first matching symbol in id order). The file node is not a heading. +inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) { + ix.wholeFile.assign( ing.files.size(), kNoNode ); std::vector widest( ing.files.size(), 0 ); for( const Symbol& sym : ing.symbols ) { @@ -6515,14 +6517,6 @@ inline std::vector markdownWidestEnd( const IngestResult& ing ) widest[ sym.fileId ] = std::max( widest[ sym.fileId ], sym.endByte ); } } - return widest; -} - -// Headings, and each file's own node (the first matching symbol in id order). The file node is not a heading. -inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) -{ - ix.wholeFile.assign( ing.files.size(), kNoNode ); - const std::vector widest = markdownWidestEnd( ing ); for( const Symbol& sym : ing.symbols ) { if( !langFacts( sym.lang ).linkSections || sym.kind != SymKind::Section || sym.fileId >= ing.files.size() ) From cddf0f0fe96e94786caeaa532421c77c92439975 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Mon, 19 Oct 2026 11:46:35 -0400 Subject: [PATCH 16/18] Mark anchor="missing" only when the slug is provably absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark said a link named a heading the file does not have, but the slug function is ASCII-only and a file that is one heading line has its heading as the file node, so it also fired on headings that exist: a non-ASCII slug (#überblick, #概要), a percent-encoded one (#real%20heading, #%C3%BCberblick), GitHub's duplicate suffix (#dup-1 for a second ## Dup), and one.md#title for a file that is just `# Title`. Those keep the file edge and carry no mark. A plain ASCII slug that no heading has is still marked. Nine arms in test/mdsectioncheck.sh: six unmarked (each fails on 2254f8e6) and three that keep the mark. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- src/graph.h | 44 ++++++++++++++++++++++++++++++++++++------ src/infra/mdslug.h | 14 +++++++++++++- test/mdsectioncheck.sh | 29 ++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16917d791..95e1ba1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ the path against the linking file, so `../b/notes.md` never lands on a same-name 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"`. Columnar `` is `2` for a direct markdown link and `1` for a name-only edge. Gate: `test/mdsectioncheck.sh`. +`--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) diff --git a/src/graph.h b/src/graph.h index 469a81549..7399c883f 100644 --- a/src/graph.h +++ b/src/graph.h @@ -6492,6 +6492,7 @@ struct MdLinkIndex HashMap fileByPath; // every .md/.markdown file, by lexicalNormalize(ing.files). A `.`-rooted crawl stores `./docs/a.md`; the lookup key does not. An absolute root stores the absolute path, and the lookup joins to that same absolute path. std::vector headings; // sorted (fileId, slug, startByte, id): the first of a slug is its heading std::vector wholeFile; // per file: the file-level Section node, kNoNode when none + std::vector wholeSlug; // parallel to wholeFile: that node's own slug (a file that is one heading line IS that heading) }; inline bool isMarkdownLinkRef( const Reference& r ) noexcept @@ -6545,6 +6546,7 @@ inline bool isMarkdownFileNode( const Symbol& sym, std::uint32_t widestEnd ) noe inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) { ix.wholeFile.assign( ing.files.size(), kNoNode ); + ix.wholeSlug.assign( ing.files.size(), std::string() ); std::vector widest( ing.files.size(), 0 ); for( const Symbol& sym : ing.symbols ) { @@ -6564,6 +6566,7 @@ inline void indexMarkdownSections( MdLinkIndex& ix, const IngestResult& ing ) if( ix.wholeFile[ sym.fileId ] == kNoNode ) { ix.wholeFile[ sym.fileId ] = sym.id; + ix.wholeSlug[ sym.fileId ] = mdSlugOf( sym.name ); } continue; } @@ -6597,6 +6600,38 @@ struct MarkdownLinkTarget bool slugMiss = false; }; +// The first heading of `file` whose slug is `slug` (headings are sorted by (file, slug, start)), else headings.end(). +inline std::vector::const_iterator findMarkdownHeading( const MdLinkIndex& ix, std::uint32_t file, std::string_view slug ) +{ + using Key = std::pair; + const auto hit = std::lower_bound( ix.headings.begin(), ix.headings.end(), Key{ file, slug }, + []( const MdLinkIndex::Heading& h, const Key& key ) { return Key{ h.fileId, h.slug } < key; } ); + return hit != ix.headings.end() && hit->fileId == file && hit->slug == slug ? hit : ix.headings.end(); +} + +// A link's slug that matched no heading is a CLAIM of absence only when mdSlugOf could have matched it. It cannot decide: +// a byte >= 0x80 or a '%' (non-ASCII and encoded slugs); a "-" suffix whose stem names a heading (GitHub's +// duplicate numbering, `#dup-1` for a second `## Dup`); a file that is one heading line, whose heading IS the file node +// (the slug equals the node's own). Those keep the file edge and carry no anchor="missing". +inline bool mdSlugProvablyAbsent( const MdLinkIndex& ix, std::uint32_t file, std::string_view slug ) +{ + if( slug.empty() || mdSlugOutsideModel( slug ) ) + { + return false; + } + const std::size_t dash = slug.find_last_of( '-' ); + if( dash != std::string_view::npos && dash > 0 && dash + 1 < slug.size() + && slug.find_first_not_of( "0123456789", dash + 1 ) == std::string_view::npos + && findMarkdownHeading( ix, file, slug.substr( 0, dash ) ) != ix.headings.end() ) + { + return false; + } + const auto first = std::lower_bound( ix.headings.begin(), ix.headings.end(), file, + []( const MdLinkIndex::Heading& h, std::uint32_t f ) { return h.fileId < f; } ); + const bool hasHeadings = first != ix.headings.end() && first->fileId == file; + return hasHeadings || ix.wholeSlug[ file ] != slug; +} + inline MarkdownLinkTarget bindOneMarkdownLink( const MdLinkIndex& ix, std::string_view linkerPath, std::string_view written, std::string_view crawlRoot ) { @@ -6639,16 +6674,13 @@ inline MarkdownLinkTarget bindOneMarkdownLink( const MdLinkIndex& ix, std::strin return {}; } const std::uint32_t target = file->second; - using Key = std::pair; - const auto hit = slug.empty() ? ix.headings.end() - : std::lower_bound( ix.headings.begin(), ix.headings.end(), Key{ target, slug }, - []( const MdLinkIndex::Heading& h, const Key& key ) { return Key{ h.fileId, h.slug } < key; } ); - if( hit != ix.headings.end() && hit->fileId == target && hit->slug == slug ) + const auto hit = slug.empty() ? ix.headings.end() : findMarkdownHeading( ix, target, slug ); + if( hit != ix.headings.end() ) { return { hit->id, false }; } const NodeId fileNode = ix.wholeFile[ target ]; - return { fileNode, !slug.empty() && fileNode != kNoNode }; + return { fileNode, fileNode != kNoNode && mdSlugProvablyAbsent( ix, target, slug ) }; } struct MdLinkPins diff --git a/src/infra/mdslug.h b/src/infra/mdslug.h index de0c8da97..608ec125d 100644 --- a/src/infra/mdslug.h +++ b/src/infra/mdslug.h @@ -14,7 +14,8 @@ 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). +// 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; @@ -28,4 +29,15 @@ inline std::string mdSlugOf( std::string_view name ) 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 +{ + for( const char c : slug ) + { + if( c == '%' || static_cast( c ) >= 0x80 ) { return true; } + } + return false; +} + } // namespace rw diff --git a/test/mdsectioncheck.sh b/test/mdsectioncheck.sh index bf57e12a1..8a4f2412a 100755 --- a/test/mdsectioncheck.sh +++ b/test/mdsectioncheck.sh @@ -396,6 +396,35 @@ 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 ' From 5f3f83d856cf312760b766967a2cec5f8a80977f Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Mon, 19 Oct 2026 11:46:59 -0400 Subject: [PATCH 17/18] Re-pin qschemetrip after the merge with train 31 The merge took main's pin (kParserVer 198); this branch's watched text is 206 again, which hashes to the value the branch pinned before the merge. RE-PIN LOG line added. Co-Authored-By: Claude Sonnet 5.5 --- test/qschemetrip.hash | 2 +- test/qschemetripcheck.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 cab6b9b9d..1d38afd18 100755 --- a/test/qschemetripcheck.sh +++ b/test/qschemetripcheck.sh @@ -35,7 +35,7 @@ ING="$ROOT/src/ingest_cache.h" # extraction-identity constants moved here (202 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 -# HASHPLACEHOLDER). The only watched text that moved is the kParserVer line and quality.h's mirror: renumbered again at +# 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. From e1a9d7f4aacf9fa8b9ce4367f118a093202eba72 Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Mon, 19 Oct 2026 12:02:00 -0400 Subject: [PATCH 18/18] Spell the out-of-model slug test without cloning another ASCII scan quality-delta paired mdSlugOutsideModel with naminglens' ncAllAscii loop; the test is one std::any_of over the two byte classes. Co-Authored-By: Claude Sonnet 5.5 --- src/infra/mdslug.h | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/infra/mdslug.h b/src/infra/mdslug.h index 608ec125d..2d2086d36 100644 --- a/src/infra/mdslug.h +++ b/src/infra/mdslug.h @@ -6,6 +6,7 @@ // 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 @@ -33,11 +34,7 @@ inline std::string mdSlugOf( std::string_view name ) // (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 { - for( const char c : slug ) - { - if( c == '%' || static_cast( c ) >= 0x80 ) { return true; } - } - return false; + return std::any_of( slug.begin(), slug.end(), []( char c ) { return c == '%' || static_cast( c ) >= 0x80; } ); } } // namespace rw