diff --git a/src/discover/discover.c b/src/discover/discover.c index aaa6047712..92f02221a1 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -346,6 +346,56 @@ static bool resolve_global_excludes_path(char *out, size_t out_sz) { return false; } +/* JVM source-set language roots: src/// in Maven and Gradle layouts. */ +static const char *JVM_SOURCE_LANG_DIRS[] = {"java", "kotlin", "scala", "groovy", NULL}; + +static bool component_equals(const char *start, size_t len, const char *word) { + return strlen(word) == len && strncmp(start, word, len) == 0; +} + +/* True when rel_path names a directory strictly below a JVM source root + * ("src/main/java/com/acme/target", "app/src/test/kotlin/x/tmp"). There every + * directory is a package, so a built-in skip name such as "target", "tmp" or + * "vendor" is an ordinary package name, not build output (#2661). The root + * itself and directories beside it ("src/main/target") are not packages. */ +bool cbm_discover_path_in_jvm_package(const char *rel_path) { + if (!rel_path) { + return false; + } + enum { JVM_MAX_COMPONENTS = 256 }; + const char *starts[JVM_MAX_COMPONENTS]; + size_t lens[JVM_MAX_COMPONENTS]; + size_t count = 0; + const char *p = rel_path; + while (*p && count < JVM_MAX_COMPONENTS) { + while (*p == '/' || *p == '\\') { + p++; + } + if (!*p) { + break; + } + const char *start = p; + while (*p && *p != '/' && *p != '\\') { + p++; + } + starts[count] = start; + lens[count] = (size_t)(p - start); + count++; + } + /* The language root sits at i + 2; the directory must be below it. */ + for (size_t i = 0; i + 3 < count; i++) { + if (!component_equals(starts[i], lens[i], "src")) { + continue; + } + for (int l = 0; JVM_SOURCE_LANG_DIRS[l]; l++) { + if (component_equals(starts[i + 2], lens[i + 2], JVM_SOURCE_LANG_DIRS[l])) { + return true; + } + } + } + return false; +} + /* True when rel_path equals a suffix rule or ends with "/" + the rule. */ bool cbm_discover_path_has_skip_suffix(const char *rel_path) { if (!rel_path) { @@ -803,8 +853,10 @@ static dir_verdict_t directory_verdict(const char *entry_name, const char *rel_p const gitignore_link_t *ignore_chain, const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore) { - if (cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL) || - cbm_discover_path_has_skip_suffix(rel_path)) { + bool builtin_skip = cbm_should_skip_dir(entry_name, opts ? opts->mode : CBM_MODE_FULL) && + (cbm_discover_is_safety_core_directory(entry_name) || + !cbm_discover_path_in_jvm_package(rel_path)); + if (builtin_skip || cbm_discover_path_has_skip_suffix(rel_path)) { /* #500: a .cbmignore negation (e.g. "!obj/") whose rule is the last * match for this dir un-skips a built-in skip-list dir — except the * non-negatable safety core. Fall through so .gitignore/global/local diff --git a/src/discover/discover_policy_internal.h b/src/discover/discover_policy_internal.h index 09068ec41c..92674c1835 100644 --- a/src/discover/discover_policy_internal.h +++ b/src/discover/discover_policy_internal.h @@ -5,5 +5,6 @@ /* Pure legacy policy predicates; callers provide live terminated strings. */ bool cbm_discover_path_has_skip_suffix(const char *relative_path); bool cbm_discover_is_safety_core_directory(const char *name); +bool cbm_discover_path_in_jvm_package(const char *relative_path); #endif diff --git a/src/discover/inventory_filter.c b/src/discover/inventory_filter.c index ec8277ea0b..f0ea16f94c 100644 --- a/src/discover/inventory_filter.c +++ b/src/discover/inventory_filter.c @@ -102,7 +102,9 @@ static bool cif_directory_reason(cif_context *c, size_t parent, cbm_inventory_pa if (!cif_poll(c)) { return false; } - bool builtin = cbm_should_skip_dir(name, CBM_MODE_FULL); + bool builtin = cbm_should_skip_dir(name, CBM_MODE_FULL) && + (cbm_discover_is_safety_core_directory(name) || + !cbm_discover_path_in_jvm_package((const char *)path.data)); bool suffix = !builtin && cbm_discover_path_has_skip_suffix((const char *)path.data); bool safety = (builtin || suffix) && c->owner->cbm_program && cbm_discover_is_safety_core_directory(name); diff --git a/tests/test_discover.c b/tests/test_discover.c index c98ec26a83..91d100c547 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -6,6 +6,7 @@ #include "test_framework.h" #include "test_helpers.h" #include "discover/discover.h" +#include "discover/discover_policy_internal.h" #include "foundation/platform.h" typedef struct { @@ -1328,6 +1329,73 @@ TEST(discover_cbmignore_negates_always_skip_dir) { PASS(); } +/* ── JVM packages named like build output (issue #2661) ─────────── */ + +/* Below src//{java,kotlin,scala,groovy}/ every directory is a package, so + * "target", "tmp", "vendor" there are package names, not build output. */ +TEST(jvm_package_path_predicate_issue2661) { + ASSERT_TRUE(cbm_discover_path_in_jvm_package("src/main/java/com/acme/target")); + ASSERT_TRUE(cbm_discover_path_in_jvm_package("app/src/test/kotlin/x/tmp")); + ASSERT_TRUE(cbm_discover_path_in_jvm_package("src/main/scala/vendor")); + /* The language root itself and its siblings are not packages. */ + ASSERT_FALSE(cbm_discover_path_in_jvm_package("src/main/java")); + ASSERT_FALSE(cbm_discover_path_in_jvm_package("src/main/target")); + ASSERT_FALSE(cbm_discover_path_in_jvm_package("target")); + ASSERT_FALSE(cbm_discover_path_in_jvm_package("module/target/classes")); + ASSERT_FALSE(cbm_discover_path_in_jvm_package("src/java/main/tmp")); + ASSERT_FALSE(cbm_discover_path_in_jvm_package(NULL)); + PASS(); +} + +TEST(discover_keeps_jvm_packages_named_like_build_dirs_issue2661) { + char *base = th_mktempdir("cbm_disc_jvm_pkg"); + ASSERT(base != NULL); + + th_write_file(TH_PATH(base, "src/main/java/com/acme/target/Foo.java"), + "package com.acme.target;\nclass Foo {}\n"); + th_write_file(TH_PATH(base, "src/test/java/com/acme/tmp/T.java"), + "package com.acme.tmp;\nclass T {}\n"); + th_write_file(TH_PATH(base, "src/main/kotlin/com/acme/vendor/V.kt"), + "package com.acme.vendor\nclass V\n"); + /* Real build output stays skipped. */ + th_write_file(TH_PATH(base, "target/classes/Gen.java"), "class Gen {}\n"); + th_write_file(TH_PATH(base, "src/main/target/Out.java"), "class Out {}\n"); + /* The safety core is never un-skipped, even below a package root. */ + th_write_file(TH_PATH(base, "src/main/java/com/.git/H.java"), "class H {}\n"); + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int rc = cbm_discover(base, &opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(discover_has_rel_path(files, count, "src/main/java/com/acme/target/Foo.java")); + ASSERT_TRUE(discover_has_rel_path(files, count, "src/test/java/com/acme/tmp/T.java")); + ASSERT_TRUE(discover_has_rel_path(files, count, "src/main/kotlin/com/acme/vendor/V.kt")); + ASSERT_FALSE(discover_has_rel_path(files, count, "target/classes/Gen.java")); + ASSERT_FALSE(discover_has_rel_path(files, count, "src/main/target/Out.java")); + ASSERT_FALSE(discover_has_rel_path(files, count, "src/main/java/com/.git/H.java")); + ASSERT_EQ(count, 3); + cbm_discover_free(files, count); + + /* Moderate/fast mode: a package named like a fast-mode skip dir is kept + * below a JVM source root, while the same name at the top stays skipped. */ + th_write_file(TH_PATH(base, "src/main/java/com/acme/integration/I.java"), + "package com.acme.integration;\nclass I {}\n"); + th_write_file(TH_PATH(base, "integration/Top.java"), "class Top {}\n"); + + cbm_discover_opts_t mod_opts = {.mode = CBM_MODE_MODERATE}; + files = NULL; + count = 0; + rc = cbm_discover(base, &mod_opts, &files, &count); + ASSERT_EQ(rc, 0); + ASSERT_TRUE(discover_has_rel_path(files, count, "src/main/java/com/acme/integration/I.java")); + ASSERT_FALSE(discover_has_rel_path(files, count, "integration/Top.java")); + + cbm_discover_free(files, count); + th_cleanup(base); + PASS(); +} + /* ── Laravel compiled Blade view cache (issue #1735) ─────────────── */ /* storage/framework/views/ holds Laravel's compiled Blade templates: generated @@ -2381,6 +2449,8 @@ SUITE(discover) { RUN_TEST(skip_venv); RUN_TEST(skip_dist); RUN_TEST(skip_target); + RUN_TEST(jvm_package_path_predicate_issue2661); + RUN_TEST(discover_keeps_jvm_packages_named_like_build_dirs_issue2661); RUN_TEST(skip_vendor); RUN_TEST(skip_vendored); RUN_TEST(skip_terraform); diff --git a/tests/test_doc_mentions_jvm.c b/tests/test_doc_mentions_jvm.c index 3fa1bc459c..7d9955fa0c 100644 --- a/tests/test_doc_mentions_jvm.c +++ b/tests/test_doc_mentions_jvm.c @@ -578,8 +578,10 @@ TEST(doc_mentions_jvm_java_families_and_reasons) { jt_write(&r, "src/main/java/b/Helper.java", JT_HELPER); jt_write(&r, "src/main/java/a/Shapes.java", JT_SHAPES); jt_write(&r, "src/main/java/a/Widget.java", JT_WIDGET); - /* a real package in a directory the indexer skips by name */ - jt_write(&r, "src/main/java/a/env/Config.java", "package a.env;\npublic class Config {}\n"); + /* a real package in a layout discovery still skips by name: a plain + * src// tree (the open part of #2661); below src/// the + * same package is kept since #2724 */ + jt_write(&r, "src/a/env/Config.java", "package a.env;\npublic class Config {}\n"); jt_write(&r, "src/test/java/a/GadgetTests.java", "package a;\n/** Tests {@link Gadget}. */\npublic class GadgetTests {}\n"); ASSERT_EQ(dm_index(r.repo, r.db, NULL), 0); @@ -643,8 +645,8 @@ TEST(doc_mentions_jvm_java_families_and_reasons) { ASSERT_EQ(jt_edge(db, "a.Widget", "a.GadgetTests"), 0); /* ... while test code binds product code */ ASSERT_EQ(jt_edge(db, "a.GadgetTests", "a.Gadget"), 1); - /* not_indexed: a package of the repository in a directory discovery - * skips, named by an import and by its qualified name */ + /* not_indexed: a package of the repository in a layout discovery still + * skips by name, named by an import and by its qualified name */ ASSERT_STR_EQ(jt_reason(db, w, "Config"), "not_indexed"); ASSERT_STR_EQ(jt_reason(db, w, "a.env.Other"), "not_indexed"); /* graph_gap: a package (no node), a record component, an interface @@ -1117,8 +1119,10 @@ TEST(doc_mentions_jvm_kotlin_families_and_reasons) { jt_write(&r, "src/main/kotlin/far/Helper.kt", "package far\n\nclass Helper {\n fun run() {}\n}\n"); jt_write(&r, "src/main/kotlin/far/sub/Deep.kt", "package far.sub\n\nclass Deep\n"); - /* a real package in a directory the indexer skips by name */ - jt_write(&r, "src/main/kotlin/k/env/Config.kt", "package k.env\n\nclass Config\n"); + /* a real package in a layout discovery still skips by name: a plain + * src// tree (the open part of #2661); below src/// the + * same package is kept since #2724 */ + jt_write(&r, "src/k/env/Config.kt", "package k.env\n\nclass Config\n"); jt_write(&r, "src/test/kotlin/k/GadgetTests.kt", "package k\n\nclass GadgetTests\n"); jt_write(&r, "src/test/kotlin/k/samples/Samples.kt", "package k.samples\n\nfun widgetSample() {}\n"); @@ -1170,7 +1174,7 @@ TEST(doc_mentions_jvm_kotlin_families_and_reasons) { ASSERT_STR_EQ(jt_reason(db, w, "far.sub"), "graph_gap"); /* test_only_target */ ASSERT_STR_EQ(jt_reason(db, w, "GadgetTests"), "test_only_target"); - /* not_indexed: a package of the repository in a directory discovery skips */ + /* not_indexed: a package of the repository in a layout discovery still skips by name */ ASSERT_STR_EQ(jt_reason(db, w, "k.env.Config"), "not_indexed"); /* unparseable: a path with an empty name in it */ ASSERT_STR_EQ(jt_reason(db, w, "Gadget..spin"), "unparseable");