App-karr

 view release on metacpan or  search on metacpan

t/121-role-import-leakage.t  view on Meta::CPAN

use strict;
use warnings;
use Test::More;
use B ();

use App::karr::Role::BoardAccess;
use App::karr::Role::BoardDiscovery;
use App::karr::Role::ClaimTimeout;
use App::karr::Role::CliArgs;
use App::karr::Role::CompactOutput;
use App::karr::Role::DependencyArgs;
use App::karr::Role::DependencyCheck;
use App::karr::Role::ExitCodes;
use App::karr::Role::Output;
use App::karr::Role::SkillFile;
use App::karr::Role::SyncLifecycle;
use App::karr::Role::TaskMutation;

# Ticket #38: a Moo::Role composes *every* sub in its package into its
# consumers, imported ones included. App::karr::Role::BoardDiscovery said
# `use Path::Tiny;` and `use Carp qw( croak );`, and
# App::karr::Role::SyncLifecycle said the second one, so on the pre-fix tree:
#
#   $ perl -Ilib -e 'use App::karr::Cmd::List;
#       print App::karr::Cmd::List->can($_) ? "$_ YES\n" : "$_ no\n"
#         for qw( path croak )'
#   path YES
#   croak YES
#
# Nothing called either one -- the damage is latent, not broken. It is a
# collision hazard: the first command class that wants an attribute named
# `path` would silently fight an inherited Path::Tiny constructor, and
# `$cmd->croak(...)` is nonsense that type-checks fine. namespace::clean is not
# available as a cure (CLAUDE.md rules it out in this dist: it is incompatible
# with MooX::Options), so the rule is that a role loads its dependencies with an
# explicit empty import list and qualifies the call at the site, the way
# App::karr::Role::Output and App::karr::Role::TaskMutation already do.
#
# The tests below are written against the ROLE PACKAGES rather than against one
# command class, because the leak is a property of the role: whatever sits in
# its symbol table lands on all ~20 consumers.

# Sugar the object system installs into every role package. Not an import karr
# chose, not karr's to remove, and identifiable by where the sub actually lives
# rather than by name -- so a karr sub that happens to be called `has` would
# still be seen.
my $FRAMEWORK = qr{
    \A (?: Moo::Role
         | MooX::Options (?: ::\w+ )*
         | MooX::Locale::Passthrough
      ) \z
}x;

# Imports that leak today, are NOT part of the ticket in hand, and are recorded
# here so that a NEW one fails this test instead of joining them unnoticed. An
# entry also has to describe reality: the loop below fails when a listed leak is
# gone, so fixing one forces the entry out rather than leaving a licence behind
# for a future import of the same name.
#
# Empty since #105 closed the last two: App::karr::Role::ClaimTimeout and
# App::karr::Role::TaskMutation both said `use Time::Piece;`, which put its
# localtime/gmtime replacements on every mutating command class.
my %KNOWN_LEAK = ();

my @ROLES = map { "App::karr::Role::$_" } qw(
    BoardAccess BoardDiscovery ClaimTimeout CliArgs CompactOutput
    DependencyArgs DependencyCheck ExitCodes Output SkillFile SyncLifecycle
    TaskMutation
);

# Every sub in $pkg's symbol table, paired with the package it was really
# defined in. That second half is what tells an import apart from a definition.
sub subs_with_origin {
    my ($pkg) = @_;
    no strict 'refs';
    my %origin;
    for my $name ( sort keys %{"${pkg}::"} ) {
        next unless defined &{"${pkg}::$name"};
        $origin{$name} = B::svref_2object( \&{"${pkg}::$name"} )->GV->STASH->NAME;
    }
    return %origin;
}

subtest 'the two imports named in the ticket are gone from the role packages' => sub {
    my %board = subs_with_origin('App::karr::Role::BoardDiscovery');
    my %sync  = subs_with_origin('App::karr::Role::SyncLifecycle');

    ok !exists $board{path},  'BoardDiscovery no longer holds Path::Tiny::path';
    ok !exists $board{croak}, 'BoardDiscovery no longer holds Carp::croak';
    ok !exists $sync{croak},  'SyncLifecycle no longer holds Carp::croak';

    # They went by losing the import, not the dependency: both roles still use
    # these modules, qualified at the call site.
    ok $INC{'Path/Tiny.pm'},      'Path::Tiny is still loaded';
    ok $INC{'App/karr/Error.pm'}, 'App::karr::Error is still loaded';

    # And the replacement must not have re-created the problem one name over:
    # App::karr::Error is imported by name in the Cmd/* classes, but a role has
    # to load it with `()` and call App::karr::Error::user_error.
    ok !exists $board{user_error}, 'BoardDiscovery did not swap croak for an imported user_error';
    ok !exists $sync{user_error},  'SyncLifecycle did not either';
};

# Ticket #105: the same hazard in the two mutation roles, which both said
# `use Time::Piece;`. That one shadows builtins, so it was the worse of the
# family: a later `sub localtime` or an attribute of that name on move, edit,
# delete, archive or handoff would have fought an inherited export, and the
# failure would have read as a core function misbehaving.
subtest 'the mutation roles no longer compose Time::Piece over the builtins' => sub {
    my %claim = subs_with_origin('App::karr::Role::ClaimTimeout');
    my %mut   = subs_with_origin('App::karr::Role::TaskMutation');

    for my $name (qw( gmtime localtime )) {
        ok !exists $claim{$name}, "ClaimTimeout no longer holds Time::Piece::$name";
        ok !exists $mut{$name},   "TaskMutation no longer holds Time::Piece::$name";
    }

    # ClaimTimeout kept the dependency and lost only the import: _claim_expired
    # still needs the overloaded object, because the builtin gmtime returns a
    # string there and the subtraction against the parsed stamp would be
    # nonsense. t/72-claim-timeout.t is what proves the arithmetic still works;
    # this only proves the module is still there to do it.
    ok $INC{'Time/Piece.pm'}, 'Time::Piece is still loaded';
    is $claim{check_claim}, 'App::karr::Role::ClaimTimeout',
        'ClaimTimeout still defines its own subs';
};

# Deliberately not a real command class: MooX::Cmd::Role does `use Carp;`
# upstream and composes croak into every one of them, which karr cannot fix from
# here and which would mask a regression in karr's own roles.
{
    package RoleConsumer;
    use Moo;
    use MooX::Options;
    with 'App::karr::Role::BoardDiscovery', 'App::karr::Role::SyncLifecycle';
}

{
    package MutationConsumer;
    use Moo;
    use MooX::Options;
    # Stubs, not App::karr::Role::BoardAccess: since ticket #128
    # App::karr::Role::DependencyCheck declares what it calls on its consumer,
    # and TaskMutation composes it, so those names have to be here for the
    # composition to go through. Composing the real supplier would pull in three
    # more roles and defeat the point of this class, which is to inherit nothing
    # but the roles under test. t/135-dependency-check-requires.t is where the
    # requirement itself is checked -- json joined the list and usage_error left
    # it when ticket #137 split the set-time helpers out into
    # App::karr::Role::DependencyArgs, which TaskMutation does not compose.
    #
    # git, save_task and log_task_write are TaskMutation's own, added by ticket
    # #141: it called all three on its consumer while declaring nothing, so this
    # class composed by accident rather than by contract.
    # t/142-task-mutation-requires.t is that list's own test.
    sub store          { }
    sub find_task      { }
    sub json           { }
    sub quiet          { }
    sub git            { }
    sub save_task      { }
    sub log_task_write { }
    with 'App::karr::Role::TaskMutation';
}

subtest 'a consumer of the mutation roles inherits no time function' => sub {
    for my $leak (qw( gmtime localtime )) {
        ok( !MutationConsumer->can($leak),
            "a consumer of TaskMutation has no ->$leak" );
    }

    ok( MutationConsumer->can($_), "...but still has ->$_" )
        for qw( run_batch update_task_guarded apply_status_change check_claim );
};

subtest 'a consumer of the board roles inherits none of it' => sub {
    for my $leak (qw( path croak user_error clean_error )) {
        ok( !RoleConsumer->can($leak),
            "a consumer of BoardDiscovery + SyncLifecycle has no ->$leak" );
    }

    # The roles are still doing their job, i.e. the cleanup did not empty them.
    ok( RoleConsumer->can($_), "...but still has ->$_" )
        for qw( git_root store config sync_before sync_after );
};



( run in 1.578 second using v1.01-cache-2.11-cpan-85d3896f969 )