#!/bin/sh set -eu status=0 check_controller_forbidden_persistence() { file="$1" if grep -nE -- '->resultset[[:space:]]*\(|DBIx::Class|GPForum::Schema' "$file"; then echo "architecture-check: controller persistence boundary violation in $file" >&2 status=1 fi } check_os_specific_logic() { if grep -RInE -- '\$\^O|OSNAME' lib t script bin \ | grep -vE '^lib/GPForum/OS([.]pm|/[^:]*[.]pm):' \ | grep -vE '^script/architecture-check:'; then echo "architecture-check: OS-specific logic must live behind lib/GPForum/OS*.pm" >&2 status=1 fi } check_query_topology_gate() { if ! script/query-plan-check >/dev/null; then echo "architecture-check: query topology/static hot-path gate failed" >&2 status=1 fi } check_template_business_logic() { if [ ! -d templates ]; then return fi if grep -RInE -- 'resultset|DBIx::Class|GPForum::Schema|->search[[:space:]]*\(|->create[[:space:]]*\(|->update[[:space:]]*\(|->delete[[:space:]]*\(|gp_[[:alnum:]_]+' templates; then echo "architecture-check: templates must render prepared view models only" >&2 status=1 fi } check_authority_language() { if grep -RInE -- 'cache[^[:cntrl:]]*authoritative|authoritative[^[:cntrl:]]*cache|projection[^[:cntrl:]]*authoritative|authoritative[^[:cntrl:]]*projection' lib templates script bin \ | grep -vE '^script/architecture-check:'; then echo "architecture-check: cache/projection authority language is forbidden in runtime code" >&2 status=1 fi } # Entrypoint layout rule; see docs/ENTRYPOINTS.md. # # bin/ Perl application entrypoints (deployed; load GPForum::* at runtime). # script/ repository tooling for developers and CI (not deployed). # # script/ holds no wrapper of a bin/ entrypoint (bin/gpforum VERB runs it), # so no basename may appear in both directories, and the allowlist below # stays empty. ENTRYPOINT_COLLISION_ALLOWLIST='' is_allowed_entrypoint_collision() { candidate="$1" for allowed in $ENTRYPOINT_COLLISION_ALLOWLIST; do if [ "$candidate" = "$allowed" ]; then return 0 fi done return 1 } check_entrypoint_layout() { for file in bin/*; do [ -f "$file" ] || continue name=${file#bin/} if ! head -n 1 "$file" | grep -q -- 'perl'; then echo "architecture-check: bin/ takes Perl entrypoints only: $file" >&2 status=1 fi if [ ! -x "$file" ]; then echo "architecture-check: $file must be executable" >&2 status=1 fi if [ -e "script/$name" ] && ! is_allowed_entrypoint_collision "$name"; then echo "architecture-check: $name exists in both bin/ and script/; see docs/ENTRYPOINTS.md" >&2 status=1 fi done for file in script/*; do [ -f "$file" ] || continue if [ ! -x "$file" ]; then echo "architecture-check: $file must be executable" >&2 status=1 fi targets=$(grep -o -- 'bin/gpforum[a-z0-9-]*' "$file" | sort -u || true) for target in $targets; do if [ ! -f "$target" ]; then echo "architecture-check: $file wraps a missing entrypoint: $target" >&2 status=1 fi done done } check_internal_dependency_cycles() { if ! perl -MFile::Find -e ' my %file_for; find( { no_chdir => 1, wanted => sub { return if !/[.]pm\z/; my $path = $File::Find::name; ( my $module = $path ) =~ s{\Alib/|[.]pm\z}{}g; $module =~ s{/}{::}g; $file_for{$module} = $path; }, }, q{lib} ); my %edges; for my $module ( keys %file_for ) { open my $handle, q{<}, $file_for{$module} or die qq{cannot read $file_for{$module}: $!}; my $in_pod = 0; while ( my $line = <$handle> ) { last if $line =~ /\A__ (?: END | DATA ) __/msx; if ( $line =~ /\A=cut\b/msx ) { $in_pod = 0; next; } if ( $line =~ /\A=\w+/msx ) { $in_pod = 1; next; } next if $in_pod; next if $line =~ /\A\s*[#]/msx; while ( $line =~ /\buse\s+(GPForum(?:::\w+)+)/gmsx ) { push @{ $edges{$module} }, $1 if exists $file_for{$1}; } } close $handle or die qq{cannot close $file_for{$module}: $!}; } my ( @stack, %seen, %active, @cycles ); sub walk { my ($module) = @_; $seen{$module} = 1; $active{$module} = 1; push @stack, $module; for my $next ( @{ $edges{$module} || [] } ) { if ( !$seen{$next} ) { walk($next); } elsif ( $active{$next} ) { my @cycle = @stack; shift @cycle while @cycle && $cycle[0] ne $next; push @cycles, [ @cycle, $next ]; } } pop @stack; delete $active{$module}; return; } for my $module ( keys %file_for ) { walk($module) if !$seen{$module}; } if (@cycles) { print join qq{\n}, map { join q{ -> }, @{$_} } @cycles; print qq{\n}; exit 1; } exit 0; '; then echo "architecture-check: internal GPForum dependency cycle detected" >&2 status=1 fi } # Every script/ and bin/ path invoked from a CI workflow, the Makefile or # another entrypoint must exist. A rename that updates the tree but not its callers leaves the release # and platform gates permanently red while the badges still look plausible, # because a workflow that cannot find its entrypoint only fails at run time. # # Only repository-relative paths count: an absolute /usr/bin/perl, a glob such # as 'script/staging-*' in a paths: filter, and a runner-installed ~/bin tool # are not this repository's entrypoints. check_entrypoint_references() { for source in .github/workflows/*.yml Makefile script/* bin/*; do [ -f "$source" ] || continue references=$(perl -ne ' while ( m{ (?&2 status=1 done done } # A PostgreSQL unique violation aborts the whole transaction: every statement # after it fails with SQLSTATE 25P02 until a ROLLBACK or a ROLLBACK TO # SAVEPOINT. So an error captured by a bare `eval` inside an open txn_do can # never be used to recover — the recovery branch is unreachable code, and the # in-memory test doubles cannot show it because they have no storage layer. # # GPForum::Infrastructure::UniqueConflict->attempt is the primitive that gets # this right: it wraps the attempt in a savepoint and returns ($value, $error). # The invariant is therefore not "no eval" — defensive evals are fine — but # that $EVAL_ERROR and $@ must never reach conflict recovery. check_unique_conflict_recovery() { offenders=$(grep -rnE \ '(_after_conflict|_after_unique|UniqueConflict->(rethrow|is_conflict(_on)?))[[:space:]]*\(.*\$(EVAL_ERROR|@)' \ lib --include='*.pm' || true) if [ -n "$offenders" ]; then echo "$offenders" | while IFS= read -r offender; do echo "architecture-check: hand-caught error feeds conflict recovery;" \ "use UniqueConflict->attempt: $offender" >&2 done status=1 fi } check_universal_can_override() { # A `sub can` in a class overrides UNIVERSAL::can for every caller, so # $object->can('some_method') stops reporting whether the method exists. # Two authorization classes did this, and it bit for real when # search_condition was added: the capability probe called the authorization # method instead. Authorization methods are named `permits`. offenders=$(grep -rnE '^sub can[[:space:]]*[({]' lib t --include='*.pm' \ 2>/dev/null || true) if [ -n "$offenders" ]; then echo "$offenders" | while IFS= read -r offender; do echo "architecture-check: sub can overrides UNIVERSAL::can;" \ "name it permits: $offender" >&2 done status=1 fi } check_column_reader_empty_list() { # A column reader that ends in a bare `return;` yields the empty LIST in # list context, and these are called from inside hash literals: a missing # row then shifts every following key by one, with nothing but "Odd number # of elements" to show for it. Readers delegate to # GPForum::Infrastructure::Row, which returns undef. offenders=$(find lib -name '*.pm' -exec awk ' /^sub _?column(_[a-z_]+)? ?\(/ { inside = 1; name = $0; line = FNR; last_return = "" } inside && /^[[:space:]]*return[[:space:]]*;[[:space:]]*$/ { last_return = FNR } inside && /^\}/ { if (last_return != "") { printf "%s:%d: %s\n", FILENAME, last_return, name } inside = 0 } ' {} + 2>/dev/null || true) if [ -n "$offenders" ]; then echo "$offenders" | while IFS= read -r offender; do echo "architecture-check: column reader returns the empty list;" \ "delegate to GPForum::Infrastructure::Row: $offender" >&2 done status=1 fi } check_subroutine_prototypes() { # Perl::Critic lost ProhibitSubroutinePrototypes when this tree adopted # signatures: PPI 1.291 tokenizes `sub f ($self, $x)` as a prototype and # the policy cannot tell the two apart. The check is replaced rather than # dropped, and it can be exact -- a real prototype holds only sigils and # punctuation, while every signature here names at least one variable. # Under `use v5.40` (ADR 0117) a prototype can only be written as the # :prototype(...) attribute, which no policy reads, so that spelling is # refused too. It reads every Perl file the other gates read, script/'s # programs without a .pl extension included. offenders=$(perl_files | xargs grep -nHE \ '^sub [A-Za-z_][A-Za-z0-9_]*[[:space:]]*\([$@%;*&\\[:space:]]*\)[[:space:]]*\{|(^|[^[:alnum:]_])sub([[:space:]][^{;#]*)?:[[:space:]]*prototype[[:space:]]*\(' \ 2>/dev/null || true) if [ -n "$offenders" ]; then echo "$offenders" | while IFS= read -r offender; do echo "architecture-check: subroutine prototype; use a signature:" \ "$offender" >&2 done status=1 fi } # The Perl files the gates read, as script/perlcritic and script/perltidy-check # select them: bin/, every .pm, .pl and .t under lib/ and t/, and the programs # in script/ whose first line names perl. perl_files() { find lib t bin script -type f \( -path 'bin/*' -o -name '*.pm' -o -name '*.pl' -o -name '*.t' \) 2>/dev/null for candidate in script/*; do if [ -f "$candidate" ] && head -n 1 "$candidate" | grep -q perl; then echo "$candidate" fi done } # A Perl file as the gates below read its code: one entry per line, with POD, # heredoc bodies and everything from __END__ or __DATA__ on left empty. A # heredoc's lines start at column 0 whatever block they sit in, so read as # code one would close a catch block it is written in. Text that only looks # like a heredoc -- "<&2; then echo "architecture-check: a try/catch does not end with \"};\", or a file could not be read" >&2 status=1 fi } # ADR 0117 and ADR 0118, applied across lib/ by the subtraction sweep: code # catches with native try/catch, asks GPForum::X::Conflict which constraint # collided, takes a handle from Infrastructure::Storage, and declares what a # class cannot work without. These rules keep the idioms that were replaced # from coming back, in code. POD, heredocs and whole-line comments are not # read; a comment after code is, which can refuse a line but never hide one: # # eval an `eval {` block use try/catch # eval_error $EVAL_ERROR or $@ use the catch variable # index_error index($error, ...), with or ask X::Conflict->on # without the parentheses # schema_dbh a private _schema_dbh Infrastructure::Storage # undeclared `has NAME => undef;` (or a list GPForum::Base requires, # of names) without a # `# optional` comment or say why it is optional # recovery a catch variable handed to UniqueConflict->attempt # conflict recovery (see the check above) # # The allowlist holds only files the sweep was not allowed to edit, each with # its reason. An entry that no longer fires fails the check, so the list can # only shrink. # # - lib/GPForum/ViewModel/Forum/Page.pm: frontend code, which the owner # edits and the sweep may not touch. Its engagement lookup still # catches with eval and $EVAL_ERROR; it is converted with the owner's next # change there. RETIRED_IDIOM_ALLOWLIST='lib/GPForum/ViewModel/Forum/Page.pm:eval lib/GPForum/ViewModel/Forum/Page.pm:eval_error' check_retired_idioms() { # One perl reads every file, so an allowlist entry is judged against all # of lib/, never against one batch of it. if ! ALLOWLIST="$RETIRED_IDIOM_ALLOWLIST" perl -MFile::Find -e "$CODE_LINES_PERL"' my %allowed = map { $_ => 0 } split q{ }, $ENV{ALLOWLIST}; my @files; find( { no_chdir => 1, wanted => sub { push @files, $_ if /[.]pm\z/msx } }, q{lib} ) if -d q{lib}; my %rules = ( eval => qr/(? qr/\$EVAL_ERROR \b | \$@/msx, index_error => qr/(? qr/\b _schema_dbh \b/msx, ); my $recovery = qr/(?: UniqueConflict->is_conflict(?:_on)? | X::Conflict->from_error | _after_(?:conflict|unique) ) \s* [(]/msx; my @found; for my $file ( sort @files ) { my @lines = code_lines($file); my @hits; for my $index ( 0 .. $#lines ) { my $line = $lines[$index]; next if $line =~ /\A \s* (?: [#] | \z )/msx; for my $rule ( sort keys %rules ) { push @hits, [ $rule, $index ] if $line =~ $rules{$rule}; } # The declaration, read over two lines only when perltidy # wrapped it: a "# optional" on the next declaration does # not cover this one. my $next = $lines[ $index + 1 ] // q{}; my $wrapped = $line =~ /\A \s* has \s+ (?: [[] [^]]* []] | \S+ ) \s* => \s* \z/msx && $next =~ /\A \s* undef \s* ;/msx; if ( $wrapped || $line =~ /\A \s* has \s+ (?: [[] [^]]* []] | \S+ ) \s* => \s* undef \s* ;/msx ) { my $declaration = $wrapped ? "$line $next" : $line; push @hits, [ undeclared => $index ] if $declaration !~ /[#] \s* optional/msx; } my $variable = catch_opening($line); if ( defined $variable && length $variable ) { # The whole statement, which perltidy may wrap. my $closing = closing_line( \@lines, $index ) // $#lines; my $block = join qq{\n}, @lines[ $index .. $closing ]; while ( $block =~ /$recovery [^;]*? \$\Q$variable\E \b/gmsx ) { my $before = substr $block, 0, $-[0]; push @hits, [ recovery => $index + ( $before =~ tr/\n// ) ]; } } } for my $hit (@hits) { my ( $rule, $index ) = @{$hit}; if ( exists $allowed{"$file:$rule"} ) { $allowed{"$file:$rule"}++; next; } push @found, "$file:" . ( $index + 1 ) . ": $rule: $lines[$index]"; } } for my $entry ( sort keys %allowed ) { push @found, "$entry: allowlisted, but no longer found; remove it" if !$allowed{$entry}; } print map { "$_\n" } @found; exit( @found ? 1 : 0 ); ' >&2; then echo "architecture-check: a retired idiom is back in lib/, or a file could not be read; see the rules above check_retired_idioms" >&2 status=1 fi } # ADR 0107: a module depends only on its own layer and the layers below it, # as GPForum::Application::LayerMap declares them. The rule and the scan live # in t/192-layering.t, which the suite also runs; it needs the project's # dependency tree, so it goes through script/gpforum-carton. check_layering() { if ! script/gpforum-carton exec perl t/192-layering.t >/dev/null 2>&1; then script/gpforum-carton exec perl t/192-layering.t 2>&1 | grep -E '^# ' >&2 echo "architecture-check: a module depends on a layer above its own" >&2 status=1 fi } check_controller_persistence() { for file in lib/GPForum/Controller/*.pm; do [ -f "$file" ] || continue check_controller_forbidden_persistence "$file" done } ALL_CHECKS='check_controller_persistence check_os_specific_logic check_entrypoint_layout check_query_topology_gate check_template_business_logic check_authority_language check_internal_dependency_cycles check_entrypoint_references check_unique_conflict_recovery check_subroutine_prototypes check_column_reader_empty_list check_universal_can_override check_try_catch_closed check_retired_idioms check_layering' # With arguments, only the named checks run: t/341 runs each gate against a # scratch tree that breaks it. if [ "$#" -gt 0 ]; then for check in "$@"; do case " $(echo $ALL_CHECKS) " in *" $check "*) "$check" ;; *) echo "architecture-check: no check named $check" >&2 exit 2 ;; esac done exit "$status" fi for check in $ALL_CHECKS; do "$check" done exit "$status"