Skip to content

Commit a1ca272

Browse files
committed
contract: emission_scan — stub markers after a record token now win (codex P2); document the deliberate RouteBucketTyped method-name collision + UFCS rule (codex P2)
- classify_ddl_type no longer early-returns on record: precedence is global over the whole expression (Stub > RecordLink > AnyTyped > Typed); record<user> TODO / record<fixme> / array<record<user>> stub now classify as Stub instead of miscounting as real links. Stub stays the only early return (top precedence). +1 regression test, 3 cases. - RouteBucketTyped: the kind/id name reuse vs RouteBucket is deliberate (one contract shape; blanket delegates verbatim so both resolutions are identical); ambiguity under a both-traits glob is a compile error with a UFCS fix, never silent misbehavior; rename rejected — would fork the shape and break deployed op-nexgen consumers. Documented on the trait. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLBnPuScZy6w9di2QEjsXM
1 parent 3da4337 commit a1ca272

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

crates/lance-graph-contract/src/codegen_spine.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,23 @@ pub trait RouteBucket {
384384
/// implementor. A type that *also* needs `RouteBucketTyped` with a
385385
/// **different** kind must NOT impl `RouteBucket` (it would conflict).
386386
/// Non-Odoo targets simply skip the legacy trait and impl this one directly.
387+
///
388+
/// # Method-name collision with `RouteBucket` (deliberate — read this)
389+
///
390+
/// This trait intentionally reuses the `kind` / `id` / `id_owned` method
391+
/// names so the two traits carry ONE contract shape, and the blanket impl
392+
/// delegates verbatim to `RouteBucket` — the two resolutions are always
393+
/// semantically identical. The cost (codex P2, PR #632): a scope that
394+
/// brings BOTH traits in unqualified (e.g. `use codegen_spine::*;`) makes
395+
/// `bucket.kind()` ambiguous on a concrete `RouteBucket` implementor.
396+
/// This is a **compile error with an obvious fix, never silent
397+
/// misbehavior** — disambiguate with UFCS (`RouteBucket::kind(&b)` /
398+
/// `RouteBucketTyped::kind(&b)`, either is correct because the blanket
399+
/// delegates), or import only the trait you consume. Renaming the methods
400+
/// was rejected: it would fork the contract shape and break the
401+
/// already-deployed op-nexgen consumers that code against `kind()`. The
402+
/// tests below demonstrate the UFCS pattern where both traits share a
403+
/// scope.
387404
pub trait RouteBucketTyped {
388405
/// The handler-kind enum specific to this target. Must be `Copy + Eq`
389406
/// so it can drive dispatcher tables / `match` arms / hash keys.

crates/lance-graph-contract/src/emission_scan.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ pub enum TypedForm {
9191
#[inline]
9292
#[must_use]
9393
pub fn classify_ddl_type(ty: &str) -> TypedForm {
94+
let mut saw_record = false;
9495
let mut saw_any = false;
9596
let mut token_count = 0usize;
9697

@@ -100,14 +101,20 @@ pub fn classify_ddl_type(ty: &str) -> TypedForm {
100101
}
101102
token_count += 1;
102103

104+
// Stub is the ONLY early return: it is top precedence, so nothing a
105+
// later token could contain outranks it. `record`/`any` must NOT
106+
// early-return — a stub marker may still follow (e.g.
107+
// `record<user> TODO`, `record<fixme>`), and the documented
108+
// precedence says Stub wins globally, not first-token-wins
109+
// (codex P2, PR #632).
103110
if token.eq_ignore_ascii_case("todo")
104111
|| token.eq_ignore_ascii_case("stub")
105112
|| token.eq_ignore_ascii_case("fixme")
106113
{
107114
return TypedForm::Stub;
108115
}
109116
if token == "record" {
110-
return TypedForm::RecordLink;
117+
saw_record = true;
111118
}
112119
if token == "any" {
113120
saw_any = true;
@@ -118,6 +125,9 @@ pub fn classify_ddl_type(ty: &str) -> TypedForm {
118125
// Empty or whitespace-only expression.
119126
return TypedForm::Stub;
120127
}
128+
if saw_record {
129+
return TypedForm::RecordLink;
130+
}
121131
if saw_any {
122132
return TypedForm::AnyTyped;
123133
}
@@ -263,6 +273,21 @@ mod tests {
263273
assert_eq!(classify_ddl_type("TODO record<any>"), TypedForm::Stub);
264274
}
265275

276+
#[test]
277+
fn classify_ddl_type_stub_marker_after_record_still_wins() {
278+
// Regression (codex P2, PR #632): a stub marker AFTER the `record`
279+
// token must still win — precedence is global over the whole
280+
// expression, never first-token-wins. Before the fix, the early
281+
// return on `record` miscounted partially-stubbed record-link DDL
282+
// as real links.
283+
assert_eq!(classify_ddl_type("record<user> TODO"), TypedForm::Stub);
284+
assert_eq!(classify_ddl_type("record<fixme>"), TypedForm::Stub);
285+
assert_eq!(
286+
classify_ddl_type("array<record<user>> stub"),
287+
TypedForm::Stub
288+
);
289+
}
290+
266291
#[test]
267292
fn classify_ddl_type_false_positive_guard_many_and_recording() {
268293
// Substring "any" inside "many" and substring "record" inside

0 commit comments

Comments
 (0)