#include "cbm.h" #include "lsp/php_proof.h" #include "pipeline/pipeline.h" #include "resolve/policy.h" #include "resolver_quality.h" #include "store/store.h" #include "test_framework.h" #include "test_helpers.h" #include static const char PHP_SERVICE_SOURCE[] = "evidence.analysis, .rule = row->evidence.rule, .facts = row->evidence.facts, .target = row->callee_qn, .candidate_count = 1}; if (cbm_resolve_admit(&proof) && row->site_end_byte > row->site_start_byte && (include_type || row->kind == CBM_RESOLVED_INVOCATION) && (!target || !strcmp(target, row->callee_qn))) matched++; } if (tree) ts_tree_delete(tree); cbm_arena_destroy(&arena); return matched; } TEST(resolver_php_declared_receiver_and_static_call_have_proof) { const char *source = "go(); Svc::build(); }\n}\n"; ASSERT_EQ(php_proof_rows(source, PHP_SERVICE_SOURCE, true, "p.Service.go", false), 1); ASSERT_EQ(php_proof_rows(source, PHP_SERVICE_SOURCE, true, "p.Service.build", false), 1); ASSERT_GT(php_proof_rows(source, PHP_SERVICE_SOURCE, true, "p.Service", true), 0); ASSERT_EQ(php_proof_rows(source, PHP_SERVICE_SOURCE, false, NULL, true), 0); PASS(); } TEST(resolver_php_unknown_writes_references_and_symbol_effects_abstain) { const char *bodies[] = {"$value = unknown(); $value->go();", "$value->go(); $value = other();", "$alias =& $value; $alias = other(); $value->go();", "update($value); $value->go();", "extract($map); $value->go();", "$value->{$name}();", "$value->go(1);"}; for (size_t i = 0; i < sizeof(bodies) / sizeof(bodies[0]); i++) { char source[1024]; snprintf(source, sizeof(source), "go(); } }\n"; ASSERT_EQ(php_proof_rows(by_ref, PHP_SERVICE_SOURCE, true, NULL, false), 0); PASS(); } TEST(resolver_php_private_magic_and_duplicate_method_domains_abstain) { const char *source = "go(); } }\n"; const char *libraries[] = {"go(); } }\n"; ASSERT_EQ(php_proof_rows(source, PHP_SERVICE_SOURCE, true, NULL, false), 0); PASS(); } static const char PHP_DIRECT_SOURCE_0[] = "take($x); " "$value->take(7); $value->take('text'); $value->take(true); " "$value->take(null); $value->pair($x, 1); }\n" " public function staticValue($x) { Svc::build($x); }\n" " public function zero(Svc $value) { $value->go(); }\n" " public function typedGap(Svc $value) { $value->typed(1); }\n" " public function defaultGap(Svc $value) { $value->defaulted(); }\n" " public function namedGap(Svc $value) { $value->take(value: 1); }\n" " public function referenceGap(Svc $value, $x) { $value->byref($x); }\n" " public function variadicGap(Svc $value) { $value->variadic(1); }\n" " public function receiverReferenceGap(Svc &$value) { $value->take(2); }\n" " public function argumentAliasGap(Svc $value) { $value->take($value); " "$value->go(); }\n" " public function invalidReference(Svc $value) { $value->byref(1); }\n" " public function invalidNamed(Svc $value) { $value->take(missing: 1); }\n" " public function invalidUnpack(Svc $value) { $value->take(...'text'); }\n" " public function invalidStatic() { Svc::take(1); }\n" " public function invalidPrivate(Svc $value) { $value->hidden(1); }\n" " public function invalidType(Svc $value) { $value->typed('text'); }\n" " public function invalidDomain(Dup $value) { $value->take(3); }\n" " public function unknownEffect(Svc $value) { $value = unknown(); " "$value->take(4); }\n" "}\n"; static const char PHP_DIRECT_SOURCE_1[] = "helper($y); $this->hidden(2); }\n" " public static function invalidThis() { $this->helper(3); }\n" " public function localTypeGap(Calc $value) { $value->helper(4); }\n" "}\n"; static const char PHP_DIRECT_SOURCE_3[] = "helper($x); }\n" "}\n"; static const char PHP_DIRECT_SOURCE_4[] = "take($x)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}}, {.case_id = "integer-value", .site = {.file = "src/Main.php", .caller = "Main.values", .needle = "$value->take(7)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}}, {.case_id = "string-value", .site = {.file = "src/Main.php", .caller = "Main.values", .needle = "$value->take('text')", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}}, {.case_id = "boolean-value", .site = {.file = "src/Main.php", .caller = "Main.values", .needle = "$value->take(true)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}}, {.case_id = "null-value", .site = {.file = "src/Main.php", .caller = "Main.values", .needle = "$value->take(null)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}}, {.case_id = "fixed-two-arguments", .site = {.file = "src/Main.php", .caller = "Main.values", .needle = "$value->pair($x, 1)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.pair", .label = "Method", .declaration_line = 5, .declaration_text = " public function pair($left, $right) {}"}}, {.case_id = "static-untyped", .site = {.file = "src/Main.php", .caller = "Main.staticValue", .needle = "Svc::build($x)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.build", .label = "Method", .declaration_line = 6, .declaration_text = " public static function build($value) {}"}}, {.case_id = "preserve-zero-arguments", .site = {.file = "src/Main.php", .caller = "Main.zero", .needle = "$value->go()", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.go", .label = "Method", .declaration_line = 12, .declaration_text = " public function go() {}"}}, {.case_id = "lexical-public-this", .site = {.file = "src/Calc.php", .caller = "Calc.run", .needle = "$this->helper($y)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Calc.php", .suffix = "Calc.helper", .label = "Method", .declaration_line = 4, .declaration_text = " public function helper($x) { return $x + 1; }"}}, {.case_id = "lexical-private-this", .site = {.file = "src/Calc.php", .caller = "Calc.run", .needle = "$this->hidden(2)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Calc.php", .suffix = "Calc.hidden", .label = "Method", .declaration_line = 5, .declaration_text = " private function hidden($x) { return $x; }"}}, {.case_id = "typed-parameter", .site = {.file = "src/Main.php", .caller = "Main.typedGap", .needle = "$value->typed(1)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.typed", .label = "Method", .declaration_line = 7, .declaration_text = " public function typed(int $value) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-typed-parameter", .why = "Typed parameter applicability and PHP coercion modes are not " "audited.", .tried = "An exact integer literal is valid; the bridge admits only " "audited untyped by-value parameters."}, {.case_id = "default-parameter", .site = {.file = "src/Main.php", .caller = "Main.defaultGap", .needle = "$value->defaulted()", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.defaulted", .label = "Method", .declaration_line = 8, .declaration_text = " public function defaulted($value = 1) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-default-parameter", .why = "Defaulted parameter applicability remains unsupported.", .tried = "The zero-argument call is genuine, but the fixed-arity " "declaration fact excludes defaults."}, {.case_id = "named-parameter", .site = {.file = "src/Main.php", .caller = "Main.namedGap", .needle = "$value->take(value: 1)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-named-parameter", .why = "Named argument matching is not audited.", .tried = "The actual parameter name is value; only positional arguments " "have a complete binding proof."}, {.case_id = "reference-parameter", .site = {.file = "src/Main.php", .caller = "Main.referenceGap", .needle = "$value->byref($x)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.byref", .label = "Method", .declaration_line = 9, .declaration_text = " public function byref(&$value) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-reference-parameter", .why = "Reference binding and effects remain unsupported.", .tried = "A variable can bind by reference, but the source flag " "deliberately excludes reference parameters."}, {.case_id = "variadic-parameter", .site = {.file = "src/Main.php", .caller = "Main.variadicGap", .needle = "$value->variadic(1)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.variadic", .label = "Method", .declaration_line = 10, .declaration_text = " public function variadic(...$values) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-variadic-parameter", .why = "Variadic applicability remains unsupported.", .tried = "The single positional value is valid; variadic declarations are " "not fixed-arity facts."}, {.case_id = "receiver-reference", .site = {.file = "src/Main.php", .caller = "Main.receiverReferenceGap", .needle = "$value->take(2)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-receiver-reference", .why = "A receiver parameter passed by reference has no stable receiver " "fact.", .tried = "The annotation is genuine, but the whole-function alias audit " "retains this unresolved receiver."}, {.case_id = "argument-alias-call", .site = {.file = "src/Main.php", .caller = "Main.argumentAliasGap", .needle = "$value->take($value)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.take", .label = "Method", .declaration_line = 4, .declaration_text = " public function take($value) {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-argument-alias-call", .why = "A receiver also used as an argument is conservatively invalidated.", .tried = "The by-value target is genuine; retaining whole-function argument " "alias invalidation avoids borrowing stale receiver facts."}, {.case_id = "argument-alias-later", .site = {.file = "src/Main.php", .caller = "Main.argumentAliasGap", .needle = "$value->go()", .occurrence_index = 1}, .relation = "CALLS", .target = {.file = "src/Services/Service.php", .suffix = "Service.go", .label = "Method", .declaration_line = 12, .declaration_text = " public function go() {}"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-argument-alias-later", .why = "A receiver used as an argument has no retained later fact.", .tried = "This later zero-argument call is genuine, but alias-effect " "handling remains conservative."}, {.case_id = "same-file-namespace-type", .site = {.file = "src/Calc.php", .caller = "Calc.localTypeGap", .needle = "$value->helper(4)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Calc.php", .suffix = "Calc.helper", .label = "Method", .declaration_line = 4, .declaration_text = " public function helper($x) { return $x + 1; }"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-same-file-namespace-type", .why = "Ordinary local type names need namespace binding evidence.", .tried = "The same-file Calc annotation is genuine; only lexical this and " "proven imported type names are bound."}, {.case_id = "inherited-this", .site = {.file = "src/Derived.php", .caller = "Derived.inheritedGap", .needle = "$this->helper($x)", .occurrence_index = 0}, .relation = "CALLS", .target = {.file = "src/Calc.php", .suffix = "Calc.helper", .label = "Method", .declaration_line = 4, .declaration_text = " public function helper($x) { return $x + 1; }"}, .mode = RQ_KNOWN_GAP, .gap_id = "php-inherited-this", .why = "Inherited lexical this lookup remains unsupported.", .tried = "The base helper is genuine, but the new lexical proof requires " "a direct class domain without inheritance."}, }; static const RQ_Forbidden PHP_DIRECT_FORBIDDEN[] = { {.case_id = "literal-cannot-bind-reference", .site = {.file = "src/Main.php", .caller = "Main.invalidReference", .needle = "$value->byref(1)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "unknown-named-argument", .site = {.file = "src/Main.php", .caller = "Main.invalidNamed", .needle = "$value->take(missing: 1)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "noniterable-unpack", .site = {.file = "src/Main.php", .caller = "Main.invalidUnpack", .needle = "$value->take(...'text')", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "instance-method-called-statically", .site = {.file = "src/Main.php", .caller = "Main.invalidStatic", .needle = "Svc::take(1)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "private-outside-class", .site = {.file = "src/Main.php", .caller = "Main.invalidPrivate", .needle = "$value->hidden(1)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "incompatible-typed-literal", .site = {.file = "src/Main.php", .caller = "Main.invalidType", .needle = "$value->typed('text')", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "case-insensitive-duplicate-domain", .site = {.file = "src/Main.php", .caller = "Main.invalidDomain", .needle = "$value->take(3)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "unknown-function-replaces-receiver", .site = {.file = "src/Main.php", .caller = "Main.unknownEffect", .needle = "$value->take(4)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, {.case_id = "this-in-static-method", .site = {.file = "src/Calc.php", .caller = "Calc.invalidThis", .needle = "$this->helper(3)", .occurrence_index = 0}, .relation = "CALLS", .any_target = true}, }; TEST(resolver_php_untyped_arguments_and_lexical_this_require_proofs) { for (int driver = 0; driver < 2; driver++) { const char *root = th_mktempdir("cbm_php_direct"); ASSERT_NOT_NULL(root); char repo[768], database[768]; snprintf(repo, sizeof(repo), "%s/repo", root); snprintf(database, sizeof(database), "%s/index.db", root); const RQ_Source sources[] = { {.path = "src/Main.php", .text = PHP_DIRECT_SOURCE_0}, {.path = "src/Services/Service.php", .text = PHP_DIRECT_SOURCE_1}, {.path = "src/Calc.php", .text = PHP_DIRECT_SOURCE_2}, {.path = "src/Derived.php", .text = PHP_DIRECT_SOURCE_3}, {.path = "src/Duplicates/Duplicate.php", .text = PHP_DIRECT_SOURCE_4}, {.path = "src/Decoy.php", .text = PHP_DIRECT_SOURCE_5}, }; bool ok = cbm_mkdir_p(repo, 0755) && cbm_mkdir_p(TH_PATH(repo, "src/Services"), 0755) && cbm_mkdir_p(TH_PATH(repo, "src/Duplicates"), 0755); for (size_t i = 0; ok && i < sizeof(sources) / sizeof(sources[0]); i++) ok = th_write_file(TH_PATH(repo, sources[i].path), sources[i].text) == 0; ok = ok && th_write_file(TH_PATH(repo, "composer.json"), "{\"autoload\": {\"psr-4\": {\"App\\\\\": \"src/\"}}}\n") == 0; for (int i = 0; ok && i < 55; i++) { char path[32]; snprintf(path, sizeof(path), "Filler%02d.php", i); ok = th_write_file(TH_PATH(repo, path), "