diff --git a/.ripwire_quality_acks b/.ripwire_quality_acks index ee309c7a5..f34450531 100644 --- a/.ripwire_quality_acks +++ b/.ripwire_quality_acks @@ -625,6 +625,7 @@ ack duplication 5a94cb2859bf1601 71 now=71 was=0 prov=recon a probe TU deliberat ack duplication 5ae632d0ded8f363 58 now=58 was=0 prov=recon facet=switch-name-table per-enumerator switches name every enumerator and carry no default: on purpose — that is what makes -Werror=switch a compile error at each one when an enumerator is appended; two such switches over one enum share the enumerator spelling by construction ack duplication 5bfc60bf2eec4379 58 now=58 was=0 prov=recon readability-wave1 (clones.h scanCodeTokens extraction): normalizeTokens and objectKeys are both 'call a scanner, emplace_back each visit into a vector, return it' — the same 58-token visitor-to-vector adapter over two unrelated scanners in two different layers (the clone tokenizer in core vs the JSON top-level-key walker in the MCP layer). Neither can call the other; a shared generic adapter would be a cross-layer template earning six lines. Kept as written. ack duplication 5c9174022e5a0734 44 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) +ack duplication 5c98961b8ec17a64 35 now=35 was=0 p=src/docparse.h:160 #155 (pr438 B2): inSortedTable is the span form of the table lookup isStrictlySortedTable's tables share; isMarkdownGrammarExtension is the same one-line binary_search over a different table, and merging them would need docparse.h to include externalnames.h for no gain ack duplication 5e4956ffb34ce360 36 now=36 was=0 prov=recon M1: a 36-token shape coincidence, not duplication. isMcpProtocolVersionSupported walks a version list and mcpVerbDeclaresLegend walks a field list; both are the C++ spelling of 'is X in this list', over different tables, different types and different call sites. Folding them into one generic helper would couple the protocol handshake to the legend family for no shared meaning. ack duplication 5f01b66132b916bd 44 DISCLOSE sinks: a reason-to-field switch is the shape every multi-reason sink shares; -Wswitch exhaustiveness over DisclosureWhy is the point (Diagnostics.h 4b) ack duplication 5fad9d85c2552890 32 now=32 was=0 p=src/receiverevidence.h:142 lane FE-B (receiver evidence): lexical clone pairs, not shared logic — the per-language predicates (implicitReceiverLang, classNameReceiverLang, memberNeverReachesFreeFunction, isReceiverEvidenceKind, callableKind, classLike, isThisRoot) are one-line OR-lists over different enum sets that share only the shape with quality.h/model.h/slice.h predicates asking other questions; keyOf/typeKey build different keys into a reused buffer; classAt (innermost class span by byte) vs flipimpact innermostAtLine (by line, other table); impactViaColumn vs lintrules dependencyCapableMask share a bitmask idiom; the JS fixture encode|stringify is a deliberate twin (test/receiverevidencefix: same-named near misses); pargates.py is unchanged since cd87e30e (a regrouped pre-existing pair). The one real clone, the enclosing-function walk, was merged into innermostEnclosingFn instead of acked diff --git a/changelog.d/155-kotlin-scope-functions.md b/changelog.d/155-kotlin-scope-functions.md new file mode 100644 index 000000000..0405b239e --- /dev/null +++ b/changelog.d/155-kotlin-scope-functions.md @@ -0,0 +1,18 @@ +### Fixed — Kotlin scope functions no longer bind Java methods + +A Kotlin call with a trailing lambda to `let`, `run`, `with`, `apply` or `also` now stays off the Java +method of the same name. Before this change, `s.run { }` bound `Job.run`, and `with( sb ) { }` bound +`Builder.with`. Ripwire now reads the call as the stdlib scope function. It drops the Java candidates and +keeps the Kotlin ones. A call with no Kotlin target reports `external=` with that proof. + +Calls without a trailing lambda keep their edges. `j.run()` still binds `Job.run`, and `b.with( "x" )` still +binds `Builder.with`. + +Known floors: + +- A parenthesised lambda, such as `s.let( { } )`, is not a trailing lambda. Its edge is unchanged. +- A Java method called from Kotlin with a trailing lambda is dropped when its name is a scope-function name. + The spelling alone does not tell the two apart. +- A receiver of a Java type is not checked. A receiver whose type is an in-tree Kotlin class that defines the method + keeps its own binding (`t.run { }` on a `t: Task` binds `Task.run`). +- A qualified `kotlin.run { }` can still bind an in-tree Kotlin `run`, hedged. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7aa2b8c7..8b71f663b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -258,6 +258,18 @@ triple-quoted string that ends in an escaped `$`). `test/kotlincheck.sh` covers extraction, both bridge directions in flat and split layouts, the Java-edge invariant, the bodyless-type collapse, hostile nesting, metrics and determinism. +**Kotlin scope functions (#155).** A call with a trailing lambda to `let`, `run`, `with`, `apply` or `also` +(`s.run { }`, `with( sb ) { }`, `xs.also { }`) is the stdlib inline function, so FE-A (`FalseEdgeRules`, `graph.h`) +drops every Java candidate of that name and keeps the Kotlin ones; an emptied set is `external=` with that proof. +The fact is `Reference::trailingLambda`, set by `kotlinTrailingLambda` (`ingest_metrics.h`); the names are +`kKotlinScopeFunctionNames` (`externalnames.h`, regenerated by one `javap` command recorded there). A receiver rule +that already picked a Kotlin member (`t.run { }` on a `t: Task` whose class defines `run`) keeps its proof; a pick +holding a Java method is undone, so `s.run { }` is decided here. Stated floors: no receiver typing beyond that (a +receiver of a Java type is not checked); a qualified `kotlin.run { }` can still bind an in-tree Kotlin `run` (hedged); no SAM conversion; a parenthesised lambda (`s.let( { } )`) is not trailing and +keeps the ladder; and a Java method of one of these names called from Kotlin with a trailing lambda is dropped, +because the spelling alone cannot tell the two apart. `test/kotlincheck.sh` §15 and `test/falseedgecheck.sh` arm J +pin it. + **Clojure extraction.** The vendored Yogthos parser is a fork of Sogaiu's grammar. The grammar rules match at the pinned revisions, while Yogthos' generated ABI-15 parser completed 4,930 of 4,933 diff --git a/src/externalnames.h b/src/externalnames.h index 7c3657365..bc4181383 100644 --- a/src/externalnames.h +++ b/src/externalnames.h @@ -375,6 +375,19 @@ inline constexpr std::string_view kRustStdMemberNames[] = { "to_uppercase", "to_vec", "trim", "unwrap", "unwrap_or", "unwrap_or_default", "unwrap_or_else", "values" }; +// ── THE KOTLIN SCOPE-FUNCTION TABLE (graph.h FalseEdgeRules, the Kotlin arm; gate test/kotlincheck.sh §15 and test/falseedgecheck.sh +// arm J). A Kotlin call with a TRAILING lambda to one of these names is the stdlib scope function, never a Java method of the same +// name: `s.run { }`, `with( sb ) { }`, `s?.let { }`, `xs.also { }`. The fact is the call's trailing lambda (ingest_metrics.h +// kotlinTrailingLambda); the name alone is never a veto — `block.run()` on a Runnable has no lambda and keeps its edge. +// PROVENANCE: the five functions the Kotlin documentation names as scope functions — "There are five of them: let, run, with, +// apply, and also" (https://kotlinlang.org/docs/scope-functions.html, read 2026-10-24) — each an inline function of kotlin-stdlib +// 2.4.0 (https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib/2.4.0/kotlin-stdlib-2.4.0.jar), class +// kotlin.StandardKt__StandardKt. Regenerated and diffed by ONE command, which prints exactly these five names: +// javap -p -cp kotlin-stdlib-2.4.0.jar kotlin.StandardKt__StandardKt | sed -n 's/.* \([a-z][A-Za-z]*\)(.*/\1/p' | grep -xE 'let|run|with|apply|also' | LC_ALL=C sort -u +// takeIf, takeUnless, repeat and use stay OUT: the documentation does not call them scope functions, and no corpus evidence joined them. +// 5 names; sorted by byte (isStrictlySortedTable below). +inline constexpr std::string_view kKotlinScopeFunctionNames[] = { "also", "apply", "let", "run", "with" }; + // The standard templates whose `->` reaches their FIRST template argument, so a `->` call through a declaration written // `std::unique_ptr` / `std::shared_ptr<…>` / `std::optional<…>` may be a member of that argument (graph.h // StdMemberGate::stdArrowPointee reads the declaration's recorded text). std::weak_ptr and std::reference_wrapper have no @@ -406,6 +419,16 @@ static_assert( isStrictlySortedTable( kRustStdMemberNames ), "kRustStdMemberName static_assert( isStrictlySortedTable( kCppStdArrowTemplates ), "kCppStdArrowTemplates must be strictly sorted (binary search)" ); static_assert( isStrictlySortedTable( kCppStdElementFirstArg ), "kCppStdElementFirstArg must be strictly sorted (binary search)" ); static_assert( isStrictlySortedTable( kCppStdElementSecondArg ), "kCppStdElementSecondArg must be strictly sorted (binary search)" ); +static_assert( isStrictlySortedTable( kKotlinScopeFunctionNames ), "kKotlinScopeFunctionNames must be strictly sorted (binary search)" ); +// #155: the lookup of a strictly sorted table (isStrictlySortedTable), taken as a span so a new table reuses it. +inline bool inSortedTable( std::span table, std::string_view name ) noexcept +{ + return std::binary_search( table.begin(), table.end(), name, rw::sortutil::svLess ); +} +inline bool isKotlinScopeFunction( std::string_view name ) noexcept +{ + return inSortedTable( kKotlinScopeFunctionNames, name ); +} // Which template argument an element access of the standard container `name` ("map", "vector") yields: 0 or 1, or -1 when `name` // is no container of kCppStdElementFirstArg / kCppStdElementSecondArg. diff --git a/src/graph.h b/src/graph.h index 189755c76..9380aea47 100644 --- a/src/graph.h +++ b/src/graph.h @@ -5668,8 +5668,17 @@ struct DispositionTally // external= with proof (one `C external` census row) or unresolved= without, and every other call keeps the unchanged ladder. // CLASS-BODY KEEP. A function of the class whose BODY is the caller survives rule (1): Python evaluates a class body as a // scope, so `DEFAULTS = _default()` there reaches the `_default` defined above it. -// STATED FLOORS (named in test/falseedgecheck.sh's header): implicit-receiver languages (Java, C#, C++, Kotlin, Swift, -// Ruby, ObjC) are untouched — a bare call there reaches the enclosing class's methods; PHP, Lua and Zig have no arm; a +// (4) KOTLIN SCOPE FUNCTIONS (#155; test/kotlincheck.sh §15, test/falseedgecheck.sh arm J). A Kotlin call with a TRAILING lambda to +// let/run/with/apply/also (externalnames.h kKotlinScopeFunctionNames) is the stdlib inline function. Java and other JVM +// candidates of that name drop; Kotlin candidates keep; an emptied set is external=. A receiver rule that already picked a Kotlin +// member (`t.run { }` on a `t: Task` whose class defines run) keeps its proof; a pick holding a Java method is undone, so +// `s.run { }` (which carries a receiver) is decided here. One fact: Reference::trailingLambda (ingest_metrics.h kotlinTrailingLambda). +// STATED FLOORS (named in test/falseedgecheck.sh's header): implicit-receiver languages (Java, C#, C++, Swift, Ruby, ObjC) are +// untouched — a bare call there reaches the enclosing class's methods. Kotlin's scope arm (4) keys on the spelling and the +// trailing lambda ALONE: no receiver typing beyond the Kotlin pick above (a receiver of a Java type is not checked), no SAM conversion, qualified `kotlin.run { }` can still bind an in-tree Kotlin `run` (hedged), +// a parenthesised lambda (`s.let( { } )`, `with( x, { } )`) is NOT trailing and keeps the ladder, and a Java method of +// that name called from Kotlin WITH a trailing lambda is dropped (the same spelling is indistinguishable here). +// PHP, Lua and Zig have no arm; a // Go package whose name differs from its path's last element is not recognised as an import (its calls keep the // ladder); a JS `require` inside a function body and browser-only globals are not modelled; a JS script's top-level // function reached from another script by a bare global-table name would read as external. @@ -5999,6 +6008,32 @@ struct FalseEdgeRules } return ( !lookup( r.fileId, r.calleeName, bound ) && externalnames::isJsGlobalName( r.calleeName ) ) ? Verdict::External : Verdict::Keep; } + // #155 KOTLIN SCOPE FUNCTIONS (test/kotlincheck.sh §15, test/falseedgecheck.sh arm J): a Kotlin call with a trailing lambda to + // let/run/with/apply/also IS the stdlib inline function. A Java (or other JVM-compatible) candidate of that name is a method + // the call cannot reach: dropped. Kotlin candidates are kept and narrowed. An emptied set is External with that proof (the name + // is the stdlib scope function, externalnames.h). Nothing dropped → Keep: the ladder decides exactly as before. + Verdict kotlinScopeDecide( const Reference& r, std::span ids, rw::SmallVec& kept ) const + { + kept.clear(); + bool dropped = false; + for( NodeId c : ids ) + { + const Symbol& s = ing.symbols[ c ]; + if( langCompatible( s.lang, r.lang ) && !langFacts( s.lang ).trailingLambdaScopeFns ) + { + dropped = true; + continue; + } + kept.push_back( c ); + } + if( !dropped ) + { + kept.clear(); + return Verdict::Keep; + } + const bool anyKotlin = std::any_of( kept.begin(), kept.end(), [ & ]( NodeId c ) { return langFacts( ing.symbols[ c ].lang ).trailingLambdaScopeFns; } ); + return anyKotlin ? Verdict::Narrow : Verdict::External; + } bool aliasIsForeign( const Alias& a, const Reference& r ) const { if( a.fromIdentifier ) // `const { x } = G`: foreign iff G is a global object no binding in the file hides @@ -6012,6 +6047,10 @@ struct FalseEdgeRules Verdict decide( const Reference& r, std::span ids, rw::SmallVec& kept ) const { kept.clear(); + if( active && langFacts( r.lang ).trailingLambdaScopeFns && r.role == RefRole::Call && r.trailingLambda && externalnames::isKotlinScopeFunction( r.calleeName ) ) + { + return kotlinScopeDecide( r, ids, kept ); // #155: ahead of the receiver gate — `s.run { }` carries a receiver + } if( !active || r.role != RefRole::Call || !r.qualifier.empty() || r.fromSymbol >= ing.symbols.size() || r.recv != RecvKind::None ) { return Verdict::Keep; @@ -10189,9 +10228,29 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null // nested namesakes dropped, an inherited body, an interface's dispatch split); otherwise narrowed stays false and the name-based fallback // runs. Skipped when the call was already pinned canonically or by Rule 1 (the more specific / already-resolved signals). const bool narrowedBeforeReceiverRules = narrowed; + // #155: a Kotlin trailing-lambda scope call is the stdlib function unless its receiver's type proves a Kotlin member; FE-A decides the rest. + const bool kotlinScopeCall = langFacts( r.lang ).trailingLambdaScopeFns && r.role == RefRole::Call && r.trailingLambda + && externalnames::isKotlinScopeFunction( r.calleeName ); + // #155: a receiver rule still decides such a call when every candidate it picks is a Kotlin definition (`t.run { }` on a + // `t: Task` whose class defines run, beside another class's run); a pick holding a Java (non-Kotlin) method is undone, and + // FE-A below decides the call (kotlincheck §15 tree f). + const auto kotlinScopeUndo = [ & ]() noexcept -> bool + { + if( !kotlinScopeCall + || std::all_of( cand.begin(), cand.end(), [ & ]( NodeId c ) noexcept { return langFacts( ing.symbols[ c ].lang ).trailingLambdaScopeFns; } ) ) + { + return false; + } + cand.clear(); + return true; + }; if( !scipPinned && !canonical && !narrowed && writtenRoot != JsWrittenRoot::Pinned ) { narrowed = narrowTo( narrower.rule2RecvVarType( r, classIds, chaUp ), r, cand ) || narrower.forgetClaim(); + if( narrowed && kotlinScopeUndo() ) + { + narrowed = narrower.forgetClaim(); + } } // P2-D Rule 2c (CLASS-NAME receiver, Phase 4b): `Cls.m()` resolves to `Cls::m` (or the shallowest base defining `m`) when Cls is an in-repo class that no // local and no C++ member of the caller's class or its bases hides (then 2b reads the member). After Rule 2, before 2b. See Narrower::rule2cClassNameRecv. @@ -10230,7 +10289,7 @@ inline Graph buildGraph( const IngestResult& ing, const ScipOverlay* scip = null cand.push_back( c ); } } - narrowed = !cand.empty(); + narrowed = !cand.empty() && !kotlinScopeUndo(); } // lane L6 (ifacedispatch.h): the receiver's recorded type is an interface, a trait or a class declaring the callee with // no body — C++ through class identity's dispatch split (Rule 2 on a local or parameter, Rule 2b's field), every other diff --git a/src/ingest_cache.h b/src/ingest_cache.h index 6c2b744c1..dca0b1e12 100644 --- a/src/ingest_cache.h +++ b/src/ingest_cache.h @@ -91,6 +91,7 @@ struct RawRef std::string memberVia; // FE-B on #373: the method a Ruby typed receiver was built by (`new`, a finder); see model.h bool memberFactory = false; // FE-B on #373: memberCtor names a FactoryBot factory, not a class bool forwarded = false; // lane fcall-forward fix 1: a JS/TS `f.call/f.apply` site standing for `f( … )` (model.h Reference::forwarded) + bool trailingLambda = false; // #155: a Kotlin call whose lambda is written after its argument list (model.h Reference::trailingLambda) std::uint32_t baseBind = 0; // lane lego-transitive fix 1, an inherit ref only: 1 + the name byte of the same-file // declaration the base binds to (ingest_relations.h localBaseBinding); 0 none, ~0u unknown }; @@ -144,7 +145,10 @@ constexpr std::uint32_t kCacheMagic = 0x4b505443; // "CTPK" // all match) rather than silently re-absolutizing a key that was never root-relative to begin // with — a v2 cache simply misses on every lookup that survives the guard, which is exactly the // self-healing full-reparse path already used for any other corrupt/stale cache. -constexpr std::uint32_t kCacheVersion = 40; // 40: train 34 — strictly above main 39 and every live value (train-33 refs, #432 39; #427 / #429 38): the +// PLACEHOLDER (#155 Kotlin scope functions): 41 is a placeholder above every live value, and the train renumbers it. The +// `trailingLambda` u8 in writeRef/readRef changes the on-disk ref record, so an older cache must be rejected. +constexpr std::uint32_t kCacheVersion = 41; // 41: PLACEHOLDER — #155 Kotlin trailingLambda ref byte (train renumbers) + // 40: train 34 — strictly above main 39 and every live value (train-33 refs, #432 39; #427 / #429 38): the // fc3-js-rebind record change (LocalBindKind JsRebind, appended) lands; no other train-34 lane changed a format. // 39: train 33 — strictly above main 36 and every live value (lane l6-iface 38, #427 / #429 38): the // l6-iface record change (LocalBindKind DispatchType + ImplBlock, appended) lands; no other train-33 lane changed a format. @@ -353,7 +357,7 @@ constexpr std::uint32_t kCacheVersion = 40; // 40: train 34 — strict // (Py `pkg.mod`, TS `./x`, Rust `crate::a::b`/`mod:x`) — // a target FORMAT change → old caches must be rejected. // 4: Include gained a `bool isAngle` (quote/angle) field -constexpr std::uint32_t kParserVer = 226; // bump on any grammar/.scm/extraction change +constexpr std::uint32_t kParserVer = 228; // 228: PLACEHOLDER — #155 Kotlin trailing-lambda capture (train renumbers); bump on any grammar/.scm/extraction change // 226 = 2026-10-24 (train 34: lean2b, fc2-lego-extparent, honest-lex, ind4b-k5-tighten-k5c, hop1, fc3-js-rebind): // strictly above every live number — main 224 (rich 225), #432 224, lane ind4b's placeholder 224 (it collided with // main's), lane ind5-glob-imports 222 (223, reserved), lane fc3-js-rebind 218, #426 216, #420 214. Rich file tag 227. @@ -2452,7 +2456,7 @@ inline void writeDef( ByteW& w, const RawDef& d, bool withLex, std::size_t fileD } } } -inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8( std::uint8_t( r.lang ) ); w.str( r.name ); w.u8( r.isInherit ? 1 : 0 ); w.u8( r.isDocLink ? 1 : 0 ); w.str( r.qualifier ); w.u8( std::uint8_t( r.recv ) ); w.str( r.recvVar ); w.u8( r.isCompose ? 1 : 0 ); w.str( r.fieldName ); w.str( r.composeRel ); w.u8( std::uint8_t( r.role ) ); w.u32( r.line ); w.u32( r.argCount ); w.u8( r.argCountKnown ? 1 : 0 ); w.u8( r.viaArrow ? 1 : 0 ); w.u8( r.qualifierRootsStd ? 1 : 0 ); w.u8( r.memberCall ? 1 : 0 ); w.str( r.memberRoot ); w.str( r.memberPath ); w.str( r.memberCtor ); w.str( r.memberVia ); w.u8( r.memberFactory ? 1 : 0 ); w.u8( r.forwarded ? 1 : 0 ); if( r.isInherit ) { w.u32( r.baseBind ); } } +inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8( std::uint8_t( r.lang ) ); w.str( r.name ); w.u8( r.isInherit ? 1 : 0 ); w.u8( r.isDocLink ? 1 : 0 ); w.str( r.qualifier ); w.u8( std::uint8_t( r.recv ) ); w.str( r.recvVar ); w.u8( r.isCompose ? 1 : 0 ); w.str( r.fieldName ); w.str( r.composeRel ); w.u8( std::uint8_t( r.role ) ); w.u32( r.line ); w.u32( r.argCount ); w.u8( r.argCountKnown ? 1 : 0 ); w.u8( r.viaArrow ? 1 : 0 ); w.u8( r.qualifierRootsStd ? 1 : 0 ); w.u8( r.memberCall ? 1 : 0 ); w.str( r.memberRoot ); w.str( r.memberPath ); w.str( r.memberCtor ); w.str( r.memberVia ); w.u8( r.memberFactory ? 1 : 0 ); w.u8( r.forwarded ? 1 : 0 ); w.u8( r.trailingLambda ? 1 : 0 ); if( r.isInherit ) { w.u32( r.baseBind ); } } // loadCache's countFits() bounds a corrupt on-disk record COUNT against remaining bytes / // minRecordBytes BEFORE reserve() — the guard that keeps a hostile blob's 0xFFFFFFFF count from reaching @@ -2473,13 +2477,13 @@ inline void writeRef( ByteW& w, const RawRef& r ) { w.u32( r.startByte ); w.u8 // `qualifierRootsStd` u8 then added a 9th u8 — 40 -> 41, kCacheVersion 25; FE-A's `memberCall` u8 and `memberRoot` // str then added a 10th u8 and a 6th str — 41 -> 46, kCacheVersion 28; FE-B's `memberPath` and `memberCtor` strs then // added a 7th and 8th str — 46 -> 54, kCacheVersion 29; fcall-forward fix 1's `forwarded` u8 then added a 12th u8 — -// 59 -> 60, kCacheVersion 33). +// 59 -> 60, kCacheVersion 33; #155's `trailingLambda` u8 then added a 13th u8 — 60 -> 61, kCacheVersion 41). // verifyCacheRecordMinimaTripwire() below derives these // same numbers from the REAL writer functions at runtime so the next field added to writeDef/writeRef // can't silently stale them. inline constexpr std::size_t kMinDefRecordBytesLean = 92; // 17×u32 + 16×u8 + 2×str(len u32, empty) (paramsMin u32: 88 -> 92, kCacheVersion 33) inline constexpr std::size_t kMinDefRecordBytesRichExtra = 9; // v10 rich withLex extra: dlWeighted u32 + tokenCount u32 + tfWidth u8 -inline constexpr std::size_t kMinRefRecordBytes = 60; // 3×u32 + 12×u8 + 9×str(len u32, empty) +inline constexpr std::size_t kMinRefRecordBytes = 61; // 3×u32 + 13×u8 + 9×str(len u32, empty) inline std::size_t minDefRecordBytes( bool captureValueUses ) noexcept { @@ -2596,7 +2600,7 @@ inline RawDef readDef( ByteR& r, bool withLex, const std::vector& } return d; } -inline RawRef readRef ( ByteR& r ) { RawRef x; x.startByte = r.u32(); x.lang = r.enumU8( kLangCount ); x.name = r.str(); x.isInherit = r.u8() != 0; x.isDocLink = r.u8() != 0; x.qualifier = r.str(); x.recv = r.enumU8( kRecvKindCount ); x.recvVar = r.str(); x.isCompose = r.u8() != 0; x.fieldName = r.str(); x.composeRel = r.str(); x.role = r.enumU8( kRefRoleCount ); x.line = r.u32(); x.argCount = r.u16Of32(); x.argCountKnown = r.u8() != 0; x.viaArrow = r.u8() != 0; x.qualifierRootsStd = r.u8() != 0; x.memberCall = r.u8() != 0; x.memberRoot = r.str(); x.memberPath = r.str(); x.memberCtor = r.str(); x.memberVia = r.str(); x.memberFactory = r.u8() != 0; x.forwarded = r.u8() != 0; if( x.isInherit ) { x.baseBind = r.u32(); } return x; } +inline RawRef readRef ( ByteR& r ) { RawRef x; x.startByte = r.u32(); x.lang = r.enumU8( kLangCount ); x.name = r.str(); x.isInherit = r.u8() != 0; x.isDocLink = r.u8() != 0; x.qualifier = r.str(); x.recv = r.enumU8( kRecvKindCount ); x.recvVar = r.str(); x.isCompose = r.u8() != 0; x.fieldName = r.str(); x.composeRel = r.str(); x.role = r.enumU8( kRefRoleCount ); x.line = r.u32(); x.argCount = r.u16Of32(); x.argCountKnown = r.u8() != 0; x.viaArrow = r.u8() != 0; x.qualifierRootsStd = r.u8() != 0; x.memberCall = r.u8() != 0; x.memberRoot = r.str(); x.memberPath = r.str(); x.memberCtor = r.str(); x.memberVia = r.str(); x.memberFactory = r.u8() != 0; x.forwarded = r.u8() != 0; x.trailingLambda = r.u8() != 0; if( x.isInherit ) { x.baseBind = r.u32(); } return x; } inline void writeBind( ByteW& w, const RawBind& b ) { w.u32( b.startByte ); w.u8( std::uint8_t( b.lang ) ); w.u8( std::uint8_t( b.kind ) ); w.u8( b.isFromAssignment ? 1 : 0 ); w.u8( b.resultIndex ); w.u8( b.unwrapDepth ); w.u32( b.spanStart ); w.u32( b.spanEnd ); w.str( b.var ); w.str( b.typeName ); w.str( b.importedName ); } inline RawBind readBind( ByteR& r ) { RawBind b; b.startByte = r.u32(); b.lang = r.enumU8( kLangCount ); b.kind = r.enumU8( kLocalBindKindCount ); b.isFromAssignment = r.u8() != 0; b.resultIndex = r.u8(); b.unwrapDepth = r.u8(); b.spanStart = r.u32(); b.spanEnd = r.u32(); b.var = r.str(); b.typeName = r.str(); b.importedName = r.str(); return b; } inline void writeFfi( ByteW& w, const BindingAlias& a ) { w.u8( std::uint8_t( a.kind ) ); w.u8( a.lowConf ? 1 : 0 ); w.str( a.aliasName ); w.str( a.targetName ); w.str( a.targetScope ); } diff --git a/src/ingest_metrics.h b/src/ingest_metrics.h index 276148977..58c84a385 100644 --- a/src/ingest_metrics.h +++ b/src/ingest_metrics.h @@ -1679,6 +1679,16 @@ inline ParamShape cc_paramShape( TSNode defNode, Lang lang, SymKind kind, std::s return {}; // no parameter-list node found at all → can't be sure → not filterable (safe) } +// #155 KOTLIN TRAILING LAMBDA: true when a Kotlin call_expression writes its lambda AFTER the argument list — the grammar's +// annotated_lambda directly under its call_suffix: `s.run { }`, `with( sb ) { }`, `s?.let { }`, `xs.also { }`. The lambda of +// `s.let( { } )` sits inside value_arguments, so it is NOT a trailing lambda (a disclosed floor, FalseEdgeRules' Kotlin arm). +// Not noexcept: firstChildOfKind's cursor allocates (infra/tschildren.h A4-F25). +inline bool kotlinTrailingLambda( TSNode call ) +{ + const TSNode suffix = kindIs( ts_node_type( call ), "call_expression" ) ? firstChildOfKind( call, /*namedOnly=*/true, { "call_suffix" } ) : TSNode{}; // one call_suffix per call_expression + return !ts_node_is_null( suffix ) && !ts_node_is_null( firstChildOfKind( suffix, /*namedOnly=*/true, { "annotated_lambda" } ) ); +} + // B2.2 CALL-SITE ARITY: count the positional arguments of the call whose callee identifier is `nameNode`. // Returns { argCount, known }. `known` is true ONLY when a recognized call node + argument container is // found for a supported language AND no spread/splat/apply/pack-expansion argument is present (which would make the count diff --git a/src/ingest_model.h b/src/ingest_model.h index 40bda0130..ab137000d 100644 --- a/src/ingest_model.h +++ b/src/ingest_model.h @@ -930,6 +930,7 @@ inline void emitReferences( IngestResult& result, std::vector& rawRefs, ref.memberVia = std::move( r.memberVia ); // FE-B on #373: the method a Ruby typed receiver was built by ref.memberFactory = r.memberFactory; // FE-B on #373: memberCtor names a FactoryBot factory ref.forwarded = r.forwarded; // lane fcall-forward fix 1: an `f.call/f.apply` site standing for `f( … )` + ref.trailingLambda = r.trailingLambda; // #155: a Kotlin call whose lambda trails its argument list (scope-function arm) ref.baseBind = r.baseBind; // lane lego-transitive fix 1: an inherit ref's same-file base binding ref.fieldName = std::move( r.fieldName ); // S5-E: the member variable name (e.g. "m_pool") ref.composeRel = std::move( r.composeRel ); // S5-E: "creates" or "uses" diff --git a/src/ingest_sidecap.h b/src/ingest_sidecap.h index 11cc7776e..ec10247c0 100644 --- a/src/ingest_sidecap.h +++ b/src/ingest_sidecap.h @@ -2556,6 +2556,10 @@ void captureTagsFacts( TSQueryCursor* cursor, const LangEntry& le, std::uint32_t } auto [ ac, ak ] = callArity( nameNode, le.lang, src ); // B2.2: call-site positional arg count r.argCount = ac; r.argCountKnown = ak; // → arity filter in graph.h + if( langFacts( le.lang ).trailingLambdaScopeFns ) + { + r.trailingLambda = kotlinTrailingLambda( roleNode ); // #155: `x.run { }` / `with( x ) { }` — graph.h FalseEdgeRules' Kotlin arm + } } refs.push_back( std::move( r ) ); } diff --git a/src/langfacts.h b/src/langfacts.h index bd9713a39..31b36fb1f 100644 --- a/src/langfacts.h +++ b/src/langfacts.h @@ -116,6 +116,11 @@ struct LangFacts /// open too (ifacedispatch.h). A Kotlin-only tree is unchanged. bool samFromJavaLambdas = false; + /// Kotlin: `run`, `let`, `apply`, `also` and `with` are stdlib scope functions, and a call to one with a trailing lambda is + /// that function, never a Java or other-language method of the same name (graph.h FE-A, kotlinScopeDecide; the call-site fact + /// is captured in ingest_sidecap.h). + bool trailingLambdaScopeFns = false; + /// Kotlin: an `interface` is a class_declaration like a class (queries/kotlin/tags.scm: "both land here as @definition.class"), /// so the symbol's kind cannot tell the two apart; shared code that needs an interface (ifacedispatch.h's lambda test) reads a /// class of such a language as a possible one. @@ -206,7 +211,7 @@ inline constexpr std::array kLangFacts = { { { .lang = Lang::Lua }, { .lang = Lang::Elixir, .hasInertQuoteRegions = true }, { .lang = Lang::Dart }, - { .lang = Lang::Kotlin, .samFromJavaLambdas = true, .interfaceReadsAsClass = true, .anonymousImplementors = true, .overloadsWithoutArity = true, + { .lang = Lang::Kotlin, .samFromJavaLambdas = true, .trailingLambdaScopeFns = true, .interfaceReadsAsClass = true, .anonymousImplementors = true, .overloadsWithoutArity = true, .baseImportEvidence = BaseImportEvidence::JvmImport }, { .lang = Lang::GDScript }, { .lang = Lang::Clojure }, diff --git a/src/model.h b/src/model.h index 36817ea3b..624a208d1 100644 --- a/src/model.h +++ b/src/model.h @@ -929,6 +929,10 @@ struct Reference // Function/Method (no via="name", no class/const/object target): an import proves a binding, not a function. // Sits beside memberFactory in the alignment padding before baseBind: sizeof(Reference) is unchanged. bool forwarded = false; + // #155 (test/kotlincheck.sh §15, test/falseedgecheck.sh arm J): a Kotlin call whose lambda is written AFTER the argument list + // (`s.run { }`, `with( x ) { }`); the trailing lambda of a stdlib scope function. graph.h FalseEdgeRules' Kotlin arm drops + // the Java-method edge such a call would bind. + bool trailingLambda = false; // lane lego-transitive fix 1 (test/legotransitivecheck.sh NS arms), an INHERIT ref only (isInherit): the same-file declaration // the base name binds to by the language's lexical lookup, as 1 + the byte its name starts at (ingest_relations.h // localBaseBinding; 0 = no enclosing scope declares it, 0xFFFFFFFF = a scope too large to read, nothing proven). On an diff --git a/src/quality.h b/src/quality.h index 9c45885c6..6b0c39d45 100644 --- a/src/quality.h +++ b/src/quality.h @@ -2337,10 +2337,8 @@ inline std::string cacheRootKeyHex( const std::string& root ) // `static_assert( quality::kIngestParserVerMirror == kParserVer && … )`, so a missed mirror now fails the build. It does // 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 = 40; // MUST equal ingest.cpp's kCacheVersion (gated); 40 = train 34 (fc3-js-rebind JsRebind; above main 39), 39 = train 33 (above main 36 and l6-iface 38), 38 = train 32 lane l6-iface fix round 1: LocalBindKind ImplBlock (train renumbers), 37 = train 32 lane l6-iface: LocalBindKind DispatchType (train renumbers), 36 = train 30 (above call-result-typing's and fcall-forward's 33, name-template-dispatch's 35, main's 34), 34 = train 29 (above lane arity-min's and call-result-typing's 33), 33 = train 29 lane arity-min: def record paramsMin (the train renumbers), 33 = lane call-result-typing: RawBind resultIndex + unwrapDepth, 33 = lane fcall-forward: ref `forwarded` byte, 35 = lane name-template-dispatch: RefRole Pattern 11, 32 = train 28: RefRole Subscript 9 + Inert 10, 31 = train 27: lego-transitive's inherit-ref baseBind (the lane's 30), 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) -constexpr std::uint32_t kIngestParserVerMirror = 226; // MUST equal ingest.cpp's kParserVer (gated) - // 226 = 2026-10-24 (train 34: above main / #432 / ind4b 224/225, ind5 222/223, - // fc3 218/219; see the kParserVer note) +constexpr std::uint32_t kIngestCacheVersionMirror = 41; // MUST equal ingest.cpp's kCacheVersion (gated); 41 = PLACEHOLDER #155 trailingLambda ref byte (train renumbers); 39 = train 33 (above main 36 and l6-iface 38), 38 = train 32 lane l6-iface fix round 1: LocalBindKind ImplBlock (train renumbers), 37 = train 32 lane l6-iface: LocalBindKind DispatchType (train renumbers), 36 = train 30 (above call-result-typing's and fcall-forward's 33, name-template-dispatch's 35, main's 34), 34 = train 29 (above lane arity-min's and call-result-typing's 33), 33 = train 29 lane arity-min: def record paramsMin (the train renumbers), 33 = lane call-result-typing: RawBind resultIndex + unwrapDepth, 33 = lane fcall-forward: ref `forwarded` byte, 35 = lane name-template-dispatch: RefRole Pattern 11, 32 = train 28: RefRole Subscript 9 + Inert 10, 31 = train 27: lego-transitive's inherit-ref baseBind (the lane's 30), 29 = FE-B ref memberPath/memberCtor + 4 bind kinds, 28 = FE-A ref memberCall/memberRoot, 27 = corrected fnScope values, 26 = function-local def scope span (25 = #157 + #150) +constexpr std::uint32_t kIngestParserVerMirror = 228; // MUST equal ingest.cpp's kParserVer (gated); 228 = PLACEHOLDER #155 Kotlin trailing-lambda capture (train renumbers) // 224 = 2026-10-23 (train 33: above main 220/221, ind5 222/223, #420 214/215, fc1 216/217, // ind4b 218/219, l6-iface 212/213; see the kParserVer note) // 220 = 2026-10-22 (train 32b: above main 212/213, ix1-slot 204/205, #420 214/215, diff --git a/test/cachefuzzcheck.sh b/test/cachefuzzcheck.sh index 6987ffb46..d02e5555b 100755 --- a/test/cachefuzzcheck.sh +++ b/test/cachefuzzcheck.sh @@ -937,7 +937,7 @@ for i in range(n): pmin["def.paramsMin"] = (p + 14 * 4, struct.unpack_from(""$TMP/$r.map.xml"; then no "($r) the census run exited non-zero"; fi if [ ! -s "$TMP/$r.tsv" ]; then no "($r) the census run wrote no census — every census arm for this root would be vacuous"; fi @@ -462,6 +466,26 @@ elif ! grep -q ']* defs="1"' "$deepf"; then no "(jsrebind) --callers elif grep -q 'test/assign.test.js' "$deepf"; then no "(jsrebind) the 20000-deep assignment still binds a row: $( tr '<' '\n' <"$deepf" | grep 'assign.test' | tr '\n' ';' )" else ok "(jsrebind) 20000-deep object patterns: exit 0, the written \`Eng\` binds no row"; fi +echo "=== (J) Kotlin: a trailing-lambda scope function never binds a Java method (#155) ===" +# A Kotlin call with a trailing lambda to let/run/with/apply/also is the stdlib scope function: its Java candidates are dropped +# and an emptied set is external= (kotlin scope arm of FalseEdgeRules). Kotlin's own candidates are kept, and a call with no +# lambda (`block.run()` on a Runnable) keeps every candidate the ladder had. +exactly kt callers java/jobs/Job.java:run "fn dispatch kt/Dispatch.kt;fn kick kt/Use.kt" +lacks kt callers java/jobs/Job.java:run "build kt/Use.kt" +exactly kt callers java/lib/Builder.java:with "fn realWith kt/Scopes.kt" +exactly kt callers java/lib/Builder.java:apply "" +exactly kt callers java/lib/Builder.java:let "" +exactly kt callers java/lib/Builder.java:also "" +# Kotlin DEFINES run (ktown): own-language-first keeps the Kotlin run for go and build (the receiver gap, pinned); kick's j.run() +# has no lambda and keeps Job.run, as it did before the fix. +exactly ktown callers kt/Task.kt:run "fn build kt/Task.kt;fn go kt/Task.kt" +exactly ktown callers java/jobs/Job.java:run "fn kick kt/Task.kt" + +externals kt kt/Use.kt build run +externals kt kt/Scopes.kt useWith with +externals kt kt/Scopes.kt useApply apply +externals kt kt/Scopes.kt useLet let +externals kt kt/Scopes.kt useAlso also echo "=== (F) propagation: the decoys' callers and impact ===" exactly go callers hist/history.go:append "fn Remember hist/history.go" lacks go impact hist/history.go:append "Collect algo/algo.go" diff --git a/test/falseedgefix/README.md b/test/falseedgefix/README.md index 572e3093c..7dcaec4f5 100644 --- a/test/falseedgefix/README.md +++ b/test/falseedgefix/README.md @@ -34,6 +34,10 @@ plus near-miss calls whose edges are real and must stay. callable variable, a bare `Worker()` construction, a class-body call. - `c/` — the functions `opts_parse()` and `region()` beside same-named structs; the macro `CLAMP` is kept; `find_type()` (an outside library) beside `enum find_type`. `cpp/` is the control: `Point( v )` constructs a struct in C++ and keeps its rows. +- `kt/` (Kotlin beside Java) — `s.run { }`, `with( sb ) { }`, `s?.let { }`, `xs.also { }`, `apply { }` each carry a TRAILING + lambda and bind no Java method (`Job.run`, `Builder.with/apply/let/also`); the same Java names reached with no lambda + (`block.run()` on a Runnable) and a real `b.with( "x" )` keep their edges. `ktown/` — Kotlin DEFINES `run`: the + trailing-lambda `go` binds the Kotlin `Task.run`, and `build`'s `s.run { length }` is pinned there (the receiver gap). - `rs/` — `use termkit::render; render( n )` beside the method `History::render`. Kept: a same-module function, `h.render()`, `History::new()`, `History::render( &h )`, `Self::width()`. diff --git a/test/falseedgefix/kt/java/jobs/Job.java b/test/falseedgefix/kt/java/jobs/Job.java new file mode 100644 index 000000000..c1758eaa3 --- /dev/null +++ b/test/falseedgefix/kt/java/jobs/Job.java @@ -0,0 +1,7 @@ +package jobs; + +public class Job implements Runnable { + @Override + public void run() { + } +} diff --git a/test/falseedgefix/kt/java/lib/Builder.java b/test/falseedgefix/kt/java/lib/Builder.java new file mode 100644 index 000000000..c8366ec4b --- /dev/null +++ b/test/falseedgefix/kt/java/lib/Builder.java @@ -0,0 +1,8 @@ +package lib; + +public class Builder { + public Builder with(String s) { return this; } + public Builder apply(int x) { return this; } + public void let() { } + public void also() { } +} diff --git a/test/falseedgefix/kt/kt/Dispatch.kt b/test/falseedgefix/kt/kt/Dispatch.kt new file mode 100644 index 000000000..f9843b450 --- /dev/null +++ b/test/falseedgefix/kt/kt/Dispatch.kt @@ -0,0 +1 @@ +fun dispatch(block: Runnable) = block.run() diff --git a/test/falseedgefix/kt/kt/Scopes.kt b/test/falseedgefix/kt/kt/Scopes.kt new file mode 100644 index 000000000..e552c4826 --- /dev/null +++ b/test/falseedgefix/kt/kt/Scopes.kt @@ -0,0 +1,9 @@ +fun useWith(sb: StringBuilder): String = with(sb) { toString() } + +fun useApply(): MutableList = mutableListOf().apply { add(1) } + +fun useLet(s: String?): Int = s?.let { it.length } ?: 0 + +fun useAlso(xs: MutableList) = xs.also { it.clear() } + +fun realWith(b: lib.Builder) = b.with("x") diff --git a/test/falseedgefix/kt/kt/Use.kt b/test/falseedgefix/kt/kt/Use.kt new file mode 100644 index 000000000..bcfeb5599 --- /dev/null +++ b/test/falseedgefix/kt/kt/Use.kt @@ -0,0 +1,7 @@ +data class User(val name: String) + +fun build(s: String): User = s.run { User(this) } + +fun kick(j: jobs.Job) { + j.run() +} diff --git a/test/falseedgefix/ktown/java/jobs/Job.java b/test/falseedgefix/ktown/java/jobs/Job.java new file mode 100644 index 000000000..c1758eaa3 --- /dev/null +++ b/test/falseedgefix/ktown/java/jobs/Job.java @@ -0,0 +1,7 @@ +package jobs; + +public class Job implements Runnable { + @Override + public void run() { + } +} diff --git a/test/falseedgefix/ktown/kt/Task.kt b/test/falseedgefix/ktown/kt/Task.kt new file mode 100644 index 000000000..4f823043d --- /dev/null +++ b/test/falseedgefix/ktown/kt/Task.kt @@ -0,0 +1,11 @@ +class Task { + fun run(block: () -> Unit) = block() +} + +fun go(t: Task) = t.run { } + +fun build(s: String): Int = s.run { length } + +fun kick(j: jobs.Job) { + j.run() +} diff --git a/test/kotlincheck.sh b/test/kotlincheck.sh index 18a15ca53..37f213304 100755 --- a/test/kotlincheck.sh +++ b/test/kotlincheck.sh @@ -1127,6 +1127,261 @@ pyedit "$TMP/jvminv_mut/k/Kt.kt" 'interface Marker' '// interface Marker removed || no "mutation 14c: with no Kotlin Marker, Tagged did not become the Java Marker's implementor: [$( markerImpls "$TMP/jvminv_mut" )]"; } \ || no "mutation 14c: the Marker deletion did not apply — the arm would have been inert" +# ════════════════════════════════════════════════════════════════════════ +echo +echo "=== 15. KOTLIN SCOPE FUNCTIONS (#155): a trailing-lambda call to let/run/with/apply/also never binds a Java method ===" +# ════════════════════════════════════════════════════════════════════════ +# The fact is the call's TRAILING LAMBDA, read at the call site (`x.run { }`, `with( x ) { }`, `x?.let { }`), never the name: +# `block.run()` on a Runnable (no lambda) keeps its Java edge, and a Kotlin `run` the tree defines keeps its own. Each tree +# asks ONE question (does Kotlin define the name or not), so no arm mixes the own-language rule with the trailing-lambda rule. +# Trees: a = Use.kt (build, kick) beside Job.java; b = Scopes.kt (the four bare/safe-call shapes and a real b.with()) beside +# Builder.java; c = Task.kt, which DEFINES run (tree C of the issue); d = the true-edge shapes; e = a parenthesised lambda (floor). +SC="$TMP/scope"; rm -rf "$SC"; mkdir -p "$SC/a/java/jobs" "$SC/a/kt" "$SC/b/java/lib" "$SC/b/kt" "$SC/c/java/jobs" "$SC/c/kt" "$SC/d/java/jobs" "$SC/d/kt" "$SC/e/java/lib" "$SC/e/kt" "$SC/jo/java/jobs" "$SC/jo/java/lib" +cat > "$SC/a/java/jobs/Job.java" <<'JAVA' +package jobs; + +public class Job implements Runnable { + @Override + public void run() { + } +} +JAVA +cat > "$SC/a/kt/Use.kt" <<'KT' +data class User(val name: String) + +fun build(s: String): User = s.run { User(this) } + +fun kick(j: jobs.Job) { + j.run() +} +KT +cat > "$SC/b/java/lib/Builder.java" <<'JAVA' +package lib; + +public class Builder { + public Builder with(String s) { return this; } + public Builder apply(int x) { return this; } + public void let() { } + public void also() { } +} +JAVA +cat > "$SC/b/kt/Scopes.kt" <<'KT' +fun useWith(sb: StringBuilder): String = with(sb) { toString() } + +fun useApply(): MutableList = mutableListOf().apply { add(1) } + +fun useLet(s: String?): Int = s?.let { it.length } ?: 0 + +fun useAlso(xs: MutableList) = xs.also { it.clear() } + +fun realWith(b: lib.Builder) = b.with("x") + +fun typedApply(b: lib.Builder) = b.apply { } +KT +cp "$SC/a/java/jobs/Job.java" "$SC/c/java/jobs/Job.java"; cp "$SC/a/java/jobs/Job.java" "$SC/d/java/jobs/Job.java"; cp "$SC/a/java/jobs/Job.java" "$SC/jo/java/jobs/Job.java" +cat > "$SC/c/kt/Task.kt" <<'KT' +class Task { + fun run(block: () -> Unit) = block() +} + +fun go(t: Task) = t.run { } + +fun build(s: String): Int = s.run { length } + +fun kick(j: jobs.Job) { + j.run() +} +KT +cat > "$SC/d/kt/Dispatch.kt" <<'KT' +fun dispatch(block: Runnable) = block.run() +KT +cat > "$SC/d/kt/Kick.kt" <<'KT' +fun kick(j: jobs.Job) { + j.run() +} +KT +cp "$SC/b/java/lib/Builder.java" "$SC/e/java/lib/Builder.java" +cat > "$SC/e/kt/Paren.kt" <<'KT' +fun useParen(s: String): Int = s.let({ it.length }) +KT +cat > "$SC/jo/java/lib/Builder.java" <<'JAVA' +package lib; + +public class Builder { + public Builder with(String s) { return this; } + public Builder apply(int x) { return this; } + public void let() { } + public void also() { } +} +JAVA +cat > "$SC/jo/java/lib/Caller.java" <<'JAVA' +package lib; + +public class Caller { + public void go() { + Builder b = new Builder(); + b.with("x"); + b.apply(1); + b.let(); + b.also(); + } + + public void spin(jobs.Job j) { + j.run(); + } +} +JAVA + +# callerNames ROOT SEL — the --callers rows as "name@file " tokens, sorted (the line number is dropped: a shifted line is no failure) +callerNames(){ "$BIN" "$1" --callers="$2" --no-cache 2>/dev/null | grep -oE '/dev/null | grep -oE ' "$SC/f/kt/A.kt" <<'KT' +class Task { + fun run(block: () -> Unit) = block() +} +class Other { + fun run(block: () -> Unit) = block() +} +fun go(t: Task) = t.run { } +fun go2() { val o = Other(); o.run { } } +KT +expectCallers "$SC/f" Task.run 'go@kt/A.kt ' "15l tree F: t.run { } on a typed t: Task binds Task.run alone (the receiver rule still decides a Kotlin pick)" +expectCallers "$SC/f" Other.run 'go2@kt/A.kt ' "15l tree F: o.run { } on a constructed Other binds Other.run alone" +goRow="$( "$BIN" "$SC/c" --callers=Task.kt:run --no-cache 2>/dev/null | grep -oE ']*>' )" +case "$goRow" in + "") no "15m tree C: --callers=Task.kt:run printed no go row — the via= check below would be vacuous" ;; + *'via="name"'*) no "15m tree C: go's t.run { } binds Task.run only by name ($goRow), but its receiver t: Task proves it" ;; + *) ok "15m tree C: go's t.run { } binds Task.run with its receiver proof (no via=\"name\")" ;; +esac + +# DISPOSITION: every refused site is a C external census row, the header gauge rises by exactly that many, and nothing is unaccounted. +ext_names(){ python3 - "$1" <<'PY' +import sys +for line in open( sys.argv[ 1 ], encoding="utf-8", errors="replace" ): + f = line.rstrip( "\n" ).split( "\t" ) + if len( f ) >= 9 and f[ 0 ] == "C" and f[ 1 ] == "external": + head, _, tail = f[ 5 ].partition( "::" ) + print( tail.rsplit( "#", 1 )[ 0 ].split( "::" )[ -1 ] + ">" + f[ 6 ] ) +PY +} +for t in a b c; do + "$BIN" "$SC/$t" --no-cache --pin-census="$TMP/sc_$t.tsv" > "$TMP/sc_$t.xml" 2>/dev/null +done +want_ext(){ # tree the external= the census must state, the sorted census rows it must hold + local t="$1" n="$2" rows="$3" got gx + gx="$( grep -m1 '^# dispositions ' "$TMP/sc_$t.tsv" | grep -oE ' external=[0-9]+' | grep -oE '[0-9]+$' )" + got="$( ext_names "$TMP/sc_$t.tsv" | LC_ALL=C sort | tr '\n' ' ' )" + if [ "${gx:-x}" = "$n" ] && [ "$got" = "$rows" ] && grep -m1 '^# dispositions ' "$TMP/sc_$t.tsv" | grep -q ' unaccounted=0'; then + ok "disposition (tree $t): external=$n, the census rows are [${rows:-none}], unaccounted=0 — each refusal is counted" + else + no "disposition (tree $t): external=${gx:-?} census=[${got:-none}], want external=$n [${rows:-none}] with unaccounted=0" + fi +} +want_ext a 1 'build>run ' +want_ext b 5 'typedApply>apply useAlso>also useApply>apply useLet>let useWith>with ' +want_ext c 0 '' + +# MUTATION, keyed on the fact: the same site with NO trailing lambda (s.run()) must bind Job.run again, so the arm reads the lambda. +rm -rf "$TMP/scmut"; cp -R "$SC/a" "$TMP/scmut" +if pyedit "$TMP/scmut/kt/Use.kt" 's.run { User(this) }' 's.run()'; then + expectCallers "$TMP/scmut" run 'build@kt/Use.kt kick@kt/Use.kt ' "15k mutation: s.run() with no lambda binds Job.run again (the arm keys on the trailing lambda, not the name)" +else + no "15k mutation: the s.run { } -> s.run() edit did not apply — the arm would be inert" +fi + +# JAVA INVARIANT (the §14c pattern): a Java-only tree, then the same tree plus the Kotlin scope shapes. Every Java caller row +# of the Java definitions named below, and every Java row of the full map, must be exactly what the Java-only tree gave. +cp -R "$SC/jo" "$SC/jk"; mkdir -p "$SC/jk/kt"; cp "$SC/b/kt/Scopes.kt" "$SC/jk/kt/"; cp "$SC/a/kt/Use.kt" "$SC/jk/kt/" +javaCallers(){ "$BIN" "$1" --callers="$2" --no-cache 2>/dev/null | grep -oE ' "$TMP/jo.xml" 2>/dev/null +"$BIN" "$SC/jk" --top-k=100000 --no-cache > "$TMP/jk.xml" 2>/dev/null +read -r jrows jdiff <<< "$( python3 - "$TMP/jo.xml" "$TMP/jk.xml" <<'PYEOF' +import sys, xml.etree.ElementTree as ET +def java_rows(path): + raw = open(path, encoding="utf-8", errors="replace").read() + root = ET.fromstring(raw[raw.find(" "$TMP/scd_cold.xml" 2>/dev/null +"$BIN" "$SC/b" --top-k=100000 --no-cache > "$TMP/scd_cold2.xml" 2>/dev/null +TMPDIR="$TMP/scd_warm" "$BIN" "$SC/b" --top-k=100000 > "$TMP/scd_w1.xml" 2>/dev/null +TMPDIR="$TMP/scd_warm" "$BIN" "$SC/b" --top-k=100000 > "$TMP/scd_w2.xml" 2>/dev/null +if cmp -s "$TMP/scd_cold.xml" "$TMP/scd_cold2.xml" && cmp -s "$TMP/scd_cold.xml" "$TMP/scd_w1.xml" && cmp -s "$TMP/scd_cold.xml" "$TMP/scd_w2.xml"; then + ok "determinism: cold run, second cold run and warm runs (cache written, then served) are byte-identical" +else + no "determinism: the scope tree's cold, warm or repeated runs differ" +fi +if xmllint --noout "$TMP/scd_cold.xml" 2>/dev/null && xmllint --noout "$TMP/sc_a.xml" 2>/dev/null; then + ok "determinism: the map XML of the scope trees is well formed (xmllint --noout)" +else + no "determinism: xmllint rejects the scope tree's map XML" +fi + # ─── Summary ────────────────────────────────────────────────────────────────── echo if [ "$fail" -eq 0 ]; then diff --git a/test/pyimportprecisecheck.sh b/test/pyimportprecisecheck.sh index 5769a8f00..6e3f5f2bf 100755 --- a/test/pyimportprecisecheck.sh +++ b/test/pyimportprecisecheck.sh @@ -214,7 +214,7 @@ from collections import Counter # string searches: walk the counted arrays and require complete file coverage, clean parse health and exact EOF. blob = pathlib.Path(sys.argv[2]).read_bytes() endian = '<' if sys.byteorder == 'little' else '>' -assert blob[:4] == b'CTPK' and struct.unpack_from(endian + 'I', blob, 4)[0] == 40, 'update the cache walker for a new layout' +assert blob[:4] == b'CTPK' and struct.unpack_from(endian + 'I', blob, 4)[0] == 41, 'update the cache walker for a new layout' table, count = struct.unpack_from(endian + 'QI', blob, len(blob) - 24) assert count == 5 == struct.unpack_from(endian + 'I', blob, 21)[0], 'all five fixtures must be cached' assert table + count * 32 == len(blob) - 24, 'cache table bounds' @@ -244,7 +244,7 @@ for i in range(count): string(); string(); take(13) # argCount is serialized u32, narrowed by u16Of32 on read for _ in range(4): string() # memberRoot/memberPath/memberCtor/memberVia - take(2) # memberFactory, forwarded (fcall-forward) + take(3) # memberFactory, forwarded (fcall-forward), trailingLambda (#155) if inherit: take(4) # baseBind incs = [] diff --git a/test/qschemetrip.hash b/test/qschemetrip.hash index 1db263ca9..52d33b7a1 100644 --- a/test/qschemetrip.hash +++ b/test/qschemetrip.hash @@ -1 +1 @@ -ec5455c6c9bcf87e8a617d937aa6fc94c039111cd7ae573b813e059b451ca188 +89afd39c53da2a89496957ab92c9ff8fab54802c23ac4435d3f8335a2b1a8922 diff --git a/test/qschemetripcheck.sh b/test/qschemetripcheck.sh index 9afaaf79d..a32407c47 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-25, #155 (Kotlin scope functions, fix/155-kotlin-scope-fns): RE-DERIVED ONCE with UPDATE_GOLDEN=1 (ec5455c6c9…ca188 +# -> the pin above). Watched text: kParserVer 226 -> 228 and kCacheVersion 40 -> 41 — both PLACEHOLDERS +# above every live value (the train renumbers them), with quality.h's two mirrors moved in the feature commit. The +# kMinRefRecordBytes 60 -> 61 change rode with them. kQSnapCacheScheme stays 22: no watched quality.h function moved. # 2026-10-24, train 34 (lean2b, fc2-lego-extparent, honest-lex, ind4b-k5-tighten-k5c, hop1, fc3-js-rebind merged onto main # 7abad955): RE-DERIVED ONCE with UPDATE_GOLDEN=1 on the merged tree (3e88ca157f…36842 -> ec5455c6c9…ca188). Watched text: # kParserVer 224 -> 226 (rich file tag 227; above main / #432 224 / 225, lane ind4b's placeholder 224, lane ind5-glob-imports