feat(parser): an unknown C++ spelling is a build error, not a silent any

cppTypeToLidl ended with `// Fallback: treat as opaque` -> `any`, and `any`
is ADMITTED by every backend gate. So a spelling nobody had written a branch
for was accepted in silence, published as `any`, and dispatched as a bare
`lidlImpl().f(args.at(0))` — no logos::fromJson<>, no check. That is the one
hole #113-#122 closed for every typed slot and left open for anything that
reached `any`.

An 11-method hostile probe was admitted wholesale: uint32_t, size_t, float
and uint8_t all typed as `any`; std::set, std::vector<std::pair<...>> and a
non-string-keyed map as `any`; std::vector<uint32_t> as `[any]`.

Now every spelling with no LIDL type is collected with the declaration that
carries it, and parseImplHeader turns the list into a parse error naming the
offending type and the fix:

  method 'send_generic_public_transaction': parameter 'instruction' declared
  `const std::vector<uint32_t>&`, whose element `uint32_t` has no LIDL type.
    LIDL numbers are 64-bit only. Declare it `uint64_t` (LIDL `uint`).
    Widening is source-compatible for every caller; a narrow type on the
    wire is not, which is why LIDL has none.

Numbers get that tailored hint (uint8_t its own — it means bytes here, and
only as std::vector<uint8_t>); sets, pairs/tuples, non-string map keys,
list/deque/array, Qt types and pointers each get theirs; anything else gets
the full table of recognised spellings. A hint that does not name a
replacement just moves the guesswork, so all of them do.

std::unordered_map<std::string, T> joins std::map as a spelling of
`{tstr: T}` — the codec has always handled both, and the previous commit made
the generated dispatch bind whichever the author declared. Two slots are the
exception and say so: a record FIELD and an event PARAMETER, where the
generator writes the spelling out into code the author's own declaration has
to match and can only pick one name.

Three properties keep this from breaking things it should not:

  * The MAPPING is unchanged. cppTypeToLidl still returns `any` for an
    unsupported spelling; only a diagnostic is recorded. A diagnostic that is
    later withdrawn therefore leaves output byte-identical.
  * Diagnostics are withdrawn for declarations that never reach the contract
    — a helper struct dropped by keepOnlyReferencedRecords, a reserved
    lifecycle hook (onContextReady and friends), a struct with no parsed
    fields. Publishing is what makes a type a promise.
  * An empty spelling is not a C++ type, it is this line-based parser failing
    to find one. It keeps the old behaviour rather than reporting `''`.

Also fixes a latent ordering bug found while threading the context through:
metadata.json's event parameters were typed BEFORE the header was read, i.e.
against whatever g_recordNames the previous module's parse had left behind.
They are now read there and typed after scanForRecords, in the same position
in module.events as before.

Verified by generating over every impl header and every .lidl in the
workspace: 31 derived contracts byte-identical, 368 consumer-umbrella files
byte-identical under both --api-style qt and --api-style lp, 46 generated
types headers byte-identical. Exactly two modules now fail, at exactly the
four slots a prior scan identified as silent admissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Gabriel Lipicar
2026-07-31 10:16:30 -03:00
co-authored by Claude Opus 5
parent 24a7e9a376
commit 16144d4efa
2 changed files with 507 additions and 42 deletions
@@ -743,3 +743,190 @@ TEST_F(ImplHeaderParserTest, NlohmannJsonIsAnyByName)
ASSERT_EQ(r.module.events.size(), 1u);
EXPECT_EQ(r.module.events[0].params[0].type.name, "any");
}
// ---------------------------------------------------------------------------
// A C++ spelling with no LIDL type is a BUILD ERROR, not a silent `any`.
//
// cppTypeToLidl used to end with `// Fallback: treat as opaque` -> `any`, and
// `any` is admitted by every backend gate. So an unrecognised spelling was
// accepted in silence, published as `any`, and dispatched as a raw
// `lidlImpl().f(args.at(0))` with no decode and no check.
// ---------------------------------------------------------------------------
TEST_F(ImplHeaderParserTest, NarrowNumericIsRejectedWithTheWidening)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(uint32_t depth);\n"
"};\n");
ASSERT_FALSE(hp.isEmpty());
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_TRUE(r.hasError()) << "uint32_t was admitted as `any`";
// Names the declaration, the offending type, and what to write instead.
EXPECT_TRUE(r.error.contains("method 'f': parameter 'depth'")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint32_t`")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint64_t`")) << r.error.toStdString();
}
// The offender may be nested. Report BOTH: the element with no LIDL type, and
// the declaration that carries it.
TEST_F(ImplHeaderParserTest, NestedOffenderNamesTheDeclarationToo)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(const std::vector<uint32_t>& instruction);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_TRUE(r.hasError());
EXPECT_TRUE(r.error.contains("const std::vector<uint32_t>&")) << r.error.toStdString();
EXPECT_TRUE(r.error.contains("`uint32_t`")) << r.error.toStdString();
}
// Each family gets a hint that names a replacement. A diagnostic without one
// just moves the guesswork.
TEST_F(ImplHeaderParserTest, EachUnsupportedFamilyNamesItsReplacement)
{
struct Case { const char* decl; const char* mentions; };
const Case cases[] = {
{"int64_t f(float v);", "`double`"},
{"int64_t f(uint8_t v);", "std::vector<uint8_t>"},
{"int64_t f(size_t v);", "`uint64_t`"},
{"int64_t f(const std::set<std::string>& v);", "std::vector<T>"},
{"int64_t f(const std::pair<std::string, std::string>& v);", "struct"},
{"int64_t f(const std::map<int64_t, std::string>& v);", "`tstr`"},
};
for (const Case& c : cases) {
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
QString("class ProbeImpl {\npublic:\n %1\n};\n").arg(c.decl));
QString e;
QTextStream es(&e);
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es);
ASSERT_TRUE(r.hasError()) << c.decl;
EXPECT_TRUE(r.error.contains(c.mentions))
<< c.decl << "\n" << r.error.toStdString();
}
}
// std::unordered_map<std::string, T> is the second C++ spelling of `{tstr: T}`.
// logos_codec.h has always specialized Codec for it; the parser had not, so it
// published `any`.
TEST_F(ImplHeaderParserTest, UnorderedMapIsAStringKeyedMap)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" int64_t f(const std::unordered_map<std::string, std::string>& m);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.methods.size(), 1u);
const TypeExpr& t = r.module.methods[0].params[0].type;
EXPECT_EQ(t.kind, TypeExpr::Map);
ASSERT_EQ(t.elements.size(), 2u);
EXPECT_EQ(t.elements[0].name, "tstr");
EXPECT_EQ(t.elements[1].name, "tstr");
}
// ...except in the two slots whose C++ spelling the generator WRITES OUT — a
// record field's codec and an event's generated body. There it has to pick one
// container name, and picking the wrong one is a compile error in code the
// author never wrote. Say so at the declaration instead.
TEST_F(ImplHeaderParserTest, UnorderedMapIsRejectedWhereTheSpellingIsEmitted)
{
for (const char* body : {
"struct Rec {\n"
" std::unordered_map<std::string, std::string> m;\n"
"};\n"
"class ProbeImpl {\npublic:\n Rec echo(const Rec& v);\n};\n",
"class ProbeImpl {\n"
"public:\n"
" bool fire();\n"
"logos_events:\n"
" void changed(const std::unordered_map<std::string, std::string>& m);\n"
"};\n"}) {
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir, body);
QString e;
QTextStream es(&e);
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es);
ASSERT_TRUE(r.hasError()) << body;
EXPECT_TRUE(r.error.contains("std::map<std::string, T>")) << r.error.toStdString();
}
}
// A helper struct the API never mentions is dropped from the contract, so an
// unsupported spelling INSIDE it promises nothing and must not fail the build.
// Publishing is what makes a declaration's type a promise.
TEST_F(ImplHeaderParserTest, UnreferencedHelperStructDoesNotFailTheBuild)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"struct PendingAction {\n"
" uint32_t attempts;\n"
"};\n"
"class ProbeImpl {\n"
"public:\n"
" int64_t f(int64_t n);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
EXPECT_TRUE(r.module.types.empty());
// ...and the same struct DOES fail once a method publishes it.
QTemporaryDir dir2;
ASSERT_TRUE(dir2.isValid());
const QString hp2 = probeHeader(dir2,
"struct PendingAction {\n"
" uint32_t attempts;\n"
"};\n"
"class ProbeImpl {\n"
"public:\n"
" PendingAction f(int64_t n);\n"
"};\n");
QString e2;
QTextStream es2(&e2);
auto r2 = parseImplHeader(hp2, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", es2);
ASSERT_TRUE(r2.hasError());
EXPECT_TRUE(r2.error.contains("type 'PendingAction': field 'attempts'"))
<< r2.error.toStdString();
}
// The reserved LogosModuleContext hooks are framework plumbing, not contract.
// They are dropped after parsing, so their spellings are not a contract defect.
TEST_F(ImplHeaderParserTest, ReservedHookSpellingsAreNotContractDefects)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString hp = probeHeader(dir,
"class ProbeImpl {\n"
"public:\n"
" void onContextReady(uint32_t generation);\n"
" int64_t f(int64_t n);\n"
"};\n");
auto r = parseImplHeader(hp, "ProbeImpl",
fixturesDir() + "/sample_metadata.json", err);
ASSERT_FALSE(r.hasError()) << r.error.toStdString();
ASSERT_EQ(r.module.methods.size(), 1u);
EXPECT_EQ(r.module.methods[0].name, "f");
}