App-Ariza.git | t/ | 18-licensing.rakutest


use v6.d;
use Test;

use JSON::Fast;

use App::Ariza::Config;
use App::Ariza::Licensing;
use App::Ariza::Resources;
use App::Ariza::Site;
use App::Ariza::Tools;

plan 16;

sub tmp-dir(--> IO::Path) {
    my $dir = $*TMPDIR.add("ariza-licensing-{$*PID}-{(^1_000_000).pick}");
    $dir.mkdir;
    $dir;
}

#| An app checkout: META6.json, ariza.toml, and (optionally) a LICENSE.
sub fake-app(
    IO::Path $dir,
    Str :$licensing = '',
    Str :$license = 'Artistic-2.0',
    Bool :$license-file = True,
    --> IO::Path
) {
    ensure-dir($dir);
    my %meta =
        name => 'App::ExampleApp', version => '9.9.9',
        authors => ['A Person'],
        source-url => 'https://example.invalid/exampleapp.git',
    ;
    %meta<license> = $license if $license.defined;
    $dir.add('META6.json').spurt(to-json(%meta));
    $dir.add('LICENSE').spurt("The application's own licence text.\n")
        if $license-file;
    $dir.add('ariza.toml').spurt(
        "[app]\nname = \"App::ExampleApp\"\nexec = \"exampleapp\"\n"
      ~ "display = \"Example App\"\n" ~ $licensing);
    $dir
}

#| One installed distribution in a repository inside the bundle.
sub fake-dist(IO() $repo, *%meta) {
    ensure-dir($repo.add('dist'))
        .add(%meta<name>.subst('::', '-', :g))
        .spurt(to-json(%meta.Hash));
}

#| The smallest bundle the collectors can be pointed at: one site
#| repository holding the app, and nothing else.
sub fake-bundle(IO::Path $dir --> IO::Path) {
    my $bundle = ensure-dir($dir.add('bundle'));
    fake-dist(App::Ariza::Site.site-dir($bundle), :name<App::ExampleApp>, :ver<9.9.9>,
              :auth<zef:apogee>, :license<Artistic-2.0>);
    $bundle
}

sub write-licensing(IO::Path $bundle, IO::Path $app, *%extra --> Hash) {
    App::Ariza::Licensing.write(
        :bundle-dir($bundle), :config(App::Ariza::Config.load($app)),
        :app-dir($app), :app-version<9.9.9>, :app-display('Example App'),
        :platform<macos-arm64>,
        :placeholders(%(
            'rakudo-version'    => '2026.07',
            'rakudo-tag'        => '2026.07-01',
            'rakudo-url'        => 'https://rakudo.org/dl/rakudo/x.tar.gz',
            'sqlcipher-version' => '4.14.0',
            'sqlcipher-source'  => 'homebrew keg: /opt/x/lib/libsqlcipher.0.dylib',
            'runner-tag'        => 'runner-v1',
            'runner-url'        => 'https://example.invalid/runner.exe',
            'app-exec'          => 'exampleapp',
            'ariza-version'     => '0.1.0',
        )),
        |%extra)
}

#| A pack kit, generated in the shape the upstream emitter produces.
sub fake-md-kit(IO() $dir, :@components!, :@texts = <MIT.txt>) {
    ensure-dir($dir);
    my @lines =
        '# Third-party components in this package', '',
        'Platform: `macos`', '',
        '## Summary', '',
        '| Component | Version | Licence (SPDX) | Source |',
        '|---|---|---|---|',
    ;
    for @components -> %c {
        @lines.push("| {%c<name>} | {%c<version>} | `{%c<spdx>}` | [tarball]({%c<url>}) |");
    }
    @lines.push('', '## Details');
    for @components -> %c {
        @lines.append('', "### {%c<name>} — {%c<version>}", '',
            "* Licence: `{%c<spdx>}`",
            "* Licence text: `LICENSES/{%c<text>}`",
            "* {%c<copyright>}",
            "* Upstream: <{%c<url>}>");
    }
    $dir.add('THIRD-PARTY.md').spurt(@lines.join("\n") ~ "\n");
    my $licenses = ensure-dir($dir.add('LICENSES'));
    $licenses.add($_).spurt("Text of $_\n") for @texts;
    $dir
}

subtest 'an SPDX expression is read as the identifiers in it', {
    plan 7;
    is spdx-ids('MIT').List, ('MIT',), 'one identifier is itself';
    is spdx-ids('LGPL-2.1-or-later').List, ('LGPL-2.1',),
        '`-or-later` names the same text as the licence it qualifies';
    is spdx-ids('GPL-2.0+').List, ('GPL-2.0',), 'and so does the older `+`';
    is spdx-ids('GPL-3.0-only').List, ('GPL-3.0',), 'as does `-only`';
    is spdx-ids('MIT AND BSD-2-Clause AND ISC').List,
        ('MIT', 'BSD-2-Clause', 'ISC'),
        'AND means every one of them travels with the binary';
    is spdx-ids('LGPL-3.0-or-later OR GPL-2.0-or-later').List,
        ('LGPL-3.0', 'GPL-2.0'),
        'and OR means the recipient chooses, so both texts ship';
    is spdx-ids('GPL-3.0-or-later WITH GCC-exception-3.1').List,
        ('GPL-3.0', 'GCC-exception-3.1'),
        'an exception is a text of its own';
};

subtest 'the runtime data file is complete, and every text it cites ships', {
    plan 7;
    my @c = App::Ariza::Licensing.runtime-components;

    ok @c >= 15, 'the vendored runtime and MoarVM\'s own 3rdparty are all in it'
        or diag "components: {@c.map(*.<id>).join(', ')}";

    my %by = @c.map({ .<id> => $_ }).Hash;
    is %by<rakudo><spdx-license>, 'Artistic-2.0', 'Rakudo is Artistic-2.0';
    ok %by<nqp>.defined && %by<moarvm>.defined,
        'and so are the two things inside its archive that are not it';

    # The libraries MoarVM vendors are the reason this file exists: they
    # arrive as compiled bytes inside a binary release with no manifest,
    # so nothing in the bundle can be asked about them.
    my @vendored = @c.map(*.<id>).grep(*.starts-with('moarvm-'));
    ok @vendored >= 10,
        'the C libraries compiled into MoarVM are enumerated, not summarised'
        or diag "vendored: {@vendored.join(', ')}";

    # A row citing a text ariza does not ship would fail at the end of
    # every build, and the shipped data file is not allowed to be the
    # thing that does that.
    my @shipped = resource-list('licenses').map({ .subst('licenses/', '') });
    is @shipped.List,
        ('AGPL-3.0.txt', 'Apache-2.0.txt', 'Artistic-2.0.txt',
         'BSD-2-Clause.txt', 'BSD-3-Clause.txt', 'GPL-2.0.txt', 'GPL-3.0.txt',
         'ISC.txt', 'LGPL-2.1.txt', 'LGPL-3.0.txt',
         'LicenseRef-public-domain.txt', 'MIT.txt', 'OFL-1.1.txt',
         'Unlicense.txt', 'X11.txt', 'Zlib.txt'),
        'the shipped licence texts are exactly this set — adding or losing'
      ~ ' one is a decision, not a side effect';
    my @missing = @c.map({ .<license-files>.list }).flat.unique.sort
                    .grep({ !@shipped.first($_) });
    is @missing.List, (), 'every licence text the data file names is shipped';

    # A `condition` ariza cannot decide would silently drop a row, and a
    # dropped row is a notice not given.
    my @conditions = @c.map({ .<condition> }).grep(*.defined).unique.sort;
    is @conditions.List, ('runner', 'sqlcipher', 'updater'),
        'and the only conditional rows are the three facts a build knows';
};

subtest 'runtime rows are substituted, conditional, and can name what they cover', {
    plan 6;
    my %p = 'rakudo-version' => '2026.07', 'rakudo-tag' => '2026.07-01',
            'rakudo-url' => 'https://example.invalid/r.tar.gz',
            'sqlcipher-version' => '4.14.0', 'sqlcipher-source' => 'a keg',
            'runner-tag' => 'runner-v1', 'runner-url' => 'https://example.invalid/x.exe',
            'app-exec' => 'exampleapp', 'ariza-version' => '0.1.0';

    my @plain = App::Ariza::Licensing.runtime-rows(:placeholders(%p));
    my %by = @plain.map({ .<id> => $_ }).Hash;
    is %by<rakudo><version>, '2026.07',
        'the pinned runtime version is substituted in';
    nok %by<sqlcipher>.defined,
        'a bundle without SQLCipher does not claim to carry it';
    nok %by<ariza-runner>.defined, 'nor a runner it never staged';

    my @with = App::Ariza::Licensing.runtime-rows(
        :placeholders(%p), :conditions(<sqlcipher runner>),
        :files(%( sqlcipher => <libsqlcipher.0.dylib libcrypto.3.dylib> )));
    my %on = @with.map({ .<id> => $_ }).Hash;
    is %on<sqlcipher><version>, '4.14.0', 'a staged SQLCipher gets its row';
    is %on<sqlcipher><files>.List, ('libsqlcipher.0.dylib', 'libcrypto.3.dylib'),
        'naming the files that were actually staged, not a guess';
    ok %on<ariza-runner><name>.contains('exampleapp.exe'),
        'and the runner row is named after the executable it became';
};

subtest 'a data file ariza cannot trust stops the build', {
    plan 5;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my sub data(%d --> IO::Path) {
        my $p = $dir.add("d{(^1_000_000).pick}.json");
        $p.spurt(to-json(%d));
        $p
    }
    my %ok-component =
        id => 'x', name => 'X', version => '1', kind => 'runtime',
        'spdx-license' => 'MIT', 'license-files' => ['MIT.txt'],
        copyright => 'Copyright someone', 'project-url' => 'https://example.invalid/',
        source => 'somewhere',
    ;

    throws-like { App::Ariza::Licensing.runtime-components(:path($dir.add('nope.json'))) },
        Exception, 'a missing data file is not an empty one';

    throws-like {
        App::Ariza::Licensing.runtime-components(
            :path(data(%( 'schema-version' => 2, components => [] ))))
    }, Exception, message => /'schema-version 2'/,
        'a schema this ariza does not read is named rather than guessed at';

    throws-like {
        App::Ariza::Licensing.runtime-components(:path(data(%(
            'schema-version' => 1,
            components => [%(|%ok-component, kind => 'thing'),]))))
    }, Exception, message => /'kind'/, 'an unknown kind is an error';

    throws-like {
        App::Ariza::Licensing.runtime-components(:path(data(%(
            'schema-version' => 1,
            components => [%(|%ok-component, condition => 'phase-of-the-moon'),]))))
    }, Exception, message => /'phase-of-the-moon'/,
        'and so is a condition ariza cannot decide — the alternative is a'
      ~ ' row silently dropped';

    # A placeholder nothing supplies would publish a row with a hole in
    # it, which does not fail, it says something wrong.
    throws-like {
        App::Ariza::Licensing.runtime-rows(:path(data(%(
            'schema-version' => 1,
            components => [%(|%ok-component, version => '{who-knows}'),]))))
    }, Exception, message => /'unknown placeholder'/,
        'an unknown placeholder is an error, not an empty string';
};

subtest 'a native pack manifest is the source of truth for that pack', {
    plan 6;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = ensure-dir($dir.add('bundle'));
    my $kit = ensure-dir($bundle.add('native/Some-Pack/build-7/lib'));

    $kit.add('third-party.json').spurt(to-json(%(
        'schema-version' => 1,
        components => [
            %( id => 'thing', name => 'libthing', version => '2.1',
               'spdx-license' => 'LGPL-2.1-or-later',
               'license-files' => ['LGPL-2.1.txt'],
               copyright => 'Copyright 2026 The libthing authors',
               'project-url' => 'https://example.invalid/libthing',
               source => %( kind => 'tarball', url => 'https://example.invalid/t.tar.gz',
                            sha256 => 'abc' ),
               binaries => %( macos => %( patterns => ['libthing.*.dylib'] ),
                              windows => %( patterns => ['thing.dll'] ) ) ),
            %( id => 'windows-only', name => 'libwin', version => '1.0',
               'spdx-license' => 'MIT', 'license-files' => ['MIT.txt'],
               copyright => 'Copyright 2026 someone',
               'project-url' => 'https://example.invalid/libwin',
               source => %( kind => 'package-manager' ),
               binaries => %( macos => %( patterns => [] ),
                              windows => %( patterns => ['win.dll'] ) ) ),
        ],
    )));
    ensure-dir($kit.add('LICENSES')).add('LGPL-2.1.txt').spurt("LGPL text\n");

    my %macos = App::Ariza::Licensing.pack-rows(:bundle-dir($bundle), :family<macos>);
    my @rows = %macos<rows>.list;

    is +@rows, 1, 'a component that ships nothing on this platform is not listed';
    is @rows[0]<name>, 'libthing', 'the one that does is';
    is @rows[0]<spdx>, 'LGPL-2.1-or-later',
        'with the licence the pack itself declares';
    is @rows[0]<license-files>.List, ('LGPL-2.1.txt',),
        'and the texts it says travel with it';
    ok @rows[0]<provenance>.contains('third-party.json'),
        'the row says the manifest is where that came from';
    ok %macos<texts>.list.first({ .basename eq 'LICENSES' }).defined,
        'and the pack\'s own licence texts are collected for merging';
};

subtest 'a pack with only its generated document is read from the document', {
    plan 8;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = ensure-dir($dir.add('bundle'));

    # The kit every pack ships today: THIRD-PARTY.md and LICENSES/, with
    # no machine-readable manifest beside them. Prose is not the source
    # of truth, but a bundle that ignored it would under-report a real,
    # complete notice sitting inside itself.
    fake-md-kit($bundle.add('native/Some-Pack/build-7/lib'), :components(
        %( name => 'libthing', version => '2.1', spdx => 'LGPL-2.1-or-later',
           text => 'LGPL-2.1.txt', url => 'https://example.invalid/libthing',
           copyright => 'Copyright 2026 The libthing authors' ),
        %( name => 'libother', version => '9', spdx => 'MIT',
           text => 'MIT.txt', url => 'https://example.invalid/libother',
           copyright => 'Copyright 2026 Someone Else' ),
    ), :texts(<LGPL-2.1.txt MIT.txt>));

    my @rows = App::Ariza::Licensing.pack-rows(
        :bundle-dir($bundle), :family<macos>)<rows>.list;

    is +@rows, 2, 'every component in the Summary table becomes a row';
    is @rows[0]<name>, 'libthing', 'named as the document names it';
    is @rows[0]<spdx>, 'LGPL-2.1-or-later', 'with its licence';
    is @rows[0]<copyright>, 'Copyright 2026 The libthing authors',
        'and the copyright notice out of the Details section';
    is @rows[0]<license-files>.List, ('LGPL-2.1.txt',),
        'citing the texts that section names, not ones inferred from the id';
    ok @rows[0]<provenance>.contains('THIRD-PARTY.md'),
        'the row says the document is where that came from, not a manifest';

    # A component whose name is a prefix of another's must not pick up
    # the other's copyright -- and must not pick a different one on the
    # next run, which unsorted hash keys would have done.
    my @prefixed = App::Ariza::Licensing.md-components(q:to/MD/);
        ## Summary

        | Component | Version | Licence (SPDX) | Source |
        |---|---|---|---|
        | notcurses | 3.0 | `Apache-2.0` | [tarball](https://example.invalid/n) |

        ## Details

        ### notcurses-core — 3.0

        * Copyright 2026 Somebody Else

        ### notcurses — 3.0

        * Copyright 2026 The Right Person
        MD
    is @prefixed[0]<copyright>, 'Copyright 2026 The Right Person',
        'a heading is matched on the whole name, not on a prefix of it';

    # The fallback's whole safety story: a table whose header is not the
    # generated one is not this parser's table, and producing nothing is
    # the only safe answer.
    is App::Ariza::Licensing.md-components(
        "## Summary\n\n| Thing | Who |\n|---|---|\n| a | b |\n").List, (),
        'a document in some other shape produces no rows at all';
};

subtest 'a pack nobody attributed is visible, and strict makes it fatal', {
    plan 6;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = ensure-dir($dir.add('bundle'));
    ensure-dir($bundle.add('native/mystery')).add('libwhatever.dylib').spurt('x');

    my @warnings;
    my @rows = App::Ariza::Licensing.pack-rows(
        :bundle-dir($bundle), :family<macos>, :@warnings)<rows>.list;

    is +@rows, 1, 'the pack is listed rather than dropped';
    is @rows[0]<spdx>, '', 'with no licence, because ariza does not know one';
    ok @rows[0]<notes>.contains('third-party.json'),
        'and a note saying what would fix it';
    ok @warnings.first({ .contains('mystery') }).defined,
        'the build says so out loud as well';

    throws-like {
        App::Ariza::Licensing.pack-rows(:bundle-dir($bundle), :family<macos>,
                                        :strict)
    }, Exception, message => /'licensing.strict'/,
        'and an app that will not ship one says so in its config';

    # A directory ariza staged itself is accounted for by the runtime
    # data, so reporting it as unattributed would be a false alarm.
    ensure-dir($bundle.add('native/sqlcipher')).add('libsqlcipher.so.0').spurt('x');
    my @claimed = App::Ariza::Licensing.pack-rows(:bundle-dir($bundle),
        :family<linux>, :claimed(App::Ariza::Licensing.runtime-claims(
            :conditions(('sqlcipher',)))))<rows>.list;
    is @claimed.map(*.<name>).List, ('mystery',),
        'the SQLCipher ariza staged is not reported as a pack nobody claimed';
};

subtest 'the Raku closure fails closed', {
    plan 5;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $app = fake-app($dir.add('app'));
    my $bundle = fake-bundle($dir);
    fake-dist(App::Ariza::Site.site-dir($bundle), :name<JSON::Fast>, :ver<0.19>,
              :auth<cpan:TIMOTIMO>, :license<Artistic-2.0>,
              :authors(['Timo Paulssen']));
    fake-dist($bundle.add('rakudo/share/perl6/site'), :name<zef>, :ver<1.1.3>,
              :auth<zef:ugexe>, :license<Artistic-2.0>, :authors(['Nick Logan']));

    my @rows = write-licensing($bundle, $app)<rows>.list
                   .grep({ .<kind> eq 'module' });
    is @rows.map(*.<name>).List, ('JSON::Fast', 'zef'),
        'both of a bundle\'s repositories are walked — zef ships inside the'
      ~ ' runtime archive and is redistributed like anything else';
    is @rows.first({ .<name> eq 'zef' })<license-files>.List,
        ('Artistic-2.0.txt',),
        'and each one cites the text for the licence its metadata declares';

    # The app is the application row, not one of its own dependencies.
    nok write-licensing($bundle, $app)<rows>.first({ .<id> eq 'dist/App::ExampleApp' }),
        'the app itself is not listed twice';

    fake-dist(App::Ariza::Site.site-dir($bundle), :name<Silent::Module>, :ver<1.0>, :auth<zef:x>);
    fake-dist(App::Ariza::Site.site-dir($bundle), :name<Vague::Module>, :ver<1.0>, :auth<zef:x>,
              :license('Same as Perl 6'));
    throws-like { write-licensing($bundle, $app) }, Exception,
        message => /'Silent::Module declares no licence' .* 'Vague::Module'
                    .* 'no licence text for'/,
        'a distribution with no usable licence fails the build — and every'
      ~ ' offender is named at once, so a closure with three of them costs'
      ~ ' one build rather than three';

    # The fix an app can apply without waiting for an upstream release.
    my $fixed = fake-app($dir.add('app-fixed'), :licensing(q:to/TOML/));

        [[licensing.dists]]
        name = "Silent::Module"
        spdx-license = "Artistic-2.0"
        notes = "Its META6 has no license field; taken from its LICENSE file."

        [[licensing.dists]]
        name = "Vague::Module"
        spdx-license = "Artistic-2.0"
        TOML
    my @after = write-licensing($bundle, $fixed)<rows>.list
                    .grep({ .<name> eq 'Silent::Module' });
    ok @after[0]<provenance>.contains('licensing.dists'),
        'an app-level override fixes it, and says that it did';
};

subtest 'the app declares its own row, and what ariza cannot see', {
    plan 8;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }

    my $app = fake-app($dir.add('app'));
    my $bundle = fake-bundle($dir);
    my %row = write-licensing($bundle, $app)<rows>.first({ .<kind> eq 'application' });

    is %row<name>, 'App::ExampleApp', 'the application row is the dist name';
    is %row<spdx>, 'Artistic-2.0', 'its licence comes from its own META6';
    is %row<license-files>.List, ('App-ExampleApp.txt',),
        'and its text is the LICENSE file in its repository, not a template';
    is %row<copyright>, 'Authors: A Person',
        'META6 has authors and no copyright, so that is what is said';

    my $declared = fake-app($dir.add('app2'), :licensing(q:to/TOML/));

        [licensing.app]
        copyright = "Copyright 2026 A Person"
        notes = "Built with the encrypted store enabled."

        [[licensing.third-party]]
        name = "The cover artwork"
        version = "1.0"
        spdx-license = "CC-BY-4.0"
        copyright = "Copyright 2026 An Illustrator"
        license-files = ["licenses/CC-BY-4.0.txt"]
        files = ["resources/art/*.png"]
        TOML
    ensure-dir($declared.add('licenses')).add('CC-BY-4.0.txt')
        .spurt("Creative Commons Attribution 4.0 text\n");

    my @rows = write-licensing($bundle, $declared)<rows>.list;
    my %own  = @rows.first({ .<kind> eq 'application' });
    is %own<copyright>, 'Copyright 2026 A Person',
        'what the app says about itself wins over what is derived';
    my %art = @rows.first({ .<name> eq 'The cover artwork' });
    is %art<license-files>.List, ('CC-BY-4.0.txt',),
        'a licence ariza ships no text for travels from the app\'s repository';
    ok $bundle.add('LICENSES/CC-BY-4.0.txt').f,
        'and lands in the bundle beside the rest';

    # An absolute path in a committed config file is a path that exists
    # on one machine.
    my $bad = fake-app($dir.add('app3'), :licensing(q:to/TOML/));

        [[licensing.third-party]]
        name = "Thing"
        spdx-license = "MIT"
        license-files = ["/etc/hosts"]
        TOML
    throws-like { write-licensing($bundle, $bad) }, Exception,
        message => /'absolute'/,
        'a license-files entry outside the app\'s repository is refused';
};

subtest 'every citation resolves, and a text nobody ships is an error', {
    plan 7;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);
    my $app = fake-app($dir.add('app'));

    # A pack citing a text it does not carry and ariza does not ship.
    my $kit = ensure-dir($bundle.add('native/Some-Pack/lib'));
    $kit.add('third-party.json').spurt(to-json(%(
        'schema-version' => 1,
        components => [%( id => 'thing', name => 'libthing', version => '1',
            'spdx-license' => 'Beerware', 'license-files' => ['Beerware.txt'],
            copyright => 'Copyright 2026 nobody',
            'project-url' => 'https://example.invalid/',
            source => %( kind => 'in-tree', url => 'https://example.invalid/' ) ),],
    )));

    throws-like { write-licensing($bundle, $app) }, Exception,
        message => /'Beerware.txt'/,
        'a cited licence text that is nowhere is a hard error, not a dangling link';

    # The same pack, carrying the text: nothing to complain about.
    ensure-dir($kit.add('LICENSES')).add('Beerware.txt').spurt("Beer text\n");
    my %l = write-licensing($bundle, $app);
    ok $bundle.add('LICENSES/Beerware.txt').f,
        'a pack that carries its own text has it merged in';

    # Two sources, same name, different bytes: the difference is said out
    # loud rather than resolved by whichever directory was read first,
    # and the copy nearest the software wins. ariza's texts are SPDX
    # templates -- `<year> <owner>` where a real notice carries a name --
    # so a pack's own copy is the notice that actually travels with its
    # binaries, and the template is only what to fall back to.
    ensure-dir($kit.add('LICENSES')).add('MIT.txt').spurt("The pack's MIT text\n");
    my %again = write-licensing($bundle, $app);
    ok %again<warnings>.first({ .contains('MIT.txt') }).defined,
        'a licence text two sources disagree about warns';
    ok %again<warnings>.first({ .contains("ariza's own text") }).defined,
        'naming the copy that is not used, which is the generic one';
    is $bundle.add('LICENSES/MIT.txt').slurp, "The pack's MIT text\n",
        'and the pack\'s own copy is the one that ships';

    # And the app beats the pack, which is the case that decided the
    # order: an OFL font's text carries a Reserved Font Name, so the
    # file an app names is a different document from the template rather
    # than a formatting variant of it.
    my $own = fake-app($dir.add('own'), :licensing(q:to/TOML/));

        [[licensing.third-party]]
        name = "A vendored library"
        spdx-license = "MIT"
        license-files = ["licenses/MIT.txt"]
        TOML
    ensure-dir($own.add('licenses')).add('MIT.txt').spurt("The app's MIT text\n");
    write-licensing($bundle, $own);
    is $bundle.add('LICENSES/MIT.txt').slurp, "The app's MIT text\n",
        'a text the app named beats both the pack\'s and ariza\'s';

    # A text left over from a previous build is as misleading as a
    # missing one: it says the bundle contains something it does not.
    $bundle.add('LICENSES/Stale-1.0.txt').spurt("left over\n");
    write-licensing($bundle, $app);
    nok $bundle.add('LICENSES/Stale-1.0.txt').e,
        'and a text no row cites is removed rather than left to mislead';
};

subtest 'the merged document is what the bundle root gets', {
    plan 6;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);
    my $app = fake-app($dir.add('app'));
    ensure-dir($bundle.add('native/mystery')).add('lib.dylib').spurt('x');

    my %l = write-licensing($bundle, $app);
    my $text = $bundle.add('THIRD-PARTY.md').slurp;

    ok $bundle.add('THIRD-PARTY.md').f, 'THIRD-PARTY.md is written at the root';
    ok $bundle.add('LICENSES').d, 'with LICENSES/ beside it';
    ok $text.contains('| App::ExampleApp | 9.9.9 | `Artistic-2.0` |'),
        'the application is the first row';
    ok $text.contains('could not be attributed'),
        'an unattributed component is called out at the top, not buried';

    # Deterministic: the same bundle rendered twice is the same bytes.
    # Anything else makes the document undiffable and the golden below
    # meaningless.
    write-licensing($bundle, $app);
    is $bundle.add('THIRD-PARTY.md').slurp, $text,
        'and a rebuild of the same inputs produces byte-identical output';

    # Nothing in it is a timestamp or a path from this machine.
    nok $text.contains($dir.absolute),
        'nothing in it names the machine it was built on';
};

subtest 'the emitted document matches its golden', {
    plan 1;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);
    my $app = fake-app($dir.add('app'));
    fake-dist($bundle.add('rakudo/share/perl6/site'), :name<zef>, :ver<1.1.3>,
              :auth<zef:ugexe>, :license<Artistic-2.0>, :authors(['Nick Logan']));

    write-licensing($bundle, $app, :conditions(('sqlcipher',)),
                    :files(%( sqlcipher => ('libsqlcipher.0.dylib',) )));

    # The golden covers the shipped runtime data file as well as the
    # rendering, so a change to what ariza claims about Rakudo, MoarVM or
    # MoarVM's vendored C libraries shows up in review as a diff rather
    # than as nothing at all.
    my $golden = $*PROGRAM.parent.add('golden').add('third-party.md');

    # Set ARIZA_REGENERATE_GOLDEN=1 to rewrite this after an intentional
    # change; read the diff before you commit it. The same switch the
    # launcher, installer and workflow goldens take, and for the same
    # reason: a golden that can only be updated by hand is one that gets
    # updated by hand, from something other than the generator.
    $golden.spurt($bundle.add('THIRD-PARTY.md').slurp)
        if %*ENV<ARIZA_REGENERATE_GOLDEN> || !$golden.f;

    is $bundle.add('THIRD-PARTY.md').slurp, $golden.slurp,
        'the merged document is byte for byte the golden';
};

subtest 'the summary is what a release gate downstream reads', {
    plan 5;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);
    my $app = fake-app($dir.add('app'));
    fake-md-kit($bundle.add('native/Some-Pack/lib'), :components(
        %( name => 'libthing', version => '2.1', spdx => 'LGPL-2.1-or-later',
           text => 'LGPL-2.1.txt', url => 'https://example.invalid/libthing',
           copyright => 'Copyright 2026 The libthing authors' ),
    ), :texts(('LGPL-2.1.txt',)));

    my %s = write-licensing($bundle, $app)<summary>;

    ok %s<rows> > 15, 'the row count is the whole inventory';
    is %s<unknown>, 0, 'with the number nobody could attribute beside it';
    ok %s<spdx-ids>.first('LGPL-2.1').defined,
        'and the set of identifiers the bundle is conveyed under —'
      ~ ' a copyleft one in a pack is exactly what a gate exists to catch';
    is %s<spdx-ids>.List, %s<spdx-ids>.sort.List,
        'sorted, so two builds of the same bundle compare equal';
    is %s<document>, 'THIRD-PARTY.md',
        'naming the document it summarises, for tooling that wants to read it';
};

subtest 'two rows cannot claim the same identity', {
    plan 2;
    my @rows =
        %( id => 'x', name => 'A', kind => 'native', provenance => 'one',
           version => '', spdx => '', conveyed => '', copyright => '',
           url => '', source => '', notes => '', license-files => (), files => () ),
        %( id => 'x', name => 'B', kind => 'native', provenance => 'two',
           version => '', spdx => '', conveyed => '', copyright => '',
           url => '', source => '', notes => '', license-files => (), files => () ),
    ;
    throws-like { App::Ariza::Licensing.merge(@rows) }, Exception,
        message => /"share the id 'x'"/,
        'because one of them is describing something else';

    my @ordered = App::Ariza::Licensing.merge((
        %( |@rows[1], id => 'b', kind => 'module' ),
        %( |@rows[0], id => 'a', kind => 'application' ),
    ));
    is @ordered.map(*.<id>).List, ('a', 'b'),
        'and the order is by kind, never by the order they arrived in';
};

subtest 'NOASSERTION is a declaration an app makes, never a gap ariza fills', {
    plan 11;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);
    fake-dist(App::Ariza::Site.site-dir($bundle), :name<Silent::Module>, :ver<1.0>, :auth<zef:x>);

    # It cannot be reached implicitly. A distribution that says nothing
    # fails exactly as before -- somebody has to look first, and then say
    # so on the record.
    throws-like { write-licensing($bundle, fake-app($dir.add('a1'))) },
        Exception, message => /'Silent::Module declares no licence'/,
        'a dist with no licence still dies rather than becoming NOASSERTION';

    # Nor by a distribution asserting it about itself: that is the same
    # "nobody looked" with different spelling.
    fake-dist(App::Ariza::Site.site-dir($bundle), :name<Silent::Module>, :ver<1.0>,
              :auth<zef:x>, :license<NOASSERTION>);
    throws-like { write-licensing($bundle, fake-app($dir.add('a2'))) },
        Exception, message => /'declares NOASSERTION in its own'/,
        'and neither does a dist whose own metadata declines to say';

    my $app = fake-app($dir.add('app'), :licensing(q:to/TOML/));

        [[licensing.dists]]
        name = "Silent::Module"
        spdx-license = "NOASSERTION"
        project-url = "https://example.invalid/silent"
        notes = "No LICENSE file, no statement in its README or on raku.land."

        [[licensing.third-party]]
        name = "Some Sound Effects"
        spdx-license = "NOASSERTION"
        files = ["resources/audio/*.wav"]
        TOML

    my %l = write-licensing($bundle, $app);
    my %row = %l<rows>.first({ .<name> eq 'Silent::Module' });
    is %row<spdx>, 'NOASSERTION', 'an explicit override row carries it';
    is %row<license-files>.List, (),
        'and cites no licence text, because there is no licence to cite';
    ok %row<notes>.contains('Licensing not asserted'),
        'the row says so in words rather than leaving a bare token';
    ok %row<notes>.contains('https://example.invalid/silent'),
        'and points at the component\'s own repository';
    ok %row<notes>.contains('raku.land'),
        'keeping what the app said about how it looked';

    is %l<summary><noassertion>, 2,
        'the manifest counts them, so a gate downstream has a number';
    nok %l<summary><spdx-ids>.first('NOASSERTION').defined,
        'and never lists NOASSERTION as a licence the bundle is conveyed under';

    my $text = $bundle.add('THIRD-PARTY.md').slurp;
    ok $text.contains('declared `NOASSERTION`'),
        'the document calls them out at the top, beside the unattributed';

    # Strict is the statement that this bundle ships nothing it cannot
    # name, and "we could not find out" is not a name.
    my $strict = fake-app($dir.add('strict'), :licensing(q:to/TOML/));

        [licensing]
        strict = true

        [[licensing.dists]]
        name = "Silent::Module"
        spdx-license = "NOASSERTION"
        TOML
    throws-like { write-licensing($bundle, $strict) }, Exception,
        message => /'licensing.strict is on'/,
        'and licensing.strict refuses them, as it refuses an unattributed pack';
};

subtest 'an application may not decline to state its own licence', {
    plan 2;
    my $dir = tmp-dir;
    LEAVE { rm-rf($dir) }
    my $bundle = fake-bundle($dir);

    # NOASSERTION means "somebody looked at a third party's work and
    # could not find out". There is nobody to look for the licence of the
    # thing being built here.
    my $app = fake-app($dir.add('app'), :license('NOASSERTION'),
                       :!license-file);
    throws-like { write-licensing($bundle, $app) }, Exception,
        message => /'declares NOASSERTION as its own'/,
        'its META6 saying so is an error';

    my $declared = fake-app($dir.add('app2'), :licensing(q:to/TOML/));

        [licensing.app]
        spdx-license = "NOASSERTION"
        TOML
    throws-like { write-licensing($bundle, $declared) }, Exception,
        message => /'declares NOASSERTION as its own'/,
        'and so is [licensing.app] saying so';
};