mirror of
https://github.com/logos-co/logos-package-manager-module.git
synced 2026-08-31 12:31:11 +00:00
* feat(deps): carry dependency constraints and the mismatch status across the ABI
toLogosMap(const InstalledPackage&) is where the range and the signer died.
lgpm carried {name, version, signer} intact from metadata.json through the
.lgx, `lgx verify` and `lgpm install` onto disk; two readers over the SAME
installed tree then disagreed:
lgpm --json info app -> dependencyConstraints:[{...}]
package_manager.getInstalledPackages -> dependencies:["lib"]
Same library, same files, one serialiser -- this one. Everything past it,
basecamp included, was reading a payload the constraint had been stripped
from.
`dependencyConstraints` is added as a SEPARATE key rather than by widening
`dependencies`, because widening it would fail SILENTLY. basecamp's
PluginLoader::loadCoreDependencies reads that very list (via UIPluginManager's
m_uiPluginMetadata, filled from getInstalledUiPlugins) and does
QString depName = dep.toString(); if (depName.isEmpty()) continue;
-- a QVariantMap stringifies to empty, so an object-form entry would be
skipped and the dependency simply never loaded, with no diagnostic. The graph
edge and the constraint on that edge are different facts; they now travel in
different keys.
toFlatLogosMap gains the same treatment for the tree APIs: version_mismatch
keeps `version` and `installType` because it resolved to a real installed
package and "needs ^2.0.0, have 1.0.0" takes both numbers, and the per-edge
`requiredVersion` / `requiredSigner` are emitted only when the parent declared
them. Nothing here evaluates the signer.
Both keys are absent whenever every entry is a bare name, which is every
package in the fleet today, so this is backward-compatible by construction.
The unit tests build against tests/stubs/package_manager_lib.h, so the stub
and its mock are updated to mirror the real header -- otherwise the impl
compiles in the integration target and not the mocked one.
Tests 100 -> 107. Red on the base, reading only emitted keys so the probe
needs no field the base lacks:
ASSERT failed: list[0].contains("dependencyConstraints")
Note: the five integration test files are NOT built by the `unit-tests` check
-- CMake reports "package_manager_lib not found in ../lib -- skipping
integration tests" because the test derivation never stages the external lib.
The end-to-end tests added to tests/test_scanning.cpp are therefore correct
but unexercised in CI; they were driven by hand against the real library.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(deps): carry the observed signer, and the two signer statuses, across the ABI
Two facts have to reach the far side, and they are different facts:
requiredSigner — a PIN a dependant declared. A demand.
observedSigner — the DID whose signature over the installed package was
VERIFIED at install. An observation.
Everything downstream ("published by a different signer") needs both. The pin
alone does not say what went wrong; the observation alone is an accusation with
no charge attached. `requiredSigner` already crossed and was, in this file's own
words, "carried and compared by nobody" — logos-package-manager now compares it,
so the other half has to arrive too.
New statuses, both about packages that ARE installed:
signer_mismatch — provably not the package the dependant named
signer_unknown — the pin could not be checked; nothing recorded a
publisher. Absence of evidence, its own status.
`observedSigner` is additive and emitted only when something was observed, so
an unsigned, embedded or pre-sidecar install crosses byte-identically to
before, and a reader can tell "nothing recorded" (key absent) from a recorded
value. A key present with an empty string would be neither, and would read as
"observed to be unsigned" — which nothing on disk records.
Also replaces `Installed || VersionMismatch` with
nodeResolvedToAnInstalledPackage at the version/installType gate. That chain
named the two statuses that existed when it was written, so the moment these
two were appended it would have started blanking `version` on packages sitting
right there on disk — in this file and in the library's JSON emitter, quietly,
at two call sites in two repositories. The RED run below shows exactly that:
"expected [1.0.0] but got []" for a signer-mismatched dependency.
And the mock's flatten() is brought back in line with the real library, which
PROMOTES on dedup (a later edge judging a package more harshly wins) rather
than merely deduping. The mock had been left behind by that change, so a module
test could have seen a satisfied row for a package the real resolver rejects. A
test double that disagrees with the thing it doubles is worse than none.
RED (observedSigner dropped from both emitters, gate back to the two-status
chain): 109 passed, 4 failed
getInstalledPackages_carries_the_observed_signer
resolveDependencies_surfaces_signer_mismatch_with_both_dids
resolveFlatDependencies_surfaces_signer_mismatch
resolveDependencies_surfaces_signer_unknown_without_an_observed_did
ASSERT_EQ failed: expected [1.0.0] but got []
GREEN: 113 passed.
Both runs use --override-input logos-package-manager path:<local checkout>:
this branch's flake.lock is still pinned at the parent branch's mid-history
commit and gets relocked when the parent merges, per the stack's merge order.
checks.x86_64-linux.unit-tests
-> /nix/store/rd7lrab96jvi9n3ycjx2h8czhs9j91a3-logos-package_manager-tests-1.0.0
packages.x86_64-linux.lib
-> /nix/store/hlnjk6rrz73q6hjcsn4c3vr14y57liis-logos-package_manager-module-lib-1.0.0
packages.x86_64-linux.default
-> /nix/store/wvlj38yw066icjypvppzncjpc442ympr-logos-package_manager-module
That .so was then staged into a real installed tree and driven through a real
logoscore daemon; the five wire payloads are in the branch summary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(deps): follow the rename of observedSigner to signerDid
logos-package-manager stopped recording who it verified at install and started
carrying the package's own manifest.sig into the install tree, so the value
that crosses this ABI changed meaning and changed name with it.
`observedSigner` was an observation the installer made and wrote down.
`signerDid` is what the installed signature says about itself, reported only
once that signature has been checked against the key its own DID carries. The
difference matters to a reader on the far side: it is not corroborated by
anything outside the document, so it is for display, and the verdict beside it
is NOT these two values compared — it comes from verifying the installed
signature under the PIN's key. A `signer_mismatch` row therefore carries a
`signerDid` that differs from `requiredSigner`, and that is the normal shape of
that row rather than an inconsistency.
Wire key renamed to match, in both the installed-packages map and the
dependency-tree node. Still additive and still absent when there is no usable
signature, so an unsigned or embedded package crosses the ABI byte-identically
to before.
The test stub mirrors the real struct and moves with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(deps): build against the library this module needs, and pin the wire shape exactly
CI here runs `nix build -L` — the real module, linking the real
logos-package-manager. The lock pinned 7c5aad9a (2026-08-22), whose
DependencyTreeNode has neither `requiredSigner` nor `signerDid`, so that build
does not compile:
error: no member named requiredSigner in const struct DependencyTreeNode
error: no member named signerDid in const struct DependencyTreeNode
That is a red build, not a latent risk. And the one check would never have said
so: `tests.mockCLibs = ["logos_pm"]` builds the unit tests against
tests/stubs/package_manager_lib.h, a hand-maintained MIRROR of the real header.
It compiles and passes against a library it never links, so the check is green
while `nix build` is red and nothing connects the two. flake.nix now pins the
branch carrying those fields, with a REVERT-ON-MERGE note saying exactly this.
Measured: `nix build` and `nix build .#unit-tests` both pass with NO override.
The backward-compatibility test moves from three absences to an EXACT KEY SET.
Naming three keys only rules out the three somebody thought of, and the way an
additive key fails is by turning out not to be conditional. `signerDid` is that
shape: it is a property of the PACKAGE, not of the edge, so it does not become
absent because an edge declared no constraint. A real capture of a satisfied
pin carries `status:"installed"` and a `signerDid` together — so a test that
only looked for requiredVersion/requiredSigner would not have noticed it appear
on an unconstrained node. Counting and naming every key does. Proven by
mutation: one unconditional extra key turns the test red.
Stub and test prose follow the mechanism. What an embedded package is missing
is a manifest.sig, and it can never have one because nothing but
installPluginFile copies one into an install tree — not a record nobody wrote.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(deps): cut this PR's comments back to what a reader needs
217 added comment lines -> 132. Comments only; no code line moved or changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
299 lines
11 KiB
C++
299 lines
11 KiB
C++
/**
|
|
* Tests for module/plugin scanning methods on PackageManagerImpl.
|
|
*/
|
|
#include <logos_test.h>
|
|
#include "package_manager_impl.h"
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QTemporaryDir>
|
|
|
|
/**
|
|
* Helper: create a fake installed module directory with manifest.json.
|
|
*/
|
|
static void createFakeModule(const QString& baseDir, const QString& name,
|
|
const QString& type, const QString& version = "1.0.0") {
|
|
QString moduleDir = baseDir + "/" + name;
|
|
QDir().mkpath(moduleDir);
|
|
|
|
QJsonObject manifest;
|
|
manifest["name"] = name;
|
|
manifest["type"] = type;
|
|
manifest["version"] = version;
|
|
manifest["description"] = "Test module " + name;
|
|
manifest["category"] = "test";
|
|
manifest["main"] = name + ".so";
|
|
|
|
QFile f(moduleDir + "/manifest.json");
|
|
f.open(QIODevice::WriteOnly);
|
|
f.write(QJsonDocument(manifest).toJson());
|
|
}
|
|
|
|
// =============================================================================
|
|
// Empty / non-existent directories
|
|
// =============================================================================
|
|
|
|
LOGOS_TEST(get_installed_packages_empty_dir) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList packages = impl.getInstalledPackages();
|
|
LOGOS_ASSERT_EQ(packages.size(), static_cast<size_t>(0));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_modules_empty_dir) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList modules = impl.getInstalledModules();
|
|
LOGOS_ASSERT_EQ(modules.size(), static_cast<size_t>(0));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_ui_plugins_empty_dir) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
impl.setEmbeddedUiPluginsDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList plugins = impl.getInstalledUiPlugins();
|
|
LOGOS_ASSERT_EQ(plugins.size(), static_cast<size_t>(0));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Scanning populated directories
|
|
// =============================================================================
|
|
|
|
LOGOS_TEST(get_installed_modules_finds_core_modules) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
createFakeModule(tmpDir.path(), "core_mod", "core");
|
|
createFakeModule(tmpDir.path(), "ui_mod", "ui");
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList modules = impl.getInstalledModules();
|
|
// Only core modules should be returned
|
|
LOGOS_ASSERT_EQ(modules.size(), static_cast<size_t>(1));
|
|
|
|
LogosMap mod = modules[0];
|
|
LOGOS_ASSERT_EQ(mod["name"].get<std::string>(), std::string("core_mod"));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_ui_plugins_finds_ui_modules) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
createFakeModule(tmpDir.path(), "core_mod", "core");
|
|
createFakeModule(tmpDir.path(), "ui_mod", "ui");
|
|
createFakeModule(tmpDir.path(), "qml_mod", "ui_qml");
|
|
|
|
impl.setEmbeddedUiPluginsDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList plugins = impl.getInstalledUiPlugins();
|
|
// Should find ui and ui_qml modules
|
|
LOGOS_ASSERT_EQ(plugins.size(), static_cast<size_t>(2));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_packages_returns_all_types) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
createFakeModule(tmpDir.path(), "core_mod", "core");
|
|
createFakeModule(tmpDir.path(), "ui_mod", "ui");
|
|
createFakeModule(tmpDir.path(), "qml_mod", "ui_qml");
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList packages = impl.getInstalledPackages();
|
|
LOGOS_ASSERT_EQ(packages.size(), static_cast<size_t>(3));
|
|
}
|
|
|
|
LOGOS_TEST(scanned_modules_contain_manifest_fields) {
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
createFakeModule(tmpDir.path(), "test_mod", "core", "2.1.0");
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList modules = impl.getInstalledModules();
|
|
LOGOS_ASSERT_EQ(modules.size(), static_cast<size_t>(1));
|
|
|
|
LogosMap mod = modules[0];
|
|
LOGOS_ASSERT_EQ(mod["name"].get<std::string>(), std::string("test_mod"));
|
|
LOGOS_ASSERT_EQ(mod["version"].get<std::string>(), std::string("2.1.0"));
|
|
LOGOS_ASSERT_EQ(mod["type"].get<std::string>(), std::string("core"));
|
|
}
|
|
|
|
// =============================================================================
|
|
// Dependency constraints, end to end through the REAL PackageManagerLib
|
|
//
|
|
// test_package_manager.cpp mocks the library, so it pins the serialiser but not
|
|
// the evaluation. These run the real scanner and semver engine over an on-disk
|
|
// manifest — the only place the range surviving the scan, being evaluated,
|
|
// and crossing THIS ABI intact are all exercised together.
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Fake installed module whose `dependencies` array is written verbatim, so a
|
|
* test can use the object form { name, version, signer } that the plain-string
|
|
* helper above cannot express.
|
|
*/
|
|
static void createFakeModuleWithRawDeps(const QString& baseDir, const QString& name,
|
|
const QJsonArray& deps,
|
|
const QString& version = "1.0.0") {
|
|
QString moduleDir = baseDir + "/" + name;
|
|
QDir().mkpath(moduleDir);
|
|
|
|
QJsonObject manifest;
|
|
manifest["name"] = name;
|
|
manifest["type"] = "core";
|
|
manifest["version"] = version;
|
|
manifest["main"] = name + ".so";
|
|
manifest["dependencies"] = deps;
|
|
|
|
QFile f(moduleDir + "/manifest.json");
|
|
f.open(QIODevice::WriteOnly);
|
|
f.write(QJsonDocument(manifest).toJson());
|
|
}
|
|
|
|
LOGOS_TEST(resolve_dependencies_reports_version_mismatch_end_to_end) {
|
|
// app needs lib ^2.0.0; lib 1.0.0 is installed.
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
QJsonObject dep;
|
|
dep["name"] = "lib";
|
|
dep["version"] = "^2.0.0";
|
|
createFakeModuleWithRawDeps(tmpDir.path(), "app", QJsonArray{dep});
|
|
createFakeModule(tmpDir.path(), "lib", "core", "1.0.0");
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosMap tree = impl.resolveDependencies("app", true);
|
|
LOGOS_ASSERT_EQ(tree["children"].size(), static_cast<size_t>(1));
|
|
LogosMap child = tree["children"][0];
|
|
LOGOS_ASSERT_EQ(child["name"].get<std::string>(), std::string("lib"));
|
|
LOGOS_ASSERT_EQ(child["status"].get<std::string>(), std::string("version_mismatch"));
|
|
// Both numbers reach the caller: what was asked for, and what is there.
|
|
LOGOS_ASSERT_EQ(child["requiredVersion"].get<std::string>(), std::string("^2.0.0"));
|
|
LOGOS_ASSERT_EQ(child["version"].get<std::string>(), std::string("1.0.0"));
|
|
}
|
|
|
|
LOGOS_TEST(resolve_dependencies_satisfied_range_is_installed_end_to_end) {
|
|
// The control: same shape, lib inside the range.
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
QJsonObject dep;
|
|
dep["name"] = "lib";
|
|
dep["version"] = "^2.0.0";
|
|
createFakeModuleWithRawDeps(tmpDir.path(), "app", QJsonArray{dep});
|
|
createFakeModule(tmpDir.path(), "lib", "core", "2.1.0");
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosMap tree = impl.resolveDependencies("app", true);
|
|
LogosMap child = tree["children"][0];
|
|
LOGOS_ASSERT_EQ(child["status"].get<std::string>(), std::string("installed"));
|
|
LOGOS_ASSERT_EQ(child["requiredVersion"].get<std::string>(), std::string("^2.0.0"));
|
|
}
|
|
|
|
LOGOS_TEST(resolve_dependencies_absent_outranks_mismatch_end_to_end) {
|
|
// Absent AND constrained reports absence, the stronger fact; the range
|
|
// still travels so a caller can name the version to install.
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
QJsonObject dep;
|
|
dep["name"] = "lib";
|
|
dep["version"] = "^2.0.0";
|
|
createFakeModuleWithRawDeps(tmpDir.path(), "app", QJsonArray{dep});
|
|
// lib deliberately not created.
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosMap tree = impl.resolveDependencies("app", true);
|
|
LogosMap child = tree["children"][0];
|
|
LOGOS_ASSERT_EQ(child["status"].get<std::string>(), std::string("not_installed"));
|
|
LOGOS_ASSERT_EQ(child["version"].get<std::string>(), std::string(""));
|
|
LOGOS_ASSERT_EQ(child["requiredVersion"].get<std::string>(), std::string("^2.0.0"));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_packages_carries_constraints_end_to_end) {
|
|
// Over a real manifest, `lgpm --json info` and this API report the same
|
|
// constraint.
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
QJsonObject dep;
|
|
dep["name"] = "lib";
|
|
dep["version"] = "^2.0.0";
|
|
dep["signer"] = "did:jwk:eyJrdHkiOiJPS1AifQ";
|
|
createFakeModuleWithRawDeps(tmpDir.path(), "app", QJsonArray{QString("plain"), dep});
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList packages = impl.getInstalledPackages();
|
|
LOGOS_ASSERT_EQ(packages.size(), static_cast<size_t>(1));
|
|
LogosMap app = packages[0];
|
|
|
|
// The edge set is untouched: still plain strings, both entries, in order.
|
|
LOGOS_ASSERT_EQ(app["dependencies"].size(), static_cast<size_t>(2));
|
|
LOGOS_ASSERT_EQ(app["dependencies"][0].get<std::string>(), std::string("plain"));
|
|
LOGOS_ASSERT_EQ(app["dependencies"][1].get<std::string>(), std::string("lib"));
|
|
|
|
LOGOS_ASSERT_TRUE(app.contains("dependencyConstraints"));
|
|
LOGOS_ASSERT_EQ(app["dependencyConstraints"].size(), static_cast<size_t>(1));
|
|
LogosMap c = app["dependencyConstraints"][0];
|
|
LOGOS_ASSERT_EQ(c["name"].get<std::string>(), std::string("lib"));
|
|
LOGOS_ASSERT_EQ(c["version"].get<std::string>(), std::string("^2.0.0"));
|
|
LOGOS_ASSERT_EQ(c["signer"].get<std::string>(), std::string("did:jwk:eyJrdHkiOiJPS1AifQ"));
|
|
}
|
|
|
|
LOGOS_TEST(get_installed_packages_omits_constraints_for_bare_names_end_to_end) {
|
|
// The key must be absent entirely.
|
|
PackageManagerImpl impl;
|
|
|
|
QTemporaryDir tmpDir;
|
|
LOGOS_ASSERT_TRUE(tmpDir.isValid());
|
|
|
|
createFakeModuleWithRawDeps(tmpDir.path(), "app", QJsonArray{QString("lib")});
|
|
|
|
impl.setEmbeddedModulesDirectory(tmpDir.path().toStdString());
|
|
|
|
LogosList packages = impl.getInstalledPackages();
|
|
LOGOS_ASSERT_EQ(packages.size(), static_cast<size_t>(1));
|
|
LOGOS_ASSERT_EQ(packages[0]["dependencies"].size(), static_cast<size_t>(1));
|
|
LOGOS_ASSERT_FALSE(packages[0].contains("dependencyConstraints"));
|
|
}
|